mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -59,7 +59,10 @@ Create the agent via Paseo with a `[Handoff] <task>` 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: "<short-task-branch>" } } }`.
|
||||
- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "<short-task-slug>", branchName: "fix/<short-task-slug>" } } }`.
|
||||
- Existing worktree already created by `create_worktree`: `workspace: { kind: "existing", workspaceId: "<returned-workspace-id>" }`.
|
||||
|
||||
Do not use `workspace: { kind: "current", cwd: "<worktreePath>" }` 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.
|
||||
|
||||
|
||||
@@ -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 } } }`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user