diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index b69d9481c..adec268d6 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -11,7 +11,11 @@ interface BackHeaderProps { onBack?: () => void; } -export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { +export function BackHeader({ + title, + rightContent, + onBack, +}: BackHeaderProps) { const { theme } = useUnistyles(); return ( diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index c68d28d4d..9e21fa37a 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import { Text } from "react-native"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Menu, PanelLeft } from "lucide-react-native"; +import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { HeaderToggleButton } from "./header-toggle-button"; import { usePanelStore } from "@/stores/panel-store"; @@ -12,7 +12,39 @@ interface MenuHeaderProps { rightContent?: ReactNode; } -export function MenuHeader({ title, rightContent }: MenuHeaderProps) { +interface SidebarMenuToggleProps { + style?: StyleProp; + tooltipSide?: "left" | "right" | "top" | "bottom"; + testID?: string; + nativeID?: string; +} + +const MOBILE_MENU_LINE_WIDTH = 16; +const MOBILE_MENU_LINE_SHORT_WIDTH = 8; +const MOBILE_MENU_LINE_HEIGHT = 2; + +function MobileMenuIcon({ color }: { color: string }) { + return ( + + + + + + ); +} + +export function SidebarMenuToggle({ + style, + tooltipSide = "right", + testID = "menu-button", + nativeID = "menu-button", +}: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) { const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const MenuIcon = isMobile ? Menu : PanelLeft; const menuIconColor = !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + + {isMobile ? ( + + ) : ( + + )} + + ); +} + +export function MenuHeader({ + title, + rightContent, +}: MenuHeaderProps) { return ( - - - + {title && ( {title} @@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({ }, color: theme.colors.foreground, }, + mobileMenuIcon: { + width: MOBILE_MENU_LINE_WIDTH, + height: 12, + justifyContent: "space-between", + alignItems: "flex-start", + }, + mobileMenuLine: { + width: MOBILE_MENU_LINE_WIDTH, + height: MOBILE_MENU_LINE_HEIGHT, + borderRadius: theme.borderRadius.full, + }, + mobileMenuLineShort: { + width: MOBILE_MENU_LINE_SHORT_WIDTH, + }, })); diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 1f8c86830..80845e5ad 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -16,7 +16,12 @@ interface ScreenHeaderProps { * Shared frame for the home/back headers so we only maintain padding, border, * and safe-area logic in one place. */ -export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) { +export function ScreenHeader({ + left, + right, + leftStyle, + rightStyle, +}: ScreenHeaderProps) { const insets = useSafeAreaInsets(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 5aece480f..39f69a053 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -1089,8 +1089,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, opacity: 0.78, fontSize: theme.fontSize.xs, - flexShrink: 1, - minWidth: 0, + flexShrink: 0, }, agentMetaBranchBadge: { borderRadius: theme.borderRadius.full, @@ -1098,7 +1097,8 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, paddingHorizontal: theme.spacing[1], paddingVertical: 1, - flexShrink: 0, + flexShrink: 1, + minWidth: 0, }, agentMetaBranchBadgeSelected: { borderColor: theme.colors.borderAccent, @@ -1107,6 +1107,8 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, opacity: 0.76, fontSize: theme.fontSize.xs, + flexShrink: 1, + minWidth: 0, }, selectionBar: { position: "absolute", diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 5bc46b7a7..38b72de18 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -290,6 +290,35 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }); }, [client, cwd, isConnected, queryClient, serverId]); + useEffect(() => { + if (!client || !isConnected || !cwd.startsWith("/")) { + return; + } + + const unsubscribe = client.on("terminals_changed", (message) => { + if (message.type !== "terminals_changed") { + return; + } + if (message.payload.cwd !== cwd) { + return; + } + void queryClient.invalidateQueries({ + queryKey: ["terminals", serverId, cwd], + }); + void queryClient.refetchQueries({ + queryKey: ["terminals", serverId, cwd], + type: "active", + }); + }); + + client.subscribeTerminals({ cwd }); + + return () => { + unsubscribe(); + client.unsubscribeTerminals({ cwd }); + }; + }, [client, cwd, isConnected, queryClient, serverId]); + const createTerminalMutation = useMutation({ mutationFn: async () => { if (!client) { diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a7098530e..0482e1ef3 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -89,6 +89,43 @@ export function ToolCallDetailsContent({ ); + } else if (detail?.type === "worktree_setup") { + const setupLog = detail.log.replace(/^\n+/, ""); + const hasLog = setupLog.length > 0; + sections.push( + + + + + + + {hasLog + ? setupLog + : `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`} + + + + + + + ); } else if (detail?.type === "edit") { sections.push( state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const params = useLocalSearchParams(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); @@ -226,11 +222,6 @@ export function DraftAgentScreen({ : undefined; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const SidebarIcon = isMobile ? Menu : PanelLeft; - const sidebarIconColor = !isMobile && isSidebarOpen - ? theme.colors.foreground - : theme.colors.foregroundMuted; const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); const [baseBranch, setBaseBranch] = useState(""); @@ -977,20 +968,7 @@ export function DraftAgentScreen({ isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null, ]} > - - - + diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index c4f1e24ed..04ea25cc2 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -76,6 +76,34 @@ describe("tool-call-display", () => { }); }); + it("builds display model from worktree setup detail", () => { + const display = buildToolCallDisplayModel({ + name: "paseo_worktree_setup", + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch", + branchName: "feature-branch", + log: "==> [1/1] Running: npm install\n", + commands: [ + { + index: 1, + command: "npm install", + cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + status: "running", + exitCode: null, + }, + ], + }, + }); + + expect(display).toEqual({ + displayName: "Worktree Setup", + summary: "feature-branch", + }); + }); + it("does not derive command summary from unknown raw detail", () => { const display = buildToolCallDisplayModel({ name: "exec_command", diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index d3e8cb320..c0087b9fc 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record = edit: Pencil, write: Pencil, search: Search, + worktree_setup: SquareTerminal, unknown: Wrench, }; diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index c906fd95c..5316d6ad4 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1236,4 +1236,89 @@ describe("DaemonClient", () => { expect(received).toHaveLength(0); expect(logger.warn).toHaveBeenCalled(); }); + + test("sends subscribe/unsubscribe terminals messages", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + client.subscribeTerminals({ cwd: "/tmp/project" }); + client.unsubscribeTerminals({ cwd: "/tmp/project" }); + + expect(mock.sent).toHaveLength(2); + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "subscribe_terminals_request", + cwd: "/tmp/project", + }, + }); + expect(JSON.parse(String(mock.sent[1]))).toEqual({ + type: "session", + message: { + type: "unsubscribe_terminals_request", + cwd: "/tmp/project", + }, + }); + }); + + test("dispatches terminals_changed events to typed listeners", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const received: Array<{ cwd: string; names: string[] }> = []; + const unsubscribe = client.on("terminals_changed", (message) => { + received.push({ + cwd: message.payload.cwd, + names: message.payload.terminals.map((terminal) => terminal.name), + }); + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "terminals_changed", + payload: { + cwd: "/tmp/project", + terminals: [ + { + id: "term-1", + name: "Dev Server", + }, + ], + }, + }) + ); + + unsubscribe(); + + expect(received).toEqual([ + { + cwd: "/tmp/project", + names: ["Dev Server"], + }, + ]); + }); }); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index e11f114fb..e135a3ae8 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -356,6 +356,7 @@ export class DaemonClient { string, { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } >(); + private terminalDirectorySubscriptions = new Set(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; private relayClientId: string | null = null; @@ -474,6 +475,7 @@ export class DaemonClient { this.updateConnectionState({ status: "connected" }); this.resubscribeAgentUpdates(); this.resubscribeCheckoutDiffSubscriptions(); + this.resubscribeTerminalDirectorySubscriptions(); this.flushPendingSendQueue(); this.resolveConnect(); }), @@ -1093,6 +1095,18 @@ export class DaemonClient { } } + private resubscribeTerminalDirectorySubscriptions(): void { + if (this.terminalDirectorySubscriptions.size === 0) { + return; + } + for (const cwd of this.terminalDirectorySubscriptions) { + this.sendSessionMessage({ + type: "subscribe_terminals_request", + cwd, + }); + } + } + // ============================================================================ // Agent Lifecycle // ============================================================================ @@ -2314,6 +2328,28 @@ export class DaemonClient { // Terminals // ============================================================================ + subscribeTerminals(input: { cwd: string }): void { + this.terminalDirectorySubscriptions.add(input.cwd); + if (!this.transport || this.connectionState.status !== "connected") { + return; + } + this.sendSessionMessage({ + type: "subscribe_terminals_request", + cwd: input.cwd, + }); + } + + unsubscribeTerminals(input: { cwd: string }): void { + this.terminalDirectorySubscriptions.delete(input.cwd); + if (!this.transport || this.connectionState.status !== "connected") { + return; + } + this.sendSessionMessage({ + type: "unsubscribe_terminals_request", + cwd: input.cwd, + }); + } + async listTerminals( cwd: string, requestId?: string diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index dd0f45cd8..45f51c6b4 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -46,7 +46,10 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; -import { appendTimelineItemIfAgentKnown } from "./timeline-append.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "./timeline-append.js"; import { type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; @@ -357,6 +360,12 @@ export async function createAgentManagementMcpServer( agentId: snapshot.id, item, }), + emitLiveTimelineItem: (item) => + emitLiveTimelineItemIfAgentKnown({ + agentManager, + agentId: snapshot.id, + item, + }), logger: childLogger, }); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index dcd8f06ae..4a1c6e791 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -405,6 +405,66 @@ describe("AgentManager", () => { expect(result.rows[result.rows.length - 1]?.seq).toBe(3); }); + test("emits live timeline updates without recording canonical timeline rows", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-timeline-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const streamEvents: Array<{ + seq?: number; + epoch?: string; + eventType?: string; + itemType?: string; + }> = []; + manager.subscribe( + (event) => { + if (event.type !== "agent_stream") { + return; + } + streamEvents.push({ + seq: event.seq, + epoch: event.epoch, + eventType: event.event.type, + itemType: event.event.type === "timeline" ? event.event.item.type : undefined, + }); + }, + { agentId: snapshot.id, replayState: false } + ); + + await manager.emitLiveTimelineItem(snapshot.id, { + type: "assistant_message", + text: "live-only update", + }); + + expect(streamEvents).toHaveLength(1); + expect(streamEvents[0]).toMatchObject({ + eventType: "timeline", + itemType: "assistant_message", + }); + expect(streamEvents[0]?.seq).toBeUndefined(); + expect(streamEvents[0]?.epoch).toBeUndefined(); + + expect(manager.getTimeline(snapshot.id)).toEqual([]); + const fetched = manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows).toEqual([]); + }); + test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 517144b42..b6849fc3c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -895,6 +895,19 @@ export class AgentManager { await this.persistSnapshot(agent); } + async emitLiveTimelineItem( + agentId: string, + item: AgentTimelineItem + ): Promise { + const agent = this.requireAgent(agentId); + this.touchUpdatedAt(agent); + this.dispatchStream(agentId, { + type: "timeline", + item, + provider: agent.provider, + }); + } + streamAgent( agentId: string, prompt: AgentPromptInput, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index d26ce1cbe..afffb5014 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -134,6 +134,21 @@ export type ToolCallDetail = type: "search"; query: string; } + | { + type: "worktree_setup"; + worktreePath: string; + branchName: string; + log: string; + commands: Array<{ + index: number; + command: string; + cwd: string; + status: "running" | "completed" | "failed"; + exitCode: number | null; + durationMs?: number; + }>; + truncated?: boolean; + } | { type: "unknown"; input: unknown | null; diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index d0a306173..cfa6f664b 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -28,7 +28,10 @@ import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentStorage } from "./agent-storage.js"; -import { appendTimelineItemIfAgentKnown } from "./timeline-append.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "./timeline-append.js"; import { type WorktreeConfig } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; @@ -563,6 +566,12 @@ export async function createAgentMcpServer( agentId: snapshot.id, item, }), + emitLiveTimelineItem: (item) => + emitLiveTimelineItemIfAgentKnown({ + agentManager, + agentId: snapshot.id, + item, + }), logger: childLogger, }); } diff --git a/packages/server/src/server/agent/timeline-append.ts b/packages/server/src/server/agent/timeline-append.ts index f68f6b848..d0b8e3ab9 100644 --- a/packages/server/src/server/agent/timeline-append.ts +++ b/packages/server/src/server/agent/timeline-append.ts @@ -21,3 +21,18 @@ export async function appendTimelineItemIfAgentKnown( throw error; } } + +export async function emitLiveTimelineItemIfAgentKnown( + options: AppendTimelineItemIfAgentKnownOptions +): Promise { + try { + await options.agentManager.emitLiveTimelineItem(options.agentId, options.item); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Unknown agent")) { + return false; + } + throw error; + } +} diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index cd087b4c5..cbd318ba8 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -397,9 +397,10 @@ describe("daemon E2E", () => { ); expect(completed.callId).toBeTruthy(); - expect(completed.detail.type).toBe("unknown"); - if (completed.detail.type === "unknown") { - expect(completed.detail.output).toBeTruthy(); + expect(completed.detail.type).toBe("worktree_setup"); + if (completed.detail.type === "worktree_setup") { + expect(completed.detail.commands.length).toBeGreaterThan(0); + expect(completed.detail.log.length).toBeGreaterThan(0); } expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true); @@ -595,10 +596,12 @@ describe("daemon E2E", () => { expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); - const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined; - const commands = output?.commands as any[] | undefined; - expect(Array.isArray(commands)).toBe(true); - expect(commands?.[0]?.exitCode).toBe(7); + expect(failed.detail.type).toBe("worktree_setup"); + if (failed.detail.type === "worktree_setup") { + expect(Array.isArray(failed.detail.commands)).toBe(true); + expect(failed.detail.commands[0]?.exitCode).toBe(7); + expect(failed.detail.log).toContain("Exit 7"); + } await ctx.client.deleteAgent(agent.id); rmSync(repoRoot, { recursive: true, force: true }); diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index d83293243..c1769bea8 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -97,6 +97,42 @@ const shouldRun = !process.env.CI; 30000 ); + test( + "emits terminals_changed for subscribed cwd when terminals are created", + async () => { + const cwd = tmpCwd(); + await ctx.client.listTerminals(cwd); + + const snapshots: Array<{ cwd: string; names: string[] }> = []; + const unsubscribe = ctx.client.on("terminals_changed", (message) => { + if (message.type !== "terminals_changed") { + return; + } + snapshots.push({ + cwd: message.payload.cwd, + names: message.payload.terminals.map((terminal) => terminal.name), + }); + }); + + ctx.client.subscribeTerminals({ cwd }); + await ctx.client.createTerminal(cwd, "Dev Server"); + + await waitForCondition( + () => + snapshots.some( + (snapshot) => + snapshot.cwd === cwd && snapshot.names.includes("Dev Server") + ), + 10000 + ); + + ctx.client.unsubscribeTerminals({ cwd }); + unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, + 30000 + ); + test( "subscribes to terminal and receives state", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8cf30a548..24a8131c4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -15,6 +15,8 @@ import { type FileDownloadTokenRequest, type GitSetupOptions, type ListTerminalsRequest, + type SubscribeTerminalsRequest, + type UnsubscribeTerminalsRequest, type CreateTerminalRequest, type SubscribeTerminalRequest, type UnsubscribeTerminalRequest, @@ -27,7 +29,10 @@ import { type ProjectCheckoutLitePayload, type ProjectPlacementPayload, } from "./messages.js"; -import type { TerminalManager } from "../terminal/terminal-manager.js"; +import type { + TerminalManager, + TerminalsChangedEvent, +} from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { BinaryMuxChannel, @@ -68,7 +73,10 @@ import type { } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; import { toAgentPayload } from "./agent/agent-projections.js"; -import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "./agent/timeline-append.js"; import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js"; import { StructuredAgentResponseError, @@ -559,6 +567,8 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; + private readonly subscribedTerminalDirectories = new Set(); + private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalSubscriptions: Map void> = new Map(); private terminalExitSubscriptions: Map void> = new Map(); private readonly terminalStreams = new Map< @@ -627,6 +637,11 @@ export class Session { this.agentStorage = agentStorage; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + if (this.terminalManager) { + this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged( + (event) => this.handleTerminalsChanged(event) + ); + } this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; const configuredModelsDir = dictation?.localModels?.modelsDir?.trim(); this.localSpeechModelsDir = @@ -1439,6 +1454,14 @@ export class Session { this.handleRegisterPushToken(msg.token); break; + case "subscribe_terminals_request": + this.handleSubscribeTerminalsRequest(msg); + break; + + case "unsubscribe_terminals_request": + this.handleUnsubscribeTerminalsRequest(msg); + break; + case "list_terminals_request": await this.handleListTerminalsRequest(msg); break; @@ -2417,6 +2440,12 @@ export class Session { agentId: snapshot.id, item, }), + emitLiveTimelineItem: (item) => + emitLiveTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId: snapshot.id, + item, + }), logger: this.sessionLogger, }); } @@ -6097,6 +6126,12 @@ export class Session { this.isVoiceMode = false; // Unsubscribe from all terminals + if (this.unsubscribeTerminalsChanged) { + this.unsubscribeTerminalsChanged(); + this.unsubscribeTerminalsChanged = null; + } + this.subscribedTerminalDirectories.clear(); + for (const unsubscribe of this.terminalSubscriptions.values()) { unsubscribe(); } @@ -6155,6 +6190,81 @@ export class Session { } } + private emitTerminalsChangedSnapshot(input: { + cwd: string; + terminals: Array<{ id: string; name: string }>; + }): void { + this.emit({ + type: "terminals_changed", + payload: { + cwd: input.cwd, + terminals: input.terminals, + }, + }); + } + + private handleTerminalsChanged(event: TerminalsChangedEvent): void { + if (!this.subscribedTerminalDirectories.has(event.cwd)) { + return; + } + + this.emitTerminalsChangedSnapshot({ + cwd: event.cwd, + terminals: event.terminals.map((terminal) => ({ + id: terminal.id, + name: terminal.name, + })), + }); + } + + private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { + this.subscribedTerminalDirectories.add(msg.cwd); + void this.emitInitialTerminalsChangedSnapshot(msg.cwd); + } + + private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { + this.subscribedTerminalDirectories.delete(msg.cwd); + } + + private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise { + if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) { + return; + } + + const hadDirectoryBeforeSubscribe = this.terminalManager + .listDirectories() + .includes(cwd); + + try { + const terminals = await this.terminalManager.getTerminals(cwd); + for (const terminal of terminals) { + this.ensureTerminalExitSubscription(terminal); + } + + // New directories auto-create Terminal 1, which already emits through + // terminal-manager change listeners. + if (!hadDirectoryBeforeSubscribe) { + return; + } + if (!this.subscribedTerminalDirectories.has(cwd)) { + return; + } + + this.emitTerminalsChangedSnapshot({ + cwd, + terminals: terminals.map((terminal) => ({ + id: terminal.id, + name: terminal.name, + })), + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd }, + "Failed to emit initial terminal snapshot" + ); + } + } + private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise { if (!this.terminalManager) { this.emit({ diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts new file mode 100644 index 000000000..6ea51a23e --- /dev/null +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; +import { + createAgentWorktree, + runAsyncWorktreeBootstrap, +} from "./worktree-bootstrap.js"; + +describe("runAsyncWorktreeBootstrap", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); + repoDir = join(tempDir, "repo"); + paseoHome = join(tempDir, "paseo-home"); + + execSync(`mkdir -p ${repoDir}`); + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" }); + execSync("git add .", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("streams running setup updates live and persists only a final setup timeline row", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-streaming-setup", + baseBranch: "main", + worktreeSlug: "feature-streaming-setup", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + const live: AgentTimelineItem[] = []; + + await runAsyncWorktreeBootstrap({ + agentId: "agent-test", + worktree, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async (item: AgentTimelineItem) => { + live.push(item); + return true; + }, + }); + + const liveSetupItems = live.filter( + (item) => + item.type === "tool_call" && + item.name === "paseo_worktree_setup" && + item.status === "running" + ); + expect(liveSetupItems.length).toBeGreaterThan(0); + + const persistedSetupItems = persisted.filter( + (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup" + ); + expect(persistedSetupItems).toHaveLength(1); + expect(persistedSetupItems[0]?.type).toBe("tool_call"); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(persistedSetupItems[0].status).toBe("completed"); + expect(persistedSetupItems[0].detail.type).toBe("worktree_setup"); + + if (persistedSetupItems[0].detail.type === "worktree_setup") { + expect(persistedSetupItems[0].detail.log).toContain( + "==> [1/2] Running: echo \"line-one\"; echo \"line-two\" 1>&2" + ); + expect(persistedSetupItems[0].detail.log).toContain("line-one"); + expect(persistedSetupItems[0].detail.log).toContain("line-two"); + expect(persistedSetupItems[0].detail.log).toContain( + "==> [2/2] Running: echo \"line-three\"" + ); + expect(persistedSetupItems[0].detail.log).toContain("line-three"); + expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/); + expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/); + + expect(persistedSetupItems[0].detail.commands).toHaveLength(2); + expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ + index: 1, + command: 'echo "line-one"; echo "line-two" 1>&2', + status: "completed", + exitCode: 0, + }); + expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ + index: 2, + command: 'echo "line-three"', + status: "completed", + exitCode: 0, + }); + expect( + typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number" + ).toBe(true); + expect( + typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number" + ).toBe(true); + } + } + + const liveCallIds = new Set( + liveSetupItems + .filter((item): item is Extract => item.type === "tool_call") + .map((item) => item.callId) + ); + expect(liveCallIds.size).toBe(1); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true); + } + }); + + it("does not fail setup when live timeline emission throws", async () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ['echo "ok"'], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-live-failure", + baseBranch: "main", + worktreeSlug: "feature-live-failure", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + await expect( + runAsyncWorktreeBootstrap({ + agentId: "agent-live-failure", + worktree, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => { + throw new Error("live emit failed"); + }, + }) + ).resolves.toBeUndefined(); + + const persistedSetupItems = persisted.filter( + (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup" + ); + expect(persistedSetupItems).toHaveLength(1); + if (persistedSetupItems[0]?.type === "tool_call") { + expect(persistedSetupItems[0].status).toBe("completed"); + } + }); + + it("truncates each command output to 64kb in the middle", async () => { + const largeOutputCommand = + "node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\""; + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [largeOutputCommand], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", { + cwd: repoDir, + stdio: "pipe", + }); + + const worktree = await createAgentWorktree({ + cwd: repoDir, + branchName: "feature-large-output", + baseBranch: "main", + worktreeSlug: "feature-large-output", + paseoHome, + }); + + const persisted: AgentTimelineItem[] = []; + await runAsyncWorktreeBootstrap({ + agentId: "agent-large-output", + worktree, + terminalManager: null, + appendTimelineItem: async (item) => { + persisted.push(item); + return true; + }, + emitLiveTimelineItem: async () => true, + }); + + const persistedSetupItem = persisted.find( + (item): item is Extract => + item.type === "tool_call" && item.name === "paseo_worktree_setup" + ); + expect(persistedSetupItem).toBeDefined(); + expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); + if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { + throw new Error("Expected worktree_setup tool detail"); + } + + expect(persistedSetupItem.detail.truncated).toBe(true); + expect(persistedSetupItem.detail.log).toContain("prefix-"); + expect(persistedSetupItem.detail.log).toContain("-suffix"); + expect(persistedSetupItem.detail.log).toContain("......"); + }); +}); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 873ebc1ed..6fd5e21b3 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -24,6 +24,7 @@ export interface RunAsyncWorktreeBootstrapOptions { worktree: WorktreeConfig; terminalManager: TerminalManager | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; + emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; logger?: Logger; } @@ -35,6 +36,116 @@ export interface CreateAgentWorktreeOptions { paseoHome?: string; } +const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; +const WORKTREE_SETUP_TRUNCATION_MARKER = "\n......\n"; + +type MiddleTruncationAccumulator = { + totalBytes: number; + head: string; + tail: string; + truncated: boolean; +}; + +function byteLength(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +function sliceFirstBytes(text: string, maxBytes: number): string { + if (maxBytes <= 0 || text.length === 0) { + return ""; + } + const bytes = Buffer.from(text, "utf8"); + if (bytes.length <= maxBytes) { + return text; + } + return bytes.subarray(0, maxBytes).toString("utf8"); +} + +function sliceLastBytes(text: string, maxBytes: number): string { + if (maxBytes <= 0 || text.length === 0) { + return ""; + } + const bytes = Buffer.from(text, "utf8"); + if (bytes.length <= maxBytes) { + return text; + } + return bytes.subarray(bytes.length - maxBytes).toString("utf8"); +} + +function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator { + return { + totalBytes: 0, + head: "", + tail: "", + truncated: false, + }; +} + +function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: number } { + const markerBytes = byteLength(WORKTREE_SETUP_TRUNCATION_MARKER); + const availableBytes = Math.max(0, maxBytes - markerBytes); + const headBytes = Math.floor(availableBytes / 2); + const tailBytes = availableBytes - headBytes; + return { headBytes, tailBytes }; +} + +function appendToMiddleTruncationAccumulator( + accumulator: MiddleTruncationAccumulator, + chunk: string +): void { + if (!chunk) { + return; + } + accumulator.totalBytes += byteLength(chunk); + + if (!accumulator.truncated) { + const combined = `${accumulator.head}${chunk}`; + if (byteLength(combined) <= MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES) { + accumulator.head = combined; + return; + } + const { headBytes, tailBytes } = getHeadTailBudgets( + MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES + ); + accumulator.head = sliceFirstBytes(combined, headBytes); + accumulator.tail = sliceLastBytes(combined, tailBytes); + accumulator.truncated = true; + return; + } + + const { tailBytes } = getHeadTailBudgets(MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES); + accumulator.tail = sliceLastBytes(`${accumulator.tail}${chunk}`, tailBytes); +} + +function truncateTextInMiddle( + text: string, + maxBytes: number +): { text: string; truncated: boolean } { + if (maxBytes <= 0 || !text) { + return { text: "", truncated: text.length > 0 }; + } + if (byteLength(text) <= maxBytes) { + return { text, truncated: false }; + } + const { headBytes, tailBytes } = getHeadTailBudgets(maxBytes); + return { + text: `${sliceFirstBytes(text, headBytes)}${WORKTREE_SETUP_TRUNCATION_MARKER}${sliceLastBytes(text, tailBytes)}`, + truncated: true, + }; +} + +function renderMiddleTruncationAccumulator( + accumulator: MiddleTruncationAccumulator +): { text: string; truncated: boolean } { + if (!accumulator.truncated) { + return { text: accumulator.head, truncated: false }; + } + return { + text: `${accumulator.head}${WORKTREE_SETUP_TRUNCATION_MARKER}${accumulator.tail}`, + truncated: true, + }; +} + export async function createAgentWorktree( options: CreateAgentWorktreeOptions ): Promise { @@ -48,25 +159,88 @@ export async function createAgentWorktree( }); } +function formatDurationMs(durationMs: number): string { + return `${(durationMs / 1000).toFixed(2)}s`; +} + +function commandStatusFromResult( + result: WorktreeSetupCommandResult +): "running" | "completed" | "failed" { + if (result.exitCode === null) { + return "running"; + } + return result.exitCode === 0 ? "completed" : "failed"; +} + +function buildWorktreeSetupLog(input: { + results: WorktreeSetupCommandResult[]; + outputAccumulatorsByIndex?: Map; +}): { log: string; truncated: boolean } { + const { results, outputAccumulatorsByIndex } = input; + if (results.length === 0) { + return { + log: "", + truncated: false, + }; + } + + const lines: string[] = []; + let anyTruncated = false; + const total = results.length; + for (const [index, result] of results.entries()) { + lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`); + const accumulator = outputAccumulatorsByIndex?.get(index + 1); + const output = accumulator + ? renderMiddleTruncationAccumulator(accumulator) + : truncateTextInMiddle( + `${result.stdout ?? ""}${result.stderr ?? ""}`, + MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES + ); + if (output.text.length > 0) { + lines.push(output.text.replace(/\n$/, "")); + } + if (output.truncated) { + anyTruncated = true; + } + if (result.exitCode !== null) { + lines.push( + `<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}` + ); + } + } + return { + log: lines.join("\n"), + truncated: anyTruncated, + }; +} + function buildSetupTimelineItem(input: { callId: string; status: "running" | "completed" | "failed"; worktree: WorktreeConfig; results: WorktreeSetupCommandResult[]; + outputAccumulatorsByIndex?: Map; errorMessage: string | null; }): AgentTimelineItem { - const detailInput = { + const commands = input.results.map((result, index) => ({ + index: index + 1, + command: result.command, + cwd: result.cwd, + status: commandStatusFromResult(result), + exitCode: result.exitCode, + ...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), + })); + const renderedLog = buildWorktreeSetupLog({ + results: input.results, + outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, + }); + const detail = { + type: "worktree_setup" as const, worktreePath: input.worktree.worktreePath, branchName: input.worktree.branchName, - }; - const detailOutput = { - worktreePath: input.worktree.worktreePath, - commands: input.results.map((result) => ({ - command: result.command, - cwd: result.cwd, - exitCode: result.exitCode, - output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(), - })), + log: renderedLog.log, + commands, + ...(renderedLog.truncated ? { truncated: true } : {}), }; if (input.status === "running") { @@ -75,11 +249,7 @@ function buildSetupTimelineItem(input: { name: "paseo_worktree_setup", callId: input.callId, status: "running", - detail: { - type: "unknown", - input: detailInput, - output: null, - }, + detail, error: null, }; } @@ -90,11 +260,7 @@ function buildSetupTimelineItem(input: { name: "paseo_worktree_setup", callId: input.callId, status: "completed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, + detail, error: null, }; } @@ -104,11 +270,7 @@ function buildSetupTimelineItem(input: { name: "paseo_worktree_setup", callId: input.callId, status: "failed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, + detail, error: { message: input.errorMessage ?? "Worktree setup failed" }, }; } @@ -258,40 +420,105 @@ export async function runAsyncWorktreeBootstrap( ): Promise { const setupCallId = uuidv4(); let setupResults: WorktreeSetupCommandResult[] = []; + const emitLiveTimelineItem = options.emitLiveTimelineItem; + const runningResultsByIndex = new Map(); + const outputAccumulatorsByIndex = new Map(); + let liveEmitQueue = Promise.resolve(); - try { - const started = await options.appendTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "running", - worktree: options.worktree, - results: [], - errorMessage: null, - }) - ); - if (!started) { + const queueLiveRunningEmit = () => { + if (!emitLiveTimelineItem) { return; } + const runningResults = Array.from(runningResultsByIndex.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, result]) => result); + liveEmitQueue = liveEmitQueue.then(async () => { + try { + await emitLiveTimelineItem( + buildSetupTimelineItem({ + callId: setupCallId, + status: "running", + worktree: options.worktree, + results: runningResults, + outputAccumulatorsByIndex, + errorMessage: null, + }) + ); + } catch (error) { + options.logger?.warn( + { err: error, agentId: options.agentId }, + "Failed to emit live worktree setup timeline update" + ); + } + }); + }; + try { setupResults = await runWorktreeSetupCommands({ worktreePath: options.worktree.worktreePath, branchName: options.worktree.branchName, cleanupOnFailure: false, + onEvent: (event) => { + const existing = runningResultsByIndex.get(event.index); + const baseResult: WorktreeSetupCommandResult = existing ?? { + command: event.command, + cwd: event.cwd, + stdout: "", + stderr: "", + exitCode: null, + durationMs: 0, + }; + if (event.type === "output") { + const outputAccumulator = + outputAccumulatorsByIndex.get(event.index) ?? + createMiddleTruncationAccumulator(); + appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk); + outputAccumulatorsByIndex.set(event.index, outputAccumulator); + runningResultsByIndex.set(event.index, { + ...baseResult, + // Keep the timeline command model lightweight; output is carried in + // outputAccumulatorsByIndex. + stdout: baseResult.stdout, + stderr: baseResult.stderr, + }); + queueLiveRunningEmit(); + return; + } + if (event.type === "command_completed") { + runningResultsByIndex.set(event.index, { + ...baseResult, + stdout: event.stdout, + stderr: event.stderr, + exitCode: event.exitCode, + durationMs: event.durationMs, + }); + queueLiveRunningEmit(); + return; + } + runningResultsByIndex.set(event.index, baseResult); + queueLiveRunningEmit(); + }, }); + await liveEmitQueue; - await options.appendTimelineItem( + const completed = await options.appendTimelineItem( buildSetupTimelineItem({ callId: setupCallId, status: "completed", worktree: options.worktree, results: setupResults, + outputAccumulatorsByIndex, errorMessage: null, }) ); + if (!completed) { + return; + } } catch (error) { if (error instanceof WorktreeSetupError) { setupResults = error.results; } + await liveEmitQueue; const message = error instanceof Error ? error.message : String(error); await options.appendTimelineItem( buildSetupTimelineItem({ @@ -299,6 +526,7 @@ export async function runAsyncWorktreeBootstrap( status: "failed", worktree: options.worktree, results: setupResults, + outputAccumulatorsByIndex, errorMessage: message, }) ); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index fd3ae1c41..a92e11635 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -188,6 +188,23 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn type: z.literal("search"), query: z.string(), }), + z.object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array( + z.object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }) + ), + truncated: z.boolean().optional(), + }), z.object({ type: z.literal("unknown"), input: UnknownValueSchema, @@ -948,6 +965,16 @@ export const ListTerminalsRequestSchema = z.object({ requestId: z.string(), }); +export const SubscribeTerminalsRequestSchema = z.object({ + type: z.literal("subscribe_terminals_request"), + cwd: z.string(), +}); + +export const UnsubscribeTerminalsRequestSchema = z.object({ + type: z.literal("unsubscribe_terminals_request"), + cwd: z.string(), +}); + export const CreateTerminalRequestSchema = z.object({ type: z.literal("create_terminal_request"), cwd: z.string(), @@ -1060,6 +1087,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ExecuteCommandRequestSchema, RegisterPushTokenMessageSchema, ListTerminalsRequestSchema, + SubscribeTerminalsRequestSchema, + UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, @@ -1885,6 +1914,14 @@ export const ListTerminalsResponseSchema = z.object({ }), }); +export const TerminalsChangedSchema = z.object({ + type: z.literal("terminals_changed"), + payload: z.object({ + cwd: z.string(), + terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + }), +}); + export const CreateTerminalResponseSchema = z.object({ type: z.literal("create_terminal_response"), payload: z.object({ @@ -2006,6 +2043,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListCommandsResponseSchema, ExecuteCommandResponseSchema, ListTerminalsResponseSchema, + TerminalsChangedSchema, CreateTerminalResponseSchema, SubscribeTerminalResponseSchema, TerminalOutputSchema, @@ -2149,6 +2187,9 @@ export type RegisterPushTokenMessage = z.infer; export type ListTerminalsResponse = z.infer; +export type SubscribeTerminalsRequest = z.infer; +export type UnsubscribeTerminalsRequest = z.infer; +export type TerminalsChanged = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; export type SubscribeTerminalRequest = z.infer; diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index 22c909f7d..3b99051bf 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -59,6 +59,34 @@ describe("shared tool-call display mapping", () => { }); }); + it("builds display model for worktree setup detail", () => { + const display = buildToolCallDisplayModel({ + name: "paseo_worktree_setup", + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch", + branchName: "feature-branch", + log: "==> [1/1] Running: npm install\n", + commands: [ + { + index: 1, + command: "npm install", + cwd: "/tmp/repo/.paseo/worktrees/repo/branch", + status: "running", + exitCode: null, + }, + ], + }, + }); + + expect(display).toEqual({ + displayName: "Worktree Setup", + summary: "feature-branch", + }); + }); + it("provides errorText for failed calls", () => { const display = buildToolCallDisplayModel({ name: "shell", diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index e54bf40a3..b6eb12233 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -83,6 +83,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall displayName = "Search"; summary = input.detail.query; break; + case "worktree_setup": + displayName = "Worktree Setup"; + summary = input.detail.branchName; + break; case "unknown": break; } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 5d6933571..b52e687a9 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -201,4 +201,52 @@ describe("TerminalManager", () => { expect(manager.getTerminal(homeId)).toBeUndefined(); }); }); + + describe("subscribeTerminalsChanged", () => { + it("emits cwd snapshots when terminals are created", async () => { + manager = createTerminalManager(); + const snapshots: Array<{ cwd: string; terminalNames: string[] }> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push({ + cwd: input.cwd, + terminalNames: input.terminals.map((terminal) => terminal.name), + }); + }); + + await manager.getTerminals("/tmp"); + await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); + + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalNames: ["Terminal 1"], + }); + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalNames: ["Terminal 1", "Dev Server"], + }); + + unsubscribe(); + }); + + it("emits empty snapshot when last terminal is removed", async () => { + manager = createTerminalManager(); + const snapshots: Array<{ cwd: string; terminalCount: number }> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push({ + cwd: input.cwd, + terminalCount: input.terminals.length, + }); + }); + + const terminals = await manager.getTerminals("/tmp"); + manager.killTerminal(terminals[0].id); + + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalCount: 0, + }); + + unsubscribe(); + }); + }); }); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 970557eb1..d47e0e345 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,5 +1,18 @@ import { createTerminal, type TerminalSession } from "./terminal.js"; +export interface TerminalListItem { + id: string; + name: string; + cwd: string; +} + +export interface TerminalsChangedEvent { + cwd: string; + terminals: TerminalListItem[]; +} + +export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void; + export interface TerminalManager { getTerminals(cwd: string): Promise; createTerminal(options: { cwd: string; name?: string }): Promise; @@ -7,12 +20,14 @@ export interface TerminalManager { killTerminal(id: string): void; listDirectories(): string[]; killAll(): void; + subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; } export function createTerminalManager(): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); + const terminalsChangedListeners = new Set(); function assertAbsolutePath(cwd: string): void { if (!cwd.startsWith("/")) { @@ -48,6 +63,8 @@ export function createTerminalManager(): TerminalManager { if (options.kill) { session.kill(); } + + emitTerminalsChanged({ cwd: session.cwd }); } function registerSession(session: TerminalSession): TerminalSession { @@ -59,6 +76,36 @@ export function createTerminalManager(): TerminalManager { return session; } + function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem { + return { + id: input.session.id, + name: input.session.name, + cwd: input.session.cwd, + }; + } + + function emitTerminalsChanged(input: { cwd: string }): void { + if (terminalsChangedListeners.size === 0) { + return; + } + + const terminals = (terminalsByCwd.get(input.cwd) ?? []).map((session) => + toTerminalListItem({ session }) + ); + const event: TerminalsChangedEvent = { + cwd: input.cwd, + terminals, + }; + + for (const listener of terminalsChangedListeners) { + try { + listener(event); + } catch { + // no-op + } + } + } + return { async getTerminals(cwd: string): Promise { assertAbsolutePath(cwd); @@ -70,6 +117,7 @@ export function createTerminalManager(): TerminalManager { ); terminals = [session]; terminalsByCwd.set(cwd, terminals); + emitTerminalsChanged({ cwd }); } return terminals; }, @@ -88,6 +136,7 @@ export function createTerminalManager(): TerminalManager { terminals.push(session); terminalsByCwd.set(options.cwd, terminals); + emitTerminalsChanged({ cwd: options.cwd }); return session; }, @@ -109,5 +158,12 @@ export function createTerminalManager(): TerminalManager { removeSessionById(id, { kill: true }); } }, + + subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void { + terminalsChangedListeners.add(listener); + return () => { + terminalsChangedListeners.delete(listener); + }; + }, }; } diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 9c62c09b6..474a9d89a 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,6 +5,8 @@ import { getWorktreeTerminalSpecs, isPaseoOwnedWorktreeCwd, listPaseoWorktrees, + type WorktreeSetupCommandProgressEvent, + runWorktreeSetupCommands, slugify, } from "./worktree"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; @@ -233,6 +235,36 @@ describe("createWorktree", () => { expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false); }); + it("streams setup command progress events while commands are executing", async () => { + const paseoConfig = { + worktree: { + setup: [ + 'echo "first line"; echo "second line" 1>&2', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync( + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'", + { cwd: repoDir } + ); + + const progressEvents: WorktreeSetupCommandProgressEvent[] = []; + const results = await runWorktreeSetupCommands({ + worktreePath: repoDir, + branchName: "main", + cleanupOnFailure: false, + onEvent: (event) => { + progressEvents.push(event); + }, + }); + + expect(results).toHaveLength(1); + expect(progressEvents.some((event) => event.type === "command_started")).toBe(true); + expect(progressEvents.some((event) => event.type === "output")).toBe(true); + expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true); + }); + it("cleans up worktree if setup command fails", async () => { // Create paseo.json with failing setup command const paseoConfig = { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index e87c41a26..6bcaa1c77 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { exec, spawn } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; @@ -32,8 +32,38 @@ export type WorktreeSetupCommandResult = { stdout: string; stderr: string; exitCode: number | null; + durationMs: number; }; +export type WorktreeSetupCommandProgressEvent = + | { + type: "command_started"; + index: number; + total: number; + command: string; + cwd: string; + } + | { + type: "output"; + index: number; + total: number; + command: string; + cwd: string; + stream: "stdout" | "stderr"; + chunk: string; + } + | { + type: "command_completed"; + index: number; + total: number; + command: string; + cwd: string; + exitCode: number | null; + durationMs: number; + stdout: string; + stderr: string; + }; + export interface WorktreeTerminalConfig { name?: string; command: string; @@ -153,6 +183,7 @@ async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv } ): Promise { + const startedAt = Date.now(); try { const { stdout, stderr } = await execAsync(command, { cwd: options.cwd, @@ -165,6 +196,7 @@ async function execSetupCommand( stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0, + durationMs: Date.now() - startedAt, }; } catch (error: any) { return { @@ -175,10 +207,105 @@ async function execSetupCommand( error?.stderr ?? (error instanceof Error ? error.message : String(error)), exitCode: typeof error?.code === "number" ? error.code : null, + durationMs: Date.now() - startedAt, }; } } +async function execSetupCommandStreamed(options: { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + index: number; + total: number; + onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; +}): Promise { + return new Promise((resolve) => { + const startedAt = Date.now(); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + let settled = false; + + const finish = (exitCode: number | null) => { + if (settled) { + return; + } + settled = true; + const result: WorktreeSetupCommandResult = { + command: options.command, + cwd: options.cwd, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + exitCode, + durationMs: Date.now() - startedAt, + }; + options.onEvent?.({ + type: "command_completed", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + exitCode: result.exitCode, + durationMs: result.durationMs, + stdout: result.stdout, + stderr: result.stderr, + }); + resolve(result); + }; + + options.onEvent?.({ + type: "command_started", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + }); + + const child = spawn("/bin/bash", ["-lc", options.command], { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stdoutChunks.push(text); + options.onEvent?.({ + type: "output", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + stream: "stdout", + chunk: text, + }); + }); + + child.stderr?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stderrChunks.push(text); + options.onEvent?.({ + type: "output", + index: options.index, + total: options.total, + command: options.command, + cwd: options.cwd, + stream: "stderr", + chunk: text, + }); + }); + + child.on("error", (error) => { + stderrChunks.push(error instanceof Error ? error.message : String(error)); + finish(null); + }); + + child.on("close", (code) => { + finish(typeof code === "number" ? code : null); + }); + }); +} + async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -233,6 +360,7 @@ export async function runWorktreeSetupCommands(options: { branchName: string; cleanupOnFailure: boolean; repoRootPath?: string; + onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) const setupCommands = getWorktreeSetupCommands(options.worktreePath); @@ -258,11 +386,20 @@ export async function runWorktreeSetupCommands(options: { }; const results: WorktreeSetupCommandResult[] = []; - for (const cmd of setupCommands) { - const result = await execSetupCommand(cmd, { - cwd: options.worktreePath, - env: setupEnv, - }); + for (const [index, cmd] of setupCommands.entries()) { + const result = options.onEvent + ? await execSetupCommandStreamed({ + command: cmd, + cwd: options.worktreePath, + env: setupEnv, + index: index + 1, + total: setupCommands.length, + onEvent: options.onEvent, + }) + : await execSetupCommand(cmd, { + cwd: options.worktreePath, + env: setupEnv, + }); results.push(result); if (result.exitCode !== 0) {