Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 9c2bbac1a0 perf(claude): summarize only the subagent log lines kept after a restart 2026-10-05 13:07:17 +02:00
paseo-bot[bot] 8a4abfedfb fix(claude): keep a subagent card's action log after a daemon restart
Live, the parent's card for a foreground Claude subagent carries the
child's tool calls as its action log, built by the sidechain tracker
as the child's frames stream. After a restart the card is rebuilt from
the transcript, and the replay builder hardcoded an empty log, so the
expanded card lost the child's calls.

The child's transcript on disk holds the same tool_use blocks. The
replay source now reads each root subagent's log from its own
transcript with the tracker's line format, and the replayed card
carries it.
2026-10-05 12:36:03 +02:00
paseo-bot[bot] 8ced059b4d fix(omp): fail the turn when OMP rejects a prompt before its agent runs (#6110)
OMP reports a prompt it rejects before running the agent, such as one
with no API key for the selected model, as a prompt_result with
agentInvoked false, status error, and the error message. Paseo read only
agentInvoked and ended the turn as completed, so the agent went idle with
no reply and no error. The turn now fails with OMP's message.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 12:03:18 +02:00
paseo-bot[bot] da9fae172d fix(claude): keep a subagent's card labeled after a daemon restart (#6100)
After a restart the parent's timeline is rebuilt from Claude Code's
transcript, and the Task/Agent call went through the generic tool
mapper, which has no subagent branch, so the card replayed as a bare
"Agent" with detail type "unknown". Live, the card is built from the
task protocol's declaration instead.

Replay already reads each Task call's type and description from the
parent transcript for the subagent track. Label the replayed cards
from those same facts, through the builder the live path uses.

The shared tool-call parser is left alone: a completed sub_agent card
from it would replace the action log a foreground child's card
carries live.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 10:10:42 +02:00
paseo-bot[bot] f24bb523e8 Exit CLI commands quietly when their output is no longer read (#6097)
* fix(cli): exit quietly when stdout's reader is gone

When the program that launched the CLI closed its end of stdout, the
output write failed with EPIPE and nothing handled the error. Node
printed an unhandled 'error' event and exited 1, and the desktop app's
CLI passthrough could raise Electron's main-process error dialog.

runCli now exits with code 0 on EPIPE from stdout. Other stdout errors
still surface as before.

Closes #5982

* fix(cli): keep running when stdout's reader is gone

Exiting on EPIPE ended a command's own lifecycle: `paseo daemon run`
writes its ready line and then supervises the daemon child, so exiting
there left the child running without its parent. Drop output nobody
reads instead, and let every command finish with its own exit code.

* fix(cli): stop following when stdout's reader is gone

With EPIPE ignored, `paseo agent logs --follow` and `paseo agent attach`
kept running after the program reading their output closed it, until
interrupted. Both now wait through one helper that stops on SIGINT,
SIGTERM, or EPIPE on stdout, so a closed reader stops them at their next
line of output the same way Ctrl+C does.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 09:18:15 +02:00
paseo-bot[bot] cf2b3f4bb3 Name a linked global install as the reason daemon self-update is refused (#6095)
* fix(server): name a linked global install as the reason self-update is refused

npm 7 and later print no link flag in `npm ls --json --long`, so a global
install made with `npm install -g <dir>` or `npm link` was never detected as
linked and self-update reported that the daemon was not running from the npm
global install. Detect the link from the install directory being a symlink,
the same way npm decides it.

Closes #6086

* fix(server): fail the self-update inspection when the install path can't be read

An unreadable install path now surfaces as the update error instead of being
treated as a normal install. Tests build real install directories for both
the linked and the normal case.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 08:04:00 +02:00
a50e14b69e fix(app): keep the centered new workspace form above the keyboard on tablets (#6093)
* fix(app): keep the centered new workspace form usable with the keyboard open

The centered tablet dock translated the whole form by the full keyboard
shift and reserved the keyboard twice to keep its top below the header.
On short tablet viewports such as an unfolded foldable, that left about
20pt for the setup fields and composer, which then overlapped.

Translate the centered form only by the part of the keyboard that
reaches it, and size it to fit above the keyboard.

Fixes #6012

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): rest the centered new workspace form on the keyboard

The keyboard shift excludes the bottom safe area, but the keyboard also
covers it. Measuring the centered form's clearance and capacity down to
the screen edge left the form one navigation-bar height under the
keyboard, hiding Import session on Android tablets.

Measure both above the bottom safe area so the form ends at the
keyboard's top edge.

Fixes #6012

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 07:21:35 +02:00
paseo-bot[bot]androyenheart 7b43f21940 fix(app): let Remote SSH connections authenticate to a password-protected daemon (#6081)
Co-authored-by: royenheart <royenheart@outlook.com>
2026-10-05 06:02:33 +02:00
paseo-ai[bot] 2f9a786061 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-05 02:13:12 +00:00
38 changed files with 963 additions and 173 deletions
+1 -1
View File
@@ -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(
+6
View File
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
fields: {
target: "مضيف SSH",
password: "كلمة مرور الدايمون",
optional: "خياري",
},
passwordVisibility: {
show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور",
},
actions: {
cancel: "إلغاء",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
helper: "リモートホストで動作する Paseo デーモンに接続します。",
fields: {
target: "SSH ホスト",
password: "デーモンのパスワード",
optional: "任意",
},
passwordVisibility: {
show: "パスワードを表示",
hide: "パスワードを非表示",
},
actions: {
cancel: "キャンセル",
+6
View File
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
fields: {
target: "SSH 호스트",
password: "데몬 비밀번호",
optional: "선택 사항",
},
passwordVisibility: {
show: "비밀번호 표시",
hide: "비밀번호 숨기기",
},
actions: {
cancel: "취소",
+6
View File
@@ -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",
+6
View File
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
helper: "Подключитесь к демону Paseo на удалённом хосте.",
fields: {
target: "Хост SSH",
password: "Пароль демона",
optional: "Необязательно",
},
passwordVisibility: {
show: "Показать пароль",
hide: "Скрыть пароль",
},
actions: {
cancel: "Отмена",
+6
View File
@@ -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);
+5
View File
@@ -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;
+5 -25
View File
@@ -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);
+3 -11
View File
@@ -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();
}
+42
View File
@@ -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");
+14
View File
@@ -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(),
+24
View File
@@ -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(),
};
}
+4 -1
View File
@@ -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.