Clean up MCP agent orchestration inputs

Require relationship and workspace on create_agent, and reuse the same worktree target union for create_worktree. Agent-scoped prompt follow-ups now default to background finish notifications so callers can continue without polling.
This commit is contained in:
Mohamed Boudra
2026-06-20 15:53:49 +07:00
parent b8bf2345fd
commit 80fc11541f
8 changed files with 667 additions and 243 deletions

View File

@@ -14,16 +14,23 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
- `relationship` decides whether the new agent belongs under the caller.
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
## Archive
@@ -106,11 +113,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -51,7 +51,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |

View File

@@ -46,9 +46,7 @@ interface CreateAgentCommandDependencies {
terminalManager?: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh workspace for a cwd and returns its id. Used when an agent is
// created with no parent and no worktree: it owns a brand-new workspace rather
// than being attributed to an existing same-cwd workspace by path.
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
}
@@ -81,6 +79,7 @@ export interface CreateAgentFromMcpInput {
title: string;
initialPrompt: string;
cwd?: string;
workspaceId?: string;
thinking?: string;
features?: Record<string, unknown>;
labels?: Record<string, string>;
@@ -245,15 +244,15 @@ async function resolveMcpCreateAgent(
initialPrompt: input.initialPrompt,
});
// A child agent created in its parent's working tree belongs to the parent's
// workspace. When a new worktree is created the child lives in that fresh
// workspace, so it is stamped with the new worktree's workspaceId instead
// (mirrors the session path) — keeping the agent discoverable by
// workspaceId-scoped archive. With neither a parent nor a worktree, the agent
// mints its own workspace; ownership is never resolved from cwd.
// MCP callers resolve workspace ownership before this point. Worktree
// creation wins because the new agent lives in the fresh worktree workspace.
// Otherwise use the explicit workspace id, then the parent workspace for
// direct internal callers. Ownership is never resolved from cwd.
const workspaceId = setupContinuation
? createdWorkspaceId
: (parentAgent?.workspaceId ?? (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
: (input.workspaceId ??
parentAgent?.workspaceId ??
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({

View File

@@ -201,20 +201,24 @@ async function makeCwd(prefix: string): Promise<string> {
async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> {
const cwd = typeof args?.cwd === "string" ? args.cwd : await makeCwd("agent-cwd");
const { cwd: _cwd, ...rest } = args ?? {};
const payload = await callToolStructured(topLevelClient, "create_agent", {
cwd,
relationship: { kind: "detached" },
workspace: { kind: "create", source: { kind: "directory", path: cwd } },
title: "Parity agent",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
settings: { modeId: "bypassPermissions" },
background: true,
...args,
...rest,
});
return str(payload.agentId);
}
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
const payload = await callToolStructured(agentScopedClient, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Parity child",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
@@ -288,7 +292,8 @@ beforeAll(async () => {
topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
cwd: parentAgentCwd,
relationship: { kind: "detached" },
workspace: { kind: "create", source: { kind: "directory", path: parentAgentCwd } },
title: "MCP parity parent",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
@@ -338,10 +343,10 @@ describe("Suite A: Core Fixes", () => {
}
});
test("create_agent with detached true omits the parent agent label", async () => {
test("create_agent with detached relationship omits the parent agent label", async () => {
let agentId: string | null = null;
try {
agentId = await createChildAgent({ detached: true });
agentId = await createChildAgent({ relationship: { kind: "detached" } });
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(snapshot?.labels?.[PARENT_AGENT_ID_LABEL]).toBeUndefined();
} finally {
@@ -836,9 +841,9 @@ describe("Suite E: Worktree Tools", () => {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
mode: "branch-off",
newBranch: branchName,
base: "main",
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
},
});
worktreePath = str(created.worktreePath);
@@ -867,9 +872,9 @@ describe("Suite E: Worktree Tools", () => {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
mode: "branch-off",
newBranch: branchName,
base: "main",
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
},
});
worktreePath = str(created.worktreePath);
@@ -900,9 +905,9 @@ describe("Suite E: Worktree Tools", () => {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
mode: "branch-off",
newBranch: branchName,
base: "main",
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
},
});
worktreePath = str(created.worktreePath);

View File

@@ -629,6 +629,29 @@ describe("terminal MCP tools", () => {
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
const detachedDirectoryWorkspace = (path = existingCwd) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "directory" as const, path } },
});
const detachedWorktreeWorkspace = (
cwd: string,
target:
| { kind: "branch-off"; worktreeSlug?: string; baseBranch?: string }
| { kind: "checkout-branch"; branch: string }
| { kind: "checkout-pr"; githubPrNumber: number },
) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } },
});
const subagentCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "subagent" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
});
const detachedCurrentWorkspace = (cwd?: string) => ({
relationship: { kind: "detached" as const },
workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) },
});
const ensureWorkspaceForCreate = async () => "workspace-created";
it("requires a concise title no longer than 60 characters", async () => {
const { agentManager, agentStorage } = createTestDeps();
@@ -636,13 +659,14 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
expect(tool).toBeDefined();
const missingTitle = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
initialPrompt: "test",
@@ -651,7 +675,7 @@ describe("create_agent MCP tool", () => {
expect(missingTitle.error.issues[0].path).toEqual(["title"]);
const tooLong = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "x".repeat(61),
@@ -661,7 +685,7 @@ describe("create_agent MCP tool", () => {
expect(tooLong.error.issues[0].path).toEqual(["title"]);
const ok = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
@@ -676,11 +700,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
@@ -693,6 +718,88 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("requires an explicit workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
relationship: { kind: "detached" },
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
expect(parsed.success).toBe(false);
expect(
parsed.error.issues.some(
(issue: { path: Array<string | number> }) => issue.path[0] === "workspace",
),
).toBe(true);
});
it("rejects caller-only relationship and workspace intents without a caller agent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("relationship subagent requires an agent-scoped MCP session");
await expect(
tool.handler({
...detachedCurrentWorkspace(),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("workspace current requires an agent-scoped MCP session");
});
it("requires a caller workspace for current workspace intent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
}),
).rejects.toThrow("Caller agent parent-agent has no current workspace");
});
it("accepts provider features and passes them through createAgent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
@@ -708,11 +815,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const input = {
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Feature test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -732,7 +840,7 @@ describe("create_agent MCP tool", () => {
featureValues: { fast_mode: true },
}),
undefined,
undefined,
{ workspaceId: "workspace-created" },
);
});
@@ -760,11 +868,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Mode test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -780,12 +889,13 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
const missingProvider = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
initialPrompt: "test",
@@ -798,7 +908,7 @@ describe("create_agent MCP tool", () => {
).toBe(true);
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "codex",
@@ -807,7 +917,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithoutModel.success).toBe(false);
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "codex/",
@@ -816,7 +926,7 @@ describe("create_agent MCP tool", () => {
expect(providerWithEmptyModel.success).toBe(false);
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "/gpt-5.4",
@@ -826,7 +936,7 @@ describe("create_agent MCP tool", () => {
await expect(
tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
settings: { modeId: "default" },
title: "Short title",
provider: "codex/gpt-5.4",
@@ -836,7 +946,7 @@ describe("create_agent MCP tool", () => {
).rejects.toThrow("Unrecognized key");
});
it("accepts optional worktree intent fields in create_agent input validation", async () => {
it("accepts worktree workspace intent in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -847,20 +957,19 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedWorktreeWorkspace(existingCwd, {
kind: "checkout-pr",
githubPrNumber: 42,
}),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "test",
worktreeName: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
});
expect(parsed.success).toBe(true);
});
it("accepts each create_worktree target mode", async () => {
it("accepts each create_worktree target kind", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -871,9 +980,10 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_worktree");
for (const target of [
{ mode: "branch-off", newBranch: "feature-x", base: "main" },
{ mode: "checkout-branch", branch: "head-ref" },
{ mode: "checkout-pr", prNumber: 42 },
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
{ kind: "branch-off" },
{ kind: "checkout-branch", branch: "head-ref" },
{ kind: "checkout-pr", githubPrNumber: 42 },
] as const) {
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
@@ -903,13 +1013,14 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
cwd: "/path/that/does/not/exist",
...detachedDirectoryWorkspace("/path/that/does/not/exist"),
title: "Short title",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -932,11 +1043,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: " Fix auth bug ",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -948,7 +1060,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth bug",
}),
undefined,
undefined,
{ workspaceId: "workspace-created" },
);
});
@@ -967,11 +1079,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: " Fix auth ",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -982,7 +1095,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth",
}),
undefined,
undefined,
{ workspaceId: "workspace-created" },
);
});
@@ -1001,11 +1114,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Config test",
initialPrompt: "Do work",
provider: "codex/gpt-5.4",
@@ -1022,7 +1136,7 @@ describe("create_agent MCP tool", () => {
thinkingOptionId: "think-hard",
}),
undefined,
{ labels: { source: "mcp" } },
{ labels: { source: "mcp" }, workspaceId: "workspace-created" },
);
});
@@ -1077,12 +1191,14 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: repoDir,
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
worktreeSlug: "agent-worktree",
baseBranch: "main",
}),
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
worktreeName: "agent-worktree",
baseBranch: "main",
background: true,
});
@@ -1156,12 +1272,13 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: repoDir,
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
baseBranch: "main",
}),
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
action: "branch-off",
baseBranch: "main",
background: true,
});
@@ -1241,12 +1358,13 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: repoDir,
...detachedWorktreeWorkspace(repoDir, {
kind: "checkout-branch",
branch: "existing-feature",
}),
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
action: "checkout",
refName: "existing-feature",
background: true,
});
@@ -1333,11 +1451,13 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: REPO_CWD,
...detachedWorktreeWorkspace(REPO_CWD, {
kind: "checkout-pr",
githubPrNumber: 123,
}),
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
githubPrNumber: 123,
background: true,
});
@@ -1408,7 +1528,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_worktree");
const response = await tool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "tool-worktree", base: "main" },
target: { kind: "branch-off", worktreeSlug: "tool-worktree", baseBranch: "main" },
});
expect(response.structuredContent.branchName).toBe("tool-worktree");
@@ -1483,7 +1603,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-tool-worktree", base: "main" },
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
listActiveWorkspaces.mockImplementation(async () => [
@@ -1585,7 +1705,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-multi-worktree", base: "main" },
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
@@ -1661,7 +1781,7 @@ describe("create_agent MCP tool", () => {
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { mode: "branch-off", newBranch: "archive-slug-worktree", base: "main" },
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
});
const response = await archiveTool.handler({
@@ -1736,7 +1856,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Custom provider agent",
settings: { modeId: "default" },
provider: "zai/custom-model",
@@ -1754,6 +1874,7 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "voice-agent",
cwd: baseDir,
workspaceId: "wks_voice",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
@@ -1780,7 +1901,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: "subdir",
...subagentCurrentWorkspace("subdir"),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1796,6 +1917,7 @@ describe("create_agent MCP tool", () => {
[PARENT_AGENT_ID_LABEL]: "voice-agent",
source: "voice",
},
workspaceId: "wks_voice",
},
);
await rm(baseDir, { recursive: true, force: true });
@@ -1806,6 +1928,7 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
@@ -1821,6 +1944,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1829,6 +1953,7 @@ describe("create_agent MCP tool", () => {
).rejects.toThrow(/Unrecognized key/);
const parsed = await tool.inputSchema.safeParseAsync({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1838,7 +1963,8 @@ describe("create_agent MCP tool", () => {
throw new Error("Expected caller create_agent input to parse");
}
expect(parsed.data).toMatchObject({
detached: false,
relationship: { kind: "subagent" },
workspace: { kind: "current" },
notifyOnFinish: true,
});
});
@@ -1848,6 +1974,7 @@ describe("create_agent MCP tool", () => {
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
@@ -1876,6 +2003,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const response = await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1891,6 +2019,7 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent);
@@ -1913,10 +2042,10 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedCurrentWorkspace(),
title: "Detached",
provider: "codex/gpt-5.4",
initialPrompt: "Take over",
detached: true,
labels: {
[PARENT_AGENT_ID_LABEL]: "spoofed-parent",
source: "handoff",
@@ -1932,6 +2061,7 @@ describe("create_agent MCP tool", () => {
labels: {
source: "handoff",
},
workspaceId: "wks_parent",
},
);
});
@@ -1941,6 +2071,7 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
@@ -1967,6 +2098,7 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
const input = {
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -1989,6 +2121,7 @@ describe("create_agent MCP tool", () => {
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
workspaceId: "wks_parent",
},
);
});
@@ -2018,6 +2151,7 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
const result = await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
@@ -2047,11 +2181,12 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Injected config test",
settings: { modeId: "auto" },
provider: "codex/gpt-5.4",
@@ -2065,7 +2200,7 @@ describe("create_agent MCP tool", () => {
});
expect(configArg.mcpServers).toBeUndefined();
expect(agentIdArg).toBeUndefined();
expect(optionsArg).toBeUndefined();
expect(optionsArg).toEqual({ workspaceId: "workspace-created" });
});
it("rejects an explicit mode that is not valid for the target provider", async () => {
@@ -2078,13 +2213,14 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: providerSnapshot.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Bad mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "bypassPermissions" },
@@ -2125,12 +2261,13 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Dynamic mode",
provider: "codex/gpt-5.4",
settings: { modeId: "dynamic" },
@@ -2140,7 +2277,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "dynamic" }),
undefined,
undefined,
{ workspaceId: "workspace-created" },
);
});
@@ -2163,12 +2300,13 @@ describe("create_agent MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: providerSnapshot.manager,
ensureWorkspaceForCreate,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
...detachedDirectoryWorkspace(existingCwd),
title: "Legacy mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "full-access" },
@@ -2178,7 +2316,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined,
undefined,
{ workspaceId: "workspace-created" },
);
});
@@ -2187,6 +2325,7 @@ describe("create_agent MCP tool", () => {
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent;
@@ -2214,6 +2353,7 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "claude/claude-sonnet-4-20250514",
initialPrompt: "Do work",
@@ -2237,6 +2377,7 @@ describe("create_agent MCP tool", () => {
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "claude",
currentModeId: "bypassPermissions",
} as ManagedAgent);
@@ -2258,6 +2399,7 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "opencode/gpt-5.4",
settings: { modeId: "build" },
@@ -2272,6 +2414,151 @@ describe("create_agent MCP tool", () => {
});
});
describe("send_agent_prompt MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
it("defaults agent-scoped prompts to background finish notifications", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "running",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
const parsed = await tool.inputSchema.safeParseAsync({
agentId: "child-agent",
prompt: "Follow up",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected caller send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: true,
notifyOnFinish: true,
});
const response = await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
);
});
it("keeps top-level prompts blocking by default", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
const parsed = await tool.inputSchema.safeParseAsync({
agentId: "child-agent",
prompt: "Follow up",
});
expect(parsed.success).toBe(true);
if (!parsed.success) {
throw new Error("Expected top-level send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: false,
notifyOnFinish: false,
});
await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
});
it("does not arm a finish notification for blocking agent-scoped prompts", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
cwd: existingCwd,
workspaceId: "wks_parent",
provider: "codex",
currentModeId: "full-access",
} as ManagedAgent;
const childAgent = {
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child" },
} as ManagedAgent;
spies.agentManager.getAgent.mockImplementation((agentId: string) => {
if (agentId === "parent-agent") return parentAgent;
if (agentId === "child-agent") return childAgent;
return null;
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: "child-agent",
prompt: "Follow up",
background: false,
});
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
});
});
describe("update_agent MCP tool", () => {
const logger = createTestLogger();

View File

@@ -34,7 +34,7 @@ import {
type ArchiveDependencies,
} from "../workspace-archive-service.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { createAgentCommand } from "./create-agent/create.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "./create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -100,8 +100,7 @@ export interface AgentMcpServerOptions {
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh workspace for a cwd and returns its id, used when an agent is
// created with no parent and no worktree.
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
paseoHome?: string;
worktreesRoot?: string;
@@ -747,11 +746,91 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.describe("Draft provider feature values."),
})
.strict();
const agentToAgentInputSchema = {
cwd: z
.string()
.optional()
.describe("Optional working directory. Defaults to your current working directory."),
const AgentRelationshipInputSchema = z.discriminatedUnion("kind", [
z
.object({ kind: z.literal("subagent") })
.strict()
.describe("Create a child agent under this agent's subagent track."),
z
.object({ kind: z.literal("detached") })
.strict()
.describe("Create a root agent that does not appear in this agent's subagent track."),
]);
const AgentCreateWorktreeTargetInputSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("branch-off"),
worktreeSlug: z
.string()
.min(1)
.optional()
.describe("Optional worktree branch/slug. Omit to let Paseo generate one."),
baseBranch: z
.string()
.min(1)
.optional()
.describe("Optional base branch. Defaults to the repository default branch."),
})
.strict()
.describe("Create a new branch in a new Paseo worktree."),
z
.object({
kind: z.literal("checkout-branch"),
branch: z.string().min(1).describe("Existing branch to check out."),
})
.strict()
.describe("Check out an existing branch in a new Paseo worktree."),
z
.object({
kind: z.literal("checkout-pr"),
githubPrNumber: z.number().int().positive().describe("GitHub pull request number."),
})
.strict()
.describe("Check out a GitHub pull request in a new Paseo worktree."),
]);
const AgentWorkspaceInputSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("current"),
cwd: z.string().optional().describe("Optional runtime cwd. Defaults to the caller's cwd."),
})
.strict()
.describe("Use the caller's current workspace."),
z
.object({
kind: z.literal("create"),
source: z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("directory"),
path: z
.string()
.optional()
.describe("Optional directory path. Defaults to the caller's cwd."),
})
.strict(),
z
.object({
kind: z.literal("worktree"),
cwd: z
.string()
.optional()
.describe("Optional source repository. Defaults to the caller's cwd."),
target: AgentCreateWorktreeTargetInputSchema,
})
.strict(),
]),
})
.strict()
.describe("Create a new workspace for the agent."),
]);
const commonCreateAgentInputSchema = {
relationship: AgentRelationshipInputSchema.describe(
"Whether the created agent is a subagent under you or a detached root agent.",
),
workspace: AgentWorkspaceInputSchema.describe(
"Workspace ownership/location for the created agent.",
),
title: z
.string()
.trim()
@@ -770,13 +849,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
detached: z
.boolean()
.optional()
.default(false)
.describe(
"If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.",
),
};
const agentToAgentInputSchema = {
...commonCreateAgentInputSchema,
notifyOnFinish: z
.boolean()
.optional()
@@ -785,48 +860,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.",
),
};
const topLevelInputSchema = {
cwd: z
.string()
.describe("Required working directory for the agent (absolute, relative, or ~)."),
title: z
.string()
.trim()
.min(1, "Title is required")
.max(60, "Title must be 60 characters or fewer")
.describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."),
provider: ProviderModelInputSchema.describe(
"Required provider/model pair, for example codex/gpt-5.4.",
),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
settings: CreateAgentSettingsInputSchema.optional().describe(
"Initial runtime settings for the new agent.",
),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
worktreeName: z
.string()
.optional()
.describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."),
baseBranch: z
.string()
.optional()
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
refName: z.string().min(1).optional().describe("Optional source ref for worktree creation."),
action: z
.enum(["branch-off", "checkout"])
.optional()
.describe("Optional worktree creation action."),
githubPrNumber: z
.number()
.int()
.positive()
.optional()
.describe("Optional GitHub pull request number to checkout."),
...commonCreateAgentInputSchema,
background: z
.boolean()
.optional()
@@ -846,6 +881,48 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
const commonSendAgentPromptInputSchema = {
agentId: z.string(),
prompt: z.string(),
sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."),
};
const agentToAgentSendAgentPromptInputSchema = {
...commonSendAgentPromptInputSchema,
background: z
.boolean()
.optional()
.default(true)
.describe(
"Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(true)
.describe(
"Get notified when the prompted agent finishes, errors, or needs permission. Set false only for truly fire-and-forget prompts.",
),
};
const topLevelSendAgentPromptInputSchema = {
...commonSendAgentPromptInputSchema,
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(false)
.describe(
"Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.",
),
};
const sendAgentPromptInputSchema = callerAgentId
? agentToAgentSendAgentPromptInputSchema
: topLevelSendAgentPromptInputSchema;
const inspectProviderInputSchema = {
provider: ProviderOrProviderModelInputSchema.describe(
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
@@ -909,7 +986,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "Create agent",
description:
"Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Do not guess; call list_providers and list_models first if uncertain. Optionally run an initial prompt immediately or create a git worktree for the agent.",
"Create an agent. Requires relationship, workspace, provider/model (for example codex/gpt-5.4), and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -924,19 +1001,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
},
async (args: unknown) => {
const resolvedArgs = resolveCreateAgentToolArgs(args);
const resolvedArgs = await resolveCreateAgentToolArgs(args);
const { parsedArgs, worktree } = resolvedArgs;
let requestedBackground: boolean;
let notifyOnFinish: boolean;
let detached: boolean;
if (resolvedArgs.kind === "agent-scoped") {
requestedBackground = true;
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish;
detached = resolvedArgs.parsedArgs.detached;
notifyOnFinish = parsedArgs.notifyOnFinish;
detached = resolvedArgs.relationship.kind === "detached";
} else {
requestedBackground = resolvedArgs.parsedArgs.background;
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false;
detached = false;
detached = resolvedArgs.parsedArgs.relationship.kind === "detached";
}
const {
snapshot,
@@ -961,7 +1038,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
provider: parsedArgs.provider,
title: parsedArgs.title,
initialPrompt: parsedArgs.initialPrompt,
cwd: parsedArgs.cwd,
cwd: resolvedArgs.cwd,
workspaceId: resolvedArgs.workspaceId,
thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features,
labels: parsedArgs.labels,
@@ -1033,48 +1111,113 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
| {
kind: "agent-scoped";
parsedArgs: AgentToAgentCreateAgentArgs;
worktree: undefined;
relationship: AgentToAgentCreateAgentArgs["relationship"];
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
}
| {
kind: "top-level";
parsedArgs: TopLevelCreateAgentArgs;
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
};
function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs {
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
if (callerAgentId) {
const parsed = agentToAgentCreateAgentArgsSchema.parse(args);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace);
return {
kind: "agent-scoped",
parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args),
worktree: undefined,
parsedArgs: parsed,
relationship: parsed.relationship,
cwd,
workspaceId,
worktree,
};
}
const parsedArgs = topLevelCreateAgentArgsSchema.parse(args);
if (parsedArgs.relationship.kind === "subagent") {
throw new Error("relationship subagent requires an agent-scoped MCP session");
}
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace);
return {
kind: "top-level",
parsedArgs,
worktree: resolveTopLevelCreateAgentWorktree(parsedArgs),
cwd,
workspaceId,
worktree,
};
}
function resolveTopLevelCreateAgentWorktree(args: TopLevelCreateAgentArgs):
| {
worktreeName?: string;
baseBranch?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
async function resolveCreateAgentWorkspace(
workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"],
): Promise<{
cwd: string | undefined;
workspaceId: string | undefined;
worktree: CreateAgentFromMcpInput["worktree"];
}> {
if (workspace.kind === "current") {
if (!callerAgentId) {
throw new Error("workspace current requires an agent-scoped MCP session");
}
| undefined {
const callerAgent = resolveCallerAgent();
if (!callerAgent?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
}
return {
cwd: workspace.cwd,
workspaceId: callerAgent.workspaceId,
worktree: undefined,
};
}
if (workspace.source.kind === "directory") {
const cwd = resolveScopedCwd(workspace.source.path, { required: true });
if (!options.ensureWorkspaceForCreate) {
throw new Error("Workspace creation is not configured");
}
return {
cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd),
worktree: undefined,
};
}
const cwd = resolveScopedCwd(workspace.source.cwd, { required: true });
return {
worktreeName: args.worktreeName,
baseBranch: args.baseBranch,
refName: args.refName,
action: args.action,
githubPrNumber: args.githubPrNumber,
cwd,
workspaceId: undefined,
worktree: resolveCreateAgentWorktree(workspace.source.target),
};
}
function resolveCreateAgentWorktree(
target: z.infer<typeof AgentCreateWorktreeTargetInputSchema>,
): NonNullable<CreateAgentFromMcpInput["worktree"]> {
switch (target.kind) {
case "branch-off":
return {
action: "branch-off",
worktreeName: target.worktreeSlug,
baseBranch: target.baseBranch,
};
case "checkout-branch":
return {
action: "checkout",
refName: target.branch,
};
case "checkout-pr":
return {
action: "checkout",
githubPrNumber: target.githubPrNumber,
};
default:
throw new Error("unreachable");
}
}
registerTool(
"wait_for_agent",
{
@@ -1157,40 +1300,27 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
{
title: "Send agent prompt",
description:
"Send a task to a running agent. Returns immediately after the agent begins processing.",
inputSchema: {
agentId: z.string(),
prompt: z.string(),
sessionMode: z
.string()
.optional()
.describe("Optional mode to set before running the prompt."),
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
notifyOnFinish: z
.boolean()
.optional()
.default(false)
.describe(
"Agent-scoped only: get notified when this run finishes, errors, or needs permission.",
),
},
"Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.",
inputSchema: sendAgentPromptInputSchema,
outputSchema: {
success: z.boolean(),
status: AgentStatusEnum,
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
guidance: z.string().optional(),
},
},
async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => {
async ({
agentId,
prompt,
sessionMode,
background = Boolean(callerAgentId),
notifyOnFinish = Boolean(callerAgentId),
}) => {
if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
}
const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background);
await sendPromptToAgent({
agentManager,
@@ -1201,7 +1331,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
logger: childLogger,
});
if (notifyOnFinish && callerAgentId) {
if (shouldNotifyOnFinish && callerAgentId) {
setupFinishNotification({
agentManager,
agentStorage,
@@ -1241,6 +1371,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
status: currentSnapshot?.lifecycle ?? "idle",
lastMessage: null,
permission: null,
...(shouldNotifyOnFinish
? {
guidance:
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
}
: {}),
};
const validJson = ensureValidJson(responseData);
@@ -2151,33 +2287,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"Create a Paseo-managed git worktree. Branch off a new branch, check out an existing branch, or check out a GitHub PR.",
inputSchema: {
cwd: z.string().optional().describe("Repository directory. Defaults to the agent's cwd."),
target: z
.discriminatedUnion("mode", [
z
.object({
mode: z.literal("branch-off"),
newBranch: z.string().min(1).describe("Name for the new branch."),
base: z
.string()
.min(1)
.optional()
.describe("Base ref. Defaults to the repo's default branch."),
})
.describe("Create a new branch off a base."),
z
.object({
mode: z.literal("checkout-branch"),
branch: z.string().min(1).describe("Existing branch to check out."),
})
.describe("Check out an existing branch."),
z
.object({
mode: z.literal("checkout-pr"),
prNumber: z.number().int().positive().describe("Pull request number."),
})
.describe("Check out a GitHub pull request."),
])
.describe("What the worktree should contain."),
target: AgentCreateWorktreeTargetInputSchema.describe("What the worktree should contain."),
},
outputSchema: {
branchName: z.string(),
@@ -2420,9 +2530,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}
type McpCreateWorktreeTarget =
| { mode: "branch-off"; newBranch: string; base?: string }
| { mode: "checkout-branch"; branch: string }
| { mode: "checkout-pr"; prNumber: number };
| { kind: "branch-off"; worktreeSlug?: string; baseBranch?: string }
| { kind: "checkout-branch"; branch: string }
| { kind: "checkout-pr"; githubPrNumber: number };
interface ArchiveWorktreeCommandContext {
agentManager: AgentManager;
@@ -2489,18 +2599,18 @@ function createMcpWorktreeCommandInput(
target: McpCreateWorktreeTarget,
): CreatePaseoWorktreeCommandInput {
const base = { cwd: repoRoot } as const;
switch (target.mode) {
switch (target.kind) {
case "branch-off":
return {
...base,
worktreeSlug: target.newBranch,
worktreeSlug: target.worktreeSlug,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
...(target.baseBranch ? { refName: target.baseBranch } : {}),
};
case "checkout-branch":
return { ...base, action: "checkout", refName: target.branch };
case "checkout-pr":
return { ...base, action: "checkout", githubPrNumber: target.prNumber };
return { ...base, action: "checkout", githubPrNumber: target.githubPrNumber };
default:
throw new Error("unreachable");
}

View File

@@ -54,7 +54,14 @@ The receiving agent has zero context. Include:
## Launch
Create the agent via Paseo with a `[Handoff] <task>` title, the briefing as initial prompt, `detached: true`, and cwd set to the worktree path if `--worktree`. Leave `notifyOnFinish` omitted unless the user explicitly wants no callback.
Create the agent via Paseo with a `[Handoff] <task>` title, the briefing as initial prompt, and `relationship: { kind: "detached" }`.
Use `workspace` for placement:
- No worktree: `workspace: { kind: "current" }`.
- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "<short-task-branch>" } } }`.
Leave `notifyOnFinish` omitted unless the user explicitly wants no callback.
Handoff agents are siblings/root agents, not your subagents. They must survive you being archived and must not appear in your subagent track.

View File

@@ -7,11 +7,11 @@ Paseo is a daemon that supervises AI coding agents on your machine. Control it t
## Worktrees
**`create_worktree`** — three modes:
**`create_worktree`** — same target union as `create_agent.workspace.source.worktree.target`:
- From a PR: `{ githubPrNumber: 503 }`.
- Branch off a base: `{ action: "branch-off", branchName: "fix/foo", baseBranch: "main" }`.
- Checkout an existing ref: `{ action: "checkout", refName: "feat/bar" }`.
- From a PR: `{ target: { kind: "checkout-pr", githubPrNumber: 503 } }`.
- Branch off a base: `{ target: { kind: "branch-off", worktreeSlug: "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.
@@ -20,21 +20,30 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo.
## Agents
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `notifyOnFinish`, `settings`, `detached`. Returns `{ agentId, … }`.
**`create_agent`** — required: `relationship`, `workspace`, `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, … }`.
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.
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
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.
### Agent relationships
Agents you create default to **your subagents**: omit `detached` or pass `detached: false`. Use this for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task. Subagents appear under you and are archived with you.
`relationship` controls parentage only:
Pass `detached: true` only when the agent you create should stand on its own, not help you finish your task. Use this for handoffs and fire-and-forget delegations the user may continue after you are archived. Detached agents do not appear in your subagent track and are not archived with you.
- `{ kind: "subagent" }` — child under your subagents track. Use for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task.
- `{ kind: "detached" }` — root/sibling agent. Use for handoffs and fire-and-forget delegations the user may continue after you are archived.
For subagents, leave `notifyOnFinish` omitted or set it to `true`. You will get notified when the created agent finishes, errors, or needs permission. Set `notifyOnFinish: false` only when the created agent is truly fire-and-forget and you do not need to follow up.
`workspace` controls placement only:
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent.
- `{ kind: "current" }` — same workspace as the caller, with optional `cwd`.
- `{ 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: "checkout-branch", branch: string } } }`
- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-pr", githubPrNumber: number } } }`
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Agent-scoped prompt calls default to `background: true` and `notifyOnFinish: true`; top-level calls default to blocking with no callback. For a synchronous follow-up, pass `background: false` and use the returned result.
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
@@ -94,7 +103,7 @@ If the file is missing, use sensible defaults and tell the user once.
Agents take time — 1030+ minutes is routine. Favor asynchronous workflows.
For `create_agent`, leave `notifyOnFinish` omitted or set it to `true` unless the created agent is truly fire-and-forget. You will get notified when the created agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own.
For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own.
Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.