From 0718e7c91447ee61f0c5d46c58a53d0f9aa3e71d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 20 Jun 2026 19:38:57 +0700 Subject: [PATCH] Support split worktree agent launches Expose the workspace id from worktree creation and let agent creation attach to an existing workspace, so callers can split worktree setup from agent launch without reusing the caller workspace. Keep worktreeSlug as the worktree path slug and add branchName for branch-off targets. --- .../src/server/agent/create-agent/create.ts | 2 + .../src/server/agent/mcp-server.test.ts | 65 ++++++++++++++++++- .../server/src/server/agent/mcp-server.ts | 53 ++++++++++++++- .../resolve-worktree-creation-intent.ts | 9 ++- packages/server/src/server/worktree-core.ts | 7 +- skills/paseo-handoff/SKILL.md | 5 +- skills/paseo/SKILL.md | 11 ++-- 7 files changed, 137 insertions(+), 15 deletions(-) diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 882e800a7..4a6aad495 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -95,6 +95,7 @@ export interface CreateAgentFromMcpInput { } | null; worktree?: { worktreeName?: string; + branchName?: string; baseBranch?: string; refName?: string; action?: "branch-off" | "checkout"; @@ -419,6 +420,7 @@ async function resolveMcpCwd(params: { input: { cwd: params.cwd, worktreeSlug: worktree.worktreeName, + branchName: worktree.branchName, refName: worktree.refName, action: worktree.action, githubPrNumber: worktree.githubPrNumber, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b28d62609..9b1db10c4 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -636,7 +636,7 @@ describe("create_agent MCP tool", () => { const detachedWorktreeWorkspace = ( cwd: string, target: - | { kind: "branch-off"; worktreeSlug?: string; baseBranch?: string } + | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } | { kind: "checkout-branch"; branch: string } | { kind: "checkout-pr"; githubPrNumber: number }, ) => ({ @@ -651,6 +651,10 @@ describe("create_agent MCP tool", () => { relationship: { kind: "detached" as const }, workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) }, }); + const detachedExistingWorkspace = (workspaceId: string, cwd?: string) => ({ + relationship: { kind: "detached" as const }, + workspace: { kind: "existing" as const, workspaceId, ...(cwd ? { cwd } : {}) }, + }); const ensureWorkspaceForCreate = async () => "workspace-created"; it("requires a concise title no longer than 60 characters", async () => { @@ -800,6 +804,46 @@ describe("create_agent MCP tool", () => { ).rejects.toThrow("Caller agent parent-agent has no current workspace"); }); + it("attaches create_agent to an existing workspace id", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "existing-workspace-agent", + cwd: existingCwd, + workspaceId: "wks_existing", + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Existing workspace" }, + } as ManagedAgent); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + listActiveWorkspaces: async () => [ + { workspaceId: "wks_existing", cwd: existingCwd, kind: "worktree" }, + ], + logger, + }); + const tool = registeredTool(server, "create_agent"); + + const response = await tool.handler({ + ...detachedExistingWorkspace("wks_existing"), + title: "Existing workspace", + provider: "codex/gpt-5.4", + initialPrompt: "Do work", + background: true, + }); + + expect(response.structuredContent.workspaceId).toBe("wks_existing"); + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: existingCwd, + }), + undefined, + { workspaceId: "wks_existing" }, + ); + }); + it("accepts provider features and passes them through createAgent", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ @@ -1194,6 +1238,7 @@ describe("create_agent MCP tool", () => { ...detachedWorktreeWorkspace(repoDir, { kind: "branch-off", worktreeSlug: "agent-worktree", + branchName: "feature/agent-worktree", baseBranch: "main", }), title: "Worktree agent", @@ -1207,6 +1252,14 @@ describe("create_agent MCP tool", () => { expect(broadcasts[0]).toBe(createdWorkspaceIds[0]); expect(setupContinuations).toEqual(["agent"]); expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]); + const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); + const branchName = execFileSync("git", ["branch", "--show-current"], { + cwd: agentCwd, + stdio: "pipe", + }) + .toString() + .trim(); + expect(branchName).toBe("feature/agent-worktree"); // The agent is stamped with the freshly created worktree's workspaceId so // workspaceId-scoped archive can find and tear it down later. expect(spies.agentManager.createAgent).toHaveBeenCalledWith( @@ -1528,11 +1581,17 @@ describe("create_agent MCP tool", () => { const tool = registeredTool(server, "create_worktree"); const response = await tool.handler({ cwd: repoDir, - target: { kind: "branch-off", worktreeSlug: "tool-worktree", baseBranch: "main" }, + target: { + kind: "branch-off", + worktreeSlug: "tool-worktree", + branchName: "feature/tool-worktree", + baseBranch: "main", + }, }); - expect(response.structuredContent.branchName).toBe("tool-worktree"); + expect(response.structuredContent.branchName).toBe("feature/tool-worktree"); expect(response.structuredContent.worktreePath).toContain("tool-worktree"); + expect(response.structuredContent.workspaceId).toBe(broadcasts[0]); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, { force: true, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 07d832f52..1887a7263 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -764,7 +764,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .string() .min(1) .optional() - .describe("Optional worktree branch/slug. Omit to let Paseo generate one."), + .describe("Optional worktree slug/path label. Omit to let Paseo generate one."), + branchName: z + .string() + .min(1) + .optional() + .describe("Optional git branch name. Defaults to the worktree slug."), baseBranch: z .string() .min(1) @@ -796,6 +801,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }) .strict() .describe("Use the caller's current workspace."), + z + .object({ + kind: z.literal("existing"), + workspaceId: z.string().min(1).describe("Existing workspace id to attach the agent to."), + cwd: z + .string() + .optional() + .describe("Optional runtime cwd. Defaults to the existing workspace cwd."), + }) + .strict() + .describe("Attach the agent to an existing workspace."), z .object({ kind: z.literal("create"), @@ -993,6 +1009,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom type: AgentProviderEnum, status: AgentStatusEnum, cwd: z.string(), + workspaceId: z.string().optional(), currentModeId: z.string().nullable(), availableModes: z.array(ProviderModeSchema), lastMessage: z.string().nullable().optional(), @@ -1065,6 +1082,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom type: snapshot.provider, status: result.status, cwd: liveSnapshot.cwd, + ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), currentModeId: liveSnapshot.currentModeId, availableModes: liveSnapshot.availableModes, lastMessage: result.lastMessage, @@ -1096,6 +1114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom type: snapshot.provider, status: currentSnapshot.lifecycle, cwd: currentSnapshot.cwd, + ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), currentModeId: currentSnapshot.currentModeId, availableModes: currentSnapshot.availableModes, lastMessage: null, @@ -1173,6 +1192,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }; } + if (workspace.kind === "existing") { + if (!options.listActiveWorkspaces) { + throw new Error("Workspace lookup is not configured"); + } + const existingWorkspace = (await options.listActiveWorkspaces()).find( + (candidate) => candidate.workspaceId === workspace.workspaceId, + ); + if (!existingWorkspace) { + throw new Error(`Workspace ${workspace.workspaceId} not found`); + } + const cwd = workspace.cwd + ? resolveScopedCwd(workspace.cwd, { required: true }) + : existingWorkspace.cwd; + const lockedCwd = callerContext?.lockedCwd?.trim(); + if (lockedCwd && !isSameOrDescendantPath(expandUserPath(lockedCwd), cwd)) { + throw new Error(`Workspace ${workspace.workspaceId} is outside the allowed cwd`); + } + return { + cwd, + workspaceId: workspace.workspaceId, + worktree: undefined, + }; + } + if (workspace.source.kind === "directory") { const cwd = resolveScopedCwd(workspace.source.path, { required: true }); if (!options.ensureWorkspaceForCreate) { @@ -1201,6 +1244,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return { action: "branch-off", worktreeName: target.worktreeSlug, + branchName: target.branchName, baseBranch: target.baseBranch, }; case "checkout-branch": @@ -2292,6 +2336,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom outputSchema: { branchName: z.string(), worktreePath: z.string(), + workspaceId: z.string(), }, }, async ({ cwd, target }) => { @@ -2307,7 +2352,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (!commandResult.ok) { throw new WorktreeRequestError(commandResult.error); } - const { worktree } = commandResult.createdWorktree; + const { worktree, workspace } = commandResult.createdWorktree; await options.workspaceGitService?.listWorktrees?.(repoRoot, { force: true, reason: "mcp:create-worktree", @@ -2318,6 +2363,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom structuredContent: ensureValidJson({ branchName: worktree.branchName, worktreePath: worktree.worktreePath, + workspaceId: workspace.workspaceId, }), }; }, @@ -2530,7 +2576,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } type McpCreateWorktreeTarget = - | { kind: "branch-off"; worktreeSlug?: string; baseBranch?: string } + | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } | { kind: "checkout-branch"; branch: string } | { kind: "checkout-pr"; githubPrNumber: number }; @@ -2604,6 +2650,7 @@ function createMcpWorktreeCommandInput( return { ...base, worktreeSlug: target.worktreeSlug, + branchName: target.branchName, action: "branch-off", ...(target.baseBranch ? { refName: target.baseBranch } : {}), }; diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index dbd56838b..5b0adba5d 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -6,18 +6,21 @@ export type WorktreeCreationIntent = WorktreeSource; export type ResolveWorktreeCreationIntentInput = | { worktreeSlug: string; + branchName?: string; refName?: string; action?: "branch-off"; githubPrNumber?: undefined; } | { worktreeSlug?: string; + branchName?: string; refName?: string; action: "checkout"; githubPrNumber?: number; } | { worktreeSlug?: string; + branchName?: string; refName?: string; action?: undefined; githubPrNumber: number; @@ -46,7 +49,7 @@ export async function resolveWorktreeCreationIntent( return { kind: "branch-off", baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)), - branchName: input.worktreeSlug, + branchName: input.branchName ?? input.worktreeSlug, }; } @@ -84,14 +87,14 @@ export async function resolveWorktreeCreationIntent( return { kind: "branch-off", baseBranch: input.refName.trim(), - branchName: input.worktreeSlug, + branchName: input.branchName ?? input.worktreeSlug, }; } return { kind: "branch-off", baseBranch: await resolveDefaultBranch(repoRoot, deps), - branchName: input.worktreeSlug, + branchName: input.branchName ?? input.worktreeSlug, }; } diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 1aa5f4a30..57d054ce5 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -19,6 +19,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; export interface CreateWorktreeCoreInput { cwd: string; worktreeSlug?: string; + branchName?: string; refName?: string; action?: "branch-off" | "checkout"; githubPrNumber?: number; @@ -49,6 +50,9 @@ export async function createWorktreeCore( const requestedWorktreeSlug = input.worktreeSlug ? normalizeWorktreeSlug(input.worktreeSlug) : undefined; + const requestedBranchName = input.branchName + ? validateWorktreeSlug(input.branchName.trim()) + : undefined; let intentInput: ResolveWorktreeCreationIntentInput; if (input.action === "checkout") { @@ -69,6 +73,7 @@ export async function createWorktreeCore( intentInput = { action: "branch-off", refName: input.refName, + branchName: requestedBranchName, worktreeSlug, }; } @@ -81,7 +86,7 @@ export async function createWorktreeCore( switch (intent.kind) { case "branch-off": { - normalizedSlug = intent.branchName; + normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName); break; } case "checkout-branch": { diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index c0c8cd5be..45f2ea4ae 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -59,7 +59,10 @@ Create the agent via Paseo with a `[Handoff] ` title, the briefing as init Use `workspace` for placement: - No worktree: `workspace: { kind: "current" }`. -- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "" } } }`. +- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "", branchName: "fix/" } } }`. +- Existing worktree already created by `create_worktree`: `workspace: { kind: "existing", workspaceId: "" }`. + +Do not use `workspace: { kind: "current", cwd: "" }` to place a handoff in a worktree; that keeps the agent in the caller's workspace with only a different runtime cwd. Leave `notifyOnFinish` omitted unless the user explicitly wants no callback. diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 18e50729c..dbf7f1c24 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -10,10 +10,12 @@ Paseo is a daemon that supervises AI coding agents on your machine. Control it t **`create_worktree`** — same target union as `create_agent.workspace.source.worktree.target`: - From a PR: `{ target: { kind: "checkout-pr", githubPrNumber: 503 } }`. -- Branch off a base: `{ target: { kind: "branch-off", worktreeSlug: "fix/foo", baseBranch: "main" } }`. +- Branch off a base: `{ target: { kind: "branch-off", worktreeSlug: "foo", branchName: "fix/foo", baseBranch: "main" } }`. - Checkout an existing branch: `{ target: { kind: "checkout-branch", branch: "feat/bar" } }`. -Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo. +Returns `{ branchName, worktreePath, workspaceId }`. Pass `cwd` to target a specific repo. + +In `branch-off`, `worktreeSlug` controls the worktree path slug and `branchName` controls the git branch. If `branchName` is omitted, Paseo defaults it from `worktreeSlug`. The returned `branchName` is authoritative; checkout and PR flows may return a branch name that differs from any requested slug. **`list_worktrees`** — current repo (or pass `cwd`). **`archive_worktree`** — `{ worktreePath }` or `{ worktreeSlug }`. Removes worktree and branch. @@ -24,7 +26,7 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo. Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. -To create a new worktree and launch an agent in it, use `create_agent.workspace.source.kind = "worktree"`. Use `create_worktree` separately only when you need a worktree without launching an agent. +To create a new worktree and launch an agent in it, use `create_agent.workspace.source.kind = "worktree"`. Use `create_worktree` separately only when you need a worktree without launching an agent, or when you need a split flow; in a split flow, pass the returned `workspaceId` to `create_agent` with `workspace: { kind: "existing", workspaceId }`. ### Agent relationships @@ -36,8 +38,9 @@ To create a new worktree and launch an agent in it, use `create_agent.workspace. `workspace` controls placement only: - `{ kind: "current" }` — same workspace as the caller, with optional `cwd`. +- `{ kind: "existing", workspaceId: string, cwd?: string }` — attach to an existing workspace, usually from `create_worktree`. - `{ kind: "create", source: { kind: "directory", path?: string } }` — new workspace rooted at a directory. -- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "branch-off", worktreeSlug?: string, baseBranch?: string } } }` +- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "branch-off", worktreeSlug?: string, branchName?: string, baseBranch?: string } } }` - `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-branch", branch: string } } }` - `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-pr", githubPrNumber: number } } }`