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 } } }`