feat: stream worktree setup progress and terminal subscriptions
This commit is contained in:
@@ -11,7 +11,11 @@ interface BackHeaderProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
export function BackHeader({
|
||||||
|
title,
|
||||||
|
rightContent,
|
||||||
|
onBack,
|
||||||
|
}: BackHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
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 { 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 { ScreenHeader } from "./screen-header";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -12,7 +12,39 @@ interface MenuHeaderProps {
|
|||||||
rightContent?: ReactNode;
|
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 { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
@@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
|||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
|
||||||
const menuIconColor = !isMobile && isOpen
|
const menuIconColor = !isMobile && isOpen
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
: theme.colors.foregroundMuted;
|
: 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 (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<HeaderToggleButton
|
<SidebarMenuToggle />
|
||||||
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>
|
|
||||||
{title && (
|
{title && (
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
@@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
color: theme.colors.foreground,
|
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,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
|
|||||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||||
* and safe-area logic in one place.
|
* 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 insets = useSafeAreaInsets();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
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
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
|
|||||||
@@ -1089,8 +1089,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 0.78,
|
opacity: 0.78,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 1,
|
flexShrink: 0,
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
agentMetaBranchBadge: {
|
agentMetaBranchBadge: {
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
@@ -1098,7 +1097,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
paddingVertical: 1,
|
paddingVertical: 1,
|
||||||
flexShrink: 0,
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
agentMetaBranchBadgeSelected: {
|
agentMetaBranchBadgeSelected: {
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
@@ -1107,6 +1107,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 0.76,
|
opacity: 0.76,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
selectionBar: {
|
selectionBar: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -290,6 +290,35 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
});
|
});
|
||||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
}, [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({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|||||||
@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
|
|||||||
</View>
|
</View>
|
||||||
</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") {
|
} else if (detail?.type === "edit") {
|
||||||
sections.push(
|
sections.push(
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
|
import { Folder, GitBranch } from "lucide-react-native";
|
||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
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 { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
@@ -125,9 +124,6 @@ export function DraftAgentScreen({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const { daemons } = useDaemonRegistry();
|
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 params = useLocalSearchParams<DraftAgentParams>();
|
||||||
|
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -226,11 +222,6 @@ export function DraftAgentScreen({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
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 [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||||
const [baseBranch, setBaseBranch] = useState("");
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
@@ -977,20 +968,7 @@ export function DraftAgentScreen({
|
|||||||
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
|
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<HeaderToggleButton
|
<SidebarMenuToggle />
|
||||||
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>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
||||||
|
|||||||
@@ -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", () => {
|
it("does not derive command summary from unknown raw detail", () => {
|
||||||
const display = buildToolCallDisplayModel({
|
const display = buildToolCallDisplayModel({
|
||||||
name: "exec_command",
|
name: "exec_command",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
|||||||
edit: Pencil,
|
edit: Pencil,
|
||||||
write: Pencil,
|
write: Pencil,
|
||||||
search: Search,
|
search: Search,
|
||||||
|
worktree_setup: SquareTerminal,
|
||||||
unknown: Wrench,
|
unknown: Wrench,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1236,4 +1236,89 @@ describe("DaemonClient", () => {
|
|||||||
expect(received).toHaveLength(0);
|
expect(received).toHaveLength(0);
|
||||||
expect(logger.warn).toHaveBeenCalled();
|
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"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export class DaemonClient {
|
|||||||
string,
|
string,
|
||||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||||
>();
|
>();
|
||||||
|
private terminalDirectorySubscriptions = new Set<string>();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private pendingSendQueue: PendingSend[] = [];
|
private pendingSendQueue: PendingSend[] = [];
|
||||||
private relayClientId: string | null = null;
|
private relayClientId: string | null = null;
|
||||||
@@ -474,6 +475,7 @@ export class DaemonClient {
|
|||||||
this.updateConnectionState({ status: "connected" });
|
this.updateConnectionState({ status: "connected" });
|
||||||
this.resubscribeAgentUpdates();
|
this.resubscribeAgentUpdates();
|
||||||
this.resubscribeCheckoutDiffSubscriptions();
|
this.resubscribeCheckoutDiffSubscriptions();
|
||||||
|
this.resubscribeTerminalDirectorySubscriptions();
|
||||||
this.flushPendingSendQueue();
|
this.flushPendingSendQueue();
|
||||||
this.resolveConnect();
|
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
|
// Agent Lifecycle
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2314,6 +2328,28 @@ export class DaemonClient {
|
|||||||
// Terminals
|
// 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(
|
async listTerminals(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ import { toAgentPayload } from "./agent-projections.js";
|
|||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.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 { type WorktreeConfig } from "../../utils/worktree.js";
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
@@ -357,6 +360,12 @@ export async function createAgentManagementMcpServer(
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
emitLiveTimelineItem: (item) =>
|
||||||
|
emitLiveTimelineItemIfAgentKnown({
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,6 +405,66 @@ describe("AgentManager", () => {
|
|||||||
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
|
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 () => {
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -895,6 +895,19 @@ export class AgentManager {
|
|||||||
await this.persistSnapshot(agent);
|
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(
|
streamAgent(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
|
|||||||
@@ -134,6 +134,21 @@ export type ToolCallDetail =
|
|||||||
type: "search";
|
type: "search";
|
||||||
query: string;
|
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";
|
type: "unknown";
|
||||||
input: unknown | null;
|
input: unknown | null;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import { toAgentPayload } from "./agent-projections.js";
|
|||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.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 { type WorktreeConfig } from "../../utils/worktree.js";
|
||||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
@@ -563,6 +566,12 @@ export async function createAgentMcpServer(
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
emitLiveTimelineItem: (item) =>
|
||||||
|
emitLiveTimelineItemIfAgentKnown({
|
||||||
|
agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ export async function appendTimelineItemIfAgentKnown(
|
|||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -397,9 +397,10 @@ describe("daemon E2E", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(completed.callId).toBeTruthy();
|
expect(completed.callId).toBeTruthy();
|
||||||
expect(completed.detail.type).toBe("unknown");
|
expect(completed.detail.type).toBe("worktree_setup");
|
||||||
if (completed.detail.type === "unknown") {
|
if (completed.detail.type === "worktree_setup") {
|
||||||
expect(completed.detail.output).toBeTruthy();
|
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);
|
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, "setup-start.txt"))).toBe(true);
|
||||||
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
|
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
|
||||||
|
|
||||||
const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
|
expect(failed.detail.type).toBe("worktree_setup");
|
||||||
const commands = output?.commands as any[] | undefined;
|
if (failed.detail.type === "worktree_setup") {
|
||||||
expect(Array.isArray(commands)).toBe(true);
|
expect(Array.isArray(failed.detail.commands)).toBe(true);
|
||||||
expect(commands?.[0]?.exitCode).toBe(7);
|
expect(failed.detail.commands[0]?.exitCode).toBe(7);
|
||||||
|
expect(failed.detail.log).toContain("Exit 7");
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.client.deleteAgent(agent.id);
|
await ctx.client.deleteAgent(agent.id);
|
||||||
rmSync(repoRoot, { recursive: true, force: true });
|
rmSync(repoRoot, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -97,6 +97,42 @@ const shouldRun = !process.env.CI;
|
|||||||
30000
|
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(
|
test(
|
||||||
"subscribes to terminal and receives state",
|
"subscribes to terminal and receives state",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
type FileDownloadTokenRequest,
|
type FileDownloadTokenRequest,
|
||||||
type GitSetupOptions,
|
type GitSetupOptions,
|
||||||
type ListTerminalsRequest,
|
type ListTerminalsRequest,
|
||||||
|
type SubscribeTerminalsRequest,
|
||||||
|
type UnsubscribeTerminalsRequest,
|
||||||
type CreateTerminalRequest,
|
type CreateTerminalRequest,
|
||||||
type SubscribeTerminalRequest,
|
type SubscribeTerminalRequest,
|
||||||
type UnsubscribeTerminalRequest,
|
type UnsubscribeTerminalRequest,
|
||||||
@@ -27,7 +29,10 @@ import {
|
|||||||
type ProjectCheckoutLitePayload,
|
type ProjectCheckoutLitePayload,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
} from "./messages.js";
|
} 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 type { TerminalSession } from "../terminal/terminal.js";
|
||||||
import {
|
import {
|
||||||
BinaryMuxChannel,
|
BinaryMuxChannel,
|
||||||
@@ -68,7 +73,10 @@ import type {
|
|||||||
} from "./agent/agent-manager.js";
|
} from "./agent/agent-manager.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||||
import { toAgentPayload } from "./agent/agent-projections.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 { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||||
import {
|
import {
|
||||||
StructuredAgentResponseError,
|
StructuredAgentResponseError,
|
||||||
@@ -559,6 +567,8 @@ export class Session {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
|
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||||
|
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
private terminalSubscriptions: Map<string, () => void> = new Map();
|
||||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||||
private readonly terminalStreams = new Map<
|
private readonly terminalStreams = new Map<
|
||||||
@@ -627,6 +637,11 @@ export class Session {
|
|||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
|
if (this.terminalManager) {
|
||||||
|
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged(
|
||||||
|
(event) => this.handleTerminalsChanged(event)
|
||||||
|
);
|
||||||
|
}
|
||||||
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
||||||
const configuredModelsDir = dictation?.localModels?.modelsDir?.trim();
|
const configuredModelsDir = dictation?.localModels?.modelsDir?.trim();
|
||||||
this.localSpeechModelsDir =
|
this.localSpeechModelsDir =
|
||||||
@@ -1439,6 +1454,14 @@ export class Session {
|
|||||||
this.handleRegisterPushToken(msg.token);
|
this.handleRegisterPushToken(msg.token);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "subscribe_terminals_request":
|
||||||
|
this.handleSubscribeTerminalsRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unsubscribe_terminals_request":
|
||||||
|
this.handleUnsubscribeTerminalsRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "list_terminals_request":
|
case "list_terminals_request":
|
||||||
await this.handleListTerminalsRequest(msg);
|
await this.handleListTerminalsRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -2417,6 +2440,12 @@ export class Session {
|
|||||||
agentId: snapshot.id,
|
agentId: snapshot.id,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
|
emitLiveTimelineItem: (item) =>
|
||||||
|
emitLiveTimelineItemIfAgentKnown({
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentId: snapshot.id,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6097,6 +6126,12 @@ export class Session {
|
|||||||
this.isVoiceMode = false;
|
this.isVoiceMode = false;
|
||||||
|
|
||||||
// Unsubscribe from all terminals
|
// Unsubscribe from all terminals
|
||||||
|
if (this.unsubscribeTerminalsChanged) {
|
||||||
|
this.unsubscribeTerminalsChanged();
|
||||||
|
this.unsubscribeTerminalsChanged = null;
|
||||||
|
}
|
||||||
|
this.subscribedTerminalDirectories.clear();
|
||||||
|
|
||||||
for (const unsubscribe of this.terminalSubscriptions.values()) {
|
for (const unsubscribe of this.terminalSubscriptions.values()) {
|
||||||
unsubscribe();
|
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> {
|
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
|
||||||
if (!this.terminalManager) {
|
if (!this.terminalManager) {
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
@@ -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>...");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ export interface RunAsyncWorktreeBootstrapOptions {
|
|||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
|
||||||
|
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,116 @@ export interface CreateAgentWorktreeOptions {
|
|||||||
paseoHome?: string;
|
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(
|
export async function createAgentWorktree(
|
||||||
options: CreateAgentWorktreeOptions
|
options: CreateAgentWorktreeOptions
|
||||||
): Promise<WorktreeConfig> {
|
): 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: {
|
function buildSetupTimelineItem(input: {
|
||||||
callId: string;
|
callId: string;
|
||||||
status: "running" | "completed" | "failed";
|
status: "running" | "completed" | "failed";
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
results: WorktreeSetupCommandResult[];
|
results: WorktreeSetupCommandResult[];
|
||||||
|
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
}): AgentTimelineItem {
|
}): 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,
|
worktreePath: input.worktree.worktreePath,
|
||||||
branchName: input.worktree.branchName,
|
branchName: input.worktree.branchName,
|
||||||
};
|
log: renderedLog.log,
|
||||||
const detailOutput = {
|
commands,
|
||||||
worktreePath: input.worktree.worktreePath,
|
...(renderedLog.truncated ? { truncated: true } : {}),
|
||||||
commands: input.results.map((result) => ({
|
|
||||||
command: result.command,
|
|
||||||
cwd: result.cwd,
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (input.status === "running") {
|
if (input.status === "running") {
|
||||||
@@ -75,11 +249,7 @@ function buildSetupTimelineItem(input: {
|
|||||||
name: "paseo_worktree_setup",
|
name: "paseo_worktree_setup",
|
||||||
callId: input.callId,
|
callId: input.callId,
|
||||||
status: "running",
|
status: "running",
|
||||||
detail: {
|
detail,
|
||||||
type: "unknown",
|
|
||||||
input: detailInput,
|
|
||||||
output: null,
|
|
||||||
},
|
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -90,11 +260,7 @@ function buildSetupTimelineItem(input: {
|
|||||||
name: "paseo_worktree_setup",
|
name: "paseo_worktree_setup",
|
||||||
callId: input.callId,
|
callId: input.callId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: {
|
detail,
|
||||||
type: "unknown",
|
|
||||||
input: detailInput,
|
|
||||||
output: detailOutput,
|
|
||||||
},
|
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,11 +270,7 @@ function buildSetupTimelineItem(input: {
|
|||||||
name: "paseo_worktree_setup",
|
name: "paseo_worktree_setup",
|
||||||
callId: input.callId,
|
callId: input.callId,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
detail: {
|
detail,
|
||||||
type: "unknown",
|
|
||||||
input: detailInput,
|
|
||||||
output: detailOutput,
|
|
||||||
},
|
|
||||||
error: { message: input.errorMessage ?? "Worktree setup failed" },
|
error: { message: input.errorMessage ?? "Worktree setup failed" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -258,40 +420,105 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const setupCallId = uuidv4();
|
const setupCallId = uuidv4();
|
||||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
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 queueLiveRunningEmit = () => {
|
||||||
const started = await options.appendTimelineItem(
|
if (!emitLiveTimelineItem) {
|
||||||
buildSetupTimelineItem({
|
|
||||||
callId: setupCallId,
|
|
||||||
status: "running",
|
|
||||||
worktree: options.worktree,
|
|
||||||
results: [],
|
|
||||||
errorMessage: null,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (!started) {
|
|
||||||
return;
|
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({
|
setupResults = await runWorktreeSetupCommands({
|
||||||
worktreePath: options.worktree.worktreePath,
|
worktreePath: options.worktree.worktreePath,
|
||||||
branchName: options.worktree.branchName,
|
branchName: options.worktree.branchName,
|
||||||
cleanupOnFailure: false,
|
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({
|
buildSetupTimelineItem({
|
||||||
callId: setupCallId,
|
callId: setupCallId,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
worktree: options.worktree,
|
worktree: options.worktree,
|
||||||
results: setupResults,
|
results: setupResults,
|
||||||
|
outputAccumulatorsByIndex,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (!completed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WorktreeSetupError) {
|
if (error instanceof WorktreeSetupError) {
|
||||||
setupResults = error.results;
|
setupResults = error.results;
|
||||||
}
|
}
|
||||||
|
await liveEmitQueue;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await options.appendTimelineItem(
|
await options.appendTimelineItem(
|
||||||
buildSetupTimelineItem({
|
buildSetupTimelineItem({
|
||||||
@@ -299,6 +526,7 @@ export async function runAsyncWorktreeBootstrap(
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
worktree: options.worktree,
|
worktree: options.worktree,
|
||||||
results: setupResults,
|
results: setupResults,
|
||||||
|
outputAccumulatorsByIndex,
|
||||||
errorMessage: message,
|
errorMessage: message,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -188,6 +188,23 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
|||||||
type: z.literal("search"),
|
type: z.literal("search"),
|
||||||
query: z.string(),
|
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({
|
z.object({
|
||||||
type: z.literal("unknown"),
|
type: z.literal("unknown"),
|
||||||
input: UnknownValueSchema,
|
input: UnknownValueSchema,
|
||||||
@@ -948,6 +965,16 @@ export const ListTerminalsRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const CreateTerminalRequestSchema = z.object({
|
||||||
type: z.literal("create_terminal_request"),
|
type: z.literal("create_terminal_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1060,6 +1087,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ExecuteCommandRequestSchema,
|
ExecuteCommandRequestSchema,
|
||||||
RegisterPushTokenMessageSchema,
|
RegisterPushTokenMessageSchema,
|
||||||
ListTerminalsRequestSchema,
|
ListTerminalsRequestSchema,
|
||||||
|
SubscribeTerminalsRequestSchema,
|
||||||
|
UnsubscribeTerminalsRequestSchema,
|
||||||
CreateTerminalRequestSchema,
|
CreateTerminalRequestSchema,
|
||||||
SubscribeTerminalRequestSchema,
|
SubscribeTerminalRequestSchema,
|
||||||
UnsubscribeTerminalRequestSchema,
|
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({
|
export const CreateTerminalResponseSchema = z.object({
|
||||||
type: z.literal("create_terminal_response"),
|
type: z.literal("create_terminal_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2006,6 +2043,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListCommandsResponseSchema,
|
ListCommandsResponseSchema,
|
||||||
ExecuteCommandResponseSchema,
|
ExecuteCommandResponseSchema,
|
||||||
ListTerminalsResponseSchema,
|
ListTerminalsResponseSchema,
|
||||||
|
TerminalsChangedSchema,
|
||||||
CreateTerminalResponseSchema,
|
CreateTerminalResponseSchema,
|
||||||
SubscribeTerminalResponseSchema,
|
SubscribeTerminalResponseSchema,
|
||||||
TerminalOutputSchema,
|
TerminalOutputSchema,
|
||||||
@@ -2149,6 +2187,9 @@ export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSc
|
|||||||
// Terminal message types
|
// Terminal message types
|
||||||
export type ListTerminalsRequest = z.infer<typeof ListTerminalsRequestSchema>;
|
export type ListTerminalsRequest = z.infer<typeof ListTerminalsRequestSchema>;
|
||||||
export type ListTerminalsResponse = z.infer<typeof ListTerminalsResponseSchema>;
|
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 CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
|
||||||
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
||||||
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
|
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
|
||||||
|
|||||||
@@ -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", () => {
|
it("provides errorText for failed calls", () => {
|
||||||
const display = buildToolCallDisplayModel({
|
const display = buildToolCallDisplayModel({
|
||||||
name: "shell",
|
name: "shell",
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
|
|||||||
displayName = "Search";
|
displayName = "Search";
|
||||||
summary = input.detail.query;
|
summary = input.detail.query;
|
||||||
break;
|
break;
|
||||||
|
case "worktree_setup":
|
||||||
|
displayName = "Worktree Setup";
|
||||||
|
summary = input.detail.branchName;
|
||||||
|
break;
|
||||||
case "unknown":
|
case "unknown":
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,4 +201,52 @@ describe("TerminalManager", () => {
|
|||||||
expect(manager.getTerminal(homeId)).toBeUndefined();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
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 {
|
export interface TerminalManager {
|
||||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||||
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
||||||
@@ -7,12 +20,14 @@ export interface TerminalManager {
|
|||||||
killTerminal(id: string): void;
|
killTerminal(id: string): void;
|
||||||
listDirectories(): string[];
|
listDirectories(): string[];
|
||||||
killAll(): void;
|
killAll(): void;
|
||||||
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTerminalManager(): TerminalManager {
|
export function createTerminalManager(): TerminalManager {
|
||||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||||
const terminalsById = new Map<string, TerminalSession>();
|
const terminalsById = new Map<string, TerminalSession>();
|
||||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||||
|
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||||
|
|
||||||
function assertAbsolutePath(cwd: string): void {
|
function assertAbsolutePath(cwd: string): void {
|
||||||
if (!cwd.startsWith("/")) {
|
if (!cwd.startsWith("/")) {
|
||||||
@@ -48,6 +63,8 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
if (options.kill) {
|
if (options.kill) {
|
||||||
session.kill();
|
session.kill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitTerminalsChanged({ cwd: session.cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerSession(session: TerminalSession): TerminalSession {
|
function registerSession(session: TerminalSession): TerminalSession {
|
||||||
@@ -59,6 +76,36 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
return session;
|
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 {
|
return {
|
||||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||||
assertAbsolutePath(cwd);
|
assertAbsolutePath(cwd);
|
||||||
@@ -70,6 +117,7 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
);
|
);
|
||||||
terminals = [session];
|
terminals = [session];
|
||||||
terminalsByCwd.set(cwd, terminals);
|
terminalsByCwd.set(cwd, terminals);
|
||||||
|
emitTerminalsChanged({ cwd });
|
||||||
}
|
}
|
||||||
return terminals;
|
return terminals;
|
||||||
},
|
},
|
||||||
@@ -88,6 +136,7 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
|
|
||||||
terminals.push(session);
|
terminals.push(session);
|
||||||
terminalsByCwd.set(options.cwd, terminals);
|
terminalsByCwd.set(options.cwd, terminals);
|
||||||
|
emitTerminalsChanged({ cwd: options.cwd });
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
@@ -109,5 +158,12 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
removeSessionById(id, { kill: true });
|
removeSessionById(id, { kill: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
||||||
|
terminalsChangedListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
terminalsChangedListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
|
type WorktreeSetupCommandProgressEvent,
|
||||||
|
runWorktreeSetupCommands,
|
||||||
slugify,
|
slugify,
|
||||||
} from "./worktree";
|
} from "./worktree";
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||||
@@ -233,6 +235,36 @@ describe("createWorktree", () => {
|
|||||||
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
|
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 () => {
|
it("cleans up worktree if setup command fails", async () => {
|
||||||
// Create paseo.json with failing setup command
|
// Create paseo.json with failing setup command
|
||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { exec } from "child_process";
|
import { exec, spawn } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
|
||||||
import { join, basename, dirname, resolve, sep } from "path";
|
import { join, basename, dirname, resolve, sep } from "path";
|
||||||
@@ -32,8 +32,38 @@ export type WorktreeSetupCommandResult = {
|
|||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
exitCode: number | null;
|
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 {
|
export interface WorktreeTerminalConfig {
|
||||||
name?: string;
|
name?: string;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -153,6 +183,7 @@ async function execSetupCommand(
|
|||||||
command: string,
|
command: string,
|
||||||
options: { cwd: string; env: NodeJS.ProcessEnv }
|
options: { cwd: string; env: NodeJS.ProcessEnv }
|
||||||
): Promise<WorktreeSetupCommandResult> {
|
): Promise<WorktreeSetupCommandResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execAsync(command, {
|
const { stdout, stderr } = await execAsync(command, {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
@@ -165,6 +196,7 @@ async function execSetupCommand(
|
|||||||
stdout: stdout ?? "",
|
stdout: stdout ?? "",
|
||||||
stderr: stderr ?? "",
|
stderr: stderr ?? "",
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
@@ -175,10 +207,105 @@ async function execSetupCommand(
|
|||||||
error?.stderr ??
|
error?.stderr ??
|
||||||
(error instanceof Error ? error.message : String(error)),
|
(error instanceof Error ? error.message : String(error)),
|
||||||
exitCode: typeof error?.code === "number" ? error.code : null,
|
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> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -233,6 +360,7 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
cleanupOnFailure: boolean;
|
cleanupOnFailure: boolean;
|
||||||
repoRootPath?: string;
|
repoRootPath?: string;
|
||||||
|
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
||||||
}): Promise<WorktreeSetupCommandResult[]> {
|
}): Promise<WorktreeSetupCommandResult[]> {
|
||||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
||||||
@@ -258,11 +386,20 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const results: WorktreeSetupCommandResult[] = [];
|
const results: WorktreeSetupCommandResult[] = [];
|
||||||
for (const cmd of setupCommands) {
|
for (const [index, cmd] of setupCommands.entries()) {
|
||||||
const result = await execSetupCommand(cmd, {
|
const result = options.onEvent
|
||||||
cwd: options.worktreePath,
|
? await execSetupCommandStreamed({
|
||||||
env: setupEnv,
|
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);
|
results.push(result);
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user