From eaecd8d0a70e50f8720e321b10b116f4c0a670c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 25 Jun 2026 13:20:51 +0800 Subject: [PATCH] Prepare providers for direct Paseo tools (#1707) * refactor(agent): decouple Paseo tools from MCP Keep MCP as the fallback adapter while exposing the same runtime catalog for providers that can register tools directly. * fix(protocol): keep native tool support server-internal * fix(i18n): sync Japanese shortcut labels * fix(agent): preserve schedule provider error --- docs/architecture.md | 7 +- docs/custom-providers.md | 2 +- docs/providers.md | 2 + .../src/server/agent/agent-manager.test.ts | 79 + .../server/src/server/agent/agent-manager.ts | 64 +- .../src/server/agent/agent-mcp.e2e.test.ts | 120 +- .../src/server/agent/agent-sdk-types.ts | 7 + .../src/server/agent/mcp-server.test.ts | 16 +- .../server/src/server/agent/mcp-server.ts | 2654 +--------------- .../src/server/agent/tools/paseo-tools.ts | 2771 +++++++++++++++++ .../server/src/server/agent/tools/types.ts | 44 + packages/server/src/server/bootstrap.ts | 161 +- packages/server/src/server/session.ts | 53 - .../server/test-utils/fake-agent-client.ts | 21 +- 14 files changed, 3237 insertions(+), 2764 deletions(-) create mode 100644 packages/server/src/server/agent/tools/paseo-tools.ts create mode 100644 packages/server/src/server/agent/tools/types.ts diff --git a/docs/architecture.md b/docs/architecture.md index 21e1a8a63..c1d3389dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ The heart of Paseo. A Node.js process that: - Listens for WebSocket connections from clients - Manages agent lifecycle (create, run, stop, resume, archive) - Streams agent output in real time via a timeline model -- Exposes an MCP server for agent-to-agent control +- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter - Optionally connects outbound to a relay for remote access All paths are under `packages/server/src/`. @@ -62,7 +62,8 @@ All paths are under `packages/server/src/`. | `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | | `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | | `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | -| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts | +| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees | +| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK | | `server/agent/providers/` | Provider adapters (see "Agent providers" below) | | `server/relay-transport.ts` | Outbound relay connection with E2E encryption | | `server/schedule/` | Cron-based scheduled agents | @@ -289,6 +290,8 @@ All providers: - Map tool calls to a normalized `ToolCallDetail` type - Expose provider-specific modes (plan, default, full-access) +Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`. + ## Data flow: running an agent 1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index eaa7eac0a..bf4b70d0e 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -438,7 +438,7 @@ Required fields for ACP providers: - `label` - `command` — the command to spawn the agent process (must support ACP over stdio) -By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: +Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: ```json { diff --git a/docs/providers.md b/docs/providers.md index 6d080ba33..8f8ff6d7d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types. Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. +Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`. + Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index dd7b7210f..a8da1c141 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -33,6 +33,7 @@ import type { AgentTimelineItem, ImportProviderSessionInput, } from "./agent-sdk-types.js"; +import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; interface Deferred { @@ -1054,6 +1055,84 @@ test("createAgent injects paseo MCP server only into provider launch config", as }); }); +test("createAgent passes native Paseo tools through launch context without internal MCP", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + const paseoTools: PaseoToolCatalog = { + tools: new Map(), + getTool: () => undefined, + executeTool: async () => { + throw new Error("No tools registered in test catalog"); + }, + }; + + class NativeToolsClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + supportsNativePaseoTools: true, + }; + lastConfig: AgentSessionConfig | null = null; + lastLaunchContext: AgentLaunchContext | undefined; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastConfig = config; + this.lastLaunchContext = launchContext; + return new TestAgentSession(config); + } + } + + const client = new NativeToolsClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + paseoToolCatalogFactory: () => paseoTools, + idFactory: () => "00000000-0000-4000-8000-000000000106", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + mcpServers: { + custom: { + type: "stdio", + command: "custom-mcp", + }, + }, + }); + + expect(client.lastLaunchContext?.paseoTools).toBe(paseoTools); + expect(client.lastConfig?.mcpServers).toEqual({ + custom: { + type: "stdio", + command: "custom-mcp", + }, + }); + expect(snapshot.config.mcpServers).toEqual({ + custom: { + type: "stdio", + command: "custom-mcp", + }, + }); + + const stored = await storage.get(snapshot.id); + expect(stored?.config?.mcpServers).toEqual({ + custom: { + type: "stdio", + command: "custom-mcp", + }, + }); +}); + test("createAgent injects the MCP auth token as a bearer header into the launch config", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c27cdb170..fcb9bd099 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -64,6 +64,7 @@ import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; +import type { PaseoToolCatalogFactory } from "./tools/types.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -202,6 +203,8 @@ export interface AgentManagerOptions { terminalManager?: TerminalManager | null; mcpBaseUrl?: string; mcpAuthToken?: string; + paseoToolsEnabled?: boolean; + paseoToolCatalogFactory?: PaseoToolCatalogFactory; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -521,6 +524,8 @@ export class AgentManager { private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; + private paseoToolsEnabled = true; + private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -536,6 +541,7 @@ export class AgentManager { this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged; this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.mcpAuthToken = options?.mcpAuthToken ?? null; + this.configurePaseoTools(options); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -558,6 +564,11 @@ export class AgentManager { }); } + private configurePaseoTools(options: AgentManagerOptions): void { + this.paseoToolsEnabled = options.paseoToolsEnabled ?? true; + this.paseoToolCatalogFactory = options.paseoToolCatalogFactory ?? null; + } + registerClient(provider: AgentProvider, client: AgentClient): void { this.clients.set(provider, client); } @@ -594,6 +605,14 @@ export class AgentManager { this.mcpBaseUrl = url; } + setPaseoToolsEnabled(enabled: boolean): void { + this.paseoToolsEnabled = enabled; + } + + setPaseoToolCatalogFactory(factory: PaseoToolCatalogFactory | null): void { + this.paseoToolCatalogFactory = factory; + } + /** * Capability token the daemon's own MCP clients must present to the Agent MCP * endpoint when a daemon password is configured. Read by the per-client @@ -898,12 +917,13 @@ export class AgentManager { const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId); this.requireEnabledProvider(storedConfig.provider); - const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env); const client = await this.requireAvailableClient({ provider: storedConfig.provider, }); + const launchContext = await this.buildLaunchContext(resolvedAgentId, client, options?.env); + const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); - const session = await client.createSession(launchConfig, launchContext, createOptions); + const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); return this.registerSession(session, storedConfig, resolvedAgentId, { labels: options?.labels, initialTitle: options?.initialTitle, @@ -948,7 +968,6 @@ export class AgentManager { resolvedAgentId, ); - const launchContext = this.buildLaunchContext(resolvedAgentId); const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -956,7 +975,9 @@ export class AgentManager { `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, ); } - const session = await client.resumeSession(handle, launchConfig, launchContext); + const launchContext = await this.buildLaunchContext(resolvedAgentId, client); + const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); + const session = await client.resumeSession(handle, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, options); } @@ -982,13 +1003,14 @@ export class AgentManager { }, resolvedAgentId, ); - const launchContext = this.buildLaunchContext(resolvedAgentId); + const launchContext = await this.buildLaunchContext(resolvedAgentId, client); + const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { providerHandleId: input.providerHandleId, cwd: input.cwd, }, - { config: launchConfig, storedConfig, launchContext }, + { config: providerLaunchConfig, storedConfig, launchContext }, ); const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config)); const timelineRows = buildImportedTimelineRows(imported.timeline); @@ -1036,11 +1058,12 @@ export class AgentManager { provider, } as AgentSessionConfig; const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); - const launchContext = this.buildLaunchContext(agentId); + const launchContext = await this.buildLaunchContext(agentId, client); + const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = handle - ? await client.resumeSession(handle, launchConfig, launchContext) - : await client.createSession(launchConfig, launchContext); + ? await client.resumeSession(handle, providerLaunchConfig, launchContext) + : await client.createSession(providerLaunchConfig, launchContext); this.agentStreamCoalescer.flushAndDiscard(agentId); // Remove the existing agent entry before swapping sessions @@ -3696,14 +3719,33 @@ export class AgentManager { : next; } - private buildLaunchContext(agentId: string, env?: Record): AgentLaunchContext { - return { + private async buildLaunchContext( + agentId: string, + client: AgentClient, + env?: Record, + ): Promise { + const context: AgentLaunchContext = { agentId, env: { ...env, PASEO_AGENT_ID: agentId, }, }; + if ( + this.paseoToolsEnabled && + client.capabilities.supportsNativePaseoTools && + this.paseoToolCatalogFactory + ) { + context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId }); + } + return context; + } + + private resolveProviderLaunchConfig( + launchConfig: AgentSessionConfig, + launchContext: AgentLaunchContext, + ): AgentSessionConfig { + return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; } private async requireAvailableClient(options: { provider: AgentProvider }): Promise { diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 933d625f9..b8ffaefe9 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -89,6 +89,63 @@ async function createMcpClient(url: string, authToken?: string): Promise rawClient.close() }; } +interface LaunchRecorder { + recordedLaunches: AgentSessionConfig[]; +} + +class RecordingAgentClient implements AgentClient { + readonly provider: AgentClient["provider"]; + readonly capabilities: AgentClient["capabilities"]; + + constructor( + private readonly inner: AgentClient, + private readonly recorder: LaunchRecorder, + ) { + this.provider = inner.provider; + this.capabilities = { + ...inner.capabilities, + supportsMcpServers: true, + supportsNativePaseoTools: false, + }; + } + + async createSession( + ...args: Parameters + ): ReturnType { + this.recorder.recordedLaunches.push(args[0]); + return this.inner.createSession(...args); + } + + async resumeSession( + ...args: Parameters + ): ReturnType { + return this.inner.resumeSession(...args); + } + + async fetchCatalog( + ...args: Parameters + ): ReturnType { + return this.inner.fetchCatalog(...args); + } + + async isAvailable(): Promise { + return this.inner.isAvailable(); + } +} + +function createMcpRecordingAgentClients(recorder: LaunchRecorder) { + const clients = createTestAgentClients(); + const claude = clients.claude; + if (!claude) { + throw new Error("Fake Claude client is not configured"); + } + + return { + ...clients, + claude: new RecordingAgentClient(claude, recorder), + }; +} + async function waitForAgentCompletion(options: { client: McpClient; agentId: string; @@ -256,6 +313,7 @@ describe("agent MCP end-to-end (offline)", () => { const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-")); const port = await getAvailablePort(); + const recorder: LaunchRecorder = { recordedLaunches: [] }; const daemonConfig: PaseoDaemonConfig = { listen: `127.0.0.1:${port}`, @@ -265,7 +323,7 @@ describe("agent MCP end-to-end (offline)", () => { mcpEnabled: true, staticDir, mcpDebug: false, - agentClients: createTestAgentClients(), + agentClients: createMcpRecordingAgentClients(recorder), agentStoragePath: path.join(paseoHome, "agents"), }; @@ -278,6 +336,7 @@ describe("agent MCP end-to-end (offline)", () => { const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-")); const disabledAgentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-disabled-")); const disabledPort = await getAvailablePort(); + const disabledRecorder: LaunchRecorder = { recordedLaunches: [] }; const disabledDaemonConfig: PaseoDaemonConfig = { listen: `127.0.0.1:${disabledPort}`, paseoHome: disabledPaseoHome, @@ -287,7 +346,7 @@ describe("agent MCP end-to-end (offline)", () => { mcpInjectIntoAgents: false, staticDir: disabledStaticDir, mcpDebug: false, - agentClients: createTestAgentClients(), + agentClients: createMcpRecordingAgentClients(disabledRecorder), agentStoragePath: path.join(disabledPaseoHome, "agents"), }; const disabledDaemon = await createPaseoDaemon(disabledDaemonConfig, pino({ level: "silent" })); @@ -313,13 +372,14 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - const injectedAgent = daemon.agentManager.getAgent(agentId!); - expect(injectedAgent?.config.mcpServers).toMatchObject({ + expect(recorder.recordedLaunches.at(-1)?.mcpServers).toMatchObject({ paseo: { type: "http", url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`, }, }); + const injectedAgent = daemon.agentManager.getAgent(agentId!); + expect(injectedAgent?.config.mcpServers?.paseo).toBeUndefined(); const disabledResult = await disabledClient.callTool({ name: "create_agent", @@ -337,6 +397,7 @@ describe("agent MCP end-to-end (offline)", () => { typeof disabledPayload?.agentId === "string" ? disabledPayload.agentId : null; expect(disabledAgentId).toBeTruthy(); + expect(disabledRecorder.recordedLaunches.at(-1)?.mcpServers?.paseo).toBeUndefined(); const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!); expect(disabledAgent?.config.mcpServers?.paseo).toBeUndefined(); } finally { @@ -364,6 +425,7 @@ describe("agent MCP end-to-end (offline)", () => { const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-")); const port = await getAvailablePort(); + const recorder: LaunchRecorder = { recordedLaunches: [] }; const daemonConfig: PaseoDaemonConfig = { listen: `0.0.0.0:${port}`, @@ -373,7 +435,7 @@ describe("agent MCP end-to-end (offline)", () => { mcpEnabled: true, staticDir, mcpDebug: false, - agentClients: createTestAgentClients(), + agentClients: createMcpRecordingAgentClients(recorder), agentStoragePath: path.join(paseoHome, "agents"), }; @@ -399,13 +461,14 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - const injectedAgent = daemon.agentManager.getAgent(agentId!); - expect(injectedAgent?.config.mcpServers).toMatchObject({ + expect(recorder.recordedLaunches.at(-1)?.mcpServers).toMatchObject({ paseo: { type: "http", url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`, }, }); + const injectedAgent = daemon.agentManager.getAgent(agentId!); + expect(injectedAgent?.config.mcpServers?.paseo).toBeUndefined(); } finally { if (agentId) { await client.callTool({ name: "kill_agent", args: { agentId } }); @@ -568,6 +631,23 @@ describe("agent MCP end-to-end (offline)", () => { return true; } + async fetchCatalog(): Promise<{ + models: Array<{ provider: "codex"; id: string; label: string; isDefault: boolean }>; + modes: Array<{ id: string; label: string; description: string }>; + }> { + return { + models: [ + { + provider: "codex", + id: "gpt-5.4-mini", + label: "gpt-5.4-mini", + isDefault: true, + }, + ], + modes: [{ id: "full-access", label: "Full access", description: "No prompts" }], + }; + } + async createSession(_config: AgentSessionConfig): Promise { return new StartTurnFailureSession(); } @@ -605,6 +685,7 @@ describe("agent MCP end-to-end (offline)", () => { const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); + let agentId: string | null = null; try { const result = await client.callTool({ name: "create_agent", @@ -618,14 +699,25 @@ describe("agent MCP end-to-end (offline)", () => { }, }); - expect(result.isError).toBe(true); - const contentItem = result.content?.[0]; - const contentText: string | undefined = - contentItem != null && typeof contentItem === "object" - ? Reflect.get(contentItem, "text") - : undefined; - expect(contentText ?? "").toContain("Initial turn failed to start"); + const payload = getStructuredContent(result); + agentId = typeof payload?.agentId === "string" ? payload.agentId : null; + expect(agentId).toBeTruthy(); + + await waitForAgentCompletion({ client, agentId: agentId! }); + const statusResult = await client.callTool({ + name: "get_agent_status", + args: { agentId }, + }); + const statusPayload = getStructuredContent(statusResult); + expect(statusPayload?.status).toBe("error"); + const snapshot = statusPayload?.snapshot; + const lastError = + snapshot && typeof snapshot === "object" ? Reflect.get(snapshot, "lastError") : undefined; + expect(lastError).toContain("Initial turn failed to start"); } finally { + if (agentId) { + await client.callTool({ name: "kill_agent", args: { agentId } }); + } await client.close(); await daemon.stop(); await rm(paseoHome, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 42546aa2c..667983f2f 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,6 +1,7 @@ import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk"; import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PaseoToolCatalog } from "./tools/types.js"; export type { AgentProviderNotice }; @@ -169,6 +170,7 @@ export interface AgentCapabilityFlags { supportsSessionListing?: boolean; supportsDynamicModes: boolean; supportsMcpServers: boolean; + supportsNativePaseoTools?: boolean; supportsReasoningStream: boolean; supportsToolInvocations: boolean; supportsRewindConversation?: boolean; @@ -577,6 +579,11 @@ export interface AgentSessionConfig { export interface AgentLaunchContext { agentId?: string; env?: Record; + /** + * Runtime-only internal Paseo tools. This must never be persisted into + * AgentSessionConfig; providers may adapt it to their native tool surface. + */ + paseoTools?: PaseoToolCatalog; } export interface AgentCreateSessionOptions { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 9b1db10c4..a3e4aa27f 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -722,7 +722,7 @@ describe("create_agent MCP tool", () => { ).toBe(true); }); - it("requires an explicit workspace", async () => { + it("rejects partial explicit workspace shape", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, @@ -740,12 +740,10 @@ describe("create_agent MCP tool", () => { initialPrompt: "test", }); - expect(parsed.success).toBe(false); - expect( - parsed.error.issues.some( - (issue: { path: Array }) => issue.path[0] === "workspace", - ), - ).toBe(true); + expect(parsed.success).toBe(true); + await expect(tool.handler(parsed.data)).rejects.toThrow( + "relationship and workspace must be provided together", + ); }); it("rejects caller-only relationship and workspace intents without a caller agent", async () => { @@ -765,7 +763,7 @@ describe("create_agent MCP tool", () => { provider: "codex/gpt-5.4", initialPrompt: "test", }), - ).rejects.toThrow("relationship subagent requires an agent-scoped MCP session"); + ).rejects.toThrow("relationship subagent requires an agent-scoped tool session"); await expect( tool.handler({ @@ -774,7 +772,7 @@ describe("create_agent MCP tool", () => { provider: "codex/gpt-5.4", initialPrompt: "test", }), - ).rejects.toThrow("workspace current requires an agent-scoped MCP session"); + ).rejects.toThrow("workspace current requires an agent-scoped tool session"); }); it("requires a caller workspace for current workspace intent", async () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 1887a7263..5e9204b77 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -1,172 +1,18 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { ensureValidJson } from "../json-utils.js"; -import type { Logger } from "pino"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { CallToolResult, ServerNotification, ServerRequest, } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; -import type { AgentMode, AgentProvider } from "./agent-sdk-types.js"; -import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; -import { - AgentFeatureSchema, - AgentPermissionRequestPayloadSchema, - AgentListItemPayloadSchema, - AgentPermissionResponseSchema, - AgentSnapshotPayloadSchema, -} from "../messages.js"; -import type { AgentListItemPayload } from "../messages.js"; -import { - buildStoredAgentPayload, - toAgentListItemPayload, - toAgentPayload, -} from "./agent-projections.js"; -import { curateAgentActivity } from "./activity-curator.js"; -import { selectItemsByProjectedLimit } from "./timeline-projection.js"; -import type { AgentStorage } from "./agent-storage.js"; -import { ensureAgentLoaded } from "./agent-loading.js"; -import { isStoredAgentProviderAvailable } from "../persistence-hooks.js"; -import { - killTerminalsForWorkspace, - type ArchiveDependencies, -} from "../workspace-archive-service.js"; -import { WaitForAgentTracker } from "./wait-for-agent-tracker.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"; -import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js"; -import type { ScheduleService } from "../schedule/service.js"; -import { - ScheduleRunSchema, - ScheduleSummarySchema, - StoredScheduleSchema, - type ScheduleCadence, - type UpdateScheduleInput, -} from "@getpaseo/protocol/schedule/types"; -import { resolveSnapshotCwd, type ProviderSnapshotManager } from "./provider-snapshot-manager.js"; -import { - AgentModelSchema, - AgentProviderEnum, - AgentStatusEnum, - ProviderModeSchema, - ProviderSummarySchema, - parseDurationString, - resolveRequiredProviderModel, - sanitizePermissionRequest, - serializeSnapshotWithMetadata, - toScheduleSummary, - waitForAgentWithTimeout, -} from "./mcp-shared.js"; -import { sendPromptToAgent, setupFinishNotification } from "./agent-prompt.js"; -import { respondToAgentPermission } from "./permission-response.js"; -import { - archiveAgentCommand, - cancelAgentRunCommand, - closeAgentCommand, - setAgentModeCommand, - updateAgentCommand, -} from "./lifecycle-command.js"; -import type { GitHubService } from "../../services/github-service.js"; -import type { WorkspaceGitService } from "../workspace-git-service.js"; -import { WorktreeRequestError } from "../worktree-errors.js"; -import { - archiveCommand, - type ArchiveCommandDependencies, - createPaseoWorktreeCommand, - type CreatePaseoWorktreeCommandInput, - listPaseoWorktreesCommand, -} from "../worktree/commands.js"; +import { createPaseoToolCatalog, type PaseoToolHostDependencies } from "./tools/paseo-tools.js"; +import type { PaseoToolResult } from "./tools/types.js"; -export interface AgentMcpServerOptions { - agentManager: AgentManager; - agentStorage: AgentStorage; - terminalManager?: TerminalManager | null; - getDaemonTcpPort?: () => number | null; - scheduleService?: ScheduleService | null; - providerSnapshotManager: ProviderSnapshotManager; - github?: GitHubService; - workspaceGitService?: Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" | "resolveRepoRoot" - >; - findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; - listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; - archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; - emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; - markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; - clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; - createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; - // Mints a fresh directory workspace for a cwd and returns its id. - ensureWorkspaceForCreate?: (cwd: string) => Promise; - paseoHome?: string; - worktreesRoot?: string; - /** - * ID of the agent that is connecting to this MCP server. - * Used for cwd/mode inheritance when agents spawn child agents. - */ - callerAgentId?: string; - /** - * Optional resolver for session-bound speak handlers. - * Used by hidden voice agents to narrate through daemon-managed TTS. - */ - resolveSpeakHandler?: (callerAgentId: string) => VoiceSpeakHandler | null; - resolveCallerContext?: (callerAgentId: string) => VoiceCallerContext | null; - enableVoiceTools?: boolean; - voiceOnly?: boolean; - logger: Logger; -} +export type AgentMcpServerOptions = PaseoToolHostDependencies; -function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult { - if (result.structuredContent === undefined || result.content.length > 0) { - return result; - } - - return { - ...result, - content: [ - { - type: "text", - text: formatStructuredContentForModel(result.structuredContent), - }, - ], - }; -} - -function formatStructuredContentForModel(structuredContent: unknown): string { - if ( - !structuredContent || - typeof structuredContent !== "object" || - Array.isArray(structuredContent) - ) { - return JSON.stringify(structuredContent, null, 2); - } - - const record = structuredContent as Record; - const summary: string[] = []; - for (const [key, value] of Object.entries(record)) { - if (!Array.isArray(value)) { - continue; - } - summary.push(`${key}_count=${value.length}`); - const ids = value - .map((item) => - item && typeof item === "object" && !Array.isArray(item) - ? (item as Record).id - : null, - ) - .filter((id): id is string => typeof id === "string" && id.length > 0); - if (ids.length === value.length && ids.length > 0) { - summary.push(`${key}_ids=${ids.join(",")}`); - } - } - - const json = JSON.stringify(structuredContent, null, 2); - return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json; -} +type McpToolContext = RequestHandlerExtra; function isZodSchema(value: unknown): value is z.ZodType { return ( @@ -201,2464 +47,84 @@ function relaxMcpToolOutputSchema( } as TConfig; } -type McpToolContext = RequestHandlerExtra; - -function parseTimestamp(value: string | null | undefined): number { - if (!value) { - return 0; - } - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? 0 : parsed; -} - -function resolveAgentListActivityTime(agent: AgentListItemPayload): number { - return Math.max( - parseTimestamp(agent.updatedAt), - parseTimestamp(agent.lastUserMessageAt), - parseTimestamp(agent.attentionTimestamp), - parseTimestamp(agent.archivedAt), - parseTimestamp(agent.createdAt), - ); -} - -interface ProviderSummary { - id: AgentProvider; - label: string; - description: string; - enabled: boolean; - modes: AgentMode[]; - status: string; - error?: string; -} - -function toProviderSummary(entry: { - provider: AgentProvider; - label?: string; - description?: string; - enabled: boolean; - modes?: AgentMode[]; - status: string; - error?: string; -}): ProviderSummary { - return { - id: entry.provider, - label: entry.label ?? entry.provider, - description: entry.description ?? "", - enabled: entry.enabled, - modes: entry.modes ?? [], - status: entry.status === "ready" ? "available" : entry.status, - ...(entry.error ? { error: entry.error } : {}), - }; -} - -function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { - const attentionDelta = - Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); - if (attentionDelta !== 0) { - return attentionDelta; +function formatStructuredContentForModel(structuredContent: unknown): string { + if ( + !structuredContent || + typeof structuredContent !== "object" || + Array.isArray(structuredContent) + ) { + return JSON.stringify(structuredContent, null, 2); } - const statusOrder = { - running: 0, - initializing: 1, - idle: 2, - error: 3, - closed: 4, - } as Record; - const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); - if (statusDelta !== 0) { - return statusDelta; + const record = structuredContent as Record; + const summary: string[] = []; + for (const [key, value] of Object.entries(record)) { + if (!Array.isArray(value)) { + continue; + } + summary.push(`${key}_count=${value.length}`); + const ids = value + .map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record).id + : null, + ) + .filter((id): id is string => typeof id === "string" && id.length > 0); + if (ids.length === value.length && ids.length > 0) { + summary.push(`${key}_ids=${ids.join(",")}`); + } } - return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); + const json = JSON.stringify(structuredContent, null, 2); + return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json; } -function resolveScheduleProviderAndModel(params: { - provider?: string; - defaultProvider: AgentProvider; -}): { provider: AgentProvider; model?: string } { - const providerInput = params.provider?.trim() || params.defaultProvider; - const slashIndex = providerInput.indexOf("/"); - if (slashIndex === -1) { - return { provider: providerInput }; - } - - const provider = providerInput.slice(0, slashIndex).trim(); - const model = providerInput.slice(slashIndex + 1).trim(); - if (!provider || !model) { - throw new Error("provider must be or /"); +function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult { + if (result.structuredContent === undefined || result.content.length > 0) { + return result; } return { - provider: provider, - model, + ...result, + content: [ + { + type: "text", + text: formatStructuredContentForModel(result.structuredContent), + }, + ], }; } -function resolveScheduleUpdateProviderAndModel(params: { - provider?: string; - model?: string | null; -}): { provider?: string; model?: string | null } { - const providerInput = params.provider?.trim(); - const modelInput = typeof params.model === "string" ? params.model.trim() : params.model; - - if (params.model !== undefined && modelInput === "") { - throw new Error("model cannot be empty"); - } - - if (!providerInput) { - return params.model !== undefined ? { model: modelInput } : {}; - } - - const slashIndex = providerInput.indexOf("/"); - if (slashIndex === -1) { - return { - provider: providerInput, - ...(params.model !== undefined ? { model: modelInput } : {}), - }; - } - - const provider = providerInput.slice(0, slashIndex).trim(); - const modelFromProvider = providerInput.slice(slashIndex + 1).trim(); - if (!provider || !modelFromProvider) { - throw new Error("provider must be or /"); - } - if (params.model === null) { - throw new Error("provider specifies a model but model is null"); - } - if (typeof modelInput === "string" && modelInput !== modelFromProvider) { - throw new Error("Conflicting model values provided"); - } - - return { - provider, - model: modelInput ?? modelFromProvider, - }; -} - -interface ScheduleUpdateToolInput { - id: string; - every?: string; - cron?: string; - timezone?: string; - name?: string | null; - prompt?: string; - maxRuns?: number | null; - provider?: string; - model?: string | null; - mode?: string | null; - cwd?: string; - expiresIn?: string; - clearExpires?: boolean; -} - -function normalizeScheduleCadenceArg(value: string | undefined): string | undefined { - if (value === undefined) { - return undefined; - } - - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - return trimmed; -} - -function normalizeScheduleTimeZoneArg(value: string | undefined): string | undefined { - return normalizeScheduleCadenceArg(value); -} - -function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined { - const every = normalizeScheduleCadenceArg(input.every); - const cron = normalizeScheduleCadenceArg(input.cron); - const timeZone = normalizeScheduleTimeZoneArg(input.timezone); - - if (every !== undefined && cron !== undefined) { - throw new Error("Specify at most one of every or cron"); - } - if (timeZone !== undefined && cron === undefined) { - throw new Error("timezone can only be used with cron"); - } - if (every !== undefined) { - return { type: "every", everyMs: parseDurationString(every) }; - } - if (cron !== undefined) { - return { - type: "cron", - expression: cron, - ...(timeZone !== undefined ? { timezone: timeZone } : {}), - }; - } - return undefined; -} - -function resolveScheduleUpdateExpiresAt(input: ScheduleUpdateToolInput): string | null | undefined { - if (input.expiresIn !== undefined && input.clearExpires) { - throw new Error("Specify at most one of expiresIn or clearExpires"); - } - if (input.expiresIn !== undefined) { - return new Date(Date.now() + parseDurationString(input.expiresIn)).toISOString(); - } - if (input.clearExpires) { - return null; - } - return undefined; -} - -function buildScheduleUpdateInput(input: ScheduleUpdateToolInput): UpdateScheduleInput { - const cadence = resolveScheduleUpdateCadence(input); - const expiresAt = resolveScheduleUpdateExpiresAt(input); - const providerModelPatch = resolveScheduleUpdateProviderAndModel({ - provider: input.provider, - model: input.model, +function toMcpToolResult(result: PaseoToolResult): CallToolResult { + return addModelVisibleStructuredContent({ + content: result.content as CallToolResult["content"], + ...(result.structuredContent !== undefined + ? { structuredContent: result.structuredContent as CallToolResult["structuredContent"] } + : {}), + ...(result.isError !== undefined ? { isError: result.isError } : {}), }); - const newAgentConfig = { - ...(providerModelPatch.provider !== undefined ? { provider: providerModelPatch.provider } : {}), - ...(providerModelPatch.model !== undefined ? { model: providerModelPatch.model } : {}), - ...(input.mode !== undefined ? { modeId: input.mode } : {}), - ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), - }; - - return { - id: input.id, - ...(input.name !== undefined ? { name: input.name } : {}), - ...(input.prompt !== undefined ? { prompt: input.prompt } : {}), - ...(cadence !== undefined ? { cadence } : {}), - ...(input.maxRuns !== undefined ? { maxRuns: input.maxRuns } : {}), - ...(expiresAt !== undefined ? { expiresAt } : {}), - ...(Object.keys(newAgentConfig).length > 0 ? { newAgentConfig } : {}), - }; -} - -function resolveChildAgentCwd(params: { - parentCwd: string; - requestedCwd?: string; - lockedCwd?: string; - allowCustomCwd: boolean; -}): string { - const lockedCwd = params.lockedCwd?.trim(); - if (lockedCwd) { - return expandUserPath(lockedCwd); - } - - const requestedCwd = params.requestedCwd?.trim(); - if (!requestedCwd || !params.allowCustomCwd) { - return params.parentCwd; - } - - return resolvePathFromBase(params.parentCwd, requestedCwd); -} - -const TerminalSummarySchema = z.object({ - id: z.string(), - name: z.string(), - cwd: z.string(), -}); - -const WorktreeSummarySchema = z.object({ - path: z.string(), - createdAt: z.string(), - branchName: z.string().optional(), - head: z.string().optional(), -}); - -function resolveTerminalKeyToken(key: string, literal: boolean): string { - if (literal) { - return key; - } - - switch (key) { - case "Enter": - return "\r"; - case "Tab": - return "\t"; - case "Escape": - return "\u001b"; - case "Space": - return " "; - case "BSpace": - return "\u007f"; - case "C-c": - return "\u0003"; - case "C-d": - return "\u0004"; - case "C-z": - return "\u001a"; - case "C-l": - return "\u000c"; - case "C-a": - return "\u0001"; - case "C-e": - return "\u0005"; - default: - return key; - } } export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise { - const { - agentManager, - agentStorage, - terminalManager, - scheduleService, - providerSnapshotManager, - callerAgentId, - resolveSpeakHandler, - resolveCallerContext, - logger, - } = options; - const childLogger = logger.child({ module: "agent", component: "mcp-server" }); - const waitTracker = new WaitForAgentTracker(logger); - const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null; - + const catalog = await createPaseoToolCatalog(options); const server = new McpServer({ name: "agent-mcp", version: "2.0.0", }); - const registerRawTool = server.registerTool.bind(server); - const registerTool: McpServer["registerTool"] = (name, config, handler) => - registerRawTool(name, relaxMcpToolOutputSchema(config), (async (args: never, extra: never) => - addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler); - const buildCronScheduleCadence = (input: { - cron: string | undefined; - timezone?: string; - }): ScheduleCadence => { - const expression = input.cron?.trim() ?? ""; - if (!expression) { - throw new Error("cron is required"); - } - const timezone = normalizeScheduleTimeZoneArg(input.timezone); - return { - type: "cron", - expression, - ...(timezone !== undefined ? { timezone } : {}), - }; - }; - - const buildScheduleExpiry = (expiresIn: string | undefined): string | undefined => { - return expiresIn === undefined - ? undefined - : new Date(Date.now() + parseDurationString(expiresIn)).toISOString(); - }; - - const resolveCallerAgent = () => { - if (!callerAgentId) { - return null; - } - const parentAgent = agentManager.getAgent(callerAgentId); - if (!parentAgent) { - throw new Error(`Parent agent ${callerAgentId} not found`); - } - return parentAgent; - }; - - const resolveScopedCwd = (requestedCwd?: string, opts?: { required?: boolean }): string => { - const callerAgent = resolveCallerAgent(); - if (callerAgent) { - return resolveChildAgentCwd({ - parentCwd: callerAgent.cwd, - requestedCwd, - lockedCwd: callerContext?.lockedCwd, - allowCustomCwd: callerContext?.allowCustomCwd ?? true, - }); - } - - const trimmedCwd = requestedCwd?.trim(); - if (!trimmedCwd) { - if (opts?.required) { - throw new Error("cwd is required"); - } - throw new Error("cwd is required outside an agent-scoped session"); - } - - return expandUserPath(trimmedCwd); - }; - - async function resolveTerminalWorkspaceId(resolvedCwd: string): Promise { - // An MCP-spawned terminal belongs to the caller agent's workspace. Only if - // the caller has no workspace do we mint one for the cwd. - const callerAgent = callerAgentId ? agentManager.getAgent(callerAgentId) : null; - if (callerAgent?.workspaceId) { - return callerAgent.workspaceId; - } - - if (!options.ensureWorkspaceForCreate) { - throw new Error( - callerAgentId - ? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured` - : "workspaceId is required outside an agent-scoped session", - ); - } - - return options.ensureWorkspaceForCreate(resolvedCwd); - } - - const buildCallerAgentScheduleConfigExtras = ( - callerAgent: NonNullable>, - ): Record => { - return { - ...(callerAgent.config.thinkingOptionId - ? { thinkingOptionId: callerAgent.config.thinkingOptionId } - : {}), - ...(callerAgent.config.approvalPolicy - ? { approvalPolicy: callerAgent.config.approvalPolicy } - : {}), - ...(callerAgent.config.sandboxMode ? { sandboxMode: callerAgent.config.sandboxMode } : {}), - ...(typeof callerAgent.config.networkAccess === "boolean" - ? { networkAccess: callerAgent.config.networkAccess } - : {}), - ...(typeof callerAgent.config.webSearch === "boolean" - ? { webSearch: callerAgent.config.webSearch } - : {}), - ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), - ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), - ...(callerAgent.config.featureValues - ? { featureValues: callerAgent.config.featureValues } - : {}), - ...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}), - ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), - }; - }; - - const buildCallerAgentScheduleConfig = ( - callerAgent: NonNullable>, - params?: { provider?: string; cwd?: string }, - ) => { - const hasProviderOverride = params?.provider !== undefined; - const resolvedProviderModel = hasProviderOverride - ? resolveScheduleProviderAndModel({ - provider: params?.provider, - defaultProvider: callerAgent.provider, - }) - : null; - const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider; - let resolvedModel: string | undefined; - if (resolvedProviderModel?.model) { - resolvedModel = resolvedProviderModel.model; - } else if (!hasProviderOverride && callerAgent.config.model) { - resolvedModel = callerAgent.config.model; - } - return { - provider: resolvedProvider, - cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd, - ...(callerAgent.currentModeId && callerAgent.provider === resolvedProvider - ? { - modeId: callerAgent.currentModeId, - } - : {}), - ...(resolvedModel ? { model: resolvedModel } : {}), - ...buildCallerAgentScheduleConfigExtras(callerAgent), - }; - }; - - const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { - if (!params?.provider?.trim()) { - throw new Error("provider is required when target is new-agent"); - } - - const callerAgent = resolveCallerAgent(); - if (callerAgent) { - return { - type: "new-agent" as const, - config: buildCallerAgentScheduleConfig(callerAgent, params), - }; - } - - const resolvedProviderModel = resolveScheduleProviderAndModel({ - provider: params?.provider, - defaultProvider: params.provider, - }); - return { - type: "new-agent" as const, - config: { - provider: resolvedProviderModel.provider, - cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), - ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), - }, - }; - }; - const ProviderModelInputSchema = AgentProviderEnum.trim() - .refine((value) => value.includes("/"), { - message: "provider must be provider/model, for example codex/gpt-5.4", - }) - .refine( - (value) => { - try { - resolveRequiredProviderModel(value); - return true; - } catch { - return false; - } - }, - { message: "provider must be provider/model, for example codex/gpt-5.4" }, - ); - const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim() - .min(1, "provider is required") - .refine( - (value) => { - if (!value.includes("/")) { - return true; - } - try { - resolveRequiredProviderModel(value); - return true; - } catch { - return false; - } - }, - { message: "provider must be provider or provider/model, for example codex/gpt-5.4" }, - ); - const CreateAgentSettingsInputSchema = z - .object({ - modeId: z.string().optional().describe("Session mode to configure before the first run."), - thinkingOptionId: z.string().optional().describe("Thinking option ID."), - features: z - .record(z.string(), z.unknown()) - .optional() - .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), - }) - .strict(); - const UpdateAgentSettingsInputSchema = z - .object({ - modeId: z.string().optional().describe("Session mode ID."), - model: z.string().nullable().optional().describe("Model ID. Pass null to clear."), - thinkingOptionId: z - .string() - .nullable() - .optional() - .describe("Thinking option ID. Pass null to clear."), - features: z - .record(z.string(), z.unknown()) - .optional() - .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), - }) - .strict(); - const InspectProviderSettingsInputSchema = z - .object({ - modeId: z.string().optional().describe("Draft session mode ID."), - model: z.string().optional().describe("Draft model ID."), - thinkingOptionId: z.string().optional().describe("Draft thinking option ID."), - features: z - .record(z.string(), z.unknown()) - .optional() - .describe("Draft provider feature values."), - }) - .strict(); - 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 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) - .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("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"), - 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() - .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."), - }; - const agentToAgentInputSchema = { - ...commonCreateAgentInputSchema, - notifyOnFinish: z - .boolean() - .optional() - .default(true) - .describe( - "Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.", - ), - }; - const topLevelInputSchema = { - ...commonCreateAgentInputSchema, - 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 created agent finishes, errors, or needs permission.", - ), - }; - - 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).", - ), - cwd: z - .string() - .optional() - .describe("Working directory used to resolve provider feature availability."), - settings: InspectProviderSettingsInputSchema.optional().describe( - "Draft provider settings used to compute available features.", - ), - }; - type AgentToAgentCreateAgentArgs = z.infer; - type TopLevelCreateAgentArgs = z.infer; - - if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { - registerTool( - "speak", - { - title: "Speak", - description: - "Speak text to the user via daemon-managed voice output. Blocks until playback completes.", - inputSchema: { - text: z - .string() - .trim() - .min(1, "text is required") - .max(4000, "text must be 4000 characters or fewer"), - }, - outputSchema: { - ok: z.boolean(), - }, - }, - async (args, context?: McpToolContext) => { - if (!callerAgentId) { - throw new Error("speak is only available to agent-scoped MCP sessions"); - } - const handler = resolveSpeakHandler?.(callerAgentId) ?? null; - if (!handler) { - throw new Error(`No speak handler registered for your session '${callerAgentId}'`); - } - await handler({ - text: args.text, - callerAgentId, - signal: context?.signal, - }); - return { - content: [], - structuredContent: ensureValidJson({ ok: true }), - }; - }, + for (const tool of catalog.tools.values()) { + server.registerTool( + tool.name, + relaxMcpToolOutputSchema({ + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + }), + async (args: unknown, context?: McpToolContext) => + toMcpToolResult(await catalog.executeTool(tool.name, args, { signal: context?.signal })), ); } - if (options.voiceOnly) { - return server; - } - - registerTool( - "create_agent", - { - title: "Create agent", - description: - "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(), - type: AgentProviderEnum, - status: AgentStatusEnum, - cwd: z.string(), - workspaceId: z.string().optional(), - currentModeId: z.string().nullable(), - availableModes: z.array(ProviderModeSchema), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), - guidance: z.string().optional(), - }, - }, - async (args: unknown) => { - 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 = parsedArgs.notifyOnFinish; - detached = resolvedArgs.relationship.kind === "detached"; - } else { - requestedBackground = resolvedArgs.parsedArgs.background; - notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; - detached = resolvedArgs.parsedArgs.relationship.kind === "detached"; - } - const { - snapshot, - background: createdInBackground, - initialPromptStarted, - } = await createAgentCommand( - { - agentManager, - agentStorage, - logger: childLogger, - paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, - terminalManager, - providerSnapshotManager, - createPaseoWorktree: options.createPaseoWorktree, - ...(options.ensureWorkspaceForCreate - ? { ensureWorkspaceForCreate: options.ensureWorkspaceForCreate } - : {}), - }, - { - kind: "mcp", - provider: parsedArgs.provider, - title: parsedArgs.title, - initialPrompt: parsedArgs.initialPrompt, - cwd: resolvedArgs.cwd, - workspaceId: resolvedArgs.workspaceId, - thinking: parsedArgs.settings?.thinkingOptionId, - features: parsedArgs.settings?.features, - labels: parsedArgs.labels, - mode: parsedArgs.settings?.modeId, - background: requestedBackground, - notifyOnFinish, - detached, - callerAgentId, - callerContext, - worktree, - }, - ); - - try { - if (!createdInBackground && initialPromptStarted) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; - const responseData = { - agentId: snapshot.id, - type: snapshot.provider, - status: result.status, - cwd: liveSnapshot.cwd, - ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), - currentModeId: liveSnapshot.currentModeId, - availableModes: liveSnapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - throw error; - } - - // Return immediately for async creation. - const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; - const guidance = - callerAgentId && notifyOnFinish && initialPromptStarted - ? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives." - : undefined; - const response = { - content: [], - structuredContent: ensureValidJson({ - agentId: currentSnapshot.id, - type: snapshot.provider, - status: currentSnapshot.lifecycle, - cwd: currentSnapshot.cwd, - ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), - currentModeId: currentSnapshot.currentModeId, - availableModes: currentSnapshot.availableModes, - lastMessage: null, - permission: null, - ...(guidance ? { guidance } : {}), - }), - }; - return response; - }, - ); - - type ResolvedCreateAgentToolArgs = - | { - kind: "agent-scoped"; - parsedArgs: AgentToAgentCreateAgentArgs; - relationship: AgentToAgentCreateAgentArgs["relationship"]; - cwd: string | undefined; - workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; - } - | { - kind: "top-level"; - parsedArgs: TopLevelCreateAgentArgs; - cwd: string | undefined; - workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; - }; - - async function resolveCreateAgentToolArgs(args: unknown): Promise { - if (callerAgentId) { - const parsed = agentToAgentCreateAgentArgsSchema.parse(args); - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace); - return { - kind: "agent-scoped", - 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, - cwd, - workspaceId, - worktree, - }; - } - - 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"); - } - 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.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) { - 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 { - cwd, - workspaceId: undefined, - worktree: resolveCreateAgentWorktree(workspace.source.target), - }; - } - - function resolveCreateAgentWorktree( - target: z.infer, - ): NonNullable { - switch (target.kind) { - case "branch-off": - return { - action: "branch-off", - worktreeName: target.worktreeSlug, - branchName: target.branchName, - 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", - { - title: "Wait for agent", - description: - "Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.", - inputSchema: { - agentId: z.string().describe("Agent identifier returned by the create_agent tool"), - }, - outputSchema: { - agentId: z.string(), - status: AgentStatusEnum, - permission: AgentPermissionRequestPayloadSchema.nullable(), - lastMessage: z.string().nullable(), - }, - }, - async ({ agentId }, { signal }) => { - const abortController = new AbortController(); - const cleanupFns: Array<() => void> = []; - - const cleanup = () => { - while (cleanupFns.length) { - const fn = cleanupFns.pop(); - try { - fn?.(); - } catch { - // ignore cleanup errors - } - } - }; - - const forwardExternalAbort = () => { - if (!abortController.signal.aborted) { - const reason = signal?.reason ?? new Error("wait_for_agent aborted"); - abortController.abort(reason); - } - }; - - if (signal) { - if (signal.aborted) { - forwardExternalAbort(); - } else { - signal.addEventListener("abort", forwardExternalAbort, { once: true }); - cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort)); - } - } - - const unregister = waitTracker.register(agentId, (reason) => { - if (!abortController.signal.aborted) { - abortController.abort(new Error(reason ?? "wait_for_agent cancelled")); - } - }); - cleanupFns.push(unregister); - - try { - const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, { - signal: abortController.signal, - }); - - const validJson = ensureValidJson({ - agentId, - status: result.status, - permission: sanitizePermissionRequest(result.permission), - lastMessage: result.lastMessage, - }); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } finally { - cleanup(); - } - }, - ); - - registerTool( - "send_agent_prompt", - { - title: "Send agent prompt", - description: - "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 = 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, - agentStorage, - agentId, - prompt, - sessionMode, - logger: childLogger, - }); - - if (shouldNotifyOnFinish && callerAgentId) { - setupFinishNotification({ - agentManager, - agentStorage, - childAgentId: agentId, - callerAgentId, - logger: childLogger, - }); - } - - // If not running in background, wait for completion - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, - }); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - - // Return immediately if background=true - // Re-fetch snapshot since the state may have changed - const currentSnapshot = agentManager.getAgent(agentId); - - const responseData = { - success: true, - 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); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - }, - ); - - registerTool( - "get_agent_status", - { - title: "Get agent status", - description: - "Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - status: AgentStatusEnum, - snapshot: AgentSnapshotPayloadSchema, - }, - }, - async ({ agentId }) => { - const snapshot = agentManager.getAgent(agentId); - if (snapshot) { - const structuredSnapshot = await serializeSnapshotWithMetadata( - agentStorage, - snapshot, - childLogger, - ); - return { - content: [], - structuredContent: ensureValidJson({ - status: snapshot.lifecycle, - snapshot: structuredSnapshot, - }), - }; - } - - const record = await agentStorage.get(agentId); - if (!record || record.internal) { - throw new Error(`Agent ${agentId} not found`); - } - - const structuredSnapshot = buildStoredAgentPayload( - record, - new Set(providerSnapshotManager.listRegisteredProviderIds()), - ); - return { - content: [], - structuredContent: ensureValidJson({ - status: structuredSnapshot.status, - snapshot: structuredSnapshot, - }), - }; - }, - ); - - registerTool( - "list_agents", - { - title: "List agents", - description: "List recent agents as compact metadata.", - inputSchema: { - includeArchived: z.boolean().optional().default(false), - cwd: z.string().optional(), - sinceHours: z - .number() - .int() - .positive() - .max(24 * 30) - .optional() - .default(48), - statuses: z.array(AgentStatusEnum).optional(), - limit: z.number().int().positive().max(200).optional().default(50), - }, - outputSchema: { - agents: z.array(AgentListItemPayloadSchema), - }, - }, - async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { - const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; - const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; - const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; - const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; - const liveSnapshots = agentManager.listAgents(); - const liveAgents = await Promise.all( - liveSnapshots.map((snapshot) => - serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); - const storedRecords = await agentStorage.list(); - const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); - const storedAgents = storedRecords - .filter((record) => !record.internal && !liveIds.has(record.id)) - .filter((record) => includeArchived || !record.archivedAt) - .filter( - (record) => - includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => buildStoredAgentPayload(record, registeredProviderIds)); - const agents = [...liveAgents, ...storedAgents] - .map(toAgentListItemPayload) - .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) - .filter((agent) => !statusFilter || statusFilter.has(agent.status)) - .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) - .sort(compareAgentListItems) - .slice(0, limit); - - return { - content: [], - structuredContent: ensureValidJson({ agents }), - }; - }, - ); - - registerTool( - "cancel_agent", - { - title: "Cancel agent run", - description: "Abort the agent's current run but keep the agent alive for future tasks.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - const { cancelled } = await cancelAgentRunCommand( - { agentManager, logger: childLogger }, - agentId, - ); - if (cancelled) { - waitTracker.cancel(agentId, "Agent run cancelled"); - } - return { - content: [], - structuredContent: ensureValidJson({ success: cancelled }), - }; - }, - ); - - registerTool( - "archive_agent", - { - title: "Archive agent", - description: - "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - await archiveAgentCommand( - { - agentManager, - agentStorage, - logger: childLogger, - }, - agentId, - ); - waitTracker.cancel(agentId, "Agent archived"); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "kill_agent", - { - title: "Kill agent", - description: "Terminate an agent session permanently.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - await closeAgentCommand({ agentManager }, agentId); - waitTracker.cancel(agentId, "Agent terminated"); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "update_agent", - { - title: "Update agent", - description: "Update an agent name, labels, and/or runtime settings.", - inputSchema: { - agentId: z.string(), - name: z.string().optional(), - labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), - settings: UpdateAgentSettingsInputSchema.optional().describe( - "Runtime settings to apply to the agent.", - ), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, name, labels, settings }) => { - if (settings?.modeId !== undefined) { - await agentManager.setAgentMode(agentId, settings.modeId); - } - if (settings?.model !== undefined) { - await agentManager.setAgentModel(agentId, settings.model); - } - if (settings?.thinkingOptionId !== undefined) { - await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); - } - if (settings?.features) { - for (const [featureId, value] of Object.entries(settings.features)) { - await agentManager.setAgentFeature(agentId, featureId, value); - } - } - - await updateAgentCommand({ agentManager }, { agentId, name, labels }); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "list_terminals", - { - title: "List terminals", - description: "List terminals for a working directory or across all working directories.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional working directory. Defaults to your current working directory."), - all: z.boolean().optional().describe("List terminals across all working directories."), - }, - outputSchema: { - terminals: z.array(TerminalSummarySchema), - }, - }, - async ({ cwd, all }) => { - if (!terminalManager) { - throw new Error("Terminal manager is not configured"); - } - - const terminals = all - ? ( - await Promise.all( - terminalManager.listDirectories().map(async (directory) => - (await terminalManager.getTerminals(directory)).map((terminal) => ({ - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd, - })), - ), - ) - ).flat() - : (await terminalManager.getTerminals(resolveScopedCwd(cwd, { required: true }))).map( - (terminal) => ({ - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd, - }), - ); - - return { - content: [], - structuredContent: ensureValidJson({ terminals }), - }; - }, - ); - - registerTool( - "create_terminal", - { - title: "Create terminal", - description: "Create a terminal session for a working directory.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional working directory. Defaults to your current working directory."), - name: z.string().optional().describe("Optional terminal name."), - }, - outputSchema: TerminalSummarySchema.shape, - }, - async ({ cwd, name }) => { - if (!terminalManager) { - throw new Error("Terminal manager is not configured"); - } - - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - const workspaceId = await resolveTerminalWorkspaceId(resolvedCwd); - - const terminal = await terminalManager.createTerminal({ - cwd: resolvedCwd, - workspaceId, - ...(name?.trim() ? { name: name.trim() } : {}), - }); - - return { - content: [], - structuredContent: ensureValidJson({ - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd, - }), - }; - }, - ); - - registerTool( - "kill_terminal", - { - title: "Kill terminal", - description: "Kill an existing terminal session.", - inputSchema: { - terminalId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ terminalId }) => { - if (!terminalManager) { - throw new Error("Terminal manager is not configured"); - } - - const terminal = terminalManager.getTerminal(terminalId); - if (!terminal) { - throw new Error(`Terminal ${terminalId} not found`); - } - - terminal.kill(); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "capture_terminal", - { - title: "Capture terminal", - description: "Capture plain-text terminal output lines from a terminal session.", - inputSchema: { - terminalId: z.string(), - start: z.number().optional(), - end: z.number().optional(), - scrollback: z.boolean().optional(), - stripAnsi: z.boolean().optional().default(true), - }, - outputSchema: { - terminalId: z.string(), - lines: z.array(z.string()), - totalLines: z.number().int().nonnegative(), - }, - }, - async ({ terminalId, start, end, scrollback, stripAnsi = true }) => { - if (!terminalManager) { - throw new Error("Terminal manager is not configured"); - } - - if (!terminalManager.getTerminal(terminalId)) { - throw new Error(`Terminal ${terminalId} not found`); - } - - const capture = await terminalManager.captureTerminal(terminalId, { - start: scrollback ? 0 : start, - end, - stripAnsi, - }); - - return { - content: [], - structuredContent: ensureValidJson({ - terminalId, - lines: capture.lines, - totalLines: capture.totalLines, - }), - }; - }, - ); - - registerTool( - "send_terminal_keys", - { - title: "Send terminal keys", - description: "Send literal text or special key tokens to a terminal session.", - inputSchema: { - terminalId: z.string(), - keys: z.string(), - literal: z.boolean().optional(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ terminalId, keys, literal = false }) => { - if (!terminalManager) { - throw new Error("Terminal manager is not configured"); - } - - const terminal = terminalManager.getTerminal(terminalId); - if (!terminal) { - throw new Error(`Terminal ${terminalId} not found`); - } - - terminal.send({ - type: "input", - data: resolveTerminalKeyToken(keys, literal), - }); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "create_schedule", - { - title: "Create schedule", - description: "Create a recurring schedule that starts a new agent on a cron cadence.", - inputSchema: { - prompt: z.string().trim().min(1, "prompt is required"), - cron: z.string().trim().min(1, "cron is required"), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe("IANA time zone for the cron cadence. For example: America/New_York."), - name: z.string().optional(), - provider: AgentProviderEnum.describe( - "Provider, or provider/model (for example: codex or codex/gpt-5.4).", - ), - cwd: z.string().optional(), - maxRuns: z.number().int().positive().optional(), - expiresIn: z.string().optional(), - }, - outputSchema: ScheduleSummarySchema.shape, - }, - async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const expiresAt = buildScheduleExpiry(expiresIn); - const schedule = await scheduleService.create({ - prompt: prompt.trim(), - cadence: buildCronScheduleCadence({ - cron, - ...(timezone !== undefined ? { timezone } : {}), - }), - target: resolveNewAgentScheduleTarget({ provider, cwd }), - ...(name?.trim() ? { name: name.trim() } : {}), - ...(maxRuns === undefined ? {} : { maxRuns }), - ...(expiresAt === undefined ? {} : { expiresAt }), - }); - - return { - content: [], - structuredContent: ensureValidJson(toScheduleSummary(schedule)), - }; - }, - ); - - registerTool( - "create_heartbeat", - { - title: "Create heartbeat", - description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.", - inputSchema: { - prompt: z.string().trim().min(1, "prompt is required"), - cron: z.string().trim().min(1, "cron is required"), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe("IANA time zone for the cron cadence. For example: America/New_York."), - name: z.string().optional(), - maxRuns: z.number().int().positive().optional(), - expiresIn: z.string().optional(), - }, - outputSchema: ScheduleSummarySchema.shape, - }, - async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - if (!callerAgentId) { - throw new Error("create_heartbeat requires an agent-scoped session"); - } - resolveCallerAgent(); - - const expiresAt = buildScheduleExpiry(expiresIn); - const schedule = await scheduleService.create({ - prompt: prompt.trim(), - cadence: buildCronScheduleCadence({ - cron, - ...(timezone !== undefined ? { timezone } : {}), - }), - target: { type: "agent", agentId: callerAgentId }, - ...(name?.trim() ? { name: name.trim() } : {}), - ...(maxRuns === undefined ? {} : { maxRuns }), - ...(expiresAt === undefined ? {} : { expiresAt }), - }); - - return { - content: [], - structuredContent: ensureValidJson(toScheduleSummary(schedule)), - }; - }, - ); - - registerTool( - "list_schedules", - { - title: "List schedules", - description: "List all schedules managed by the daemon.", - inputSchema: {}, - outputSchema: { - schedules: z.array(ScheduleSummarySchema), - }, - }, - async () => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedules = (await scheduleService.list()).map((schedule) => - toScheduleSummary(schedule), - ); - return { - content: [], - structuredContent: ensureValidJson({ schedules }), - }; - }, - ); - - registerTool( - "inspect_schedule", - { - title: "Inspect schedule", - description: "Inspect a schedule and its run history.", - inputSchema: { - id: z.string(), - }, - outputSchema: StoredScheduleSchema.shape, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedule = await scheduleService.inspect(id); - return { - content: [], - structuredContent: ensureValidJson(schedule), - }; - }, - ); - - registerTool( - "pause_schedule", - { - title: "Pause schedule", - description: "Pause an active schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.pause(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "resume_schedule", - { - title: "Resume schedule", - description: "Resume a paused schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.resume(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "delete_schedule", - { - title: "Delete schedule", - description: "Delete a schedule permanently.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.delete(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "update_schedule", - { - title: "Update schedule", - description: - "Update an existing schedule. Only provided fields are changed; omitted fields remain unchanged.", - inputSchema: { - id: z.string(), - every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), - cron: z.string().optional().describe("New cron expression."), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe( - "IANA time zone for cron cadence; requires cron. For example: America/New_York.", - ), - name: z.string().nullable().optional().describe("New name (null to clear)."), - prompt: z.string().trim().min(1).optional().describe("New prompt text."), - maxRuns: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe("New max runs limit (null to clear)."), - provider: z - .string() - .trim() - .min(1) - .optional() - .describe("New provider for new-agent target."), - model: z - .string() - .trim() - .min(1) - .nullable() - .optional() - .describe("New model for new-agent target (null to clear)."), - mode: z - .string() - .trim() - .min(1) - .nullable() - .optional() - .describe("New mode for new-agent target (null to clear)."), - cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), - expiresIn: z - .string() - .optional() - .describe("New relative expiry duration (for example: 1h, 2d)."), - clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), - }, - outputSchema: StoredScheduleSchema.shape, - }, - async (input) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedule = await scheduleService.update(buildScheduleUpdateInput(input)); - - return { - content: [], - structuredContent: ensureValidJson(schedule), - }; - }, - ); - - registerTool( - "schedule_logs", - { - title: "Schedule logs", - description: "Get the run history (logs) for a schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - runs: z.array(ScheduleRunSchema), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const runs = await scheduleService.logs(id); - return { - content: [], - structuredContent: ensureValidJson({ runs }), - }; - }, - ); - - registerTool( - "list_providers", - { - title: "List providers", - description: "List configured agent providers, availability, and their modes.", - inputSchema: {}, - outputSchema: { - providers: z.array(ProviderSummarySchema), - }, - }, - async () => { - const providers = (await providerSnapshotManager.listProviders({ wait: true })).map( - toProviderSummary, - ); - return { - content: [], - structuredContent: ensureValidJson({ providers }), - }; - }, - ); - - registerTool( - "list_models", - { - title: "List models", - description: "List models for an agent provider.", - inputSchema: { - provider: AgentProviderEnum, - }, - outputSchema: { - provider: z.string(), - models: z.array(AgentModelSchema), - }, - }, - async ({ provider }) => { - const models = await providerSnapshotManager.listModels({ - cwd: resolveSnapshotCwd(), - provider, - wait: true, - }); - return { - content: [], - structuredContent: ensureValidJson({ - provider, - models, - }), - }; - }, - ); - - registerTool( - "inspect_provider", - { - title: "Inspect provider", - description: - "Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.", - inputSchema: inspectProviderInputSchema, - outputSchema: { - provider: AgentProviderEnum, - label: z.string().nullable().optional(), - description: z.string().nullable().optional(), - enabled: z.boolean(), - status: z.string(), - modes: z.array(ProviderModeSchema).nullish(), - selectedModel: z.string().nullable(), - features: z.array(AgentFeatureSchema), - }, - }, - async ({ provider, cwd, settings }) => { - const resolvedProviderModel = resolveScheduleProviderAndModel({ - provider, - defaultProvider: provider, - }); - const providerId = resolvedProviderModel.provider; - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - const entry = await providerSnapshotManager.getProvider({ - cwd: resolvedCwd, - provider: providerId, - wait: true, - }); - const summary = toProviderSummary(entry); - if (!entry.enabled) { - throw new Error(`Provider '${providerId}' is disabled`); - } - if (entry.status !== "ready") { - throw new Error(entry.error ?? `Provider '${providerId}' is unavailable`); - } - const selectedModel = settings?.model ?? resolvedProviderModel.model; - const features = await agentManager.listDraftFeatures({ - provider: providerId, - cwd: resolvedCwd, - ...(settings?.modeId ? { modeId: settings.modeId } : {}), - ...(selectedModel ? { model: selectedModel } : {}), - ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), - ...(settings?.features ? { featureValues: settings.features } : {}), - }); - return { - content: [], - structuredContent: ensureValidJson({ - provider: providerId, - label: summary.label, - description: summary.description, - enabled: summary.enabled, - status: summary.status, - modes: summary.modes, - selectedModel: selectedModel ?? null, - features, - }), - }; - }, - ); - - registerTool( - "list_worktrees", - { - title: "List worktrees", - description: "List Paseo-managed git worktrees for a repository.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to your current working directory."), - }, - outputSchema: { - worktrees: z.array(WorktreeSummarySchema), - }, - }, - async ({ cwd }) => { - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to list worktrees"); - } - const worktrees = await listPaseoWorktreesCommand( - { workspaceGitService: options.workspaceGitService }, - { - cwd: resolvedCwd, - reason: "mcp:list-worktrees", - }, - ); - - return { - content: [], - structuredContent: ensureValidJson({ worktrees }), - }; - }, - ); - - registerTool( - "create_worktree", - { - title: "Create worktree", - description: - "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: AgentCreateWorktreeTargetInputSchema.describe("What the worktree should contain."), - }, - outputSchema: { - branchName: z.string(), - worktreePath: z.string(), - workspaceId: z.string(), - }, - }, - async ({ cwd, target }) => { - const repoRoot = resolveScopedCwd(cwd, { required: true }); - const commandResult = await createPaseoWorktreeCommand( - { - paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, - createPaseoWorktreeWorkflow: options.createPaseoWorktree, - }, - createMcpWorktreeCommandInput(repoRoot, target), - ); - if (!commandResult.ok) { - throw new WorktreeRequestError(commandResult.error); - } - const { worktree, workspace } = commandResult.createdWorktree; - await options.workspaceGitService?.listWorktrees?.(repoRoot, { - force: true, - reason: "mcp:create-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - workspaceId: workspace.workspaceId, - }), - }; - }, - ); - - registerTool( - "archive_worktree", - { - title: "Archive worktree", - description: "Delete a Paseo-managed git worktree.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to your current working directory."), - worktreePath: z.string().optional(), - worktreeSlug: z.string().optional(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ cwd, worktreePath, worktreeSlug }) => { - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - if (!worktreePath && !worktreeSlug) { - throw new Error("worktreePath or worktreeSlug is required"); - } - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to archive worktrees"); - } - const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd); - - const result = await archiveCommand( - archiveWorktreeDependencies(options, { - agentManager, - agentStorage, - terminalManager: terminalManager ?? null, - logger: childLogger, - }), - { - requestId: "mcp:archive_worktree", - repoRoot, - worktreePath, - worktreeSlug, - // This tool archives every workspace on the directory, then removes the - // directory. Disk removal is derived from scope + last-reference. - scope: "worktree", - }, - ); - if (!result.ok) { - throw new Error(result.message); - } - await options.workspaceGitService.listWorktrees(repoRoot, { - force: true, - reason: "mcp:archive-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - registerTool( - "get_agent_activity", - { - title: "Get agent activity", - description: "Return recent agent timeline entries as a curated summary.", - inputSchema: { - agentId: z.string(), - limit: z - .number() - .optional() - .describe("Optional limit for number of activities to include (most recent first)."), - }, - outputSchema: { - agentId: z.string(), - updateCount: z.number(), - currentModeId: z.string().nullable(), - content: z.string(), - }, - }, - async ({ agentId, limit }) => { - await ensureAgentLoaded(agentId, { - agentManager, - agentStorage, - logger: childLogger, - }); - const timeline = agentManager.getTimeline(agentId); - const snapshot = agentManager.getAgent(agentId); - - const selection = selectItemsByProjectedLimit({ - items: timeline, - direction: "tail", - limit: limit ?? 0, - }); - const curatedContent = curateAgentActivity(selection.items); - const { totalProjected, shownProjected } = selection; - - const noun = totalProjected === 1 ? "activity" : "activities"; - const countHeader = - limit && shownProjected < totalProjected - ? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})` - : `Showing all ${totalProjected} ${noun}`; - - const contentWithCount = `${countHeader}\n\n${curatedContent}`; - - return { - content: [], - structuredContent: ensureValidJson({ - agentId, - updateCount: timeline.length, - currentModeId: snapshot?.currentModeId ?? null, - content: contentWithCount, - }), - }; - }, - ); - - registerTool( - "set_agent_mode", - { - title: "Set agent session mode", - description: - "Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).", - inputSchema: { - agentId: z.string(), - modeId: z.string(), - }, - outputSchema: { - success: z.boolean(), - newMode: z.string(), - }, - }, - async ({ agentId, modeId }) => { - const result = await setAgentModeCommand({ agentManager }, { agentId, modeId }); - return { - content: [], - structuredContent: ensureValidJson({ success: true, newMode: result.modeId }), - }; - }, - ); - - registerTool( - "list_pending_permissions", - { - title: "List pending permissions", - description: - "Return all pending permission requests across all agents with the normalized payloads.", - inputSchema: {}, - outputSchema: { - permissions: z.array( - z.object({ - agentId: z.string(), - status: AgentStatusEnum, - request: AgentPermissionRequestPayloadSchema, - }), - ), - }, - }, - async () => { - const permissions = agentManager.listAgents().flatMap((agent) => { - const payload = toAgentPayload(agent); - return payload.pendingPermissions.map((request) => ({ - agentId: agent.id, - status: payload.status, - request: sanitizePermissionRequest(request), - })); - }); - - return { - content: [], - structuredContent: ensureValidJson({ permissions }), - }; - }, - ); - - registerTool( - "respond_to_permission", - { - title: "Respond to permission", - description: - "Approve or deny a pending permission request with an AgentManager-compatible response payload.", - inputSchema: { - agentId: z.string(), - requestId: z.string(), - response: AgentPermissionResponseSchema, - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, requestId, response }) => { - await respondToAgentPermission({ - agentManager, - agentId, - requestId, - response, - logger: childLogger, - }); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - return server; } - -type McpCreateWorktreeTarget = - | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } - | { kind: "checkout-branch"; branch: string } - | { kind: "checkout-pr"; githubPrNumber: number }; - -interface ArchiveWorktreeCommandContext { - agentManager: AgentManager; - agentStorage: AgentStorage; - terminalManager: TerminalManager | null; - logger: Logger; -} - -function archiveWorktreeDependencies( - options: AgentMcpServerOptions, - context: ArchiveWorktreeCommandContext, -): ArchiveCommandDependencies { - if (!options.github) { - throw new Error("GitHub service is required to archive worktrees"); - } - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to archive worktrees"); - } - if (!options.archiveWorkspaceRecord) { - throw new Error("Workspace registry archiver is required to archive worktrees"); - } - if (!options.findWorkspaceIdForCwd) { - throw new Error("Workspace resolver is required to archive worktrees"); - } - if (!options.listActiveWorkspaces) { - throw new Error("Active workspace lister is required to archive worktrees"); - } - if (!options.emitWorkspaceUpdatesForWorkspaceIds) { - throw new Error("Workspace update emitter is required to archive worktrees"); - } - if (!options.markWorkspaceArchiving) { - throw new Error("Workspace archiving marker is required to archive worktrees"); - } - if (!options.clearWorkspaceArchiving) { - throw new Error("Workspace archiving clearer is required to archive worktrees"); - } - return { - paseoHome: options.paseoHome, - paseoWorktreesBaseRoot: options.worktreesRoot, - github: options.github, - workspaceGitService: options.workspaceGitService, - agentManager: context.agentManager, - agentStorage: context.agentStorage, - findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, - listActiveWorkspaces: options.listActiveWorkspaces, - archiveWorkspaceRecord: options.archiveWorkspaceRecord, - emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: options.markWorkspaceArchiving, - clearWorkspaceArchiving: options.clearWorkspaceArchiving, - killTerminalsForWorkspace: (workspaceId: string) => - killTerminalsForWorkspace( - { - terminalManager: context.terminalManager, - sessionLogger: context.logger, - }, - workspaceId, - ), - sessionLogger: context.logger, - }; -} - -function createMcpWorktreeCommandInput( - repoRoot: string, - target: McpCreateWorktreeTarget, -): CreatePaseoWorktreeCommandInput { - const base = { cwd: repoRoot } as const; - switch (target.kind) { - case "branch-off": - return { - ...base, - worktreeSlug: target.worktreeSlug, - branchName: target.branchName, - action: "branch-off", - ...(target.baseBranch ? { refName: target.baseBranch } : {}), - }; - case "checkout-branch": - return { ...base, action: "checkout", refName: target.branch }; - case "checkout-pr": - return { ...base, action: "checkout", githubPrNumber: target.githubPrNumber }; - default: - throw new Error("unreachable"); - } -} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts new file mode 100644 index 000000000..a7dc5da67 --- /dev/null +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -0,0 +1,2771 @@ +import { z } from "zod"; +import { ensureValidJson } from "../../json-utils.js"; +import type { Logger } from "pino"; + +import type { AgentMode, AgentProvider } from "../agent-sdk-types.js"; +import type { AgentManager, WaitForAgentResult } from "../agent-manager.js"; +import { + AgentFeatureSchema, + AgentPermissionRequestPayloadSchema, + AgentListItemPayloadSchema, + AgentPermissionResponseSchema, + AgentSnapshotPayloadSchema, +} from "../../messages.js"; +import type { AgentListItemPayload } from "../../messages.js"; +import { + buildStoredAgentPayload, + toAgentListItemPayload, + toAgentPayload, +} from "../agent-projections.js"; +import { curateAgentActivity } from "../activity-curator.js"; +import { selectItemsByProjectedLimit } from "../timeline-projection.js"; +import type { AgentStorage } from "../agent-storage.js"; +import { ensureAgentLoaded } from "../agent-loading.js"; +import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js"; +import { + killTerminalsForWorkspace, + type ArchiveDependencies, +} from "../../workspace-archive-service.js"; +import { WaitForAgentTracker } from "../wait-for-agent-tracker.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"; +import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; +import type { ScheduleService } from "../../schedule/service.js"; +import { + ScheduleRunSchema, + ScheduleSummarySchema, + StoredScheduleSchema, + type ScheduleCadence, + type UpdateScheduleInput, +} from "@getpaseo/protocol/schedule/types"; +import { resolveSnapshotCwd, type ProviderSnapshotManager } from "../provider-snapshot-manager.js"; +import { + AgentModelSchema, + AgentProviderEnum, + AgentStatusEnum, + ProviderModeSchema, + ProviderSummarySchema, + parseDurationString, + resolveRequiredProviderModel, + sanitizePermissionRequest, + serializeSnapshotWithMetadata, + toScheduleSummary, + waitForAgentWithTimeout, +} from "../mcp-shared.js"; +import { sendPromptToAgent, setupFinishNotification } from "../agent-prompt.js"; +import { respondToAgentPermission } from "../permission-response.js"; +import { + archiveAgentCommand, + cancelAgentRunCommand, + closeAgentCommand, + setAgentModeCommand, + updateAgentCommand, +} from "../lifecycle-command.js"; +import type { GitHubService } from "../../../services/github-service.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import { WorktreeRequestError } from "../../worktree-errors.js"; +import { + archiveCommand, + type ArchiveCommandDependencies, + createPaseoWorktreeCommand, + type CreatePaseoWorktreeCommandInput, + listPaseoWorktreesCommand, +} from "../../worktree/commands.js"; +import type { + PaseoToolCatalog, + PaseoToolConfig, + PaseoToolDefinition, + PaseoToolExecutionContext, + PaseoToolResult, +} from "./types.js"; + +export interface PaseoToolHostDependencies { + agentManager: AgentManager; + agentStorage: AgentStorage; + terminalManager?: TerminalManager | null; + getDaemonTcpPort?: () => number | null; + scheduleService?: ScheduleService | null; + providerSnapshotManager: ProviderSnapshotManager; + github?: GitHubService; + workspaceGitService?: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + >; + findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; + listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; + archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; + emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; + markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; + clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; + createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; + // Mints a fresh directory workspace for a cwd and returns its id. + ensureWorkspaceForCreate?: (cwd: string) => Promise; + paseoHome?: string; + worktreesRoot?: string; + /** + * ID of the agent that is using this tool catalog. + * Used for cwd/mode inheritance when agents spawn child agents. + */ + callerAgentId?: string; + /** + * Optional resolver for session-bound speak handlers. + * Used by hidden voice agents to narrate through daemon-managed TTS. + */ + resolveSpeakHandler?: (callerAgentId: string) => VoiceSpeakHandler | null; + resolveCallerContext?: (callerAgentId: string) => VoiceCallerContext | null; + enableVoiceTools?: boolean; + voiceOnly?: boolean; + logger: Logger; +} + +function parseTimestamp(value: string | null | undefined): number { + if (!value) { + return 0; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function resolveAgentListActivityTime(agent: AgentListItemPayload): number { + return Math.max( + parseTimestamp(agent.updatedAt), + parseTimestamp(agent.lastUserMessageAt), + parseTimestamp(agent.attentionTimestamp), + parseTimestamp(agent.archivedAt), + parseTimestamp(agent.createdAt), + ); +} + +interface ProviderSummary { + id: AgentProvider; + label: string; + description: string; + enabled: boolean; + modes: AgentMode[]; + status: string; + error?: string; +} + +function toProviderSummary(entry: { + provider: AgentProvider; + label?: string; + description?: string; + enabled: boolean; + modes?: AgentMode[]; + status: string; + error?: string; +}): ProviderSummary { + return { + id: entry.provider, + label: entry.label ?? entry.provider, + description: entry.description ?? "", + enabled: entry.enabled, + modes: entry.modes ?? [], + status: entry.status === "ready" ? "available" : entry.status, + ...(entry.error ? { error: entry.error } : {}), + }; +} + +function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { + const attentionDelta = + Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); + if (attentionDelta !== 0) { + return attentionDelta; + } + + const statusOrder = { + running: 0, + initializing: 1, + idle: 2, + error: 3, + closed: 4, + } as Record; + const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); + if (statusDelta !== 0) { + return statusDelta; + } + + return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); +} + +function resolveScheduleProviderAndModel(params: { + provider?: string; + defaultProvider: AgentProvider; +}): { provider: AgentProvider; model?: string } { + const providerInput = params.provider?.trim() || params.defaultProvider; + const slashIndex = providerInput.indexOf("/"); + if (slashIndex === -1) { + return { provider: providerInput }; + } + + const provider = providerInput.slice(0, slashIndex).trim(); + const model = providerInput.slice(slashIndex + 1).trim(); + if (!provider || !model) { + throw new Error("provider must be or /"); + } + + return { + provider: provider, + model, + }; +} + +function resolveScheduleUpdateProviderAndModel(params: { + provider?: string; + model?: string | null; +}): { provider?: string; model?: string | null } { + const providerInput = params.provider?.trim(); + const modelInput = typeof params.model === "string" ? params.model.trim() : params.model; + + if (params.model !== undefined && modelInput === "") { + throw new Error("model cannot be empty"); + } + + if (!providerInput) { + return params.model !== undefined ? { model: modelInput } : {}; + } + + const slashIndex = providerInput.indexOf("/"); + if (slashIndex === -1) { + return { + provider: providerInput, + ...(params.model !== undefined ? { model: modelInput } : {}), + }; + } + + const provider = providerInput.slice(0, slashIndex).trim(); + const modelFromProvider = providerInput.slice(slashIndex + 1).trim(); + if (!provider || !modelFromProvider) { + throw new Error("provider must be or /"); + } + if (params.model === null) { + throw new Error("provider specifies a model but model is null"); + } + if (typeof modelInput === "string" && modelInput !== modelFromProvider) { + throw new Error("Conflicting model values provided"); + } + + return { + provider, + model: modelInput ?? modelFromProvider, + }; +} + +interface ScheduleUpdateToolInput { + id: string; + every?: string; + cron?: string; + timezone?: string; + name?: string | null; + prompt?: string; + maxRuns?: number | null; + provider?: string; + model?: string | null; + mode?: string | null; + cwd?: string; + expiresIn?: string; + clearExpires?: boolean; +} + +function normalizeScheduleCadenceArg(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed; +} + +function normalizeScheduleTimeZoneArg(value: string | undefined): string | undefined { + return normalizeScheduleCadenceArg(value); +} + +function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined { + const every = normalizeScheduleCadenceArg(input.every); + const cron = normalizeScheduleCadenceArg(input.cron); + const timeZone = normalizeScheduleTimeZoneArg(input.timezone); + + if (every !== undefined && cron !== undefined) { + throw new Error("Specify at most one of every or cron"); + } + if (timeZone !== undefined && cron === undefined) { + throw new Error("timezone can only be used with cron"); + } + if (every !== undefined) { + return { type: "every", everyMs: parseDurationString(every) }; + } + if (cron !== undefined) { + return { + type: "cron", + expression: cron, + ...(timeZone !== undefined ? { timezone: timeZone } : {}), + }; + } + return undefined; +} + +function resolveScheduleUpdateExpiresAt(input: ScheduleUpdateToolInput): string | null | undefined { + if (input.expiresIn !== undefined && input.clearExpires) { + throw new Error("Specify at most one of expiresIn or clearExpires"); + } + if (input.expiresIn !== undefined) { + return new Date(Date.now() + parseDurationString(input.expiresIn)).toISOString(); + } + if (input.clearExpires) { + return null; + } + return undefined; +} + +function buildScheduleUpdateInput(input: ScheduleUpdateToolInput): UpdateScheduleInput { + const cadence = resolveScheduleUpdateCadence(input); + const expiresAt = resolveScheduleUpdateExpiresAt(input); + const providerModelPatch = resolveScheduleUpdateProviderAndModel({ + provider: input.provider, + model: input.model, + }); + const newAgentConfig = { + ...(providerModelPatch.provider !== undefined ? { provider: providerModelPatch.provider } : {}), + ...(providerModelPatch.model !== undefined ? { model: providerModelPatch.model } : {}), + ...(input.mode !== undefined ? { modeId: input.mode } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }; + + return { + id: input.id, + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.prompt !== undefined ? { prompt: input.prompt } : {}), + ...(cadence !== undefined ? { cadence } : {}), + ...(input.maxRuns !== undefined ? { maxRuns: input.maxRuns } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(Object.keys(newAgentConfig).length > 0 ? { newAgentConfig } : {}), + }; +} + +function resolveChildAgentCwd(params: { + parentCwd: string; + requestedCwd?: string; + lockedCwd?: string; + allowCustomCwd: boolean; +}): string { + const lockedCwd = params.lockedCwd?.trim(); + if (lockedCwd) { + return expandUserPath(lockedCwd); + } + + const requestedCwd = params.requestedCwd?.trim(); + if (!requestedCwd || !params.allowCustomCwd) { + return params.parentCwd; + } + + return resolvePathFromBase(params.parentCwd, requestedCwd); +} + +const TerminalSummarySchema = z.object({ + id: z.string(), + name: z.string(), + cwd: z.string(), +}); + +const WorktreeSummarySchema = z.object({ + path: z.string(), + createdAt: z.string(), + branchName: z.string().optional(), + head: z.string().optional(), +}); + +function resolveTerminalKeyToken(key: string, literal: boolean): string { + if (literal) { + return key; + } + + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; + } +} + +export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog { + const { + agentManager, + agentStorage, + terminalManager, + scheduleService, + providerSnapshotManager, + callerAgentId, + resolveSpeakHandler, + resolveCallerContext, + logger, + } = options; + const childLogger = logger.child({ module: "agent", component: "paseo-tool-catalog" }); + const waitTracker = new WaitForAgentTracker(logger); + const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null; + + const parseToolInput = async (tool: PaseoToolDefinition, input: unknown): Promise => { + const inputSchema = tool.inputSchema; + if (!inputSchema) { + return input; + } + const schema = + typeof inputSchema === "object" && + inputSchema !== null && + typeof (inputSchema as { safeParseAsync?: unknown }).safeParseAsync === "function" + ? (inputSchema as z.ZodType) + : z.object(inputSchema as z.ZodRawShape).passthrough(); + return schema.parseAsync(input); + }; + + const tools = new Map(); + const registerTool = ( + name: string, + config: PaseoToolConfig, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. + handler: (input: any, context: PaseoToolExecutionContext) => Promise, + ) => { + tools.set(name, { + name, + title: config.title, + description: config.description ?? name, + inputSchema: config.inputSchema, + outputSchema: config.outputSchema, + handler: handler as PaseoToolDefinition["handler"], + }); + }; + const toCatalog = (): PaseoToolCatalog => ({ + tools, + getTool(name: string): PaseoToolDefinition | undefined { + return tools.get(name); + }, + async executeTool( + name: string, + input: unknown, + context: PaseoToolExecutionContext = {}, + ): Promise { + const tool = tools.get(name); + if (!tool) { + throw new Error(`Paseo tool not found: ${name}`); + } + return tool.handler(await parseToolInput(tool, input), context); + }, + }); + + const buildCronScheduleCadence = (input: { + cron: string | undefined; + timezone?: string; + }): ScheduleCadence => { + const expression = input.cron?.trim() ?? ""; + if (!expression) { + throw new Error("cron is required"); + } + const timezone = normalizeScheduleTimeZoneArg(input.timezone); + return { + type: "cron", + expression, + ...(timezone !== undefined ? { timezone } : {}), + }; + }; + + const buildScheduleExpiry = (expiresIn: string | undefined): string | undefined => { + return expiresIn === undefined + ? undefined + : new Date(Date.now() + parseDurationString(expiresIn)).toISOString(); + }; + + const resolveCallerAgent = () => { + if (!callerAgentId) { + return null; + } + const parentAgent = agentManager.getAgent(callerAgentId); + if (!parentAgent) { + throw new Error(`Parent agent ${callerAgentId} not found`); + } + return parentAgent; + }; + + const resolveScopedCwd = (requestedCwd?: string, opts?: { required?: boolean }): string => { + const callerAgent = resolveCallerAgent(); + if (callerAgent) { + return resolveChildAgentCwd({ + parentCwd: callerAgent.cwd, + requestedCwd, + lockedCwd: callerContext?.lockedCwd, + allowCustomCwd: callerContext?.allowCustomCwd ?? true, + }); + } + + const trimmedCwd = requestedCwd?.trim(); + if (!trimmedCwd) { + if (opts?.required) { + throw new Error("cwd is required"); + } + throw new Error("cwd is required outside an agent-scoped session"); + } + + return expandUserPath(trimmedCwd); + }; + + async function resolveTerminalWorkspaceId(resolvedCwd: string): Promise { + // An agent-spawned terminal belongs to the caller agent's workspace. Only if + // the caller has no workspace do we mint one for the cwd. + const callerAgent = callerAgentId ? agentManager.getAgent(callerAgentId) : null; + if (callerAgent?.workspaceId) { + return callerAgent.workspaceId; + } + + if (!options.ensureWorkspaceForCreate) { + throw new Error( + callerAgentId + ? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured` + : "workspaceId is required outside an agent-scoped session", + ); + } + + return options.ensureWorkspaceForCreate(resolvedCwd); + } + + const buildCallerAgentScheduleConfigExtras = ( + callerAgent: NonNullable>, + ): Record => { + return { + ...(callerAgent.config.thinkingOptionId + ? { thinkingOptionId: callerAgent.config.thinkingOptionId } + : {}), + ...(callerAgent.config.approvalPolicy + ? { approvalPolicy: callerAgent.config.approvalPolicy } + : {}), + ...(callerAgent.config.sandboxMode ? { sandboxMode: callerAgent.config.sandboxMode } : {}), + ...(typeof callerAgent.config.networkAccess === "boolean" + ? { networkAccess: callerAgent.config.networkAccess } + : {}), + ...(typeof callerAgent.config.webSearch === "boolean" + ? { webSearch: callerAgent.config.webSearch } + : {}), + ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), + ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), + ...(callerAgent.config.featureValues + ? { featureValues: callerAgent.config.featureValues } + : {}), + ...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}), + ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), + }; + }; + + const buildCallerAgentScheduleConfig = ( + callerAgent: NonNullable>, + params?: { provider?: string; cwd?: string }, + ) => { + const hasProviderOverride = params?.provider !== undefined; + const resolvedProviderModel = hasProviderOverride + ? resolveScheduleProviderAndModel({ + provider: params?.provider, + defaultProvider: callerAgent.provider, + }) + : null; + const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider; + let resolvedModel: string | undefined; + if (resolvedProviderModel?.model) { + resolvedModel = resolvedProviderModel.model; + } else if (!hasProviderOverride && callerAgent.config.model) { + resolvedModel = callerAgent.config.model; + } + return { + provider: resolvedProvider, + cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd, + ...(callerAgent.currentModeId && callerAgent.provider === resolvedProvider + ? { + modeId: callerAgent.currentModeId, + } + : {}), + ...(resolvedModel ? { model: resolvedModel } : {}), + ...buildCallerAgentScheduleConfigExtras(callerAgent), + }; + }; + + const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { + if (!params?.provider?.trim()) { + throw new Error("provider is required when target is new-agent"); + } + + const callerAgent = resolveCallerAgent(); + if (callerAgent) { + return { + type: "new-agent" as const, + config: buildCallerAgentScheduleConfig(callerAgent, params), + }; + } + + const resolvedProviderModel = resolveScheduleProviderAndModel({ + provider: params?.provider, + defaultProvider: params.provider, + }); + return { + type: "new-agent" as const, + config: { + provider: resolvedProviderModel.provider, + cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), + ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), + }, + }; + }; + const ProviderModelInputSchema = AgentProviderEnum.trim() + .refine((value) => value.includes("/"), { + message: "provider must be provider/model, for example codex/gpt-5.4", + }) + .refine( + (value) => { + try { + resolveRequiredProviderModel(value); + return true; + } catch { + return false; + } + }, + { message: "provider must be provider/model, for example codex/gpt-5.4" }, + ); + const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim() + .min(1, "provider is required") + .refine( + (value) => { + if (!value.includes("/")) { + return true; + } + try { + resolveRequiredProviderModel(value); + return true; + } catch { + return false; + } + }, + { message: "provider must be provider or provider/model, for example codex/gpt-5.4" }, + ); + const CreateAgentSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Session mode to configure before the first run."), + thinkingOptionId: z.string().optional().describe("Thinking option ID."), + features: z + .record(z.string(), z.unknown()) + .optional() + .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), + }) + .strict(); + const UpdateAgentSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Session mode ID."), + model: z.string().nullable().optional().describe("Model ID. Pass null to clear."), + thinkingOptionId: z + .string() + .nullable() + .optional() + .describe("Thinking option ID. Pass null to clear."), + features: z + .record(z.string(), z.unknown()) + .optional() + .describe("Provider-specific feature values, for example { fast_mode: true } for Codex."), + }) + .strict(); + const InspectProviderSettingsInputSchema = z + .object({ + modeId: z.string().optional().describe("Draft session mode ID."), + model: z.string().optional().describe("Draft model ID."), + thinkingOptionId: z.string().optional().describe("Draft thinking option ID."), + features: z + .record(z.string(), z.unknown()) + .optional() + .describe("Draft provider feature values."), + }) + .strict(); + 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 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) + .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("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"), + 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() + .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."), + }; + const agentToAgentInputSchema = { + ...commonCreateAgentInputSchema, + notifyOnFinish: z + .boolean() + .optional() + .default(true) + .describe( + "Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.", + ), + }; + const canonicalTopLevelInputSchema = { + ...commonCreateAgentInputSchema, + 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 created agent finishes, errors, or needs permission.", + ), + }; + const legacyTopLevelCreateAgentInputSchema = { + relationship: commonCreateAgentInputSchema.relationship.optional(), + workspace: commonCreateAgentInputSchema.workspace.optional(), + cwd: z + .string() + .optional() + .describe("Legacy top-level working directory. Prefer workspace.source.path."), + mode: z.string().optional().describe("Legacy session mode ID. Prefer settings.modeId."), + thinking: z + .string() + .optional() + .describe("Legacy thinking option ID. Prefer settings.thinkingOptionId."), + features: z + .record(z.string(), z.unknown()) + .optional() + .describe("Legacy feature values. Prefer settings.features."), + worktreeName: z + .string() + .min(1) + .optional() + .describe("Legacy worktree slug. Prefer workspace.source.target.worktreeSlug."), + branchName: z + .string() + .min(1) + .optional() + .describe("Legacy branch name. Prefer workspace.source.target.branchName."), + baseBranch: z + .string() + .min(1) + .optional() + .describe("Legacy base branch. Prefer workspace.source.target.baseBranch."), + refName: z + .string() + .min(1) + .optional() + .describe("Legacy branch/ref to check out. Prefer workspace.source.target.branch."), + githubPrNumber: z + .number() + .int() + .positive() + .optional() + .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), + }; + const topLevelInputSchema = { + ...canonicalTopLevelInputSchema, + ...legacyTopLevelCreateAgentInputSchema, + }; + + const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; + const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); + const canonicalTopLevelCreateAgentArgsSchema = z.object(canonicalTopLevelInputSchema).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).", + ), + cwd: z + .string() + .optional() + .describe("Working directory used to resolve provider feature availability."), + settings: InspectProviderSettingsInputSchema.optional().describe( + "Draft provider settings used to compute available features.", + ), + }; + type AgentToAgentCreateAgentArgs = z.infer; + type TopLevelCreateAgentArgs = z.infer; + type TopLevelCreateAgentToolArgs = z.infer; + + if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { + registerTool( + "speak", + { + title: "Speak", + description: + "Speak text to the user via daemon-managed voice output. Blocks until playback completes.", + inputSchema: { + text: z + .string() + .trim() + .min(1, "text is required") + .max(4000, "text must be 4000 characters or fewer"), + }, + outputSchema: { + ok: z.boolean(), + }, + }, + async (args, context) => { + if (!callerAgentId) { + throw new Error("speak is only available to agent-scoped tool sessions"); + } + const handler = resolveSpeakHandler?.(callerAgentId) ?? null; + if (!handler) { + throw new Error(`No speak handler registered for your session '${callerAgentId}'`); + } + await handler({ + text: args.text, + callerAgentId, + signal: context?.signal, + }); + return { + content: [], + structuredContent: ensureValidJson({ ok: true }), + }; + }, + ); + } + + if (options.voiceOnly) { + return toCatalog(); + } + + registerTool( + "create_agent", + { + title: "Create agent", + description: + "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(), + type: AgentProviderEnum, + status: AgentStatusEnum, + cwd: z.string(), + workspaceId: z.string().optional(), + currentModeId: z.string().nullable(), + availableModes: z.array(ProviderModeSchema), + lastMessage: z.string().nullable().optional(), + permission: AgentPermissionRequestPayloadSchema.nullable().optional(), + guidance: z.string().optional(), + }, + }, + async (args: unknown) => { + 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 = parsedArgs.notifyOnFinish; + detached = resolvedArgs.relationship.kind === "detached"; + } else { + requestedBackground = resolvedArgs.parsedArgs.background; + notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; + detached = resolvedArgs.parsedArgs.relationship.kind === "detached"; + } + const { + snapshot, + background: createdInBackground, + initialPromptStarted, + } = await createAgentCommand( + { + agentManager, + agentStorage, + logger: childLogger, + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + terminalManager, + providerSnapshotManager, + createPaseoWorktree: options.createPaseoWorktree, + ...(options.ensureWorkspaceForCreate + ? { ensureWorkspaceForCreate: options.ensureWorkspaceForCreate } + : {}), + }, + { + kind: "mcp", + provider: parsedArgs.provider, + title: parsedArgs.title, + initialPrompt: parsedArgs.initialPrompt, + cwd: resolvedArgs.cwd, + workspaceId: resolvedArgs.workspaceId, + thinking: parsedArgs.settings?.thinkingOptionId, + features: parsedArgs.settings?.features, + labels: parsedArgs.labels, + mode: parsedArgs.settings?.modeId, + background: requestedBackground, + notifyOnFinish, + detached, + callerAgentId, + callerContext, + worktree, + }, + ); + + try { + if (!createdInBackground && initialPromptStarted) { + const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { + waitForActive: true, + }); + + const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; + const responseData = { + agentId: snapshot.id, + type: snapshot.provider, + status: result.status, + cwd: liveSnapshot.cwd, + ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), + currentModeId: liveSnapshot.currentModeId, + availableModes: liveSnapshot.availableModes, + lastMessage: result.lastMessage, + permission: sanitizePermissionRequest(result.permission), + }; + const validJson = ensureValidJson(responseData); + + const response = { + content: [], + structuredContent: validJson, + }; + return response; + } + } catch (error) { + childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); + throw error; + } + + // Return immediately for async creation. + const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; + const guidance = + callerAgentId && notifyOnFinish && initialPromptStarted + ? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives." + : undefined; + const response = { + content: [], + structuredContent: ensureValidJson({ + agentId: currentSnapshot.id, + type: snapshot.provider, + status: currentSnapshot.lifecycle, + cwd: currentSnapshot.cwd, + ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), + currentModeId: currentSnapshot.currentModeId, + availableModes: currentSnapshot.availableModes, + lastMessage: null, + permission: null, + ...(guidance ? { guidance } : {}), + }), + }; + return response; + }, + ); + + type ResolvedCreateAgentToolArgs = + | { + kind: "agent-scoped"; + parsedArgs: AgentToAgentCreateAgentArgs; + relationship: AgentToAgentCreateAgentArgs["relationship"]; + cwd: string | undefined; + workspaceId: string | undefined; + worktree: CreateAgentFromMcpInput["worktree"]; + } + | { + kind: "top-level"; + parsedArgs: TopLevelCreateAgentArgs; + cwd: string | undefined; + workspaceId: string | undefined; + worktree: CreateAgentFromMcpInput["worktree"]; + }; + + async function resolveCreateAgentToolArgs(args: unknown): Promise { + if (callerAgentId) { + const parsed = agentToAgentCreateAgentArgsSchema.parse(args); + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace); + return { + kind: "agent-scoped", + parsedArgs: parsed, + relationship: parsed.relationship, + cwd, + workspaceId, + worktree, + }; + } + const parsedArgs = normalizeTopLevelCreateAgentArgs(topLevelCreateAgentArgsSchema.parse(args)); + if (parsedArgs.relationship.kind === "subagent") { + throw new Error("relationship subagent requires an agent-scoped tool session"); + } + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace); + return { + kind: "top-level", + parsedArgs, + cwd, + workspaceId, + worktree, + }; + } + + function normalizeTopLevelCreateAgentArgs( + args: TopLevelCreateAgentToolArgs, + ): TopLevelCreateAgentArgs { + const { + cwd, + mode, + thinking, + features, + worktreeName, + branchName, + baseBranch, + refName, + githubPrNumber, + ...canonicalCandidate + } = args; + const settings = { + ...canonicalCandidate.settings, + ...(mode ? { modeId: mode } : {}), + ...(thinking ? { thinkingOptionId: thinking } : {}), + ...(features ? { features } : {}), + }; + + if (canonicalCandidate.relationship && canonicalCandidate.workspace) { + return canonicalTopLevelCreateAgentArgsSchema.parse({ + ...canonicalCandidate, + ...(Object.keys(settings).length > 0 ? { settings } : {}), + }); + } + + if (canonicalCandidate.relationship || canonicalCandidate.workspace) { + throw new Error("relationship and workspace must be provided together"); + } + + if (!cwd?.trim()) { + throw new Error("cwd is required for legacy top-level create_agent calls"); + } + + const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({ + worktreeName, + branchName, + baseBranch, + refName, + githubPrNumber, + }); + const workspace = legacyWorktreeTarget + ? { + kind: "create" as const, + source: { + kind: "worktree" as const, + cwd, + target: legacyWorktreeTarget, + }, + } + : { + kind: "create" as const, + source: { + kind: "directory" as const, + path: cwd, + }, + }; + + return canonicalTopLevelCreateAgentArgsSchema.parse({ + ...canonicalCandidate, + relationship: { kind: "detached" }, + workspace, + ...(Object.keys(settings).length > 0 ? { settings } : {}), + }); + } + + function resolveLegacyCreateAgentWorktreeTarget(input: { + worktreeName?: string; + branchName?: string; + baseBranch?: string; + refName?: string; + githubPrNumber?: number; + }): z.infer | null { + if (input.githubPrNumber !== undefined) { + return { + kind: "checkout-pr", + githubPrNumber: input.githubPrNumber, + }; + } + + if (input.refName) { + return { + kind: "checkout-branch", + branch: input.refName, + }; + } + + if (input.worktreeName || input.branchName || input.baseBranch) { + return { + kind: "branch-off", + worktreeSlug: input.worktreeName, + branchName: input.branchName, + baseBranch: input.baseBranch, + }; + } + + return null; + } + + 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 tool session"); + } + 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.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) { + 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 { + cwd, + workspaceId: undefined, + worktree: resolveCreateAgentWorktree(workspace.source.target), + }; + } + + function resolveCreateAgentWorktree( + target: z.infer, + ): NonNullable { + switch (target.kind) { + case "branch-off": + return { + action: "branch-off", + worktreeName: target.worktreeSlug, + branchName: target.branchName, + 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", + { + title: "Wait for agent", + description: + "Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.", + inputSchema: { + agentId: z.string().describe("Agent identifier returned by the create_agent tool"), + }, + outputSchema: { + agentId: z.string(), + status: AgentStatusEnum, + permission: AgentPermissionRequestPayloadSchema.nullable(), + lastMessage: z.string().nullable(), + }, + }, + async ({ agentId }, { signal }) => { + const abortController = new AbortController(); + const cleanupFns: Array<() => void> = []; + + const cleanup = () => { + while (cleanupFns.length) { + const fn = cleanupFns.pop(); + try { + fn?.(); + } catch { + // ignore cleanup errors + } + } + }; + + const forwardExternalAbort = () => { + if (!abortController.signal.aborted) { + const reason = signal?.reason ?? new Error("wait_for_agent aborted"); + abortController.abort(reason); + } + }; + + if (signal) { + if (signal.aborted) { + forwardExternalAbort(); + } else { + signal.addEventListener("abort", forwardExternalAbort, { once: true }); + cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort)); + } + } + + const unregister = waitTracker.register(agentId, (reason) => { + if (!abortController.signal.aborted) { + abortController.abort(new Error(reason ?? "wait_for_agent cancelled")); + } + }); + cleanupFns.push(unregister); + + try { + const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, { + signal: abortController.signal, + }); + + const validJson = ensureValidJson({ + agentId, + status: result.status, + permission: sanitizePermissionRequest(result.permission), + lastMessage: result.lastMessage, + }); + + const response = { + content: [], + structuredContent: validJson, + }; + return response; + } finally { + cleanup(); + } + }, + ); + + registerTool( + "send_agent_prompt", + { + title: "Send agent prompt", + description: + "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 = 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, + agentStorage, + agentId, + prompt, + sessionMode, + logger: childLogger, + }); + + if (shouldNotifyOnFinish && callerAgentId) { + setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: agentId, + callerAgentId, + logger: childLogger, + }); + } + + // If not running in background, wait for completion + if (!background) { + const result = await waitForAgentWithTimeout(agentManager, agentId, { + waitForActive: true, + }); + + const responseData = { + success: true, + status: result.status, + lastMessage: result.lastMessage, + permission: sanitizePermissionRequest(result.permission), + }; + const validJson = ensureValidJson(responseData); + + const response = { + content: [], + structuredContent: validJson, + }; + return response; + } + + // Return immediately if background=true + // Re-fetch snapshot since the state may have changed + const currentSnapshot = agentManager.getAgent(agentId); + + const responseData = { + success: true, + 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); + + const response = { + content: [], + structuredContent: validJson, + }; + return response; + }, + ); + + registerTool( + "get_agent_status", + { + title: "Get agent status", + description: + "Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + status: AgentStatusEnum, + snapshot: AgentSnapshotPayloadSchema, + }, + }, + async ({ agentId }) => { + const snapshot = agentManager.getAgent(agentId); + if (snapshot) { + const structuredSnapshot = await serializeSnapshotWithMetadata( + agentStorage, + snapshot, + childLogger, + ); + return { + content: [], + structuredContent: ensureValidJson({ + status: snapshot.lifecycle, + snapshot: structuredSnapshot, + }), + }; + } + + const record = await agentStorage.get(agentId); + if (!record || record.internal) { + throw new Error(`Agent ${agentId} not found`); + } + + const structuredSnapshot = buildStoredAgentPayload( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ); + return { + content: [], + structuredContent: ensureValidJson({ + status: structuredSnapshot.status, + snapshot: structuredSnapshot, + }), + }; + }, + ); + + registerTool( + "list_agents", + { + title: "List agents", + description: "List recent agents as compact metadata.", + inputSchema: { + includeArchived: z.boolean().optional().default(false), + cwd: z.string().optional(), + sinceHours: z + .number() + .int() + .positive() + .max(24 * 30) + .optional() + .default(48), + statuses: z.array(AgentStatusEnum).optional(), + limit: z.number().int().positive().max(200).optional().default(50), + }, + outputSchema: { + agents: z.array(AgentListItemPayloadSchema), + }, + }, + async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { + const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; + const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; + const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; + const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; + const liveSnapshots = agentManager.listAgents(); + const liveAgents = await Promise.all( + liveSnapshots.map((snapshot) => + serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), + ), + ); + const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); + const storedRecords = await agentStorage.list(); + const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); + const storedAgents = storedRecords + .filter((record) => !record.internal && !liveIds.has(record.id)) + .filter((record) => includeArchived || !record.archivedAt) + .filter( + (record) => + includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds), + ) + .map((record) => buildStoredAgentPayload(record, registeredProviderIds)); + const agents = [...liveAgents, ...storedAgents] + .map(toAgentListItemPayload) + .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) + .filter((agent) => !statusFilter || statusFilter.has(agent.status)) + .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) + .sort(compareAgentListItems) + .slice(0, limit); + + return { + content: [], + structuredContent: ensureValidJson({ agents }), + }; + }, + ); + + registerTool( + "cancel_agent", + { + title: "Cancel agent run", + description: "Abort the agent's current run but keep the agent alive for future tasks.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + const { cancelled } = await cancelAgentRunCommand( + { agentManager, logger: childLogger }, + agentId, + ); + if (cancelled) { + waitTracker.cancel(agentId, "Agent run cancelled"); + } + return { + content: [], + structuredContent: ensureValidJson({ success: cancelled }), + }; + }, + ); + + registerTool( + "archive_agent", + { + title: "Archive agent", + description: + "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + await archiveAgentCommand( + { + agentManager, + agentStorage, + logger: childLogger, + }, + agentId, + ); + waitTracker.cancel(agentId, "Agent archived"); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "kill_agent", + { + title: "Kill agent", + description: "Terminate an agent session permanently.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + await closeAgentCommand({ agentManager }, agentId); + waitTracker.cancel(agentId, "Agent terminated"); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "update_agent", + { + title: "Update agent", + description: "Update an agent name, labels, and/or runtime settings.", + inputSchema: { + agentId: z.string(), + name: z.string().optional(), + labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: UpdateAgentSettingsInputSchema.optional().describe( + "Runtime settings to apply to the agent.", + ), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId, name, labels, settings }) => { + if (settings?.modeId !== undefined) { + await agentManager.setAgentMode(agentId, settings.modeId); + } + if (settings?.model !== undefined) { + await agentManager.setAgentModel(agentId, settings.model); + } + if (settings?.thinkingOptionId !== undefined) { + await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); + } + if (settings?.features) { + for (const [featureId, value] of Object.entries(settings.features)) { + await agentManager.setAgentFeature(agentId, featureId, value); + } + } + + await updateAgentCommand({ agentManager }, { agentId, name, labels }); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "list_terminals", + { + title: "List terminals", + description: "List terminals for a working directory or across all working directories.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional working directory. Defaults to your current working directory."), + all: z.boolean().optional().describe("List terminals across all working directories."), + }, + outputSchema: { + terminals: z.array(TerminalSummarySchema), + }, + }, + async ({ cwd, all }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminals = all + ? ( + await Promise.all( + terminalManager.listDirectories().map(async (directory) => + (await terminalManager.getTerminals(directory)).map((terminal) => ({ + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + })), + ), + ) + ).flat() + : (await terminalManager.getTerminals(resolveScopedCwd(cwd, { required: true }))).map( + (terminal) => ({ + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + }), + ); + + return { + content: [], + structuredContent: ensureValidJson({ terminals }), + }; + }, + ); + + registerTool( + "create_terminal", + { + title: "Create terminal", + description: "Create a terminal session for a working directory.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional working directory. Defaults to your current working directory."), + name: z.string().optional().describe("Optional terminal name."), + }, + outputSchema: TerminalSummarySchema.shape, + }, + async ({ cwd, name }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const workspaceId = await resolveTerminalWorkspaceId(resolvedCwd); + + const terminal = await terminalManager.createTerminal({ + cwd: resolvedCwd, + workspaceId, + ...(name?.trim() ? { name: name.trim() } : {}), + }); + + return { + content: [], + structuredContent: ensureValidJson({ + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + }), + }; + }, + ); + + registerTool( + "kill_terminal", + { + title: "Kill terminal", + description: "Kill an existing terminal session.", + inputSchema: { + terminalId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ terminalId }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminal = terminalManager.getTerminal(terminalId); + if (!terminal) { + throw new Error(`Terminal ${terminalId} not found`); + } + + terminal.kill(); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "capture_terminal", + { + title: "Capture terminal", + description: "Capture plain-text terminal output lines from a terminal session.", + inputSchema: { + terminalId: z.string(), + start: z.number().optional(), + end: z.number().optional(), + scrollback: z.boolean().optional(), + stripAnsi: z.boolean().optional().default(true), + }, + outputSchema: { + terminalId: z.string(), + lines: z.array(z.string()), + totalLines: z.number().int().nonnegative(), + }, + }, + async ({ terminalId, start, end, scrollback, stripAnsi = true }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + if (!terminalManager.getTerminal(terminalId)) { + throw new Error(`Terminal ${terminalId} not found`); + } + + const capture = await terminalManager.captureTerminal(terminalId, { + start: scrollback ? 0 : start, + end, + stripAnsi, + }); + + return { + content: [], + structuredContent: ensureValidJson({ + terminalId, + lines: capture.lines, + totalLines: capture.totalLines, + }), + }; + }, + ); + + registerTool( + "send_terminal_keys", + { + title: "Send terminal keys", + description: "Send literal text or special key tokens to a terminal session.", + inputSchema: { + terminalId: z.string(), + keys: z.string(), + literal: z.boolean().optional(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ terminalId, keys, literal = false }) => { + if (!terminalManager) { + throw new Error("Terminal manager is not configured"); + } + + const terminal = terminalManager.getTerminal(terminalId); + if (!terminal) { + throw new Error(`Terminal ${terminalId} not found`); + } + + terminal.send({ + type: "input", + data: resolveTerminalKeyToken(keys, literal), + }); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "create_schedule", + { + title: "Create schedule", + description: "Create a recurring schedule that starts a new agent on a cron cadence.", + inputSchema: { + prompt: z.string().trim().min(1, "prompt is required"), + cron: z.string().trim().min(1, "cron is required"), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe("IANA time zone for the cron cadence. For example: America/New_York."), + name: z.string().optional(), + provider: AgentProviderEnum.optional().describe( + "Provider, or provider/model (for example: codex or codex/gpt-5.4).", + ), + cwd: z.string().optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: z.string().optional(), + }, + outputSchema: ScheduleSummarySchema.shape, + }, + async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const expiresAt = buildScheduleExpiry(expiresIn); + const schedule = await scheduleService.create({ + prompt: prompt.trim(), + cadence: buildCronScheduleCadence({ + cron, + ...(timezone !== undefined ? { timezone } : {}), + }), + target: resolveNewAgentScheduleTarget({ provider, cwd }), + ...(name?.trim() ? { name: name.trim() } : {}), + ...(maxRuns === undefined ? {} : { maxRuns }), + ...(expiresAt === undefined ? {} : { expiresAt }), + }); + + return { + content: [], + structuredContent: ensureValidJson(toScheduleSummary(schedule)), + }; + }, + ); + + registerTool( + "create_heartbeat", + { + title: "Create heartbeat", + description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.", + inputSchema: { + prompt: z.string().trim().min(1, "prompt is required"), + cron: z.string().trim().min(1, "cron is required"), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe("IANA time zone for the cron cadence. For example: America/New_York."), + name: z.string().optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: z.string().optional(), + }, + outputSchema: ScheduleSummarySchema.shape, + }, + async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + if (!callerAgentId) { + throw new Error("create_heartbeat requires an agent-scoped session"); + } + resolveCallerAgent(); + + const expiresAt = buildScheduleExpiry(expiresIn); + const schedule = await scheduleService.create({ + prompt: prompt.trim(), + cadence: buildCronScheduleCadence({ + cron, + ...(timezone !== undefined ? { timezone } : {}), + }), + target: { type: "agent", agentId: callerAgentId }, + ...(name?.trim() ? { name: name.trim() } : {}), + ...(maxRuns === undefined ? {} : { maxRuns }), + ...(expiresAt === undefined ? {} : { expiresAt }), + }); + + return { + content: [], + structuredContent: ensureValidJson(toScheduleSummary(schedule)), + }; + }, + ); + + registerTool( + "list_schedules", + { + title: "List schedules", + description: "List all schedules managed by the daemon.", + inputSchema: {}, + outputSchema: { + schedules: z.array(ScheduleSummarySchema), + }, + }, + async () => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedules = (await scheduleService.list()).map((schedule) => + toScheduleSummary(schedule), + ); + return { + content: [], + structuredContent: ensureValidJson({ schedules }), + }; + }, + ); + + registerTool( + "inspect_schedule", + { + title: "Inspect schedule", + description: "Inspect a schedule and its run history.", + inputSchema: { + id: z.string(), + }, + outputSchema: StoredScheduleSchema.shape, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedule = await scheduleService.inspect(id); + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + + registerTool( + "pause_schedule", + { + title: "Pause schedule", + description: "Pause an active schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.pause(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "resume_schedule", + { + title: "Resume schedule", + description: "Resume a paused schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.resume(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "delete_schedule", + { + title: "Delete schedule", + description: "Delete a schedule permanently.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + await scheduleService.delete(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "update_schedule", + { + title: "Update schedule", + description: + "Update an existing schedule. Only provided fields are changed; omitted fields remain unchanged.", + inputSchema: { + id: z.string(), + every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), + cron: z.string().optional().describe("New cron expression."), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe( + "IANA time zone for cron cadence; requires cron. For example: America/New_York.", + ), + name: z.string().nullable().optional().describe("New name (null to clear)."), + prompt: z.string().trim().min(1).optional().describe("New prompt text."), + maxRuns: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe("New max runs limit (null to clear)."), + provider: z + .string() + .trim() + .min(1) + .optional() + .describe("New provider for new-agent target."), + model: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New model for new-agent target (null to clear)."), + mode: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New mode for new-agent target (null to clear)."), + cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), + expiresIn: z + .string() + .optional() + .describe("New relative expiry duration (for example: 1h, 2d)."), + clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), + }, + outputSchema: StoredScheduleSchema.shape, + }, + async (input) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const schedule = await scheduleService.update(buildScheduleUpdateInput(input)); + + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + + registerTool( + "schedule_logs", + { + title: "Schedule logs", + description: "Get the run history (logs) for a schedule.", + inputSchema: { + id: z.string(), + }, + outputSchema: { + runs: z.array(ScheduleRunSchema), + }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + + const runs = await scheduleService.logs(id); + return { + content: [], + structuredContent: ensureValidJson({ runs }), + }; + }, + ); + + registerTool( + "list_providers", + { + title: "List providers", + description: "List configured agent providers, availability, and their modes.", + inputSchema: {}, + outputSchema: { + providers: z.array(ProviderSummarySchema), + }, + }, + async () => { + const providers = (await providerSnapshotManager.listProviders({ wait: true })).map( + toProviderSummary, + ); + return { + content: [], + structuredContent: ensureValidJson({ providers }), + }; + }, + ); + + registerTool( + "list_models", + { + title: "List models", + description: "List models for an agent provider.", + inputSchema: { + provider: AgentProviderEnum, + }, + outputSchema: { + provider: z.string(), + models: z.array(AgentModelSchema), + }, + }, + async ({ provider }) => { + const models = await providerSnapshotManager.listModels({ + cwd: resolveSnapshotCwd(), + provider, + wait: true, + }); + return { + content: [], + structuredContent: ensureValidJson({ + provider, + models, + }), + }; + }, + ); + + registerTool( + "inspect_provider", + { + title: "Inspect provider", + description: + "Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.", + inputSchema: inspectProviderInputSchema, + outputSchema: { + provider: AgentProviderEnum, + label: z.string().nullable().optional(), + description: z.string().nullable().optional(), + enabled: z.boolean(), + status: z.string(), + modes: z.array(ProviderModeSchema).nullish(), + selectedModel: z.string().nullable(), + features: z.array(AgentFeatureSchema), + }, + }, + async ({ provider, cwd, settings }) => { + const resolvedProviderModel = resolveScheduleProviderAndModel({ + provider, + defaultProvider: provider, + }); + const providerId = resolvedProviderModel.provider; + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const entry = await providerSnapshotManager.getProvider({ + cwd: resolvedCwd, + provider: providerId, + wait: true, + }); + const summary = toProviderSummary(entry); + if (!entry.enabled) { + throw new Error(`Provider '${providerId}' is disabled`); + } + if (entry.status !== "ready") { + throw new Error(entry.error ?? `Provider '${providerId}' is unavailable`); + } + const selectedModel = settings?.model ?? resolvedProviderModel.model; + const features = await agentManager.listDraftFeatures({ + provider: providerId, + cwd: resolvedCwd, + ...(settings?.modeId ? { modeId: settings.modeId } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), + ...(settings?.features ? { featureValues: settings.features } : {}), + }); + return { + content: [], + structuredContent: ensureValidJson({ + provider: providerId, + label: summary.label, + description: summary.description, + enabled: summary.enabled, + status: summary.status, + modes: summary.modes, + selectedModel: selectedModel ?? null, + features, + }), + }; + }, + ); + + registerTool( + "list_worktrees", + { + title: "List worktrees", + description: "List Paseo-managed git worktrees for a repository.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional repository cwd. Defaults to your current working directory."), + }, + outputSchema: { + worktrees: z.array(WorktreeSummarySchema), + }, + }, + async ({ cwd }) => { + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + if (!options.workspaceGitService) { + throw new Error("WorkspaceGitService is required to list worktrees"); + } + const worktrees = await listPaseoWorktreesCommand( + { workspaceGitService: options.workspaceGitService }, + { + cwd: resolvedCwd, + reason: "mcp:list-worktrees", + }, + ); + + return { + content: [], + structuredContent: ensureValidJson({ worktrees }), + }; + }, + ); + + registerTool( + "create_worktree", + { + title: "Create worktree", + description: + "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: AgentCreateWorktreeTargetInputSchema.describe("What the worktree should contain."), + }, + outputSchema: { + branchName: z.string(), + worktreePath: z.string(), + workspaceId: z.string(), + }, + }, + async ({ cwd, target }) => { + const repoRoot = resolveScopedCwd(cwd, { required: true }); + const commandResult = await createPaseoWorktreeCommand( + { + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + createPaseoWorktreeWorkflow: options.createPaseoWorktree, + }, + createMcpWorktreeCommandInput(repoRoot, target), + ); + if (!commandResult.ok) { + throw new WorktreeRequestError(commandResult.error); + } + const { worktree, workspace } = commandResult.createdWorktree; + await options.workspaceGitService?.listWorktrees?.(repoRoot, { + force: true, + reason: "mcp:create-worktree", + }); + + return { + content: [], + structuredContent: ensureValidJson({ + branchName: worktree.branchName, + worktreePath: worktree.worktreePath, + workspaceId: workspace.workspaceId, + }), + }; + }, + ); + + registerTool( + "archive_worktree", + { + title: "Archive worktree", + description: "Delete a Paseo-managed git worktree.", + inputSchema: { + cwd: z + .string() + .optional() + .describe("Optional repository cwd. Defaults to your current working directory."), + worktreePath: z.string().optional(), + worktreeSlug: z.string().optional(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ cwd, worktreePath, worktreeSlug }) => { + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + if (!worktreePath && !worktreeSlug) { + throw new Error("worktreePath or worktreeSlug is required"); + } + if (!options.workspaceGitService) { + throw new Error("WorkspaceGitService is required to archive worktrees"); + } + const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd); + + const result = await archiveCommand( + archiveWorktreeDependencies(options, { + agentManager, + agentStorage, + terminalManager: terminalManager ?? null, + logger: childLogger, + }), + { + requestId: "mcp:archive_worktree", + repoRoot, + worktreePath, + worktreeSlug, + // This tool archives every workspace on the directory, then removes the + // directory. Disk removal is derived from scope + last-reference. + scope: "worktree", + }, + ); + if (!result.ok) { + throw new Error(result.message); + } + await options.workspaceGitService.listWorktrees(repoRoot, { + force: true, + reason: "mcp:archive-worktree", + }); + + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + registerTool( + "get_agent_activity", + { + title: "Get agent activity", + description: "Return recent agent timeline entries as a curated summary.", + inputSchema: { + agentId: z.string(), + limit: z + .number() + .optional() + .describe("Optional limit for number of activities to include (most recent first)."), + }, + outputSchema: { + agentId: z.string(), + updateCount: z.number(), + currentModeId: z.string().nullable(), + content: z.string(), + }, + }, + async ({ agentId, limit }) => { + await ensureAgentLoaded(agentId, { + agentManager, + agentStorage, + logger: childLogger, + }); + const timeline = agentManager.getTimeline(agentId); + const snapshot = agentManager.getAgent(agentId); + + const selection = selectItemsByProjectedLimit({ + items: timeline, + direction: "tail", + limit: limit ?? 0, + }); + const curatedContent = curateAgentActivity(selection.items); + const { totalProjected, shownProjected } = selection; + + const noun = totalProjected === 1 ? "activity" : "activities"; + const countHeader = + limit && shownProjected < totalProjected + ? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})` + : `Showing all ${totalProjected} ${noun}`; + + const contentWithCount = `${countHeader}\n\n${curatedContent}`; + + return { + content: [], + structuredContent: ensureValidJson({ + agentId, + updateCount: timeline.length, + currentModeId: snapshot?.currentModeId ?? null, + content: contentWithCount, + }), + }; + }, + ); + + registerTool( + "set_agent_mode", + { + title: "Set agent session mode", + description: + "Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).", + inputSchema: { + agentId: z.string(), + modeId: z.string(), + }, + outputSchema: { + success: z.boolean(), + newMode: z.string(), + }, + }, + async ({ agentId, modeId }) => { + const result = await setAgentModeCommand({ agentManager }, { agentId, modeId }); + return { + content: [], + structuredContent: ensureValidJson({ success: true, newMode: result.modeId }), + }; + }, + ); + + registerTool( + "list_pending_permissions", + { + title: "List pending permissions", + description: + "Return all pending permission requests across all agents with the normalized payloads.", + inputSchema: {}, + outputSchema: { + permissions: z.array( + z.object({ + agentId: z.string(), + status: AgentStatusEnum, + request: AgentPermissionRequestPayloadSchema, + }), + ), + }, + }, + async () => { + const permissions = agentManager.listAgents().flatMap((agent) => { + const payload = toAgentPayload(agent); + return payload.pendingPermissions.map((request) => ({ + agentId: agent.id, + status: payload.status, + request: sanitizePermissionRequest(request), + })); + }); + + return { + content: [], + structuredContent: ensureValidJson({ permissions }), + }; + }, + ); + + registerTool( + "respond_to_permission", + { + title: "Respond to permission", + description: + "Approve or deny a pending permission request with an AgentManager-compatible response payload.", + inputSchema: { + agentId: z.string(), + requestId: z.string(), + response: AgentPermissionResponseSchema, + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId, requestId, response }) => { + await respondToAgentPermission({ + agentManager, + agentId, + requestId, + response, + logger: childLogger, + }); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + + return toCatalog(); +} + +type McpCreateWorktreeTarget = + | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } + | { kind: "checkout-branch"; branch: string } + | { kind: "checkout-pr"; githubPrNumber: number }; + +interface ArchiveWorktreeCommandContext { + agentManager: AgentManager; + agentStorage: AgentStorage; + terminalManager: TerminalManager | null; + logger: Logger; +} + +function archiveWorktreeDependencies( + options: PaseoToolHostDependencies, + context: ArchiveWorktreeCommandContext, +): ArchiveCommandDependencies { + if (!options.github) { + throw new Error("GitHub service is required to archive worktrees"); + } + if (!options.workspaceGitService) { + throw new Error("WorkspaceGitService is required to archive worktrees"); + } + if (!options.archiveWorkspaceRecord) { + throw new Error("Workspace registry archiver is required to archive worktrees"); + } + if (!options.findWorkspaceIdForCwd) { + throw new Error("Workspace resolver is required to archive worktrees"); + } + if (!options.listActiveWorkspaces) { + throw new Error("Active workspace lister is required to archive worktrees"); + } + if (!options.emitWorkspaceUpdatesForWorkspaceIds) { + throw new Error("Workspace update emitter is required to archive worktrees"); + } + if (!options.markWorkspaceArchiving) { + throw new Error("Workspace archiving marker is required to archive worktrees"); + } + if (!options.clearWorkspaceArchiving) { + throw new Error("Workspace archiving clearer is required to archive worktrees"); + } + return { + paseoHome: options.paseoHome, + paseoWorktreesBaseRoot: options.worktreesRoot, + github: options.github, + workspaceGitService: options.workspaceGitService, + agentManager: context.agentManager, + agentStorage: context.agentStorage, + findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, + listActiveWorkspaces: options.listActiveWorkspaces, + archiveWorkspaceRecord: options.archiveWorkspaceRecord, + emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: options.markWorkspaceArchiving, + clearWorkspaceArchiving: options.clearWorkspaceArchiving, + killTerminalsForWorkspace: (workspaceId: string) => + killTerminalsForWorkspace( + { + terminalManager: context.terminalManager, + sessionLogger: context.logger, + }, + workspaceId, + ), + sessionLogger: context.logger, + }; +} + +function createMcpWorktreeCommandInput( + repoRoot: string, + target: McpCreateWorktreeTarget, +): CreatePaseoWorktreeCommandInput { + const base = { cwd: repoRoot } as const; + switch (target.kind) { + case "branch-off": + return { + ...base, + worktreeSlug: target.worktreeSlug, + branchName: target.branchName, + action: "branch-off", + ...(target.baseBranch ? { refName: target.baseBranch } : {}), + }; + case "checkout-branch": + return { ...base, action: "checkout", refName: target.branch }; + case "checkout-pr": + return { ...base, action: "checkout", githubPrNumber: target.githubPrNumber }; + default: + throw new Error("unreachable"); + } +} diff --git a/packages/server/src/server/agent/tools/types.ts b/packages/server/src/server/agent/tools/types.ts new file mode 100644 index 000000000..593f912d7 --- /dev/null +++ b/packages/server/src/server/agent/tools/types.ts @@ -0,0 +1,44 @@ +import type { z } from "zod"; + +export interface PaseoToolExecutionContext { + signal?: AbortSignal; +} + +export interface PaseoToolResult { + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + structuredContent?: unknown; + isError?: boolean; +} + +export interface PaseoToolConfig { + title?: string; + description?: string; + inputSchema?: z.ZodRawShape | z.ZodType; + outputSchema?: z.ZodRawShape | z.ZodType; +} + +export interface PaseoToolDefinition extends PaseoToolConfig { + name: string; + description: string; + handler: (input: unknown, context: PaseoToolExecutionContext) => Promise; +} + +export interface PaseoToolCatalog { + tools: ReadonlyMap; + getTool(name: string): PaseoToolDefinition | undefined; + executeTool( + name: string, + input: unknown, + context?: PaseoToolExecutionContext, + ): Promise; +} + +export interface PaseoToolRuntimeContext { + callerAgentId?: string; + enableVoiceTools?: boolean; + voiceOnly?: boolean; +} + +export type PaseoToolCatalogFactory = ( + context: PaseoToolRuntimeContext, +) => PaseoToolCatalog | Promise; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 1dac2d2da..23034898e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -105,6 +105,11 @@ import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; +import { + createPaseoToolCatalog, + type PaseoToolHostDependencies, +} from "./agent/tools/paseo-tools.js"; +import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; @@ -858,82 +863,96 @@ export async function createPaseoDaemon( emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, }); + const createPaseoWorktreeForTools = async ( + input: Parameters[1], + serviceOptions?: Parameters[2], + ) => { + return createPaseoWorktreeWorkflow( + { + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + createPaseoWorktree: async (workflowInput, workflowOptions) => { + return createRegisteredPaseoWorktree(workflowInput, { + github, + ...(workflowOptions?.resolveDefaultBranch + ? { + resolveDefaultBranch: workflowOptions.resolveDefaultBranch, + } + : {}), + projectRegistry, + workspaceRegistry, + workspaceGitService, + }); + }, + warmWorkspaceGitData: async (workspace) => { + await Promise.all( + wsServer + ?.listActiveSessions() + .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], + ); + }, + emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { + await emitWorkspaceUpdatesExternal([workspaceId]); + }, + cacheWorkspaceSetupSnapshot: () => {}, + emit: emitExternalSessionMessage, + sessionLogger: logger, + terminalManager, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + serviceProxy, + scriptRuntimeStore, + getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), + serviceProxyPublicBaseUrl, + onScriptsChanged: null, + }, + input, + serviceOptions, + ); + }; + + const createAgentToolHostDependencies = ( + runtime: PaseoToolRuntimeContext, + ): PaseoToolHostDependencies => ({ + agentManager, + agentStorage, + terminalManager, + getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + scheduleService, + providerSnapshotManager, + github, + workspaceGitService, + findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, + listActiveWorkspaces: listActiveWorkspacesExternal, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal, + createPaseoWorktree: createPaseoWorktreeForTools, + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + callerAgentId: runtime.callerAgentId, + enableVoiceTools: runtime.enableVoiceTools, + voiceOnly: runtime.voiceOnly, + resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null, + resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null, + logger, + }); + const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) => + createPaseoToolCatalog(createAgentToolHostDependencies(runtime)); + agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog); + agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false); + const mcpEnabled = config.mcpEnabled ?? true; let agentMcpBaseUrl: string | null = null; if (mcpEnabled) { const agentMcpRoute = "/mcp/agents"; const createAgentMcpSession = async (callerAgentId?: string) => { - const agentMcpServer = await createAgentMcpServer({ - agentManager, - agentStorage, - terminalManager, - getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), - scheduleService, - providerSnapshotManager, - github, - workspaceGitService, - findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, - listActiveWorkspaces: listActiveWorkspacesExternal, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, - emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, - markWorkspaceArchiving: markWorkspaceArchivingExternal, - clearWorkspaceArchiving: clearWorkspaceArchivingExternal, - ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal, - createPaseoWorktree: async (input, serviceOptions) => { - return createPaseoWorktreeWorkflow( - { - paseoHome: config.paseoHome, - worktreesRoot: config.worktreesRoot, - createPaseoWorktree: async (workflowInput, workflowOptions) => { - return createRegisteredPaseoWorktree(workflowInput, { - github, - ...(workflowOptions?.resolveDefaultBranch - ? { - resolveDefaultBranch: workflowOptions.resolveDefaultBranch, - } - : {}), - projectRegistry, - workspaceRegistry, - workspaceGitService, - }); - }, - warmWorkspaceGitData: async (workspace) => { - await Promise.all( - wsServer - ?.listActiveSessions() - .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], - ); - }, - emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { - await emitWorkspaceUpdatesExternal([workspaceId]); - }, - cacheWorkspaceSetupSnapshot: () => {}, - emit: emitExternalSessionMessage, - sessionLogger: logger, - terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, - serviceProxy, - scriptRuntimeStore, - getDaemonTcpPort: () => - boundListenTarget?.type === "tcp" ? boundListenTarget.port : null, - getDaemonTcpHost: () => - boundListenTarget?.type === "tcp" ? boundListenTarget.host : null, - serviceProxyPublicBaseUrl, - onScriptsChanged: null, - }, - input, - serviceOptions, - ); - }, - paseoHome: config.paseoHome, - worktreesRoot: config.worktreesRoot, - callerAgentId, - enableVoiceTools: false, - resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null, - resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null, - logger, - }); + const agentMcpServer = await createAgentMcpServer( + createAgentToolHostDependencies({ callerAgentId }), + ); // Stateless mode: each HTTP request builds a fresh server + transport that is // torn down when the response closes, so no per-session state is retained between @@ -1087,8 +1106,10 @@ export async function createPaseoDaemon( const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null; agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl; agentManager.setMcpBaseUrl(agentMcpBaseUrl); + agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false); daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => { agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null); + agentManager.setPaseoToolsEnabled(value !== false); }); daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => { agentManager.setAppendSystemPrompt(typeof value === "string" ? value : ""); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 799377c5d..cfb1d568e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3,7 +3,6 @@ import { v4 as uuidv4 } from "uuid"; import { stat } from "node:fs/promises"; import { basename, normalize, resolve, sep } from "path"; import { homedir } from "node:os"; -import type { ToolSet } from "ai"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { serializeAgentStreamEvent, @@ -49,8 +48,6 @@ import { resolveFirstAgentPromptTitle, } from "./agent/create-agent-title.js"; import { respondToAgentPermission } from "./agent/permission-response.js"; -import { experimental_createMCPClient } from "ai"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; @@ -537,9 +534,6 @@ export class Session { private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; - // Per-session MCP client and tools - private agentMcpClient: Awaited> | null = null; - private agentTools: ToolSet | null = null; private agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; @@ -552,7 +546,6 @@ export class Session { private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly daemonConfigStore: DaemonConfigStore; - private readonly mcpBaseUrl: string | null; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; @@ -619,7 +612,6 @@ export class Session { generateWorkspaceName, workspaceGitService, daemonConfigStore, - mcpBaseUrl, stt, sttLanguage, tts, @@ -791,7 +783,6 @@ export class Session { logger: this.sessionLogger, }); this.daemonConfigStore = daemonConfigStore; - this.mcpBaseUrl = mcpBaseUrl ?? null; this.terminalManager = terminalManager; this.terminalController = new TerminalSessionController({ terminalManager, @@ -908,8 +899,6 @@ export class Session { dictation, }); - // Initialize agent MCP client asynchronously - void this.initializeAgentMcp(); this.subscribeToAgentEvents(); this.sessionLogger.trace({}, "agent.session.lifecycle.created"); @@ -1118,37 +1107,6 @@ export class Session { }); } - /** - * Initialize Agent MCP client for this session using the daemon's HTTP MCP endpoint. - */ - private async initializeAgentMcp(): Promise { - try { - if (!this.mcpBaseUrl) { - this.sessionLogger.info( - "Skipping Agent MCP initialization because no MCP base URL is configured", - ); - return; - } - const authToken = this.agentManager.getMcpAuthToken(); - const transport = new StreamableHTTPClientTransport( - new URL(this.mcpBaseUrl), - authToken - ? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } } - : undefined, - ); - - this.agentMcpClient = await experimental_createMCPClient({ - transport, - }); - - this.agentTools = (await this.agentMcpClient.tools()) as ToolSet; - const agentToolCount = Object.keys(this.agentTools ?? {}).length; - this.sessionLogger.trace({ agentToolCount }, "agent.session.mcp_init"); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to initialize Agent MCP"); - } - } - /** * Subscribe to AgentManager events and forward them to the client */ @@ -5667,17 +5625,6 @@ export class Session { await this.voiceSession.cleanup(); - // Close MCP clients - if (this.agentMcpClient) { - try { - await this.agentMcpClient.close(); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to close Agent MCP client"); - } - this.agentMcpClient = null; - this.agentTools = null; - } - this.terminalController.dispose(); this.checkoutSession.cleanup(); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index f0a9793f3..81335f6f8 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -37,6 +37,13 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { }; const TEST_FEATURE_ID = "test_feature"; +const TEST_MODES: AgentMode[] = [ + { id: "bypassPermissions", label: "Bypass", description: "No permissions" }, + { id: "default", label: "Default", description: "Ask for permissions" }, + { id: "full-access", label: "Full access", description: "No prompts" }, + { id: "auto", label: "Auto", description: "Ask/allow based on policy" }, + { id: "always-ask", label: "Always Ask", description: "Always prompt" }, +]; interface Deferred { promise: Promise; @@ -798,13 +805,7 @@ class FakeAgentSession implements AgentSession { } async getAvailableModes(): Promise { - return [ - { id: "bypassPermissions", label: "Bypass", description: "No permissions" }, - { id: "default", label: "Default", description: "Ask for permissions" }, - { id: "full-access", label: "Full access", description: "No prompts" }, - { id: "auto", label: "Auto", description: "Ask/allow based on policy" }, - { id: "always-ask", label: "Always Ask", description: "Always prompt" }, - ]; + return TEST_MODES; } async getCurrentMode(): Promise { @@ -1194,7 +1195,7 @@ class FakeAgentClient implements AgentClient { { provider: this.provider, id: "haiku", label: "Haiku", isDefault: true }, { provider: this.provider, id: "sonnet", label: "Sonnet", isDefault: false }, ], - modes: [], + modes: TEST_MODES, }; } if (this.provider === "codex") { @@ -1207,12 +1208,12 @@ class FakeAgentClient implements AgentClient { isDefault: true, }, ], - modes: [], + modes: TEST_MODES, }; } return { models: [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }], - modes: [], + modes: TEST_MODES, }; }