feat: terminal reattach, explorer tab memory, worktree terminals

This commit is contained in:
Mohamed Boudra
2026-02-15 14:05:29 +07:00
parent 1f72ce63cd
commit 9244f1fbd6
31 changed files with 2009 additions and 385 deletions
@@ -28,7 +28,8 @@ describe("TerminalStreamManager", () => {
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).not.toHaveBeenCalled();
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
manager.subscribe({
streamId: 7,
@@ -77,6 +78,9 @@ describe("TerminalStreamManager", () => {
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
});
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 });
const seen: string[] = [];
manager.subscribe({
@@ -87,8 +91,7 @@ describe("TerminalStreamManager", () => {
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
expect(sendAck).toHaveBeenCalledTimes(3);
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {
@@ -79,6 +79,12 @@ export class TerminalStreamManager {
const streamHandlers = this.handlers.get(chunk.streamId);
if (!streamHandlers || streamHandlers.size === 0) {
this.bufferChunk({ chunk });
// Ack buffered chunks immediately to keep the daemon-side stream window
// moving even before a UI subscriber is attached.
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
return;
}
@@ -46,14 +46,21 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
paseoHome?: string;
logger: Logger;
}
@@ -313,12 +320,13 @@ export async function createAgentManagementMcpServer(
};
let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined;
if (worktreeName) {
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createWorktree({
const worktree = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
@@ -326,6 +334,7 @@ export async function createAgentManagementMcpServer(
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
}
const provider: AgentProvider = agentType ?? "claude";
@@ -337,6 +346,21 @@ export async function createAgentManagementMcpServer(
title: normalizedTitle ?? undefined,
});
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: options.terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
@@ -23,6 +23,39 @@ type McpClient = {
close: () => Promise<void>;
};
async function withTimeout<T>(
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`
);
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
@@ -53,10 +86,13 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
return null;
}
async function waitForAgentCompletion(client: McpClient, agentId: string): Promise<void> {
const waitResult = (await client.callTool({
async function waitForAgentCompletion(options: {
client: McpClient;
agentId: string;
}): Promise<void> {
const waitResult = (await options.client.callTool({
name: "wait_for_agent",
args: { agentId },
args: { agentId: options.agentId },
})) as McpToolResult;
const payload = getStructuredContent(waitResult);
if (!payload) {
@@ -127,7 +163,7 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion(client, agentId!);
await waitForAgentCompletion({ client, agentId: agentId! });
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
@@ -148,4 +184,115 @@ describe("agent MCP end-to-end (offline)", () => {
},
30_000
);
test(
"create_agent with worktree is async and boots terminals only after setup success",
async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
const port = await getAvailablePort();
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`)
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
const { execSync } = await import("node:child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
await writeFile(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
},
}),
"utf8"
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
cwd: repoRoot,
stdio: "pipe",
});
const result = (await withTimeout({
promise: client.callTool({
name: "create_agent",
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
agentType: "claude",
initialMode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
background: true,
},
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const worktreePath = (payload?.cwd as string | undefined) ?? "";
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
await waitForPathExists({
targetPath: path.join(worktreePath, "setup-done.txt"),
timeoutMs: 15000,
});
await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 15000,
});
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(repoRoot, { recursive: true, force: true });
}
},
60_000
);
});
+27 -2
View File
@@ -28,7 +28,8 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type {
@@ -36,10 +37,16 @@ import type {
VoiceSpeakHandler,
} from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -289,6 +296,7 @@ export async function createAgentMcpServer(
const {
agentManager,
agentStorage,
terminalManager,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
@@ -469,6 +477,7 @@ export async function createAgentMcpServer(
let resolvedCwd: string;
let resolvedMode: string | undefined;
let worktreeConfig: WorktreeConfig | undefined;
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
@@ -514,7 +523,7 @@ export async function createAgentMcpServer(
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createWorktree({
const worktree = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
@@ -522,6 +531,7 @@ export async function createAgentMcpServer(
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
}
resolvedMode = initialMode;
@@ -542,6 +552,21 @@ export async function createAgentMcpServer(
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
);
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt.trim();
scheduleAgentMetadataGeneration({
agentManager,
@@ -0,0 +1,23 @@
import type { AgentManager } from "./agent-manager.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export interface AppendTimelineItemIfAgentKnownOptions {
agentManager: AgentManager;
agentId: string;
item: AgentTimelineItem;
}
export async function appendTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
): Promise<boolean> {
try {
await options.agentManager.appendTimelineItem(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;
}
}
+3
View File
@@ -306,6 +306,7 @@ export async function createPaseoDaemon(
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
enableVoiceTools: false,
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
@@ -329,6 +330,7 @@ export async function createPaseoDaemon(
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -448,6 +450,7 @@ export async function createPaseoDaemon(
return createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,
@@ -16,18 +16,16 @@ function tmpCwd(): string {
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${timeoutMs}ms (${label})`));
}, timeoutMs);
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
@@ -92,6 +90,21 @@ async function waitForTimelineToolCall(
);
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number; label: string }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for ${options.label}: ${options.targetPath}`
);
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -352,8 +365,8 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
const agent = await withTimeout(
ctx.client.createAgent({
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
@@ -367,9 +380,9 @@ describe("daemon E2E", () => {
worktreeSlug: "async-setup-test",
},
}),
2500,
"createAgent should not block on setup"
);
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
@@ -396,6 +409,107 @@ describe("daemon E2E", () => {
60000
);
test(
"bootstraps configured worktree terminals after setup succeeds",
async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
{
command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
cwd: repoRoot,
stdio: "pipe",
});
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000
);
await waitForPathExists({
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
timeoutMs: 15000,
label: "dev terminal marker",
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "lint-terminal.txt"),
timeoutMs: 15000,
label: "lint terminal marker",
});
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
test(
"reports failures via timeline tool_call without deleting the created worktree",
async () => {
@@ -421,7 +535,17 @@ describe("daemon E2E", () => {
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: [setupCommand] } })
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Should Not Start",
command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
@@ -429,8 +553,8 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
const agent = await withTimeout(
ctx.client.createAgent({
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
@@ -444,9 +568,9 @@ describe("daemon E2E", () => {
worktreeSlug: "async-setup-failure-test",
},
}),
2500,
"createAgent should not block on failing setup"
);
timeoutMs: 2500,
label: "createAgent should not block on failing setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(agent.cwd)).toBe(true);
@@ -469,6 +593,7 @@ describe("daemon E2E", () => {
);
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
const commands = output?.commands as any[] | undefined;
+37 -123
View File
@@ -68,6 +68,7 @@ import type {
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js";
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
import {
StructuredAgentResponseError,
@@ -83,7 +84,6 @@ import type {
AgentStreamEvent,
AgentProvider,
AgentPersistenceHandle,
AgentTimelineItem,
} from "./agent/agent-sdk-types.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
@@ -101,18 +101,18 @@ import {
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
createWorktree,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
getCheckoutStatus,
@@ -208,19 +208,22 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
return `remote:${cleanedHost}/${cleanedPath}`;
}
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
const remoteKey = deriveRemoteProjectKey(remoteUrl);
function deriveProjectGroupingKey(options: {
cwd: string;
remoteUrl: string | null;
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
if (remoteKey) {
return remoteKey;
}
const worktreeMarker = ".paseo/worktrees/";
const idx = cwd.indexOf(worktreeMarker);
const idx = options.cwd.indexOf(worktreeMarker);
if (idx !== -1) {
return cwd.slice(0, idx).replace(/\/$/, "");
return options.cwd.slice(0, idx).replace(/\/$/, "");
}
return cwd;
return options.cwd;
}
function deriveProjectGroupingName(projectKey: string): string {
@@ -1123,7 +1126,10 @@ export class Session {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
.then((status) => this.toProjectCheckoutLite(cwd, status))
.catch(() => this.buildFallbackProjectCheckout(cwd));
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
const projectKey = deriveProjectGroupingKey({
cwd,
remoteUrl: checkout.remoteUrl,
});
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
@@ -2398,7 +2404,18 @@ export class Session {
}
if (worktreeConfig) {
void this.runAsyncWorktreeSetup(snapshot.id, worktreeConfig);
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
logger: this.sessionLogger,
});
}
this.sessionLogger.info(
@@ -2627,12 +2644,11 @@ export class Session {
}' for branch ${targetBranch}`
);
const createdWorktree = await createWorktree({
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch: normalized.baseBranch!,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
runSetup: false,
paseoHome: this.paseoHome,
});
cwd = createdWorktree.worktreePath;
@@ -2656,108 +2672,6 @@ export class Session {
};
}
private async runAsyncWorktreeSetup(
agentId: string,
worktree: WorktreeConfig
): Promise<void> {
const callId = uuidv4();
let results: WorktreeSetupCommandResult[] = [];
try {
const started = await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "running",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: null,
},
error: null,
});
if (!started) {
return;
}
results = await runWorktreeSetupCommands({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
cleanupOnFailure: false,
});
await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "completed",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
},
error: null,
});
} catch (error: any) {
if (error instanceof WorktreeSetupError) {
results = error.results;
}
const message = error instanceof Error ? error.message : String(error);
await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "failed",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
},
error: { message },
});
}
}
private async safeAppendTimelineItem(
agentId: string,
item: AgentTimelineItem
): Promise<boolean> {
try {
await this.agentManager.appendTimelineItem(agentId, item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
): Promise<void> {
@@ -0,0 +1,309 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import {
createWorktree,
getWorktreeTerminalSpecs,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
} from "../utils/worktree.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
export interface RunAsyncWorktreeBootstrapOptions {
agentId: string;
worktree: WorktreeConfig;
terminalManager: TerminalManager | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger;
}
export interface CreateAgentWorktreeOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions
): Promise<WorktreeConfig> {
return createWorktree({
branchName: options.branchName,
cwd: options.cwd,
baseBranch: options.baseBranch,
worktreeSlug: options.worktreeSlug,
runSetup: false,
paseoHome: options.paseoHome,
});
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
errorMessage: string | null;
}): AgentTimelineItem {
const detailInput = {
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
};
const detailOutput = {
worktreePath: input.worktree.worktreePath,
commands: input.results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "running",
detail: {
type: "unknown",
input: detailInput,
output: null,
},
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "completed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "failed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: { message: input.errorMessage ?? "Worktree setup failed" },
};
}
function buildTerminalTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeBootstrapTerminalResult[];
errorMessage: string | null;
}): AgentTimelineItem {
const detailInput = {
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
};
const detailOutput = {
worktreePath: input.worktree.worktreePath,
terminals: input.results,
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "running",
detail: {
type: "unknown",
input: detailInput,
output: null,
},
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "completed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "failed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" },
};
}
async function runWorktreeTerminalBootstrap(
options: RunAsyncWorktreeBootstrapOptions
): Promise<void> {
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
if (terminalSpecs.length === 0) {
return;
}
const callId = uuidv4();
const started = await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "running",
worktree: options.worktree,
results: [],
errorMessage: null,
})
);
if (!started) {
return;
}
if (!options.terminalManager) {
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "failed",
worktree: options.worktree,
results: [],
errorMessage: "Terminal manager not available",
})
);
return;
}
const results: WorktreeBootstrapTerminalResult[] = [];
for (const spec of terminalSpecs) {
try {
const terminal = await options.terminalManager.createTerminal({
cwd: options.worktree.worktreePath,
name: spec.name,
});
terminal.send({
type: "input",
data: `${spec.command}\r`,
});
results.push({
name: terminal.name ?? spec.name ?? null,
command: spec.command,
status: "started",
terminalId: terminal.id,
error: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.logger?.warn(
{ agentId: options.agentId, command: spec.command, err: error },
"Failed to bootstrap worktree terminal"
);
results.push({
name: spec.name ?? null,
command: spec.command,
status: "failed",
terminalId: null,
error: message,
});
}
}
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "completed",
worktree: options.worktree,
results,
errorMessage: null,
})
);
}
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions
): Promise<void> {
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
try {
const started = await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "running",
worktree: options.worktree,
results: [],
errorMessage: null,
})
);
if (!started) {
return;
}
setupResults = await runWorktreeSetupCommands({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
cleanupOnFailure: false,
});
await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "completed",
worktree: options.worktree,
results: setupResults,
errorMessage: null,
})
);
} catch (error) {
if (error instanceof WorktreeSetupError) {
setupResults = error.results;
}
const message = error instanceof Error ? error.message : String(error);
await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "failed",
worktree: options.worktree,
results: setupResults,
errorMessage: message,
})
);
return;
}
await runWorktreeTerminalBootstrap(options);
}
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createWorktree,
deletePaseoWorktree,
getWorktreeTerminalSpecs,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
slugify,
@@ -262,6 +263,43 @@ describe("createWorktree", () => {
// Verify worktree was cleaned up
expect(existsSync(expectedWorktreePath)).toBe(false);
});
it("reads worktree terminal specs from paseo.json with optional name", async () => {
const paseoConfig = {
worktree: {
terminals: [
{ name: "Dev Server", command: "npm run dev" },
{ command: "cd packages/app && npm run dev" },
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
{ name: "Dev Server", command: "npm run dev" },
{ command: "cd packages/app && npm run dev" },
]);
});
it("filters invalid worktree terminal specs", async () => {
const paseoConfig = {
worktree: {
terminals: [
null,
{},
{ name: " ", command: " " },
{ name: " Watch ", command: "npm run watch", cwd: "packages/app" },
{ name: 123, command: "npm run test" },
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
{ name: "Watch", command: "npm run watch" },
{ command: "npm run test" },
]);
});
});
describe("paseo worktree manager", () => {
+42
View File
@@ -11,6 +11,7 @@ interface PaseoConfig {
worktree?: {
setup?: string[];
destroy?: string[];
terminals?: WorktreeTerminalConfig[];
};
}
@@ -33,6 +34,11 @@ export type WorktreeSetupCommandResult = {
exitCode: number | null;
};
export interface WorktreeTerminalConfig {
name?: string;
command: string;
}
export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[];
@@ -107,6 +113,42 @@ export function getWorktreeDestroyCommands(repoRoot: string): string[] {
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
}
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
const config = readPaseoConfig(repoRoot);
const terminals = config?.worktree?.terminals;
if (!Array.isArray(terminals) || terminals.length === 0) {
return [];
}
const specs: WorktreeTerminalConfig[] = [];
for (const terminal of terminals) {
if (!terminal || typeof terminal !== "object") {
continue;
}
const rawCommand = terminal.command;
if (typeof rawCommand !== "string") {
continue;
}
const command = rawCommand.trim();
if (!command) {
continue;
}
const rawName = terminal.name;
const name = typeof rawName === "string" && rawName.trim().length > 0
? rawName.trim()
: undefined;
specs.push({
...(name ? { name } : {}),
command,
});
}
return specs;
}
async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv }