mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c2bbac1a0 | ||
|
|
8a4abfedfb | ||
|
|
8ced059b4d | ||
|
|
da9fae172d | ||
|
|
f24bb523e8 | ||
|
|
cf2b3f4bb3 | ||
|
|
a50e14b69e | ||
|
|
7b43f21940 | ||
|
|
2f9a786061 |
+1
-1
@@ -1 +1 @@
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0=
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { Eye, EyeOff, Terminal } from "lucide-react-native";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import {
|
||||
CONTROL_HEIGHTS,
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
passwordRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
width: "100%",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
passwordInputWrap: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
// Boxed adornment next to the field: same height and radius as the field
|
||||
// chrome of the active form size, and the shared interaction phases.
|
||||
iconButtonSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
width: CONTROL_HEIGHTS.compact,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field,
|
||||
width: CONTROL_HEIGHTS.field,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
controlRest: {
|
||||
...geometry.controlRest,
|
||||
},
|
||||
controlHover: {
|
||||
...geometry.controlHover,
|
||||
},
|
||||
controlActive: {
|
||||
...geometry.controlActive,
|
||||
},
|
||||
controlDisabled: {
|
||||
...geometry.controlDisabled,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export interface AddRemoteSshHostModalProps {
|
||||
visible: boolean;
|
||||
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||
const targetRef = useRef("");
|
||||
const passwordRef = useRef("");
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const passwordInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
targetRef.current = "";
|
||||
passwordRef.current = "";
|
||||
inputRef.current?.replaceText("");
|
||||
passwordInputRef.current?.replaceText("");
|
||||
setIsPasswordVisible(false);
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
|
||||
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
result = await probeAndUpsertRemoteSshConnection(target);
|
||||
result = await probeAndUpsertRemoteSshConnection({
|
||||
...target,
|
||||
password: passwordRef.current === "" ? undefined : passwordRef.current,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof DaemonConnectionTestError
|
||||
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
}, []);
|
||||
const handlePasswordChange = useCallback((value: string) => {
|
||||
passwordRef.current = value;
|
||||
}, []);
|
||||
const handleTogglePasswordVisibility = useCallback(() => {
|
||||
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
|
||||
}, []);
|
||||
const iconButtonStyle = useCallback(
|
||||
(
|
||||
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
|
||||
): StyleProp<ViewStyle> => [
|
||||
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
controlHover: styles.controlHover,
|
||||
controlActive: styles.controlActive,
|
||||
controlDisabled: styles.controlDisabled,
|
||||
},
|
||||
{
|
||||
hovered: state.hovered,
|
||||
pressed: state.pressed,
|
||||
focused: state.focused,
|
||||
disabled: isSaving,
|
||||
},
|
||||
),
|
||||
],
|
||||
[isCompact, isSaving],
|
||||
);
|
||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||
|
||||
return (
|
||||
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
|
||||
<View style={styles.passwordRow}>
|
||||
<View style={styles.passwordInputWrap}>
|
||||
<FormTextInput
|
||||
ref={passwordInputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-password-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
|
||||
initialValue=""
|
||||
onChangeText={handlePasswordChange}
|
||||
placeholder={t("pairing.remoteSsh.fields.optional")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={iconButtonStyle}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPasswordVisible
|
||||
? t("pairing.remoteSsh.passwordVisibility.hide")
|
||||
: t("pairing.remoteSsh.passwordVisibility.show")
|
||||
}
|
||||
testID="remote-ssh-password-visibility-toggle"
|
||||
>
|
||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -15,7 +15,11 @@ import Animated, {
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
@@ -34,11 +38,12 @@ function ComposerViewport({
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
|
||||
function useCenteredClearance(safeAreaBottom: number) {
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [formBottom, setFormBottom] = useState(0);
|
||||
const measureViewport = useCallback((event: LayoutChangeEvent) => {
|
||||
setViewportHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const measureForm = useCallback((event: LayoutChangeEvent) => {
|
||||
const { y, height } = event.nativeEvent.layout;
|
||||
setFormBottom(y + height);
|
||||
}, []);
|
||||
return {
|
||||
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
|
||||
measureViewport,
|
||||
measureForm,
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
@@ -91,6 +114,7 @@ export function ComposerDock({
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
if (centered) {
|
||||
@@ -99,8 +123,13 @@ export function ComposerDock({
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
|
||||
const height = resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
centered: false,
|
||||
});
|
||||
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
|
||||
expect(582 - 24 - 308 - height).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps a centered tablet form below the header after translation", () => {
|
||||
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
|
||||
const viewport = 1000;
|
||||
const bottomInset = 80;
|
||||
const keyboardShift = 300;
|
||||
const height = resolveComposerCapacity({
|
||||
height: 1000,
|
||||
bottomInset: 80,
|
||||
keyboardShift: 300,
|
||||
height: viewport,
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(310);
|
||||
expect((1000 - 80 - height) / 2 - 300).toBe(5);
|
||||
expect(height).toBe(695);
|
||||
const restingTop = (viewport - bottomInset - height) / 2;
|
||||
const bottomClearance = resolveCenteredClearance({
|
||||
viewportHeight: viewport,
|
||||
safeAreaBottom: 0,
|
||||
formBottom: restingTop + height,
|
||||
});
|
||||
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
|
||||
});
|
||||
|
||||
it("leaves a centered form room to edit on a short foldable viewport", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({
|
||||
height: 725,
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
});
|
||||
|
||||
it("rests a centered form on the keyboard above the bottom safe area", () => {
|
||||
// The keyboard shift excludes the safe area that the keyboard also covers.
|
||||
const geometry = {
|
||||
height: 660,
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
|
||||
const formBottom = restingTop + height;
|
||||
const clearance = resolveCenteredClearance({
|
||||
viewportHeight: geometry.height,
|
||||
safeAreaBottom: geometry.safeAreaBottom,
|
||||
formBottom,
|
||||
});
|
||||
const translation = Math.max(0, geometry.keyboardShift - clearance);
|
||||
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
|
||||
expect(formBottom - translation).toBe(keyboardTop);
|
||||
expect(restingTop - translation).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the measured viewport before the first keyboard opening", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 300,
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
interface ComposerGeometry {
|
||||
height: number;
|
||||
bottomInset: number;
|
||||
/** The keyboard also covers this space below its shift. */
|
||||
safeAreaBottom: number;
|
||||
keyboardShift: number;
|
||||
centered: boolean;
|
||||
}
|
||||
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
|
||||
|
||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
"worklet";
|
||||
// A centered form grows upward by half its height. Reserve both halves so
|
||||
// translating it still leaves five layout points below the header.
|
||||
const clearance = input.keyboardShift + 5;
|
||||
const reservedSpace = input.centered ? clearance * 2 : clearance;
|
||||
return Math.max(0, input.height - input.bottomInset - reservedSpace);
|
||||
// A centered form only translates by the keyboard overlap, so once the keyboard
|
||||
// reaches it the form sits on the keyboard and fits above it, not twice over.
|
||||
// A bottom-anchored composer pads the safe area inside its own capacity.
|
||||
const available = input.centered
|
||||
? Math.min(
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height - input.bottomInset - input.keyboardShift;
|
||||
return Math.max(0, available - 5);
|
||||
}
|
||||
|
||||
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||
export function resolveCenteredClearance(input: {
|
||||
viewportHeight: number;
|
||||
safeAreaBottom: number;
|
||||
formBottom: number;
|
||||
}): number {
|
||||
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
|
||||
}
|
||||
|
||||
export function updateComposerCapacity(
|
||||
|
||||
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
|
||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||
fields: {
|
||||
target: "مضيف SSH",
|
||||
password: "كلمة مرور الدايمون",
|
||||
optional: "خياري",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
},
|
||||
actions: {
|
||||
cancel: "إلغاء",
|
||||
|
||||
@@ -1727,6 +1727,12 @@ export const en = {
|
||||
helper: "Connect to a Paseo daemon running on the remote host.",
|
||||
fields: {
|
||||
target: "SSH host",
|
||||
password: "Daemon password",
|
||||
optional: "Optional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Show password",
|
||||
hide: "Hide password",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancel",
|
||||
|
||||
@@ -1749,6 +1749,12 @@ export const es: TranslationResources = {
|
||||
helper: "Conéctate a un daemon de Paseo en el host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
password: "Contraseña del daemon",
|
||||
optional: "Opcional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar contraseña",
|
||||
hide: "Ocultar contraseña",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
|
||||
@@ -1753,6 +1753,12 @@ export const fr: TranslationResources = {
|
||||
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
|
||||
fields: {
|
||||
target: "Hôte SSH",
|
||||
password: "Mot de passe du démon",
|
||||
optional: "Facultatif",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Afficher le mot de passe",
|
||||
hide: "Masquer le mot de passe",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Annuler",
|
||||
|
||||
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
|
||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||
fields: {
|
||||
target: "SSH ホスト",
|
||||
password: "デーモンのパスワード",
|
||||
optional: "任意",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
},
|
||||
actions: {
|
||||
cancel: "キャンセル",
|
||||
|
||||
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
|
||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||
fields: {
|
||||
target: "SSH 호스트",
|
||||
password: "데몬 비밀번호",
|
||||
optional: "선택 사항",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
},
|
||||
actions: {
|
||||
cancel: "취소",
|
||||
|
||||
@@ -1734,6 +1734,12 @@ export const ptBR: TranslationResources = {
|
||||
helper: "Conecte-se a um daemon Paseo no host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
password: "Senha do daemon",
|
||||
optional: "Opcional",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar senha",
|
||||
hide: "Ocultar senha",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
|
||||
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
|
||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||
fields: {
|
||||
target: "Хост SSH",
|
||||
password: "Пароль демона",
|
||||
optional: "Необязательно",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Отмена",
|
||||
|
||||
@@ -1683,6 +1683,12 @@ export const zhCN: TranslationResources = {
|
||||
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
||||
fields: {
|
||||
target: "SSH 主机",
|
||||
password: "守护进程密码",
|
||||
optional: "可选",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "显示密码",
|
||||
hide: "隐藏密码",
|
||||
},
|
||||
actions: {
|
||||
cancel: "取消",
|
||||
|
||||
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: enabled ? -shift.value : 0 }],
|
||||
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
[height, insets.bottom, progress],
|
||||
() =>
|
||||
Animated.add(
|
||||
Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
bottomClearance,
|
||||
).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
[bottomClearance, height, insets.bottom, progress],
|
||||
);
|
||||
const keyboardStyle = useMemo(
|
||||
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
||||
|
||||
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
bottomClearance: _bottomClearance,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
|
||||
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
|
||||
const store = createPairingStore({
|
||||
password: " s3cret ",
|
||||
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||
});
|
||||
answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
await store.probeAndUpsertRemoteSshConnection({
|
||||
host: "deploy@example.com",
|
||||
password: " s3cret ",
|
||||
});
|
||||
|
||||
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
|
||||
expect(host?.password).toBe(" s3cret ");
|
||||
expect(host?.connections[0]).toMatchObject({
|
||||
type: "remoteSsh",
|
||||
host: "deploy@example.com",
|
||||
});
|
||||
|
||||
// The fix promises the saved password is sent on every connection: a later
|
||||
// cycle must authenticate from the stored profile alone.
|
||||
await store.runProbeCycleNow("srv_ssh");
|
||||
await waitForHostOnline(store, "srv_ssh");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
|
||||
const store = createPairingStore({
|
||||
password: "s3cret",
|
||||
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||
});
|
||||
answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
const failure = await store
|
||||
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
|
||||
.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
|
||||
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
|
||||
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
return this.probeAndUpsertConnection({
|
||||
label: input.label,
|
||||
// The daemon hashes and compares the password verbatim (`paseo daemon
|
||||
// set-password` keeps what it reads), so whitespace is significant here.
|
||||
password: input.password ? input.password : undefined,
|
||||
connection: createRemoteSshHostConnection(input),
|
||||
});
|
||||
}
|
||||
@@ -2844,6 +2848,7 @@ export interface HostMutations {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
|
||||
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
|
||||
.argument("<id>", "Agent ID (or prefix)");
|
||||
}
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
@@ -157,31 +158,10 @@ export async function runAttachCommand(
|
||||
await unsubscribe.ready;
|
||||
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
|
||||
|
||||
// Handle Ctrl+C to detach gracefully
|
||||
let detached = false;
|
||||
const detach = () => {
|
||||
if (detached) return;
|
||||
detached = true;
|
||||
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
client
|
||||
.close()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", detach);
|
||||
process.on("SIGTERM", detach);
|
||||
|
||||
// Keep the process alive
|
||||
await new Promise(() => {
|
||||
// Wait indefinitely until interrupted
|
||||
});
|
||||
await waitForStop();
|
||||
console.log("\n\nDetaching from agent...");
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
@@ -224,16 +225,7 @@ async function runFollowMode(
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
// Wait for interrupt
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
});
|
||||
|
||||
await waitForStop();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli";
|
||||
import { createCliParseArgv } from "./run";
|
||||
|
||||
const runModuleUrl = new URL("./run.ts", import.meta.url).href;
|
||||
|
||||
// Runs the CLI in a child process whose stdout pipe has no reader, as when the
|
||||
// program that launched it has already closed its end.
|
||||
async function runCliWithClosedStdout(
|
||||
argv: string[],
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
const script = `
|
||||
const { runCli } = await import(${JSON.stringify(runModuleUrl)});
|
||||
process.exitCode = await runCli(${JSON.stringify(argv)});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
process.stderr.write("still running\\n");
|
||||
`;
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
|
||||
cwd: path.dirname(fileURLToPath(import.meta.url)),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.destroy();
|
||||
let stderr = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve) => child.on("close", resolve));
|
||||
return { code, stderr };
|
||||
}
|
||||
|
||||
describe("runCli", () => {
|
||||
it("lets plugin update own --version while preserving global output options", async () => {
|
||||
const cli = createCli()
|
||||
@@ -72,6 +101,19 @@ describe("runCli", () => {
|
||||
).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]);
|
||||
});
|
||||
|
||||
it("finishes the command quietly when stdout is closed before it writes its output", async () => {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-"));
|
||||
|
||||
try {
|
||||
const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]);
|
||||
|
||||
expect(result.stderr).toBe("still running\n");
|
||||
expect(result.code).toBe(0);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("classifies existing unknown directories as open-project invocations", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
|
||||
const project = path.join(root, "repository");
|
||||
|
||||
@@ -36,7 +36,21 @@ export function createCliParseArgv(input: {
|
||||
return [...nodeArgv, ...cliArgv];
|
||||
}
|
||||
|
||||
// The program that started the CLI can close its end of stdout before the output
|
||||
// is written, as `paseo ls | head -1` or a launcher that discards output does.
|
||||
// Drop the output nobody reads and let the command finish, instead of surfacing
|
||||
// the failed write as an uncaught error.
|
||||
function ignoreClosedStdout(error: NodeJS.ErrnoException): void {
|
||||
if (error.code !== "EPIPE") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
|
||||
if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) {
|
||||
process.stdout.on("error", ignoreClosedStdout);
|
||||
}
|
||||
|
||||
const parseArgv = createCliParseArgv({
|
||||
argv,
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent
|
||||
// attach`) stop on Ctrl+C, a termination signal, or when the program reading
|
||||
// their output closes its end, as `| head` does. A closed reader shows up as
|
||||
// EPIPE on the next write to stdout, so the command stops at its next line of
|
||||
// output.
|
||||
export function waitForStop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
process.off("SIGINT", stop);
|
||||
process.off("SIGTERM", stop);
|
||||
process.stdout.off("error", stopOnClosedStdout);
|
||||
resolve();
|
||||
};
|
||||
const stopOnClosedStdout = (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EPIPE") {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
process.stdout.on("error", stopOnClosedStdout);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Follow commands stop when nobody reads their output
|
||||
*
|
||||
* `paseo agent logs --follow` and `paseo agent attach` stream until they are
|
||||
* stopped. A program that closes its end of their stdout, as `| head` or a
|
||||
* launcher that discards output does, stops them the same way Ctrl+C does.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { connectToDaemon } from "../src/utils/client.ts";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Follow Commands With Closed Stdout Tests ===\n");
|
||||
|
||||
const daemon = await startTestDaemon();
|
||||
const host = `127.0.0.1:${daemon.port}`;
|
||||
|
||||
try {
|
||||
// An agent created without a prompt stays idle, so no provider is called.
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.workDir,
|
||||
title: "closed stdout",
|
||||
});
|
||||
await client.close();
|
||||
|
||||
for (const args of [
|
||||
["agent", "logs", agent.id, "--follow"],
|
||||
["agent", "attach", agent.id],
|
||||
]) {
|
||||
const label = `paseo ${args.slice(0, 2).join(" ")}`;
|
||||
console.log(`Test: ${label} exits when its stdout reader is gone`);
|
||||
const result = await runPaseoCli(daemon, [...args, "--host", host], {
|
||||
closedStdout: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.stderr, "", `${label} should exit quietly`);
|
||||
assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`);
|
||||
console.log(`✓ ${label} exits when its stdout reader is gone\n`);
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== All follow commands with closed stdout tests passed ===");
|
||||
@@ -352,6 +352,8 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Close the reading end of the CLI's stdout, as a launcher that discards output does. */
|
||||
closedStdout?: boolean;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -380,6 +382,10 @@ export async function runPaseoCli(
|
||||
const stdout = createOutputCapture();
|
||||
const stderr = createOutputCapture();
|
||||
|
||||
if (options?.closedStdout) {
|
||||
proc.stdout?.destroy();
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
appendOutputCapture(stdout, data);
|
||||
});
|
||||
|
||||
@@ -634,6 +634,126 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
expect(events[0]).toMatchObject({ id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
test.each(["Agent", "Task"] as const)(
|
||||
"labels a background %s call's card with its type and task after a restart",
|
||||
async (toolName) => {
|
||||
writeSession({
|
||||
parentLines: [
|
||||
parentEntry([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: TOOL_USE_ID,
|
||||
name: toolName,
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
},
|
||||
},
|
||||
]),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "replay-session",
|
||||
timestamp: "2026-07-26T06:27:48.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: TOOL_USE_ID,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
|
||||
}),
|
||||
],
|
||||
meta: JSON.stringify({
|
||||
agentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
toolUseId: TOOL_USE_ID,
|
||||
spawnDepth: 1,
|
||||
requestShape: "background",
|
||||
}),
|
||||
});
|
||||
|
||||
const cards = (await replayEvents()).flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.callId === TOOL_USE_ID
|
||||
? [event.item]
|
||||
: [],
|
||||
);
|
||||
expect(cards.at(-1)).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps a foreground subagent card's action log after a restart", async () => {
|
||||
writeSession({
|
||||
parentLines: [
|
||||
parentEntry([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: TOOL_USE_ID,
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files foreground",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
run_in_background: false,
|
||||
},
|
||||
},
|
||||
]),
|
||||
taskToolResult(),
|
||||
],
|
||||
meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
sessionId: "replay-session",
|
||||
timestamp: "2026-07-26T06:27:49.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_child_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -1 | wc -l", description: "Count entries" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
sidechainEntry({ stopReason: "end_turn" }),
|
||||
],
|
||||
});
|
||||
|
||||
const cards = (await replayEvents()).flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.callId === TOOL_USE_ID
|
||||
? [event.item]
|
||||
: [],
|
||||
);
|
||||
expect(cards.at(-1)).toMatchObject({
|
||||
detail: { type: "sub_agent", log: "[Bash] ls -1 | wc -l" },
|
||||
});
|
||||
});
|
||||
|
||||
test("replays the subagent's own transcript onto its timeline", async () => {
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
type ProviderCatalog,
|
||||
type ProviderRefreshContext,
|
||||
type ResolveAgentDefaultModeInput,
|
||||
type ToolCallDetail,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../../provider-session-import.js";
|
||||
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
|
||||
@@ -295,6 +296,15 @@ interface AsyncMessageInput<T> {
|
||||
interface ClaudeReplayOwnership {
|
||||
restoredIds: ReadonlySet<string>;
|
||||
toolOwners: ReadonlyMap<string, string>;
|
||||
/** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */
|
||||
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>;
|
||||
}
|
||||
|
||||
interface ClaudeSubagentCardFacts {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** The child's tool calls, one line each. Live, the sidechain tracker streams it instead. */
|
||||
log?: string;
|
||||
}
|
||||
|
||||
interface PersistedTimelineEntry {
|
||||
@@ -4249,16 +4259,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
...(declaration.title ? { subAgentType: declaration.title } : {}),
|
||||
...(declaration.description ? { description: declaration.description } : {}),
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4977,6 +4978,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const sidechainEntries = [parentContent, ...sidechains.contents]
|
||||
.flatMap(parseClaudeHistoryRecords)
|
||||
.filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string");
|
||||
const parentFacts = readClaudeReplayParentFacts(parentEntries);
|
||||
|
||||
// Replay produces the same observations the live task protocol produces, then folds them
|
||||
// with the same function, so identity and status are derived once for both paths.
|
||||
@@ -4987,7 +4989,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
entries,
|
||||
parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]),
|
||||
})),
|
||||
parent: readClaudeReplayParentFacts(parentEntries),
|
||||
parent: parentFacts,
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry),
|
||||
});
|
||||
const observations = [
|
||||
@@ -5016,6 +5018,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const replay = {
|
||||
restoredIds: restoredProviderSubagentIds,
|
||||
toolOwners: subagentReplay.toolOwners,
|
||||
subagentToolCalls: new Map(
|
||||
[...parentFacts.toolCalls].map(([id, facts]) => {
|
||||
const log = subagentReplay.actionLogs.get(id);
|
||||
return [id, log ? { ...facts, log } : facts];
|
||||
}),
|
||||
),
|
||||
};
|
||||
if (observations.length === 0) return replay;
|
||||
|
||||
@@ -5082,7 +5090,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
const taskSnapshot = this.taskState.observe(entry);
|
||||
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
|
||||
const items = [
|
||||
...(taskSnapshot ? [taskSnapshot] : []),
|
||||
...this.convertHistoryEntry(entry).map((item) =>
|
||||
labelReplayedSubagentCard(item, replay.subagentToolCalls),
|
||||
),
|
||||
];
|
||||
const isVisibleUserEntry =
|
||||
entry.type === "user" &&
|
||||
typeof entry.uuid === "string" &&
|
||||
@@ -5832,6 +5845,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The parent's card for a subagent, labeled with its type and task. */
|
||||
function buildClaudeSubagentCardDetail(
|
||||
facts: ClaudeSubagentCardFacts,
|
||||
): Extract<ToolCallDetail, { type: "sub_agent" }> {
|
||||
return {
|
||||
type: "sub_agent",
|
||||
...(facts.title ? { subAgentType: facts.title } : {}),
|
||||
...(facts.description ? { description: facts.description } : {}),
|
||||
log: facts.log ?? "",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, a Task call's card is built from the task protocol's declaration. Replay has no task
|
||||
* protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts.
|
||||
*/
|
||||
function labelReplayedSubagentCard(
|
||||
item: AgentTimelineItem,
|
||||
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>,
|
||||
): AgentTimelineItem {
|
||||
if (item.type !== "tool_call") return item;
|
||||
const facts = subagentToolCalls.get(item.callId);
|
||||
if (!facts) return item;
|
||||
return { ...item, detail: buildClaudeSubagentCardDetail(facts) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect what the parent transcript knows about its Task calls, in the shape the shared replay
|
||||
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
|
||||
|
||||
@@ -55,6 +55,86 @@ function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
|
||||
);
|
||||
}
|
||||
|
||||
function isToolUseChunk(block: ClaudeContentChunk): block is ClaudeContentChunk & { name: string } {
|
||||
return (
|
||||
(block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use") &&
|
||||
typeof block.name === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSubAgentText(value: unknown): string | undefined {
|
||||
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
|
||||
}
|
||||
|
||||
function deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
|
||||
const runningToolCall = mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: `sub-agent-summary-${toolName}`,
|
||||
input,
|
||||
output: null,
|
||||
});
|
||||
if (!runningToolCall) {
|
||||
return undefined;
|
||||
}
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: runningToolCall.name,
|
||||
status: runningToolCall.status,
|
||||
error: runningToolCall.error,
|
||||
detail: runningToolCall.detail,
|
||||
metadata: runningToolCall.metadata,
|
||||
});
|
||||
return normalizeSubAgentText(display.summary);
|
||||
}
|
||||
|
||||
/** The action log on the parent's card for a subagent, one line per tool the child called. */
|
||||
function formatSubAgentActionLog(
|
||||
actions: readonly { toolName: string; summary?: string }[],
|
||||
): string {
|
||||
return actions
|
||||
.map((action) =>
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a subagent card's action log from the child's persisted transcript, matching the log
|
||||
* the live tracker builds from the same tool calls as they stream.
|
||||
*/
|
||||
export function readClaudeSubagentActionLog(
|
||||
entries: readonly { type?: unknown; message?: { content?: unknown } }[],
|
||||
): string {
|
||||
const callsById = new Map<string, { toolName: string; input: unknown }>();
|
||||
for (const entry of entries) {
|
||||
const content = entry.type === "assistant" ? entry.message?.content : undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) continue;
|
||||
const toolName = readTrimmedString(block.name);
|
||||
if (!toolName) continue;
|
||||
const key = readTrimmedString(block.id) ?? `assistant:${toolName}:${callsById.size}`;
|
||||
if (callsById.has(key)) continue;
|
||||
callsById.set(key, { toolName, input: block.input ?? null });
|
||||
}
|
||||
}
|
||||
const actions = [...callsById.values()]
|
||||
.slice(-MAX_SUB_AGENT_LOG_ENTRIES)
|
||||
.map(({ toolName, input }) => ({
|
||||
toolName,
|
||||
summary: deriveSubAgentActionSummary(toolName, input),
|
||||
}));
|
||||
return formatSubAgentActionLog(actions);
|
||||
}
|
||||
|
||||
export class ClaudeSidechainTracker {
|
||||
private readonly activeSidechains = new Map<string, SubAgentActivityState>();
|
||||
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
|
||||
@@ -129,11 +209,7 @@ export class ClaudeSidechainTracker {
|
||||
type: "sub_agent",
|
||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
log: state.actions
|
||||
.map((action) =>
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
||||
)
|
||||
.join("\n"),
|
||||
log: formatSubAgentActionLog(state.actions),
|
||||
actions: [],
|
||||
};
|
||||
|
||||
@@ -298,9 +374,9 @@ export class ClaudeSidechainTracker {
|
||||
parentToolUseId: string,
|
||||
): boolean {
|
||||
const taskInput = this.getToolInput(parentToolUseId);
|
||||
const nextName = this.normalizeSubAgentText(taskInput?.name);
|
||||
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
||||
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
|
||||
const nextName = normalizeSubAgentText(taskInput?.name);
|
||||
const nextSubAgentType = normalizeSubAgentText(taskInput?.subagent_type);
|
||||
const nextDescription = normalizeSubAgentText(taskInput?.description);
|
||||
|
||||
let changed = false;
|
||||
if (nextName && nextName !== state.name) {
|
||||
@@ -318,17 +394,6 @@ export class ClaudeSidechainTracker {
|
||||
return changed;
|
||||
}
|
||||
|
||||
private normalizeSubAgentText(value: unknown): string | undefined {
|
||||
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
|
||||
}
|
||||
|
||||
private extractAssistantMessageActions(
|
||||
message: Extract<SDKMessage, { type: "assistant" }>,
|
||||
): SubAgentActionCandidate[] {
|
||||
@@ -338,15 +403,7 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
const actions: SubAgentActionCandidate[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
!isClaudeContentChunk(block) ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use"
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
if (!isClaudeContentChunk(block) || !isToolUseChunk(block)) {
|
||||
continue;
|
||||
}
|
||||
const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`;
|
||||
@@ -367,15 +424,7 @@ export class ClaudeSidechainTracker {
|
||||
return [];
|
||||
}
|
||||
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
|
||||
if (
|
||||
!block ||
|
||||
!(
|
||||
block.type === "tool_use" ||
|
||||
block.type === "mcp_tool_use" ||
|
||||
block.type === "server_tool_use"
|
||||
) ||
|
||||
typeof block.name !== "string"
|
||||
) {
|
||||
if (!block || !isToolUseChunk(block)) {
|
||||
return [];
|
||||
}
|
||||
const key =
|
||||
@@ -420,7 +469,7 @@ export class ClaudeSidechainTracker {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
|
||||
const summary = deriveSubAgentActionSummary(normalizedToolName, candidate.input);
|
||||
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
@@ -471,24 +520,4 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
|
||||
const runningToolCall = mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: `sub-agent-summary-${toolName}`,
|
||||
input,
|
||||
output: null,
|
||||
});
|
||||
if (!runningToolCall) {
|
||||
return undefined;
|
||||
}
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: runningToolCall.name,
|
||||
status: runningToolCall.status,
|
||||
error: runningToolCall.error,
|
||||
detail: runningToolCall.detail,
|
||||
metadata: runningToolCall.metadata,
|
||||
});
|
||||
return this.normalizeSubAgentText(display.summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { resolveObservedClaudeModelId } from "../models.js";
|
||||
import { readClaudeSubagentActionLog } from "../sidechain-tracker.js";
|
||||
import type { SubagentObservation } from "./observation.js";
|
||||
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
|
||||
|
||||
@@ -337,9 +338,15 @@ export function observeReplaySubagents(input: {
|
||||
subagents: readonly ClaudeReplaySubagentInput[];
|
||||
parent: ClaudeReplayParentFacts;
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
|
||||
}): {
|
||||
observations: SubagentObservation[];
|
||||
toolOwners: ReadonlyMap<string, string>;
|
||||
/** Root subagent id -> the action log its card in the parent transcript shows. */
|
||||
actionLogs: ReadonlyMap<string, string>;
|
||||
} {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const toolOwners = new Map<string, string>();
|
||||
const actionLogs = new Map<string, string>();
|
||||
const unresolved = [...input.subagents].sort(
|
||||
(left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1),
|
||||
);
|
||||
@@ -360,13 +367,14 @@ export function observeReplaySubagents(input: {
|
||||
|
||||
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
|
||||
recordReplayToolOwners(toolOwners, subagent.entries, link.id);
|
||||
if (!ownerId) actionLogs.set(link.id, readClaudeSubagentActionLog(subagent.entries));
|
||||
observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId));
|
||||
if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts);
|
||||
unresolved.splice(index, 1);
|
||||
madeProgress = true;
|
||||
}
|
||||
}
|
||||
return { observations, toolOwners };
|
||||
return { observations, toolOwners, actionLogs };
|
||||
}
|
||||
|
||||
function resolveReplayOwner(
|
||||
|
||||
@@ -927,6 +927,24 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test.each(["result after ack", "result before ack"] as const)(
|
||||
"fails a prompt OMP rejects before its agent runs (%s)",
|
||||
async (order) => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await expect(
|
||||
omp.runPromptRejectedBeforeAgentRuns(
|
||||
"Reply with ok.",
|
||||
"No API key found for anthropic.",
|
||||
order,
|
||||
),
|
||||
).rejects.toThrow("No API key found for anthropic.");
|
||||
expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]);
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("completes a no-turn notify with one notification and no assistant text", async () => {
|
||||
const scheduler = new ManualNoTurnScheduler();
|
||||
const omp = new OmpHarness({ noTurnScheduler: scheduler });
|
||||
|
||||
@@ -172,6 +172,21 @@ interface OmpPersistenceMetadata {
|
||||
bridgedTools: Map<string, OmpBridgedToolIdentity>;
|
||||
}
|
||||
|
||||
/** OMP's outcome for one prompt, correlated by the prompt's request id. */
|
||||
interface OmpPromptResult {
|
||||
agentInvoked: boolean;
|
||||
/** Why OMP rejected the prompt before its agent ran, such as a missing API key. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function promptResultError(result: {
|
||||
status?: string;
|
||||
error?: { message: string };
|
||||
}): string | null {
|
||||
if (result.status !== "error") return null;
|
||||
return result.error?.message || "OMP rejected the prompt";
|
||||
}
|
||||
|
||||
interface StartTurnResult {
|
||||
turnId: string;
|
||||
}
|
||||
@@ -695,7 +710,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
|
||||
private activePromptRequestId: string | null = null;
|
||||
private activePromptAgentInvoked: boolean | null = null;
|
||||
private readonly pendingPromptResults = new Map<string, boolean>();
|
||||
private activePromptError: string | null = null;
|
||||
private readonly pendingPromptResults = new Map<string, OmpPromptResult>();
|
||||
private pendingNoTurnCompletionAbort: AbortController | null = null;
|
||||
private lastKnownThinkingOptionId: string | null;
|
||||
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
|
||||
@@ -865,12 +881,13 @@ export class OmpAgentSession implements AgentSession {
|
||||
if (ack.requestId) {
|
||||
this.pendingPromptResults.delete(ack.requestId);
|
||||
}
|
||||
this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null;
|
||||
if (correlatedResult === false) {
|
||||
this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null;
|
||||
this.activePromptError = correlatedResult?.error ?? null;
|
||||
if (correlatedResult?.agentInvoked === false) {
|
||||
this.scheduleNoTurnPromptCompletion(turnId);
|
||||
return;
|
||||
}
|
||||
if (correlatedResult !== true && ack.agentInvoked === false) {
|
||||
if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) {
|
||||
await this.completeNoTurnPrompt(turnId);
|
||||
return;
|
||||
}
|
||||
@@ -1309,7 +1326,14 @@ export class OmpAgentSession implements AgentSession {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const error = this.activePromptError;
|
||||
this.emitBufferedNoTurnOutputs(turnId);
|
||||
if (error) {
|
||||
this.usagePoller.stopTurn();
|
||||
this.resetActiveTurn({ terminalizeWork: true });
|
||||
this.emit({ type: "turn_failed", provider: this.provider, turnId, error });
|
||||
return;
|
||||
}
|
||||
this.completeTurn(turnId, []);
|
||||
}
|
||||
|
||||
@@ -1318,6 +1342,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeNoTurnPromptText = null;
|
||||
this.activePromptRequestId = null;
|
||||
this.activePromptAgentInvoked = null;
|
||||
this.activePromptError = null;
|
||||
this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length);
|
||||
}
|
||||
|
||||
@@ -1715,15 +1740,17 @@ export class OmpAgentSession implements AgentSession {
|
||||
? event.agentInvoked
|
||||
: undefined;
|
||||
if (requestId && agentInvoked !== undefined) {
|
||||
const result = { agentInvoked, error: promptResultError(event) };
|
||||
if (requestId === this.activePromptRequestId && this.activeTurnId) {
|
||||
this.activePromptAgentInvoked = agentInvoked;
|
||||
this.activePromptError = result.error;
|
||||
if (agentInvoked === false) {
|
||||
this.scheduleNoTurnPromptCompletion(this.activeTurnId);
|
||||
} else {
|
||||
this.cancelNoTurnPromptCompletion();
|
||||
}
|
||||
} else if (this.activePromptRequestId === null) {
|
||||
this.pendingPromptResults.set(requestId, agentInvoked);
|
||||
this.pendingPromptResults.set(requestId, result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -480,6 +480,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
|
||||
type: z.literal("prompt_result"),
|
||||
id: z.string().optional(),
|
||||
agentInvoked: z.boolean().optional(),
|
||||
status: z.string().optional(),
|
||||
error: z.object({ message: z.string() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(),
|
||||
|
||||
@@ -329,6 +329,31 @@ export class OmpHarness {
|
||||
return { completed: () => isCompleted, completion };
|
||||
}
|
||||
|
||||
async runPromptRejectedBeforeAgentRuns(
|
||||
input: string,
|
||||
error: string,
|
||||
order: "result after ack" | "result before ack",
|
||||
): Promise<unknown> {
|
||||
const session = this.requireSession();
|
||||
const runtime = this.omp.latestSession();
|
||||
runtime.promptAck = { requestId: "prompt-rejected" };
|
||||
const rejection = {
|
||||
type: "prompt_result",
|
||||
id: "prompt-rejected",
|
||||
agentInvoked: false,
|
||||
status: "error",
|
||||
sessionSettled: true,
|
||||
error: { message: error, retryable: false },
|
||||
} as const;
|
||||
const promptStarted = runtime.nextPrompt();
|
||||
const run = session.run(input);
|
||||
if (order === "result before ack") runtime.emit(rejection);
|
||||
await promptStarted;
|
||||
await waitForImmediate();
|
||||
if (order === "result after ack") runtime.emit(rejection);
|
||||
return await run;
|
||||
}
|
||||
|
||||
async runPromptAfterCorrelatedTrueResult(
|
||||
input: string,
|
||||
output: string,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, onTestFinished, test } from "vitest";
|
||||
import {
|
||||
DaemonSelfUpdateInProgressError,
|
||||
DaemonSelfUpdater,
|
||||
@@ -106,9 +108,11 @@ async function runUpdate(input: {
|
||||
|
||||
describe("DaemonSelfUpdater", () => {
|
||||
test("probes and updates the running install's prefix when npm defaults elsewhere", async () => {
|
||||
const prefix = path.resolve("custom npm prefix");
|
||||
const prefix = mkdtempSync(path.join(tmpdir(), "custom npm prefix "));
|
||||
onTestFinished(() => rmSync(prefix, { recursive: true, force: true }));
|
||||
const root = process.platform === "win32" ? prefix : path.join(prefix, "lib");
|
||||
const packagePath = path.join(root, "node_modules", "@getpaseo", "cli");
|
||||
mkdirSync(packagePath, { recursive: true });
|
||||
const commands: string[][] = [];
|
||||
let version = "0.1.15";
|
||||
const npm = new DefaultNpmGlobalPaseoCli(async (_command, args) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js";
|
||||
|
||||
interface CommandCall {
|
||||
@@ -10,25 +12,65 @@ interface CommandCall {
|
||||
}
|
||||
|
||||
const globalRoot = path.join(path.sep, "global", "lib");
|
||||
const globalNodeModules = path.join(globalRoot, "node_modules");
|
||||
const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli");
|
||||
|
||||
function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string {
|
||||
return JSON.stringify({
|
||||
name: "lib",
|
||||
path: globalRoot,
|
||||
dependencies: {
|
||||
"@getpaseo/cli": {
|
||||
version,
|
||||
path: cliPackagePath,
|
||||
link: options?.linked === true,
|
||||
},
|
||||
},
|
||||
});
|
||||
interface GlobalInstall {
|
||||
root: string;
|
||||
packagePath: string;
|
||||
// npm 7+ `ls --json --long` marks neither kind of install with a link flag.
|
||||
npmLsJson: string;
|
||||
}
|
||||
|
||||
describe("DefaultNpmGlobalPaseoCli", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createGlobalInstall(options: { linked: boolean }): GlobalInstall {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-npm-global-"));
|
||||
tempDirs.push(dir);
|
||||
const root = path.join(dir, "prefix", "lib");
|
||||
const packagePath = path.join(root, "node_modules", "@getpaseo", "cli");
|
||||
const checkoutCli = path.join(dir, "checkout", "packages", "cli");
|
||||
if (options.linked) {
|
||||
mkdirSync(checkoutCli, { recursive: true });
|
||||
mkdirSync(path.dirname(packagePath), { recursive: true });
|
||||
symlinkSync(checkoutCli, packagePath, "junction");
|
||||
} else {
|
||||
mkdirSync(packagePath, { recursive: true });
|
||||
}
|
||||
const npmLsJson = JSON.stringify({
|
||||
name: "lib",
|
||||
path: root,
|
||||
dependencies: {
|
||||
"@getpaseo/cli": {
|
||||
version: "0.1.15",
|
||||
resolved: options.linked
|
||||
? `file:${path.relative(root, checkoutCli)}`
|
||||
: "https://registry.npmjs.org/@getpaseo/cli/-/cli-0.1.15.tgz",
|
||||
path: packagePath,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { root, packagePath, npmLsJson };
|
||||
}
|
||||
|
||||
test("reports a global install linked to a local checkout as linked", async () => {
|
||||
const install = createGlobalInstall({ linked: true });
|
||||
const cli = new DefaultNpmGlobalPaseoCli(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: install.npmLsJson,
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(cli.inspect()).resolves.toMatchObject({ isLinked: true });
|
||||
});
|
||||
|
||||
test("inspects the npm global cli install with npm -g ls", async () => {
|
||||
const install = createGlobalInstall({ linked: false });
|
||||
const calls: CommandCall[] = [];
|
||||
const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => {
|
||||
calls.push({
|
||||
@@ -37,13 +79,13 @@ describe("DefaultNpmGlobalPaseoCli", () => {
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
});
|
||||
return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" };
|
||||
return { exitCode: 0, stdout: install.npmLsJson, stderr: "" };
|
||||
});
|
||||
|
||||
await expect(cli.inspect()).resolves.toEqual({
|
||||
version: "0.1.15",
|
||||
packagePath: cliPackagePath,
|
||||
globalRootPath: globalRoot,
|
||||
packagePath: install.packagePath,
|
||||
globalRootPath: install.root,
|
||||
isLinked: false,
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lstatSync } from "node:fs";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import { z } from "zod";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
@@ -19,7 +20,6 @@ const NpmGlobalCliPackageSchema = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
path: z.string(),
|
||||
link: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -112,7 +112,9 @@ function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | nul
|
||||
version: cliPackage.data.version,
|
||||
packagePath: cliPackage.data.path,
|
||||
globalRootPath: list.data.path ?? null,
|
||||
isLinked: cliPackage.data.link === true,
|
||||
// npm links an install by making its node_modules entry a symlink (a junction
|
||||
// on Windows), and `npm ls --json` reports no flag for it.
|
||||
isLinked: lstatSync(cliPackage.data.path).isSymbolicLink(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ paseo --host 'ssh://user@host?daemonPort=7777' ls -a
|
||||
|
||||
Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host.
|
||||
|
||||
In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination.
|
||||
In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination, including `:port` or `?daemonPort=` when those differ from the defaults.
|
||||
|
||||
If the remote daemon has a password, enter it in **Daemon password**; it is stored with the host and sent on every connection, the same as a direct connection's password. SSH login itself stays key-based — Paseo never prompts for an SSH password.
|
||||
|
||||
## Paseo relay
|
||||
|
||||
@@ -123,6 +125,7 @@ If the host was already paired through the relay, Paseo adds the direct connecti
|
||||
|
||||
- **SSH authentication failed:** Run `ssh user@host` in a terminal and fix the key, agent, host key, or `~/.ssh/config` entry there. Paseo does not prompt for SSH passwords.
|
||||
- **SSH connects but Paseo is refused:** Run `paseo daemon status` on the remote host. SSH transport does not start the daemon.
|
||||
- **SSH connects but Paseo reports "Password required":** The remote daemon is password-protected. Remove the SSH connection from the host and add it again, this time entering the daemon password in **Daemon password**.
|
||||
- **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP.
|
||||
- **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port.
|
||||
- **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it.
|
||||
|
||||
Reference in New Issue
Block a user