diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 252b30ff1e..3334a1a3a8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -28,6 +28,7 @@ import type { PersistedAgentDescriptor, } from "./agent-sdk-types.js"; import type { ProviderDefinition } from "./provider-registry.js"; +import { PaseoToolingRuntime } from "./paseo-tooling/runtime.js"; interface Deferred { promise: Promise; @@ -522,7 +523,20 @@ test("createAgent injects daemon append system prompt at runtime only", async () const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const client = new TestAgentClient(); + class CaptureClient extends TestAgentClient { + lastLaunchContext: AgentLaunchContext | null = null; + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = launchContext ?? null; + return super.resumeSession(handle, config, launchContext); + } + } + + const client = new CaptureClient(); const manager = new AgentManager({ clients: { codex: client, @@ -551,7 +565,20 @@ test("daemon append system prompt is injected into Pi configs", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const client = new TestAgentClient(); + class CaptureClient extends TestAgentClient { + lastLaunchContext: AgentLaunchContext | null = null; + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = launchContext ?? null; + return super.resumeSession(handle, config, launchContext); + } + } + + const client = new CaptureClient(); const manager = new AgentManager({ clients: { pi: client as unknown as AgentClient, @@ -948,28 +975,38 @@ test("createAgent passes persistSession to provider create options", async () => rmSync(workdir, { recursive: true, force: true }); }); -test("createAgent injects paseo MCP server only into provider launch config", async () => { +test("createAgent passes internal paseo tooling only through runtime launch context", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); class CaptureClient extends TestAgentClient { lastConfig: AgentSessionConfig | null = null; + lastLaunchContext: AgentLaunchContext | null = null; - override async createSession(config: AgentSessionConfig): Promise { + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { this.lastConfig = config; + this.lastLaunchContext = launchContext ?? null; return new TestAgentSession(config); } } const client = new CaptureClient(); + const paseoToolingRuntime = new PaseoToolingRuntime(); + paseoToolingRuntime.setEndpoints({ + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + httpBaseUrl: "http://127.0.0.1:6767", + }); const manager = new AgentManager({ clients: { codex: client, }, registry: storage, logger, - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + paseoToolingRuntime, idFactory: () => "00000000-0000-4000-8000-000000000103", }); @@ -991,15 +1028,15 @@ test("createAgent injects paseo MCP server only into provider launch config", as }, }); expect(client.lastConfig?.mcpServers).toEqual({ - paseo: { - type: "http", - url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, - }, custom: { type: "stdio", command: "custom-mcp", }, }); + expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe( + `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, + ); + expect(client.lastLaunchContext?.paseoTooling?.httpBaseUrl).toBe("http://127.0.0.1:6767"); const stored = await storage.get(snapshot.id); expect(stored?.config?.mcpServers).toEqual({ @@ -1010,18 +1047,36 @@ test("createAgent injects paseo MCP server only into provider launch config", as }); }); -test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => { +test("resumeAgentFromPersistence strips stored internal paseo MCP and passes current runtime tooling", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); - const client = new TestAgentClient(); + class CaptureClient extends TestAgentClient { + lastLaunchContext: AgentLaunchContext | null = null; + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = launchContext ?? null; + return super.resumeSession(handle, config, launchContext); + } + } + + const client = new CaptureClient(); + const paseoToolingRuntime = new PaseoToolingRuntime(); + paseoToolingRuntime.setEndpoints({ + mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents", + httpBaseUrl: "http://127.0.0.1:6768", + }); const manager = new AgentManager({ clients: { codex: client, }, registry: storage, logger, - mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents", + paseoToolingRuntime, idFactory: () => "00000000-0000-4000-8000-000000000105", }); const handle: AgentPersistenceHandle = { @@ -1047,15 +1102,14 @@ test("resumeAgentFromPersistence replaces stored internal paseo MCP with current }); expect(client.resumeOverrides[0]?.mcpServers).toEqual({ - paseo: { - type: "http", - url: `http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`, - }, custom: { type: "stdio", command: "custom-mcp", }, }); + expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe( + `http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`, + ); expect(snapshot.config.mcpServers).toEqual({ custom: { type: "stdio", @@ -1119,7 +1173,6 @@ test("createAgent preserves a user-provided paseo MCP config", async () => { }, registry: storage, logger, - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", idFactory: () => "00000000-0000-4000-8000-000000000104", }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index dc6d614803..0f9344f182 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -57,7 +57,8 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest import { IMPORTABLE_PROVIDERS } from "./provider-registry.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; -import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; +import { stripInternalPaseoMcpServer } from "./runtime-mcp-config.js"; +import type { PaseoToolingRuntime } from "./paseo-tooling/runtime.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -189,7 +190,7 @@ export interface AgentManagerOptions { onAgentAttention?: AgentAttentionCallback; durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; - mcpBaseUrl?: string; + paseoToolingRuntime?: PaseoToolingRuntime; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -432,7 +433,7 @@ export class AgentManager { private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; - private mcpBaseUrl: string | null; + private readonly paseoToolingRuntime: PaseoToolingRuntime | null; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -444,7 +445,7 @@ export class AgentManager { this.registry = options?.registry; this.durableTimelineStore = options?.durableTimelineStore; this.onAgentAttention = options?.onAgentAttention; - this.mcpBaseUrl = options?.mcpBaseUrl ?? null; + this.paseoToolingRuntime = options?.paseoToolingRuntime ?? null; this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -500,10 +501,6 @@ export class AgentManager { this.onAgentArchived = callback; } - setMcpBaseUrl(url: string | null): void { - this.mcpBaseUrl = url; - } - setAppendSystemPrompt(prompt: string | null | undefined): void { this.appendSystemPrompt = prompt ?? ""; } @@ -3460,16 +3457,10 @@ export class AgentManager { private async prepareSessionConfig( config: AgentSessionConfig, - agentId: string, + _agentId: string, ): Promise { const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config)); - const launchConfig = this.applyDaemonAppendSystemPrompt( - withRuntimePaseoMcpServer({ - config: storedConfig, - agentId, - mcpBaseUrl: this.mcpBaseUrl, - }), - ); + const launchConfig = this.applyDaemonAppendSystemPrompt(storedConfig); return { storedConfig, launchConfig }; } @@ -3489,6 +3480,7 @@ export class AgentManager { private buildLaunchContext(agentId: string, env?: Record): AgentLaunchContext { return { agentId, + paseoTooling: this.paseoToolingRuntime?.createLaunchContext(agentId), env: { ...env, PASEO_AGENT_ID: agentId, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 93d92be513..10889a9fc5 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -546,9 +546,23 @@ export interface AgentSessionConfig { internal?: boolean; } +export interface PaseoToolingProviderSessionRef { + provider: AgentProvider; + sessionId: string; +} + +export interface PaseoToolingLaunchContext { + agentId: string; + mcpUrl: string | null; + httpBaseUrl: string | null; + token: string; + bindProviderSession(ref: PaseoToolingProviderSessionRef): () => void; +} + export interface AgentLaunchContext { agentId?: string; env?: Record; + paseoTooling?: PaseoToolingLaunchContext; } export interface AgentCreateSessionOptions { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 79ca55489a..8a3eb393d5 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -1,2464 +1,2 @@ -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 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 { - killTerminalsUnderPath, - type ArchivePaseoWorktreeDependencies, -} from "../paseo-worktree-archive-service.js"; -import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { createAgentCommand } 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 { - archivePaseoWorktreeCommand, - type ArchivePaseoWorktreeCommandDependencies, - createPaseoWorktreeCommand, - type CreatePaseoWorktreeCommandInput, - listPaseoWorktreesCommand, -} from "../worktree/commands.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" - >; - archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; - emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; - markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; - clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; - createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; - 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; -} - -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; -} - -function isZodSchema(value: unknown): value is z.ZodTypeAny { - return ( - typeof value === "object" && value !== null && "_def" in value && "safeParseAsync" in value - ); -} - -function relaxMcpOutputSchema(outputSchema: unknown): unknown { - if (!outputSchema) { - return outputSchema; - } - - if (isZodSchema(outputSchema)) { - return outputSchema instanceof z.ZodObject ? outputSchema.passthrough() : outputSchema; - } - - return z.object(outputSchema as z.ZodRawShape).passthrough(); -} - -function relaxMcpToolOutputSchema( - config: TConfig, -): TConfig { - if (config.outputSchema === undefined) { - return config; - } - - return { - ...config, - outputSchema: relaxMcpOutputSchema(config.outputSchema), - } 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; - } - - 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 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 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); - }; - - 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.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.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.unknown()).optional().describe("Draft provider feature values."), - }) - .strict(); - const agentToAgentInputSchema = { - cwd: z - .string() - .optional() - .describe("Optional working directory. Defaults to your current working directory."), - 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."), - detached: z - .boolean() - .optional() - .default(false) - .describe( - "If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.", - ), - 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 = { - cwd: z - .string() - .describe("Required working directory for the agent (absolute, relative, or ~)."), - title: z - .string() - .trim() - .min(1, "Title is required") - .max(60, "Title must be 60 characters or fewer") - .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), - provider: ProviderModelInputSchema.describe( - "Required provider/model pair, for example codex/gpt-5.4.", - ), - labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), - settings: CreateAgentSettingsInputSchema.optional().describe( - "Initial runtime settings for the new agent.", - ), - initialPrompt: z - .string() - .trim() - .min(1, "initialPrompt is required") - .describe("Required first task to run immediately after creation."), - worktreeName: z - .string() - .optional() - .describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."), - baseBranch: z - .string() - .optional() - .describe("Required when worktreeName is set: the base branch to diff/merge against."), - refName: z.string().min(1).optional().describe("Optional source ref for worktree creation."), - action: z - .enum(["branch-off", "checkout"]) - .optional() - .describe("Optional worktree creation action."), - githubPrNumber: z - .number() - .int() - .positive() - .optional() - .describe("Optional GitHub pull request number to checkout."), - 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 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 }), - }; - }, - ); - } - - if (options.voiceOnly) { - return server; - } - - registerTool( - "create_agent", - { - title: "Create agent", - description: - "Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Do not guess; call list_providers and list_models first if uncertain. Optionally run an initial prompt immediately or create a git worktree for the agent.", - inputSchema: createAgentInputSchema, - outputSchema: { - agentId: z.string(), - type: AgentProviderEnum, - status: AgentStatusEnum, - cwd: z.string(), - 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 = resolveCreateAgentToolArgs(args); - const { parsedArgs, worktree } = resolvedArgs; - let requestedBackground: boolean; - let notifyOnFinish: boolean; - let detached: boolean; - if (resolvedArgs.kind === "agent-scoped") { - requestedBackground = true; - notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish; - detached = resolvedArgs.parsedArgs.detached; - } else { - requestedBackground = resolvedArgs.parsedArgs.background; - notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; - detached = false; - } - const { - snapshot, - background: createdInBackground, - initialPromptStarted, - } = await createAgentCommand( - { - agentManager, - agentStorage, - logger: childLogger, - paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, - workspaceGitService: options.workspaceGitService, - terminalManager, - providerSnapshotManager, - createPaseoWorktree: options.createPaseoWorktree, - }, - { - kind: "mcp", - provider: parsedArgs.provider, - title: parsedArgs.title, - initialPrompt: parsedArgs.initialPrompt, - cwd: parsedArgs.cwd, - 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, - 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, - currentModeId: currentSnapshot.currentModeId, - availableModes: currentSnapshot.availableModes, - lastMessage: null, - permission: null, - ...(guidance ? { guidance } : {}), - }), - }; - return response; - }, - ); - - type ResolvedCreateAgentToolArgs = - | { - kind: "agent-scoped"; - parsedArgs: AgentToAgentCreateAgentArgs; - worktree: undefined; - } - | { - kind: "top-level"; - parsedArgs: TopLevelCreateAgentArgs; - worktree: ReturnType; - }; - - function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs { - if (callerAgentId) { - return { - kind: "agent-scoped", - parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args), - worktree: undefined, - }; - } - const parsedArgs = topLevelCreateAgentArgsSchema.parse(args); - return { - kind: "top-level", - parsedArgs, - worktree: resolveTopLevelCreateAgentWorktree(parsedArgs), - }; - } - - function resolveTopLevelCreateAgentWorktree(args: TopLevelCreateAgentArgs): - | { - worktreeName?: string; - baseBranch?: string; - refName?: string; - action?: "branch-off" | "checkout"; - githubPrNumber?: number; - } - | undefined { - return { - worktreeName: args.worktreeName, - baseBranch: args.baseBranch, - refName: args.refName, - action: args.action, - githubPrNumber: args.githubPrNumber, - }; - } - - 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. Returns immediately after the agent begins processing.", - inputSchema: { - agentId: z.string(), - prompt: z.string(), - sessionMode: z - .string() - .optional() - .describe("Optional mode to set before running the prompt."), - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), - notifyOnFinish: z - .boolean() - .optional() - .default(false) - .describe( - "Agent-scoped only: get notified when this run finishes, errors, or needs permission.", - ), - }, - outputSchema: { - success: z.boolean(), - status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), - }, - }, - async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => { - if (agentManager.hasInFlightRun(agentId)) { - waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); - } - - await sendPromptToAgent({ - agentManager, - agentStorage, - agentId, - prompt, - sessionMode, - logger: childLogger, - }); - - if (notifyOnFinish && 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, - }; - 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, - 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 = 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 terminal = await terminalManager.createTerminal({ - cwd: resolveScopedCwd(cwd, { required: true }), - ...(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: z - .discriminatedUnion("mode", [ - z - .object({ - mode: z.literal("branch-off"), - newBranch: z.string().min(1).describe("Name for the new branch."), - base: z - .string() - .min(1) - .optional() - .describe("Base ref. Defaults to the repo's default branch."), - }) - .describe("Create a new branch off a base."), - z - .object({ - mode: z.literal("checkout-branch"), - branch: z.string().min(1).describe("Existing branch to check out."), - }) - .describe("Check out an existing branch."), - z - .object({ - mode: z.literal("checkout-pr"), - prNumber: z.number().int().positive().describe("Pull request number."), - }) - .describe("Check out a GitHub pull request."), - ]) - .describe("What the worktree should contain."), - }, - outputSchema: { - branchName: z.string(), - worktreePath: 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 } = commandResult.createdWorktree; - await options.workspaceGitService?.listWorktrees?.(repoRoot, { - force: true, - reason: "mcp:create-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - }), - }; - }, - ); - - 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 archivePaseoWorktreeCommand( - archiveWorktreeDependencies(options, { - agentManager, - agentStorage, - terminalManager: terminalManager ?? null, - logger: childLogger, - }), - { - requestId: "mcp:archive_worktree", - repoRoot, - worktreePath, - worktreeSlug, - }, - ); - 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, - })); - }); - - 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 = - | { mode: "branch-off"; newBranch: string; base?: string } - | { mode: "checkout-branch"; branch: string } - | { mode: "checkout-pr"; prNumber: number }; - -interface ArchiveWorktreeCommandContext { - agentManager: AgentManager; - agentStorage: AgentStorage; - terminalManager: TerminalManager | null; - logger: Logger; -} - -function archiveWorktreeDependencies( - options: AgentMcpServerOptions, - context: ArchiveWorktreeCommandContext, -): ArchivePaseoWorktreeCommandDependencies { - 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.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, - worktreesRoot: options.worktreesRoot, - github: options.github, - workspaceGitService: options.workspaceGitService, - agentManager: context.agentManager, - agentStorage: context.agentStorage, - archiveWorkspaceRecord: options.archiveWorkspaceRecord, - emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: options.markWorkspaceArchiving, - clearWorkspaceArchiving: options.clearWorkspaceArchiving, - isPathWithinRoot: isSameOrDescendantPath, - killTerminalsUnderPath: (rootPath: string) => - killTerminalsUnderPath( - { - terminalManager: context.terminalManager, - isPathWithinRoot: isSameOrDescendantPath, - killTrackedTerminal: () => {}, - sessionLogger: context.logger, - }, - rootPath, - ), - sessionLogger: context.logger, - }; -} - -function createMcpWorktreeCommandInput( - repoRoot: string, - target: McpCreateWorktreeTarget, -): CreatePaseoWorktreeCommandInput { - const base = { cwd: repoRoot } as const; - switch (target.mode) { - case "branch-off": - return { - ...base, - worktreeSlug: target.newBranch, - action: "branch-off", - ...(target.base ? { refName: target.base } : {}), - }; - case "checkout-branch": - return { ...base, action: "checkout", refName: target.branch }; - case "checkout-pr": - return { ...base, action: "checkout", githubPrNumber: target.prNumber }; - default: - throw new Error("unreachable"); - } -} +export { createAgentMcpServer } from "./paseo-tooling/mcp-adapter.js"; +export type { AgentMcpServerOptions } from "./paseo-tooling/mcp-adapter.js"; diff --git a/packages/server/src/server/agent/paseo-tooling/http-adapter.ts b/packages/server/src/server/agent/paseo-tooling/http-adapter.ts new file mode 100644 index 0000000000..fee1fe0bc9 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tooling/http-adapter.ts @@ -0,0 +1,101 @@ +import type express from "express"; +import type { Logger } from "pino"; +import { z } from "zod"; + +import type { AgentProvider } from "../agent-sdk-types.js"; +import type { PaseoToolRegistry } from "./registry.js"; +import type { PaseoToolingRuntime } from "./runtime.js"; + +const ToolExecuteBodySchema = z.object({ + provider: z.string().min(1), + sessionId: z.string().min(1), + tool: z.string().min(1), + input: z.unknown().optional(), +}); + +export interface MountPaseoToolingHttpAdapterOptions { + app: express.Express; + registry: PaseoToolRegistry; + runtime: PaseoToolingRuntime; + logger: Logger; +} + +export function mountPaseoToolingHttpAdapter(options: MountPaseoToolingHttpAdapterOptions): void { + const { app, registry, runtime, logger } = options; + const requireAuth: express.RequestHandler = (req, res, next) => { + if (req.header("authorization") === `Bearer ${runtime.token}`) { + next(); + return; + } + res.status(401).json({ error: "Unauthorized" }); + }; + + app.get("/api/paseo-tooling/manifest", requireAuth, (_req, res) => { + res.json({ tools: registry.listManifest() }); + }); + + const runExecuteRequest = async (req: express.Request, res: express.Response): Promise => { + const parsed = ToolExecuteBodySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const callerAgentId = runtime.resolveProviderSession({ + provider: parsed.data.provider as AgentProvider, + sessionId: parsed.data.sessionId, + }); + if (!callerAgentId) { + res.status(404).json({ error: "Provider session is not bound to a Paseo agent" }); + return; + } + + try { + const result = await registry.executeTool({ + name: parsed.data.tool, + input: parsed.data.input ?? {}, + callerAgentId, + }); + res.json({ output: formatPaseoToolResult(result) }); + } catch (err) { + logger.error( + { + err, + provider: parsed.data.provider, + sessionId: parsed.data.sessionId, + tool: parsed.data.tool, + }, + "Paseo tooling HTTP execution failed", + ); + res.status(500).json({ error: err instanceof Error ? err.message : "Tool execution failed" }); + } + }; + + app.post("/api/paseo-tooling/execute", requireAuth, (req, res) => { + void runExecuteRequest(req, res); + }); +} + +function formatPaseoToolResult(result: unknown): unknown { + if (!result || typeof result !== "object" || Array.isArray(result)) { + return result; + } + const record = result as Record; + if (record.structuredContent !== undefined) { + return record.structuredContent; + } + if (!Array.isArray(record.content)) { + return result; + } + const text = record.content + .map((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) { + return null; + } + const value = (part as Record).text; + return typeof value === "string" ? value : null; + }) + .filter((part): part is string => part !== null) + .join("\n"); + return text || record.content; +} diff --git a/packages/server/src/server/agent/paseo-tooling/mcp-adapter.ts b/packages/server/src/server/agent/paseo-tooling/mcp-adapter.ts new file mode 100644 index 0000000000..dead13440c --- /dev/null +++ b/packages/server/src/server/agent/paseo-tooling/mcp-adapter.ts @@ -0,0 +1,21 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { + createPaseoToolRegistry, + type AgentMcpServerOptions, + type PaseoToolingRegistryOptions, +} from "./registry.js"; + +export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise { + const server = new McpServer({ + name: "agent-mcp", + version: "2.0.0", + }); + const registry = createPaseoToolRegistry(options); + for (const tool of registry.listTools()) { + server.registerTool(tool.name, tool.config, tool.handler); + } + return server; +} + +export type { AgentMcpServerOptions, PaseoToolingRegistryOptions }; diff --git a/packages/server/src/server/agent/paseo-tooling/provider-mcp.ts b/packages/server/src/server/agent/paseo-tooling/provider-mcp.ts new file mode 100644 index 0000000000..611419f102 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tooling/provider-mcp.ts @@ -0,0 +1,24 @@ +import type { AgentLaunchContext, AgentSessionConfig } from "../agent-sdk-types.js"; + +const PASEO_MCP_SERVER_NAME = "paseo"; + +export function withPaseoToolingMcpServer( + config: AgentSessionConfig, + launchContext: AgentLaunchContext | undefined, +): AgentSessionConfig { + const mcpUrl = launchContext?.paseoTooling?.mcpUrl; + if (!mcpUrl || config.mcpServers?.[PASEO_MCP_SERVER_NAME]) { + return config; + } + + return { + ...config, + mcpServers: { + [PASEO_MCP_SERVER_NAME]: { + type: "http", + url: mcpUrl, + }, + ...config.mcpServers, + }, + }; +} diff --git a/packages/server/src/server/agent/paseo-tooling/registry.test.ts b/packages/server/src/server/agent/paseo-tooling/registry.test.ts new file mode 100644 index 0000000000..74817ed3fe --- /dev/null +++ b/packages/server/src/server/agent/paseo-tooling/registry.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createPaseoToolRegistry } from "./registry.js"; + +describe("Paseo tooling registry", () => { + test("executes tools with runtime caller identity", async () => { + const callerCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-tooling-caller-")); + const requestedCwd = path.join(callerCwd, "child"); + const createdTerminals: Array<{ cwd: string; name?: string }> = []; + const terminalManager = { + createTerminal: async (input: { cwd: string; name?: string }) => { + createdTerminals.push(input); + return { id: "terminal-1", name: input.name ?? "Terminal", cwd: input.cwd }; + }, + }; + const agentManager = { + getAgent: (agentId: string) => + agentId === "agent-1" ? { cwd: callerCwd, config: {} } : undefined, + }; + + try { + const registry = createPaseoToolRegistry({ + agentManager: agentManager as never, + agentStorage: {} as never, + terminalManager: terminalManager as never, + providerSnapshotManager: {} as never, + agentScopedTools: true, + logger: createTestLogger(), + }); + + const result = await registry.executeTool({ + name: "create_terminal", + callerAgentId: "agent-1", + input: { + cwd: "child", + name: "Build", + }, + }); + + expect(createdTerminals).toEqual([{ cwd: requestedCwd, name: "Build" }]); + expect(result.structuredContent).toEqual({ + id: "terminal-1", + name: "Build", + cwd: requestedCwd, + }); + } finally { + rmSync(callerCwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/agent/paseo-tooling/registry.ts b/packages/server/src/server/agent/paseo-tooling/registry.ts new file mode 100644 index 0000000000..834a832114 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tooling/registry.ts @@ -0,0 +1,2583 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +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 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 { + killTerminalsUnderPath, + type ArchivePaseoWorktreeDependencies, +} from "../../paseo-worktree-archive-service.js"; +import { WaitForAgentTracker } from "../wait-for-agent-tracker.js"; +import { createAgentCommand } 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 { + archivePaseoWorktreeCommand, + type ArchivePaseoWorktreeCommandDependencies, + createPaseoWorktreeCommand, + type CreatePaseoWorktreeCommandInput, + listPaseoWorktreesCommand, +} from "../../worktree/commands.js"; + +const convertZodToJsonSchema: (schema: z.ZodTypeAny, options: { $refStrategy: "none" }) => unknown = + zodToJsonSchema as unknown as ( + schema: z.ZodTypeAny, + options: { $refStrategy: "none" }, + ) => unknown; + +export interface PaseoToolingRegistryOptions { + agentManager: AgentManager; + agentStorage: AgentStorage; + terminalManager?: TerminalManager | null; + getDaemonTcpPort?: () => number | null; + scheduleService?: ScheduleService | null; + providerSnapshotManager: ProviderSnapshotManager; + github?: GitHubService; + workspaceGitService?: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + >; + archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; + emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; + markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; + clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; + createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; + paseoHome?: string; + worktreesRoot?: string; + /** + * ID of the agent that is using Paseo tooling through an agent-scoped adapter. + * Used for cwd/mode inheritance when agents spawn child agents. + */ + callerAgentId?: string; + agentScopedTools?: boolean; + /** + * 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 = PaseoToolingRegistryOptions; + +export interface PaseoToolConfig { + title?: string; + description?: string; + inputSchema?: z.ZodRawShape; + outputSchema?: z.ZodRawShape | z.ZodTypeAny; +} + +export type PaseoToolHandler = ( + args: unknown, + extra: McpToolContext, +) => Promise | CallToolResult; + +type RegisterPaseoTool = ( + name: string, + config: PaseoToolConfig & { inputSchema?: TInputSchema }, + handler: ( + args: z.infer>, + extra: McpToolContext, + ) => Promise | CallToolResult, +) => unknown; + +export interface PaseoToolDefinition { + name: string; + config: PaseoToolConfig; + handler: PaseoToolHandler; +} + +export interface PaseoToolManifestEntry { + name: string; + description: string; + inputJsonSchema: unknown; +} + +export interface PaseoToolRegistry { + listTools(): PaseoToolDefinition[]; + listManifest(): PaseoToolManifestEntry[]; + getTool(name: string): PaseoToolDefinition | null; + executeTool(params: { + name: string; + input: unknown; + callerAgentId?: string; + }): Promise; +} + +function buildRegistryApi( + tools: Map, + callerAgentIdOverride: AsyncLocalStorage, +): PaseoToolRegistry { + return { + listTools: () => Array.from(tools.values()), + listManifest: () => + Array.from(tools.values()).map((tool) => ({ + name: tool.name, + description: tool.config.description ?? tool.config.title ?? tool.name, + inputJsonSchema: convertZodToJsonSchema(buildToolInputSchema(tool.config), { + $refStrategy: "none", + }), + })), + getTool: (name) => tools.get(name) ?? null, + executeTool: async ({ name, input, callerAgentId }) => { + const tool = tools.get(name); + if (!tool) { + throw new Error(`Unknown Paseo tool '${name}'`); + } + const parsedInput = await buildToolInputSchema(tool.config).parseAsync(input ?? {}); + return callerAgentIdOverride.run(callerAgentId, async () => + tool.handler(parsedInput as never, {} as never), + ); + }, + }; +} + +function buildToolInputSchema(config: PaseoToolConfig): z.ZodTypeAny { + return z.object(config.inputSchema ?? {}) as z.ZodTypeAny; +} + +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; +} + +function isZodSchema(value: unknown): value is z.ZodTypeAny { + return ( + typeof value === "object" && value !== null && "_def" in value && "safeParseAsync" in value + ); +} + +function relaxMcpOutputSchema(outputSchema: unknown): unknown { + if (!outputSchema) { + return outputSchema; + } + + if (isZodSchema(outputSchema)) { + return outputSchema instanceof z.ZodObject ? outputSchema.passthrough() : outputSchema; + } + + return z.object(outputSchema as z.ZodRawShape).passthrough(); +} + +function relaxMcpToolOutputSchema( + config: TConfig, +): TConfig { + if (config.outputSchema === undefined) { + return config; + } + + return { + ...config, + outputSchema: relaxMcpOutputSchema(config.outputSchema), + } 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; + } + + 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 createPaseoToolRegistry(options: PaseoToolingRegistryOptions): PaseoToolRegistry { + const { + agentManager, + agentStorage, + terminalManager, + scheduleService, + providerSnapshotManager, + callerAgentId, + resolveSpeakHandler, + resolveCallerContext, + logger, + } = options; + const childLogger = logger.child({ module: "agent", component: "paseo-tooling" }); + const waitTracker = new WaitForAgentTracker(logger); + const callerAgentIdOverride = new AsyncLocalStorage(); + const tools = new Map(); + const registerTool: RegisterPaseoTool = function registerTool( + name: string, + config: PaseoToolConfig & { inputSchema?: TInputSchema }, + handler: ( + args: z.infer>, + extra: McpToolContext, + ) => Promise | CallToolResult, + ) { + const normalizedConfig = relaxMcpToolOutputSchema(config) as PaseoToolConfig; + const wrappedHandler = (async (args: unknown, extra: McpToolContext) => { + return addModelVisibleStructuredContent( + await handler(args as z.infer>, extra), + ); + }) as PaseoToolHandler; + tools.set(name, { + name, + config: normalizedConfig, + handler: wrappedHandler, + }); + return undefined; + }; + + 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 resolveEffectiveCallerAgentId = () => callerAgentIdOverride.getStore() ?? callerAgentId; + + const resolveEffectiveCallerContext = () => { + const effectiveCallerAgentId = resolveEffectiveCallerAgentId(); + return effectiveCallerAgentId ? (resolveCallerContext?.(effectiveCallerAgentId) ?? null) : null; + }; + + const resolveCallerAgent = () => { + const effectiveCallerAgentId = resolveEffectiveCallerAgentId(); + if (!effectiveCallerAgentId) { + return null; + } + const parentAgent = agentManager.getAgent(effectiveCallerAgentId); + if (!parentAgent) { + throw new Error(`Parent agent ${effectiveCallerAgentId} not found`); + } + return parentAgent; + }; + + const resolveScopedCwd = (requestedCwd?: string, opts?: { required?: boolean }): string => { + const callerAgent = resolveCallerAgent(); + if (callerAgent) { + const callerContext = resolveEffectiveCallerContext(); + 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); + }; + + 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.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.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.unknown()).optional().describe("Draft provider feature values."), + }) + .strict(); + const agentToAgentInputSchema = { + cwd: z + .string() + .optional() + .describe("Optional working directory. Defaults to your current working directory."), + 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."), + detached: z + .boolean() + .optional() + .default(false) + .describe( + "If true, the created agent stands on its own: it does not appear in your subagent track and is not archived with you.", + ), + 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 = { + cwd: z + .string() + .describe("Required working directory for the agent (absolute, relative, or ~)."), + title: z + .string() + .trim() + .min(1, "Title is required") + .max(60, "Title must be 60 characters or fewer") + .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), + provider: ProviderModelInputSchema.describe( + "Required provider/model pair, for example codex/gpt-5.4.", + ), + labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), + settings: CreateAgentSettingsInputSchema.optional().describe( + "Initial runtime settings for the new agent.", + ), + initialPrompt: z + .string() + .trim() + .min(1, "initialPrompt is required") + .describe("Required first task to run immediately after creation."), + worktreeName: z + .string() + .optional() + .describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."), + baseBranch: z + .string() + .optional() + .describe("Required when worktreeName is set: the base branch to diff/merge against."), + refName: z.string().min(1).optional().describe("Optional source ref for worktree creation."), + action: z + .enum(["branch-off", "checkout"]) + .optional() + .describe("Optional worktree creation action."), + githubPrNumber: z + .number() + .int() + .positive() + .optional() + .describe("Optional GitHub pull request number to checkout."), + 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 = + options.agentScopedTools || callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; + const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); + const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict(); + 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 || + resolveEffectiveCallerContext()?.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) => { + const effectiveCallerAgentId = resolveEffectiveCallerAgentId(); + if (!effectiveCallerAgentId) { + throw new Error("speak is only available to agent-scoped Paseo tooling sessions"); + } + const handler = resolveSpeakHandler?.(effectiveCallerAgentId) ?? null; + if (!handler) { + throw new Error( + `No speak handler registered for your session '${effectiveCallerAgentId}'`, + ); + } + await handler({ + text: args.text, + callerAgentId: effectiveCallerAgentId, + signal: context?.signal, + }); + return { + content: [], + structuredContent: ensureValidJson({ ok: true }), + }; + }, + ); + } + + if (options.voiceOnly) { + return buildRegistryApi(tools, callerAgentIdOverride); + } + + registerTool( + "create_agent", + { + title: "Create agent", + description: + "Create an agent tied to a working directory. Requires provider/model, for example codex/gpt-5.4. Do not guess; call list_providers and list_models first if uncertain. Optionally run an initial prompt immediately or create a git worktree for the agent.", + inputSchema: createAgentInputSchema, + outputSchema: { + agentId: z.string(), + type: AgentProviderEnum, + status: AgentStatusEnum, + cwd: z.string(), + 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 = resolveCreateAgentToolArgs(args); + const { parsedArgs, worktree } = resolvedArgs; + let requestedBackground: boolean; + let notifyOnFinish: boolean; + let detached: boolean; + if (resolvedArgs.kind === "agent-scoped") { + requestedBackground = true; + notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish; + detached = resolvedArgs.parsedArgs.detached; + } else { + requestedBackground = resolvedArgs.parsedArgs.background; + notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; + detached = false; + } + const { + snapshot, + background: createdInBackground, + initialPromptStarted, + } = await createAgentCommand( + { + agentManager, + agentStorage, + logger: childLogger, + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + workspaceGitService: options.workspaceGitService, + terminalManager, + providerSnapshotManager, + createPaseoWorktree: options.createPaseoWorktree, + }, + { + kind: "mcp", + provider: parsedArgs.provider, + title: parsedArgs.title, + initialPrompt: parsedArgs.initialPrompt, + cwd: parsedArgs.cwd, + thinking: parsedArgs.settings?.thinkingOptionId, + features: parsedArgs.settings?.features, + labels: parsedArgs.labels, + mode: parsedArgs.settings?.modeId, + background: requestedBackground, + notifyOnFinish, + detached, + callerAgentId: resolveEffectiveCallerAgentId(), + callerContext: resolveEffectiveCallerContext(), + 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, + 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 effectiveCallerAgentId = resolveEffectiveCallerAgentId(); + const guidance = + effectiveCallerAgentId && 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, + currentModeId: currentSnapshot.currentModeId, + availableModes: currentSnapshot.availableModes, + lastMessage: null, + permission: null, + ...(guidance ? { guidance } : {}), + }), + }; + return response; + }, + ); + + type ResolvedCreateAgentToolArgs = + | { + kind: "agent-scoped"; + parsedArgs: AgentToAgentCreateAgentArgs; + worktree: undefined; + } + | { + kind: "top-level"; + parsedArgs: TopLevelCreateAgentArgs; + worktree: ReturnType; + }; + + function resolveCreateAgentToolArgs(args: unknown): ResolvedCreateAgentToolArgs { + if (resolveEffectiveCallerAgentId()) { + return { + kind: "agent-scoped", + parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args), + worktree: undefined, + }; + } + const parsedArgs = topLevelCreateAgentArgsSchema.parse(args); + return { + kind: "top-level", + parsedArgs, + worktree: resolveTopLevelCreateAgentWorktree(parsedArgs), + }; + } + + function resolveTopLevelCreateAgentWorktree(args: TopLevelCreateAgentArgs): + | { + worktreeName?: string; + baseBranch?: string; + refName?: string; + action?: "branch-off" | "checkout"; + githubPrNumber?: number; + } + | undefined { + return { + worktreeName: args.worktreeName, + baseBranch: args.baseBranch, + refName: args.refName, + action: args.action, + githubPrNumber: args.githubPrNumber, + }; + } + + 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. Returns immediately after the agent begins processing.", + inputSchema: { + agentId: z.string(), + prompt: z.string(), + sessionMode: z + .string() + .optional() + .describe("Optional mode to set before running the prompt."), + background: z + .boolean() + .optional() + .default(false) + .describe( + "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", + ), + notifyOnFinish: z + .boolean() + .optional() + .default(false) + .describe( + "Agent-scoped only: get notified when this run finishes, errors, or needs permission.", + ), + }, + outputSchema: { + success: z.boolean(), + status: AgentStatusEnum, + lastMessage: z.string().nullable().optional(), + permission: AgentPermissionRequestPayloadSchema.nullable().optional(), + }, + }, + async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => { + if (agentManager.hasInFlightRun(agentId)) { + waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); + } + + await sendPromptToAgent({ + agentManager, + agentStorage, + agentId, + prompt, + sessionMode, + logger: childLogger, + }); + + const effectiveCallerAgentId = resolveEffectiveCallerAgentId(); + if (notifyOnFinish && effectiveCallerAgentId) { + setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: agentId, + callerAgentId: effectiveCallerAgentId, + 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, + }; + 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, + 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 = resolveEffectiveCallerAgentId() ? 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 = 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 terminal = await terminalManager.createTerminal({ + cwd: resolveScopedCwd(cwd, { required: true }), + ...(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"); + } + const effectiveCallerAgentId = resolveEffectiveCallerAgentId(); + if (!effectiveCallerAgentId) { + 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: effectiveCallerAgentId }, + ...(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: z + .discriminatedUnion("mode", [ + z + .object({ + mode: z.literal("branch-off"), + newBranch: z.string().min(1).describe("Name for the new branch."), + base: z + .string() + .min(1) + .optional() + .describe("Base ref. Defaults to the repo's default branch."), + }) + .describe("Create a new branch off a base."), + z + .object({ + mode: z.literal("checkout-branch"), + branch: z.string().min(1).describe("Existing branch to check out."), + }) + .describe("Check out an existing branch."), + z + .object({ + mode: z.literal("checkout-pr"), + prNumber: z.number().int().positive().describe("Pull request number."), + }) + .describe("Check out a GitHub pull request."), + ]) + .describe("What the worktree should contain."), + }, + outputSchema: { + branchName: z.string(), + worktreePath: 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 } = commandResult.createdWorktree; + await options.workspaceGitService?.listWorktrees?.(repoRoot, { + force: true, + reason: "mcp:create-worktree", + }); + + return { + content: [], + structuredContent: ensureValidJson({ + branchName: worktree.branchName, + worktreePath: worktree.worktreePath, + }), + }; + }, + ); + + 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 archivePaseoWorktreeCommand( + archiveWorktreeDependencies(options, { + agentManager, + agentStorage, + terminalManager: terminalManager ?? null, + logger: childLogger, + }), + { + requestId: "mcp:archive_worktree", + repoRoot, + worktreePath, + worktreeSlug, + }, + ); + 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, + })); + }); + + 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 buildRegistryApi(tools, callerAgentIdOverride); +} + +type McpCreateWorktreeTarget = + | { mode: "branch-off"; newBranch: string; base?: string } + | { mode: "checkout-branch"; branch: string } + | { mode: "checkout-pr"; prNumber: number }; + +interface ArchiveWorktreeCommandContext { + agentManager: AgentManager; + agentStorage: AgentStorage; + terminalManager: TerminalManager | null; + logger: Logger; +} + +function archiveWorktreeDependencies( + options: AgentMcpServerOptions, + context: ArchiveWorktreeCommandContext, +): ArchivePaseoWorktreeCommandDependencies { + 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.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, + worktreesRoot: options.worktreesRoot, + github: options.github, + workspaceGitService: options.workspaceGitService, + agentManager: context.agentManager, + agentStorage: context.agentStorage, + archiveWorkspaceRecord: options.archiveWorkspaceRecord, + emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: options.markWorkspaceArchiving, + clearWorkspaceArchiving: options.clearWorkspaceArchiving, + isPathWithinRoot: isSameOrDescendantPath, + killTerminalsUnderPath: (rootPath: string) => + killTerminalsUnderPath( + { + terminalManager: context.terminalManager, + isPathWithinRoot: isSameOrDescendantPath, + killTrackedTerminal: () => {}, + sessionLogger: context.logger, + }, + rootPath, + ), + sessionLogger: context.logger, + }; +} + +function createMcpWorktreeCommandInput( + repoRoot: string, + target: McpCreateWorktreeTarget, +): CreatePaseoWorktreeCommandInput { + const base = { cwd: repoRoot } as const; + switch (target.mode) { + case "branch-off": + return { + ...base, + worktreeSlug: target.newBranch, + action: "branch-off", + ...(target.base ? { refName: target.base } : {}), + }; + case "checkout-branch": + return { ...base, action: "checkout", refName: target.branch }; + case "checkout-pr": + return { ...base, action: "checkout", githubPrNumber: target.prNumber }; + default: + throw new Error("unreachable"); + } +} diff --git a/packages/server/src/server/agent/paseo-tooling/runtime.ts b/packages/server/src/server/agent/paseo-tooling/runtime.ts new file mode 100644 index 0000000000..4b800527b0 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tooling/runtime.ts @@ -0,0 +1,61 @@ +import { randomBytes } from "node:crypto"; + +import type { + AgentProvider, + PaseoToolingLaunchContext, + PaseoToolingProviderSessionRef, +} from "../agent-sdk-types.js"; + +interface PaseoToolingRuntimeEndpoints { + mcpBaseUrl: string | null; + httpBaseUrl: string | null; +} + +export class PaseoToolingRuntime { + private mcpBaseUrl: string | null = null; + private httpBaseUrl: string | null = null; + private enabled = true; + private readonly sessionBindings = new Map(); + readonly token = randomBytes(32).toString("hex"); + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + setEndpoints(endpoints: PaseoToolingRuntimeEndpoints): void { + this.mcpBaseUrl = endpoints.mcpBaseUrl; + this.httpBaseUrl = endpoints.httpBaseUrl; + } + + createLaunchContext(agentId: string): PaseoToolingLaunchContext | undefined { + if (!this.enabled || (!this.mcpBaseUrl && !this.httpBaseUrl)) { + return undefined; + } + + return { + agentId, + mcpUrl: this.mcpBaseUrl ? `${this.mcpBaseUrl}?callerAgentId=${agentId}` : null, + httpBaseUrl: this.httpBaseUrl, + token: this.token, + bindProviderSession: (ref) => this.bindProviderSession({ ...ref, agentId }), + }; + } + + bindProviderSession(ref: PaseoToolingProviderSessionRef & { agentId: string }): () => void { + const key = providerSessionKey(ref.provider, ref.sessionId); + this.sessionBindings.set(key, ref.agentId); + return () => { + if (this.sessionBindings.get(key) === ref.agentId) { + this.sessionBindings.delete(key); + } + }; + } + + resolveProviderSession(ref: PaseoToolingProviderSessionRef): string | null { + return this.sessionBindings.get(providerSessionKey(ref.provider, ref.sessionId)) ?? null; + } +} + +function providerSessionKey(provider: AgentProvider, sessionId: string): string { + return `${provider}:${sessionId}`; +} diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 17ea0146fc..b1aff4d150 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -93,6 +93,7 @@ import { type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; import { platformShell, spawnProcess } from "../../../utils/spawn.js"; @@ -596,29 +597,30 @@ export class ACPAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): Promise { this.assertProvider(config); - const session = new ACPAgentSession( + const launchConfig = withPaseoToolingMcpServer( { ...config, provider: this.provider }, - { - provider: this.provider, - logger: this.logger, - runtimeSettings: this.runtimeSettings, - defaultCommand: this.defaultCommand, - defaultModes: this.defaultModes, - modelTransformer: this.modelTransformer, - sessionResponseTransformer: this.sessionResponseTransformer, - configOptionsTransformer: this.configOptionsTransformer, - modeIdTransformer: this.modeIdTransformer, - toolSnapshotTransformer: this.toolSnapshotTransformer, - providerModeWriter: this.providerModeWriter, - beforeModeWriter: this.beforeModeWriter, - thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, - agentId: launchContext?.agentId, - launchEnv: launchContext?.env, - waitForInitialCommands: this.waitForInitialCommands, - initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, - }, + launchContext, ); + const session = new ACPAgentSession(launchConfig, { + provider: this.provider, + logger: this.logger, + runtimeSettings: this.runtimeSettings, + defaultCommand: this.defaultCommand, + defaultModes: this.defaultModes, + modelTransformer: this.modelTransformer, + sessionResponseTransformer: this.sessionResponseTransformer, + configOptionsTransformer: this.configOptionsTransformer, + modeIdTransformer: this.modeIdTransformer, + toolSnapshotTransformer: this.toolSnapshotTransformer, + providerModeWriter: this.providerModeWriter, + beforeModeWriter: this.beforeModeWriter, + thinkingOptionWriter: this.thinkingOptionWriter, + capabilities: this.capabilities, + agentId: launchContext?.agentId, + launchEnv: launchContext?.env, + waitForInitialCommands: this.waitForInitialCommands, + initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, + }); await session.initializeNewSession(); return session; } @@ -638,12 +640,15 @@ export class ACPAgentClient implements AgentClient { throw new Error(`${this.provider} resume requires the original working directory`); } - const mergedConfig: AgentSessionConfig = { - ...storedConfig, - ...overrides, - provider: this.provider, - cwd, - }; + const mergedConfig = withPaseoToolingMcpServer( + { + ...storedConfig, + ...overrides, + provider: this.provider, + cwd, + }, + launchContext, + ); const session = new ACPAgentSession(mergedConfig, { provider: this.provider, logger: this.logger, diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 852471e42e..aa9f8741df 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; +import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { @@ -1297,7 +1298,7 @@ export class ClaudeAgentClient implements AgentClient { launchContext?: AgentLaunchContext, options?: AgentCreateSessionOptions, ): Promise { - const claudeConfig = this.assertConfig(config); + const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(config, launchContext)); return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, @@ -1325,7 +1326,7 @@ export class ClaudeAgentClient implements AgentClient { provider: "claude", cwd: merged.cwd, }; - const claudeConfig = this.assertConfig(mergedConfig); + const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(mergedConfig, launchContext)); return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 9f1dc6aa4c..c711e1dcc4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -85,6 +85,7 @@ import { toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; +import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; function assertChildWithPipes( @@ -5433,7 +5434,10 @@ export class CodexAppServerAgentClient implements AgentClient { // TODO: Honor persistSession=false if app-server adds support, or route // utility generations through `codex exec --ephemeral` in a larger change. } - const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; + const sessionConfig = withPaseoToolingMcpServer( + { ...config, provider: CODEX_PROVIDER }, + launchContext, + ); const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); const session = new CodexAppServerAgentSession( @@ -5458,12 +5462,15 @@ export class CodexAppServerAgentClient implements AgentClient { launchContext?: AgentLaunchContext, ): Promise { const storedConfig = (handle.metadata ?? {}) as Partial; - const merged: AgentSessionConfig = { - ...storedConfig, - ...overrides, - provider: CODEX_PROVIDER, - cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), - }; + const merged = withPaseoToolingMcpServer( + { + ...storedConfig, + ...overrides, + provider: CODEX_PROVIDER, + cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), + }, + launchContext, + ); const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); const session = new CodexAppServerAgentSession( diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 235f975dd9..6fdf714d84 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -16,6 +16,7 @@ import { TestOpenCodeRuntime, } from "./opencode/test-utils/test-opencode-runtime.js"; import type { + AgentLaunchContext, AgentSessionConfig, AgentStreamEvent, ToolCallTimelineItem, @@ -852,6 +853,55 @@ describe("OpenCode adapter context-window normalization", () => { }); describe("OpenCode adapter startTurn error handling", () => { + test("uses native Paseo tooling plugin instead of registering internal MCP", async () => { + const runtime = new TestOpenCodeRuntime(); + const openCodeClient = new TestOpenCodeClient(); + runtime.enqueueClient(openCodeClient); + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + const boundSessions: string[] = []; + const unboundSessions: string[] = []; + const launchContext: AgentLaunchContext = { + agentId: "agent-1", + env: { PASEO_AGENT_ID: "agent-1" }, + paseoTooling: { + agentId: "agent-1", + mcpUrl: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + httpBaseUrl: "http://127.0.0.1:6767", + token: "tool-token", + bindProviderSession: ({ provider, sessionId }) => { + expect(provider).toBe("opencode"); + boundSessions.push(sessionId); + return () => unboundSessions.push(sessionId); + }, + }, + }; + + try { + const session = await client.createSession( + { + provider: "opencode", + cwd, + }, + launchContext, + ); + + await collectTurnEvents(streamSession(session, "hello")); + + expect(runtime.acquisitions[0]?.env?.PASEO_AGENT_ID).toBe("agent-1"); + expect(runtime.acquisitions[0]?.env?.OPENCODE_CONFIG_CONTENT).toContain( + "paseo-tooling-plugin.mjs", + ); + expect(openCodeClient.calls.mcpAdd).toEqual([]); + expect(boundSessions).toEqual(["session-1"]); + + await session.close(); + expect(unboundSessions).toEqual(["session-1"]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("dynamically adds injected MCP servers without config-backed connect", async () => { const runtime = new TestOpenCodeRuntime(); const openCodeClient = new TestOpenCodeClient(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c5e440a51e..a168715e42 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -78,6 +78,11 @@ import { type OpenCodeRuntime, type OpenCodeServerAcquisition, } from "./opencode/runtime.js"; +import { + bindOpenCodePaseoToolingSession, + ensureOpenCodePaseoToolingRuntime, + withOpenCodePaseoToolingEnv, +} from "./opencode/paseo-tooling.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js"; @@ -1280,9 +1285,14 @@ export class OpenCodeAgentClient implements AgentClient { options?: AgentCreateSessionOptions, ): Promise { const openCodeConfig = this.assertConfig(config); + const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime(); const acquisition = await this.runtime.acquireServer({ force: false, - env: launchContext?.env, + env: withOpenCodePaseoToolingEnv({ + env: launchContext?.env, + launchContext, + runtime: paseoToolingRuntime, + }), }); const { url } = acquisition.server; const client = this.runtime.createClient({ @@ -1307,6 +1317,10 @@ export class OpenCodeAgentClient implements AgentClient { } await this.populateModelContextWindowCache(client, openCodeConfig.cwd); + const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({ + launchContext, + sessionId: session.id, + }); return new OpenCodeAgentSession( openCodeConfig, @@ -1317,6 +1331,7 @@ export class OpenCodeAgentClient implements AgentClient { acquisition.release, options?.persistSession, launchContext?.agentId, + unbindPaseoToolingSession, ); } catch (error) { acquisition.release(); @@ -1342,7 +1357,15 @@ export class OpenCodeAgentClient implements AgentClient { cwd, }; const openCodeConfig = this.assertConfig(config); - const acquisition = await this.runtime.acquireServer({ force: false }); + const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime(); + const acquisition = await this.runtime.acquireServer({ + force: false, + env: withOpenCodePaseoToolingEnv({ + env: launchContext?.env, + launchContext, + runtime: paseoToolingRuntime, + }), + }); const { url } = acquisition.server; const client = this.runtime.createClient({ baseUrl: url, @@ -1351,6 +1374,10 @@ export class OpenCodeAgentClient implements AgentClient { try { await this.populateModelContextWindowCache(client, openCodeConfig.cwd); + const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({ + launchContext, + sessionId: handle.sessionId, + }); return new OpenCodeAgentSession( openCodeConfig, @@ -1361,6 +1388,7 @@ export class OpenCodeAgentClient implements AgentClient { acquisition.release, undefined, launchContext?.agentId, + unbindPaseoToolingSession, ); } catch (error) { acquisition.release(); @@ -2802,6 +2830,7 @@ class OpenCodeAgentSession implements AgentSession { releaseServer?: () => void, persistSession = true, private readonly agentId?: string, + private readonly unbindPaseoToolingSession?: () => void, ) { this.config = config; this.client = client; @@ -3574,6 +3603,7 @@ class OpenCodeAgentSession implements AgentSession { await this.deleteProviderSessionIfEphemeral(); this.activeForegroundTurnId = null; } finally { + this.unbindPaseoToolingSession?.(); this.releaseServer?.(); this.releaseServer = null; } diff --git a/packages/server/src/server/agent/providers/opencode/paseo-tooling.test.ts b/packages/server/src/server/agent/providers/opencode/paseo-tooling.test.ts new file mode 100644 index 0000000000..2b69b51d9e --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/paseo-tooling.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { ensureOpenCodePaseoToolingRuntime } from "./paseo-tooling.js"; + +describe("OpenCode Paseo tooling plugin", () => { + const originalPaseoHome = process.env.PASEO_HOME; + + afterEach(() => { + if (originalPaseoHome === undefined) { + delete process.env.PASEO_HOME; + } else { + process.env.PASEO_HOME = originalPaseoHome; + } + vi.restoreAllMocks(); + }); + + test("normalizes OpenCode string arguments before executing Paseo tools", async () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "opencode-paseo-tooling-test-")); + process.env.PASEO_HOME = paseoHome; + const runtime = ensureOpenCodePaseoToolingRuntime(); + const postedBodies: unknown[] = []; + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + const pathname = new URL(url.toString()).pathname; + if (pathname === "/api/paseo-tooling/manifest") { + return new Response( + JSON.stringify({ + tools: [ + { + name: "list_agents", + description: "List agents", + inputJsonSchema: { + type: "object", + properties: { + includeArchived: { type: "boolean", default: false }, + sinceHours: { type: "integer", default: 48 }, + statuses: { + type: "array", + items: { type: "string", enum: ["running", "idle"] }, + }, + limit: { type: "integer", default: 50 }, + }, + additionalProperties: false, + }, + }, + ], + }), + { status: 200 }, + ); + } + + postedBodies.push(JSON.parse(String(init?.body)) as unknown); + return new Response(JSON.stringify({ output: "ok" }), { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + try { + const plugin = (await import( + `${pathToFileURL(runtime.pluginPath).href}?test=${Date.now()}` + )) as { + server: ( + input: unknown, + options: { baseUrl: string; token: string }, + ) => Promise<{ + tool: Record< + string, + { execute: (args: unknown, context: { sessionID: string }) => Promise } + >; + }>; + }; + const server = await plugin.server({}, { baseUrl: "http://127.0.0.1:6767", token: "t" }); + + await server.tool.paseo_list_agents?.execute( + { + includeArchived: "False", + sinceHours: "", + statuses: '["running"]', + limit: "5", + }, + { sessionID: "session-1" }, + ); + + expect(postedBodies).toEqual([ + { + provider: "opencode", + sessionId: "session-1", + tool: "list_agents", + input: { + includeArchived: false, + statuses: ["running"], + limit: 5, + }, + }, + ]); + } finally { + rmSync(paseoHome, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/paseo-tooling.ts b/packages/server/src/server/agent/providers/opencode/paseo-tooling.ts new file mode 100644 index 0000000000..13a2cce5fc --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/paseo-tooling.ts @@ -0,0 +1,276 @@ +import { createRequire } from "node:module"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { AgentLaunchContext } from "../../agent-sdk-types.js"; + +const PASEO_OPENCODE_PLUGIN_FILENAME = "paseo-tooling-plugin.mjs"; + +export interface OpenCodePaseoToolingRuntime { + pluginPath: string; +} + +function resolvePaseoHome(): string { + return process.env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo"); +} + +function resolveRuntimeDirectory(): string { + return path.join(resolvePaseoHome(), "opencode"); +} + +export function ensureOpenCodePaseoToolingRuntime(): OpenCodePaseoToolingRuntime { + const dir = resolveRuntimeDirectory(); + mkdirSync(dir, { recursive: true }); + const pluginPath = path.join(dir, PASEO_OPENCODE_PLUGIN_FILENAME); + const require = createRequire(import.meta.url); + const zodImportUrl = pathToFileURL(require.resolve("zod")).href; + writeFileSync(pluginPath, createOpenCodePaseoToolingPluginSource(zodImportUrl), "utf8"); + return { pluginPath }; +} + +export function withOpenCodePaseoToolingEnv(params: { + env?: Record; + launchContext?: AgentLaunchContext; + runtime: OpenCodePaseoToolingRuntime; +}): Record | undefined { + const tooling = params.launchContext?.paseoTooling; + if (!tooling?.httpBaseUrl) { + return params.env; + } + + const pluginSpec = [ + pathToFileURL(params.runtime.pluginPath).href, + { + baseUrl: tooling.httpBaseUrl, + token: tooling.token, + }, + ]; + return { + ...params.env, + OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent( + params.env?.OPENCODE_CONFIG_CONTENT, + pluginSpec, + ), + }; +} + +export function bindOpenCodePaseoToolingSession(params: { + launchContext?: AgentLaunchContext; + sessionId: string; +}): (() => void) | undefined { + return params.launchContext?.paseoTooling?.bindProviderSession({ + provider: "opencode", + sessionId: params.sessionId, + }); +} + +function mergeOpenCodeConfigContent(existing: string | undefined, pluginSpec: unknown[]): string { + const base = parseConfigContent(existing); + const existingPlugins = Array.isArray(base.plugin) ? base.plugin : []; + const pluginUrl = pluginSpec[0]; + const withoutExistingPaseoPlugin = existingPlugins.filter((entry) => { + const spec = Array.isArray(entry) ? entry[0] : entry; + return spec !== pluginUrl; + }); + return JSON.stringify({ + ...base, + plugin: [...withoutExistingPaseoPlugin, pluginSpec], + }); +} + +function parseConfigContent(existing: string | undefined): Record { + if (!existing?.trim()) { + return {}; + } + try { + const parsed = JSON.parse(existing) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function createOpenCodePaseoToolingPluginSource(zodImportUrl: string): string { + return ` +import { z } from ${JSON.stringify(zodImportUrl)}; + +export async function server(_input, options) { + const manifestResponse = await fetch(new URL("/api/paseo-tooling/manifest", options.baseUrl), { + headers: { authorization: "Bearer " + options.token }, + }); + if (!manifestResponse.ok) { + throw new Error("Failed to load Paseo tooling manifest: " + manifestResponse.status); + } + const manifest = await manifestResponse.json(); + const tools = {}; + for (const item of manifest.tools ?? []) { + const id = "paseo_" + item.name; + tools[id] = { + description: item.description, + args: jsonSchemaObjectToZodShape(item.inputJsonSchema), + execute: async (args, context) => { + const input = normalizeJsonSchemaInput(args, item.inputJsonSchema); + const response = await fetch(new URL("/api/paseo-tooling/execute", options.baseUrl), { + method: "POST", + headers: { + authorization: "Bearer " + options.token, + "content-type": "application/json", + }, + body: JSON.stringify({ + provider: "opencode", + sessionId: context.sessionID, + tool: item.name, + input, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error ?? "Paseo tool failed: " + response.status); + } + return typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? payload, null, 2); + }, + }; + } + + return { tool: tools }; +} + +function jsonSchemaObjectToZodShape(schema) { + const properties = schema?.properties && typeof schema.properties === "object" ? schema.properties : {}; + const required = new Set(Array.isArray(schema?.required) ? schema.required : []); + const shape = {}; + for (const [key, value] of Object.entries(properties)) { + let parsed = jsonSchemaToZod(value); + if (!required.has(key)) parsed = parsed.optional(); + shape[key] = parsed; + } + return shape; +} + +function jsonSchemaToZod(schema) { + if (!schema || typeof schema !== "object") return z.any(); + if (schema.anyOf) return unionToZod(schema.anyOf); + if (schema.oneOf) return unionToZod(schema.oneOf); + if (schema.const !== undefined) return z.literal(schema.const); + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + if (schema.enum.length === 1) return z.literal(schema.enum[0]); + if (schema.enum.every((item) => typeof item === "string")) return z.enum(schema.enum); + return z.union(schema.enum.map((item) => z.literal(item))); + } + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const nullable = types.includes("null"); + const type = types.find((item) => item !== "null"); + let result; + switch (type) { + case "string": + result = z.string(); + if (typeof schema.minLength === "number") result = result.min(schema.minLength); + if (typeof schema.maxLength === "number") result = result.max(schema.maxLength); + break; + case "integer": + result = z.number().int(); + if (typeof schema.minimum === "number") result = result.min(schema.minimum); + if (typeof schema.maximum === "number") result = result.max(schema.maximum); + break; + case "number": + result = z.number(); + if (typeof schema.minimum === "number") result = result.min(schema.minimum); + if (typeof schema.maximum === "number") result = result.max(schema.maximum); + break; + case "boolean": + result = z.boolean(); + break; + case "array": + result = z.array(jsonSchemaToZod(schema.items)); + break; + case "object": + result = z.object(jsonSchemaObjectToZodShape(schema)); + break; + default: + result = z.any(); + break; + } + if (schema.description && typeof result.describe === "function") result = result.describe(schema.description); + return nullable ? result.nullable() : result; +} + +function normalizeJsonSchemaInput(input, schema) { + if (!schema || typeof schema !== "object") return input; + + if (schema.anyOf) return normalizeUnionInput(input, schema.anyOf); + if (schema.oneOf) return normalizeUnionInput(input, schema.oneOf); + + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const type = types.find((item) => item !== "null"); + + if (typeof input === "string" && !types.includes("string")) { + const trimmed = input.trim(); + if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined; + } + + switch (type) { + case "boolean": + if (typeof input === "string") { + const normalized = input.trim().toLowerCase(); + if (normalized === "true" || normalized === "yes" || normalized === "1") return true; + if (normalized === "false" || normalized === "no" || normalized === "0") return false; + } + return input; + case "integer": + case "number": + if (typeof input === "string" && input.trim() !== "" && /^-?\\d+(\\.\\d+)?$/.test(input.trim())) { + return Number(input); + } + return input; + case "array": { + const value = parseJsonString(input); + if (!Array.isArray(value)) return value; + return value.map((item) => normalizeJsonSchemaInput(item, schema.items)).filter((item) => item !== undefined); + } + case "object": { + const value = parseJsonString(input); + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {}; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const normalized = {}; + for (const [key, propertySchema] of Object.entries(properties)) { + if (!(key in value)) continue; + const next = normalizeJsonSchemaInput(value[key], propertySchema); + if (next !== undefined || required.has(key)) normalized[key] = next; + } + return normalized; + } + default: + return input; + } +} + +function normalizeUnionInput(input, schemas) { + const preferred = schemas.find((item) => item?.type && item.type !== "null") ?? schemas[0]; + return normalizeJsonSchemaInput(input, preferred); +} + +function parseJsonString(value) { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function unionToZod(items) { + const nonNull = items.filter((item) => item?.type !== "null"); + const nullable = nonNull.length !== items.length; + const parsed = nonNull.map(jsonSchemaToZod); + const result = parsed.length === 0 ? z.null() : parsed.length === 1 ? parsed[0] : z.union(parsed); + return nullable ? result.nullable() : result; +} +`; +} diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index e07176a339..837421bd02 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -30,6 +30,7 @@ import { type PersistedAgentDescriptor, } from "../../agent-sdk-types.js"; import { runProviderTurn } from "../provider-runner.js"; +import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch, @@ -1838,18 +1839,19 @@ export class PiRpcAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers); + const launchConfig = withPaseoToolingMcpServer(config, launchContext); + const mcpConfig = await this.prepareMcpConfig(launchConfig.cwd, launchConfig.mcpServers); const paseoExtension = createPiPaseoExtensionFile(); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession({ - cwd: config.cwd, - model: config.model, + cwd: launchConfig.cwd, + model: launchConfig.model, thinkingOptionId: - normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, + normalizePiThinkingOption(launchConfig.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, systemPrompt: composeSystemPromptParts( - config.systemPrompt, - config.daemonAppendSystemPrompt, + launchConfig.systemPrompt, + launchConfig.daemonAppendSystemPrompt, ), env: launchContext?.env, mcpConfigPath: mcpConfig?.path, @@ -1863,7 +1865,7 @@ export class PiRpcAgentClient implements AgentClient { try { return new PiRpcAgentSession({ runtimeSession, - config, + config: launchConfig, initialState: await runtimeSession.getState(), capabilities: withPiMcpCapability(mcpConfig !== null), cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]), @@ -1879,7 +1881,7 @@ export class PiRpcAgentClient implements AgentClient { async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, - _launchContext?: AgentLaunchContext, + launchContext?: AgentLaunchContext, ): Promise { const sessionFile = handle.nativeHandle; if (!sessionFile) { @@ -1889,7 +1891,8 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides); - const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers); + const launchConfig = withPaseoToolingMcpServer(resumeConfig.config, launchContext); + const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, launchConfig.mcpServers); const paseoExtension = createPiPaseoExtensionFile(); let runtimeSession: PiRuntimeSession; try { @@ -1899,8 +1902,8 @@ export class PiRpcAgentClient implements AgentClient { model: resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined, systemPrompt: composeSystemPromptParts( - resumeConfig.config.systemPrompt, - resumeConfig.config.daemonAppendSystemPrompt, + launchConfig.systemPrompt, + launchConfig.daemonAppendSystemPrompt, ), mcpConfigPath: mcpConfig?.path, extensionPaths: [paseoExtension.path], @@ -1913,7 +1916,7 @@ export class PiRpcAgentClient implements AgentClient { try { return new PiRpcAgentSession({ runtimeSession, - config: resumeConfig.config, + config: launchConfig, initialState: await runtimeSession.getState(), capabilities: withPiMcpCapability(mcpConfig !== null), cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]), diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index 8df9ebabb9..596e1b037f 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -26,28 +26,6 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe return next; } -export function withRuntimePaseoMcpServer(params: { - config: AgentSessionConfig; - agentId: string; - mcpBaseUrl: string | null; -}): AgentSessionConfig { - const storedConfig = stripInternalPaseoMcpServer(params.config); - if (!params.mcpBaseUrl || storedConfig.mcpServers?.[PASEO_MCP_SERVER_NAME]) { - return storedConfig; - } - - return { - ...storedConfig, - mcpServers: { - [PASEO_MCP_SERVER_NAME]: { - type: "http", - url: `${params.mcpBaseUrl}?callerAgentId=${params.agentId}`, - }, - ...storedConfig.mcpServers, - }, - }; -} - function isInternalPaseoMcpServer(config: McpServerConfig): boolean { if (config.type !== "http" && config.type !== "sse") { return false; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b33803051e..a494463a09 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -99,6 +99,10 @@ 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 { createPaseoToolRegistry } from "./agent/paseo-tooling/registry.js"; +import { mountPaseoToolingHttpAdapter } from "./agent/paseo-tooling/http-adapter.js"; +import { PaseoToolingRuntime } from "./agent/paseo-tooling/runtime.js"; +import type { PaseoToolingRegistryOptions } from "./agent/paseo-tooling/registry.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; @@ -165,6 +169,14 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null ).toString(); } +function createDaemonHttpBaseUrl(listenTarget: ListenTarget | null): string | null { + if (!listenTarget || listenTarget.type !== "tcp") { + return null; + } + const host = resolveAgentMcpClientHost(listenTarget.host); + return `http://${formatHostForHttpUrl(host)}:${listenTarget.port}`; +} + function summarizeAgentMcpDebugMessage(body: unknown): Record { if (!body || typeof body !== "object" || Array.isArray(body)) { return { @@ -542,10 +554,13 @@ export async function createPaseoDaemon( extraClients: config.agentClients, }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); + const paseoToolingRuntime = new PaseoToolingRuntime(); + paseoToolingRuntime.setEnabled(config.mcpInjectIntoAgents !== false); const agentManager = new AgentManager({ clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, + paseoToolingRuntime, appendSystemPrompt: config.appendSystemPrompt, logger, }); @@ -689,79 +704,85 @@ export async function createPaseoDaemon( const agentMcpRoute = "/mcp/agents"; const agentMcpTransports: AgentMcpTransportMap = new Map(); - const createAgentMcpTransport = async (callerAgentId?: string) => { - const agentMcpServer = await createAgentMcpServer({ - agentManager, - agentStorage, - terminalManager, - getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), - scheduleService, - providerSnapshotManager, - github, - workspaceGitService, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, - emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, - markWorkspaceArchiving: markWorkspaceArchivingExternal, - clearWorkspaceArchiving: clearWorkspaceArchivingExternal, - 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)) ?? [], - ); - }, - emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => { - await Promise.all( - wsServer - ?.listActiveSessions() - .map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [], - ); - void emitOptions; - }, - 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, + const buildPaseoToolingRegistryOptions = ( + callerAgentId?: string, + ): PaseoToolingRegistryOptions => ({ + agentManager, + agentStorage, + terminalManager, + getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + scheduleService, + providerSnapshotManager, + github, + workspaceGitService, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + 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, + }); }, - input, - serviceOptions, - ); - }, - paseoHome: config.paseoHome, - worktreesRoot: config.worktreesRoot, - callerAgentId, - enableVoiceTools: false, - resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null, - resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null, - logger, - }); + warmWorkspaceGitData: async (workspace) => { + await Promise.all( + wsServer + ?.listActiveSessions() + .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], + ); + }, + emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => { + await Promise.all( + wsServer + ?.listActiveSessions() + .map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [], + ); + void emitOptions; + }, + 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 createAgentMcpTransport = async (callerAgentId?: string) => { + const agentMcpServer = await createAgentMcpServer( + buildPaseoToolingRegistryOptions(callerAgentId), + ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), @@ -870,6 +891,16 @@ export async function createPaseoDaemon( void runAgentMcpRequest(req, res); }; + mountPaseoToolingHttpAdapter({ + app, + registry: createPaseoToolRegistry({ + ...buildPaseoToolingRegistryOptions(), + agentScopedTools: true, + }), + runtime: paseoToolingRuntime, + logger, + }); + app.post(agentMcpRoute, handleAgentMcpRequest); app.get(agentMcpRoute, handleAgentMcpRequest); app.delete(agentMcpRoute, handleAgentMcpRequest); @@ -917,10 +948,18 @@ export async function createPaseoDaemon( const logAndResolve = async () => { boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer); const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null; + const httpBaseUrl = createDaemonHttpBaseUrl(boundListenTarget); agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl; - agentManager.setMcpBaseUrl(agentMcpBaseUrl); + paseoToolingRuntime.setEndpoints({ + mcpBaseUrl: agentMcpBaseUrl, + httpBaseUrl: config.mcpInjectIntoAgents === false ? null : httpBaseUrl, + }); daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => { - agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null); + paseoToolingRuntime.setEnabled(value !== false); + paseoToolingRuntime.setEndpoints({ + mcpBaseUrl: value ? mcpBaseUrl : null, + httpBaseUrl: value ? httpBaseUrl : null, + }); }); daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => { agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");