feat: stream worktree setup progress and terminal subscriptions

This commit is contained in:
Mohamed Boudra
2026-02-16 08:09:27 +07:00
parent b980abb7af
commit 037a6a15a4
29 changed files with 1449 additions and 102 deletions

View File

@@ -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 (

View File

@@ -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<ViewStyle>;
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 (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
/>
</View>
);
}
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 (
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={16} color={menuIconColor} />
)}
</HeaderToggleButton>
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
return (
<ScreenHeader
left={
<>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{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,
},
}));

View File

@@ -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

View File

@@ -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",

View File

@@ -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) {

View File

@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "worktree_setup") {
const setupLog = detail.log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
sections.push(
<View
key="worktree-setup"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog
? setupLog
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View

View File

@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { Folder, GitBranch } from "lucide-react-native";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
@@ -31,7 +31,6 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { shortenPath } from "@/utils/shorten-path";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -125,9 +124,6 @@ export function DraftAgentScreen({
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const params = useLocalSearchParams<DraftAgentParams>();
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,
]}
>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isSidebarOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isSidebarOpen }}
>
<SidebarIcon size={isMobile ? 20 : 16} color={sidebarIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
</View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>

View File

@@ -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",

View File

@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
edit: Pencil,
write: Pencil,
search: Search,
worktree_setup: SquareTerminal,
unknown: Wrench,
};

View File

@@ -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"],
},
]);
});
});

View File

@@ -356,6 +356,7 @@ export class DaemonClient {
string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
>();
private terminalDirectorySubscriptions = new Set<string>();
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

View File

@@ -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,
});
}

View File

@@ -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");

View File

@@ -895,6 +895,19 @@ export class AgentManager {
await this.persistSnapshot(agent);
}
async emitLiveTimelineItem(
agentId: string,
item: AgentTimelineItem
): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
});
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -21,3 +21,18 @@ export async function appendTimelineItemIfAgentKnown(
throw error;
}
}
export async function emitLiveTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
): Promise<boolean> {
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;
}
}

View File

@@ -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 });

View File

@@ -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 () => {

View File

@@ -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<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalSubscriptions: Map<string, () => void> = new Map();
private terminalExitSubscriptions: Map<string, () => 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<void> {
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<void> {
if (!this.terminalManager) {
this.emit({

View File

@@ -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<AgentTimelineItem, { type: "tool_call" }> => 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<AgentTimelineItem, { type: "tool_call" }> =>
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("...<output truncated in the middle>...");
});
});

View File

@@ -24,6 +24,7 @@ export interface RunAsyncWorktreeBootstrapOptions {
worktree: WorktreeConfig;
terminalManager: TerminalManager | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
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...<output truncated in the middle>...\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<WorktreeConfig> {
@@ -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<number, MiddleTruncationAccumulator>;
}): { 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<number, MiddleTruncationAccumulator>;
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<void> {
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
const emitLiveTimelineItem = options.emitLiveTimelineItem;
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
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,
})
);

View File

@@ -188,6 +188,23 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = 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<typeof RegisterPushTokenMessageSc
// Terminal message types
export type ListTerminalsRequest = z.infer<typeof ListTerminalsRequestSchema>;
export type ListTerminalsResponse = z.infer<typeof ListTerminalsResponseSchema>;
export type SubscribeTerminalsRequest = z.infer<typeof SubscribeTerminalsRequestSchema>;
export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsRequestSchema>;
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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();
});
});
});

View File

@@ -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<TerminalSession[]>;
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
@@ -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<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
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<TerminalSession[]> {
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);
};
},
};
}

View File

@@ -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 = {

View File

@@ -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<WorktreeSetupCommandResult> {
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<WorktreeSetupCommandResult> {
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<number> {
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<WorktreeSetupCommandResult[]> {
// 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) {