From a118fd3315fd9136f0b8ea23a9bc0567a514db5c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 3 Nov 2025 11:47:15 +0100 Subject: [PATCH] refactor(agent): add wait_for_agent tool and simplify prompt sending API --- AGENTS.md | 48 ----- package-lock.json | 124 +++++++++++ packages/app/package.json | 1 + packages/app/src/components/message.tsx | 69 ++++++- packages/server/AGENTS.md | 1 + packages/server/agent-prompt.md | 43 ++-- .../src/server/acp/agent-manager.test.ts | 67 ++++++ .../server/src/server/acp/agent-manager.ts | 194 ++++++++++++++---- packages/server/src/server/acp/agent-types.ts | 5 + packages/server/src/server/acp/mcp-server.ts | 160 +++++++++++---- test-worktree-agent.ts | 13 +- test-worktree-e2e.ts | 13 +- 12 files changed, 592 insertions(+), 146 deletions(-) delete mode 100644 AGENTS.md create mode 120000 packages/server/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 34beff0fa..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Lifecycle - -This project uses client-driven lazy initialization for Claude Code agents. Sessions start without spinning up agent runtimes; the client opts in when needed. - -## Connection Bootstrapping -- The session emits `session_state` after connection with an array of agents. Each entry matches the server `AgentInfo` structure (`id`, `status`, `createdAt`, `type`, `sessionId`, `error`, `currentModeId`, `availableModes`, `title`, `cwd`). -- No history or runtime is loaded at this point. Every agent starts in `uninitialized` and only streams live status updates once subscribed. - -## Opting In To An Agent -1. When the UI needs an agent, send an inbound session message: - - ```json - { - "type": "initialize_agent_request", - "agentId": "", - "requestId": "" - } - ``` - -2. The session subscribes to the agent, calls `AgentManager.initializeAgentAndGetHistory`, and lazily starts the runtime if required. -3. The server responds with: - - ```json - { - "type": "agent_initialized", - "payload": { - "agentId": "", - "info": { /* same shape as session_state agents */ }, - "updates": [ - { - "agentId": "", - "timestamp": "2024-01-01T00:00:00.000Z", - "notification": { /* AgentNotification */ } - } - ], - "requestId": "" - } - } - ``` - -4. After the response, all ongoing traffic uses the existing channels: - - `agent_status` continues to broadcast status changes. - - `agent_update` streams real-time session notifications, permission prompts, etc. - -## Notes -- `initialize_agent_request` is idempotent. The manager short-circuits if the agent is already ready and returns the cached history. -- Session state no longer replays history when clients reconnect; clients must explicitly reinitialize agents they care about. -- `requestId` lets the caller correlate UI state but remains optional throughout the flow. diff --git a/package-lock.json b/package-lock.json index f197e4ca6..e34718adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8302,6 +8302,119 @@ "node": ">=10.0.0" } }, + "node_modules/@zed-industries/codex-acp": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.3.12.tgz", + "integrity": "sha512-hX3N06QgGFM5mleB+L/+v8XTZ4iS4ScB5YXQXXbc20fYmkd5p6tEDG7m6p8sjtfDcdeKIZNtiiaEEHQqy2hVAA==", + "license": "Apache-2.0", + "bin": { + "codex-acp": "bin/codex-acp.js" + }, + "optionalDependencies": { + "@zed-industries/codex-acp-darwin-arm64": "0.3.12", + "@zed-industries/codex-acp-darwin-x64": "0.3.12", + "@zed-industries/codex-acp-linux-arm64": "0.3.12", + "@zed-industries/codex-acp-linux-x64": "0.3.12", + "@zed-industries/codex-acp-windows-arm64": "0.3.12", + "@zed-industries/codex-acp-windows-x64": "0.3.12" + } + }, + "node_modules/@zed-industries/codex-acp-darwin-arm64": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-arm64/-/codex-acp-darwin-arm64-0.3.12.tgz", + "integrity": "sha512-ajG2aU9I4Hg57Havk+elLR47tXPmxSsnSvZE0yxITiHr59IQacVJL0z/7hDdY3S6H10K+zb/fGQAvOXhcSMEtQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "codex-acp-darwin-arm64": "bin/codex-acp" + } + }, + "node_modules/@zed-industries/codex-acp-darwin-x64": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-x64/-/codex-acp-darwin-x64-0.3.12.tgz", + "integrity": "sha512-YQMe+G6hiVLY4R/lbC2UGkPJjnR8viIjaHmSETTfuOHAeQF9p8Wvr0k1DbIvacdyPst6X7ItoIb40fvtgsmVdw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "codex-acp-darwin-x64": "bin/codex-acp" + } + }, + "node_modules/@zed-industries/codex-acp-linux-arm64": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-arm64/-/codex-acp-linux-arm64-0.3.12.tgz", + "integrity": "sha512-nrDFzs03Cm0/CB/sEvGPHJHl2Z0pMCSXEmpDVoUTKgZf2Iof23ZsV/VD+eHdLj98VEvmWiT2u7+kSBqBtpnMmA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "codex-acp-linux-arm64": "bin/codex-acp" + } + }, + "node_modules/@zed-industries/codex-acp-linux-x64": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-x64/-/codex-acp-linux-x64-0.3.12.tgz", + "integrity": "sha512-BWGzV8dmCVAG64FfER6xtBkWedo9VX6u9ZjxOxr7JA9FhQ7KSPycH9r0BV/Nym/6LlvS4hLMcgXRvSfup8gLMA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "codex-acp-linux-x64": "bin/codex-acp" + } + }, + "node_modules/@zed-industries/codex-acp-windows-arm64": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-arm64/-/codex-acp-windows-arm64-0.3.12.tgz", + "integrity": "sha512-iQgXFk3OH8WlJgxMMSc+nDcrVH6m0HpItYca07fbrD4LdR+0ByW7EEy7VvONcuYqkmKLOHT1ufuQZEEraZp1WQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "windows" + ], + "bin": { + "codex-acp-windows-arm64": "bin/codex-acp.exe" + } + }, + "node_modules/@zed-industries/codex-acp-windows-x64": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-x64/-/codex-acp-windows-x64-0.3.12.tgz", + "integrity": "sha512-26fYm64qW87eRVC2yqAdeIVFdid+tpnfsNAn1Fc6zKcGpzgxfbYxfStjKUltoxI/X0ZHQI/4GvWpeNyshg5XGQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "windows" + ], + "bin": { + "codex-acp-windows-x64": "bin/codex-acp.exe" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -12070,6 +12183,15 @@ "node": ">=10" } }, + "node_modules/expo-clipboard": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-6.0.3.tgz", + "integrity": "sha512-RIKDsuHkYfaspifbFpVC8sBVFKR05L7Pj7mU2/XkbrW9m01OBNvdpGraXEMsTFCx97xMGsZpEw9pPquL4j4xVg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.10", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", @@ -21185,6 +21307,7 @@ "expo-audio": "~1.0.13", "expo-av": "^16.0.7", "expo-build-properties": "^1.0.9", + "expo-clipboard": "~6.0.3", "expo-constants": "~18.0.9", "expo-dev-client": "^6.0.15", "expo-file-system": "~19.0.17", @@ -21237,6 +21360,7 @@ "@deepgram/sdk": "^3.4.0", "@modelcontextprotocol/sdk": "^1.20.1", "@openrouter/ai-sdk-provider": "^1.2.0", + "@zed-industries/codex-acp": "^0.3.9", "ai": "^5.0.76", "dotenv": "^17.2.3", "express": "^4.18.2", diff --git a/packages/app/package.json b/packages/app/package.json index ce94e6c2a..48f5a5337 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -24,6 +24,7 @@ "expo-audio": "~1.0.13", "expo-av": "^16.0.7", "expo-build-properties": "^1.0.9", + "expo-clipboard": "~6.0.3", "expo-constants": "~18.0.9", "expo-dev-client": "^6.0.15", "expo-file-system": "~19.0.17", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 39188aa4e..34aca3ead 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,5 +1,5 @@ import { View, Text, Pressable, Animated } from "react-native"; -import { useState, useEffect, useRef, memo, useMemo } from "react"; +import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react"; import Markdown from "react-native-markdown-display"; import { Circle, @@ -21,6 +21,7 @@ import { import { StyleSheet } from "react-native-unistyles"; import { baseColors, theme } from "@/styles/theme"; import { Colors } from "@/constants/theme"; +import * as Clipboard from "expo-clipboard"; interface UserMessageProps { message: string; @@ -47,17 +48,79 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.lg, lineHeight: 24, }, + bubblePressed: { + opacity: 0.85, + }, + copiedTagContainer: { + marginTop: theme.spacing[1], + marginRight: theme.spacing[4], + alignSelf: "flex-end", + backgroundColor: theme.colors.secondary, + borderRadius: theme.borderRadius.base, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + }, + copiedTagText: { + color: theme.colors.secondaryForeground, + fontSize: theme.fontSize.xs, + }, })); export const UserMessage = memo(function UserMessage({ message, timestamp, }: UserMessageProps) { + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef | null>(null); + + const handleLongPress = useCallback(async () => { + if (!message) { + return; + } + + await Clipboard.setStringAsync(message); + setCopied(true); + + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + + copyTimeoutRef.current = setTimeout(() => { + setCopied(false); + copyTimeoutRef.current = null; + }, 1500); + }, [message]); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + return ( - + [ + userMessageStylesheet.bubble, + pressed ? userMessageStylesheet.bubblePressed : null, + ]} + > {message} - + + {copied && ( + + + Copied to clipboard + + + )} ); }); diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/packages/server/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/packages/server/agent-prompt.md b/packages/server/agent-prompt.md index de0d24909..ca7d1cef2 100644 --- a/packages/server/agent-prompt.md +++ b/packages/server/agent-prompt.md @@ -335,7 +335,7 @@ present_artifact({ }) ``` -## 5. Claude Code Integration +## 5. Agent Integrations ### Your Role: Orchestrator @@ -354,27 +354,43 @@ Load the agent list before any agent interaction. Always. - Quick commands → Execute directly - Active agent context → Send prompt to that agent -### What is Claude Code? +### Available Agents (Source of Truth) -Claude Code is an AI coding agent that can handle complex coding tasks. Delegate work to it by creating agents with clear instructions. +We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations. + +**Claude Code (`claude`)** +- Default mode: `plan` +- Alternate mode: `bypassPermissions` +- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution. + +**Codex (`codex`)** +- Default mode: `auto` +- Other modes: `read-only`, `full-access` +- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access. ### Creating Agents **Creation requires confirmation. Always ask first.** ```javascript -// After confirmation +// Claude Code with planning create_coding_agent({ cwd: "~/dev/voice-dev", + agentType: "claude", initialPrompt: "add dark mode toggle to settings page", - initialMode: "bypassPermissions" + initialMode: "plan" +}) + +// Codex for quick edits +create_coding_agent({ + cwd: "~/dev/voice-dev", + agentType: "codex", + initialPrompt: "clean up the logging", + initialMode: "auto" }) ``` -**Modes:** -- `"bypassPermissions"` - Auto-approve (fastest, default for most tasks) -- `"plan"` - Shows plan before executing -- `"default"` - Asks permission per action +If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error. ### Working with Agents @@ -395,11 +411,11 @@ send_agent_prompt({ maxWait: 60000 // Wait up to 60 seconds }) -// Change mode and send prompt +// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access) send_agent_prompt({ agentId: "abc123", prompt: "implement user registration", - sessionMode: "bypassPermissions" // Auto-approve all actions + sessionMode: "bypassPermissions" }) ``` @@ -447,11 +463,12 @@ list_agents() // You: "Create agent in ~/dev/project for authentication?" // User: "yes" -// 3. Create with initialPrompt + mode +// 3. Create with type + mode create_coding_agent({ cwd: "~/dev/project", + agentType: "claude", initialPrompt: "add authentication", - initialMode: "bypassPermissions" + initialMode: "plan" }) // 4. Monitor or send follow-up tasks diff --git a/packages/server/src/server/acp/agent-manager.test.ts b/packages/server/src/server/acp/agent-manager.test.ts index 93413ad47..4a1c38bf3 100644 --- a/packages/server/src/server/acp/agent-manager.test.ts +++ b/packages/server/src/server/acp/agent-manager.test.ts @@ -96,6 +96,73 @@ describe("AgentManager", () => { } }, 120000); + it("should wait for permission requests", async () => { + const manager = new AgentManager(); + let capturedPermission: Awaited< + ReturnType<(typeof manager)["waitForPermissionRequest"]> + > | null = null; + + const agentId = await manager.createAgent({ + cwd: tmpDir, + type: "claude", + initialMode: "plan", + }); + createdAgents.push({ manager, agentId }); + + const waitPromise = manager + .waitForPermissionRequest(agentId) + .then((permission) => { + capturedPermission = permission; + return permission; + }); + + await manager.sendPrompt( + agentId, + "Create a file called wait-for-permission.txt with the content 'hello world'" + ); + + const permission = await waitPromise; + expect(permission).not.toBeNull(); + expect(permission!.agentId).toBe(agentId); + expect(permission!.requestId).toBeDefined(); + expect(permission!.options.length).toBeGreaterThan(0); + + const acceptOption = permission!.options.find( + (option) => option.kind === "allow_once" || option.kind === "allow_always" + ); + expect(acceptOption).toBeDefined(); + + manager.respondToPermission( + permission!.agentId, + permission!.requestId, + acceptOption!.optionId + ); + + // Ensure the captured permission matches the resolved value + expect(capturedPermission).toEqual(permission); + }, 120000); + + it("should support aborting waitForPermissionRequest", async () => { + const manager = new AgentManager(); + + const agentId = await manager.createAgent({ + cwd: tmpDir, + type: "claude", + initialMode: "plan", + }); + createdAgents.push({ manager, agentId }); + + const controller = new AbortController(); + + const waitPromise = manager.waitForPermissionRequest(agentId, { + signal: controller.signal, + }); + + controller.abort(); + + await expect(waitPromise).rejects.toMatchObject({ name: "AbortError" }); + }, 30000); + it("should not fail when sending '.' after plan permission request", async () => { const manager = new AgentManager(); let permissionRequest: RequestPermissionRequest | null = null; diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts index 30bfd2a8e..9fa0475fc 100644 --- a/packages/server/src/server/acp/agent-manager.ts +++ b/packages/server/src/server/acp/agent-manager.ts @@ -379,14 +379,13 @@ export class AgentManager { * Send a prompt to an agent * @param agentId - Agent ID * @param prompt - The prompt text or ContentBlock array - * @param options - Optional settings: maxWait (ms), sessionMode to set before sending, messageId for deduplication - * @returns Object with didComplete boolean indicating if agent finished within maxWait time + * @param options - Optional settings: sessionMode to set before sending, messageId for deduplication */ async sendPrompt( agentId: string, prompt: string | ContentBlock[], - options?: { maxWait?: number; sessionMode?: string; messageId?: string } - ): Promise<{ didComplete: boolean; stopReason?: string }> { + options?: { sessionMode?: string; messageId?: string } + ): Promise { const agent = this.agents.get(agentId); if (!agent) { throw new Error(`Agent ${agentId} not found`); @@ -577,38 +576,9 @@ export class AgentManager { ); }); - const maxWait = options?.maxWait; - - // If no maxWait specified, return immediately - if (maxWait === undefined) { - console.log( - `[Agent ${agentId}] Prompt sent (non-blocking, use get_agent_status to check completion)` - ); - return { didComplete: false }; - } - - // If maxWait specified, race between completion and timeout - try { - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("timeout")), maxWait) - ); - - const response = await Promise.race([promptPromise, timeoutPromise]); - - console.log( - `[Agent ${agentId}] Prompt completed within ${maxWait}ms with stopReason: ${response.stopReason}` - ); - return { didComplete: true, stopReason: response.stopReason }; - } catch (error) { - if (error instanceof Error && error.message === "timeout") { - console.log( - `[Agent ${agentId}] Prompt did not complete within ${maxWait}ms (still processing)` - ); - return { didComplete: false }; - } - // Real error - rethrow - throw error; - } + console.log( + `[Agent ${agentId}] Prompt sent (non-blocking, use get_agent_status to check completion)` + ); } /** @@ -1701,6 +1671,158 @@ export class AgentManager { return allPermissions; } + /** + * Wait for the next permission request from an agent, or until agent finishes + * Resolves immediately if a permission is already pending + * Returns permission data if agent requests permission, or null if agent finishes without requesting + */ + async waitForPermissionRequest( + agentId: string, + options?: { signal?: AbortSignal } + ): Promise<{ + agentId: string; + requestId: string; + sessionId: string; + toolCall: any; + options: Array<{ + kind: string; + name: string; + optionId: string; + }>; + } | null> { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + + const abortSignal = options?.signal ?? null; + + const createAbortError = () => { + const error = new Error( + `Wait for permission aborted for agent ${agentId}` + ); + error.name = "AbortError"; + return error; + }; + + const formatPending = ( + requestId: string, + permission: PendingPermission + ) => ({ + agentId, + requestId, + sessionId: permission.sessionId, + toolCall: permission.params.toolCall, + options: permission.params.options, + }); + + // Return immediately if a permission is already pending + const existingPermission = agent.pendingPermissions.entries().next(); + if (!existingPermission.done) { + const [requestId, permission] = existingPermission.value; + return formatPending(requestId, permission); + } + + if (abortSignal?.aborted) { + throw createAbortError(); + } + + const initialStatus = getAgentStatusFromState(agent.state); + + // If agent is not processing or initializing, return null (no permission requested) + if (initialStatus !== "processing" && initialStatus !== "initializing") { + return null; + } + + return new Promise((resolve, reject) => { + let settled = false; + let unsubscribe: (() => void) | null = null; + + const cleanup = () => { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + abortSignal?.removeEventListener("abort", onAbort); + }; + + const resolvePending = (value: ReturnType) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + }; + + const rejectWithError = (error: Error) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + const onAbort = () => { + rejectWithError(createAbortError()); + }; + + if (abortSignal) { + abortSignal.addEventListener("abort", onAbort, { once: true }); + } + + try { + unsubscribe = this.subscribeToUpdates(agentId, (update) => { + if (settled) { + return; + } + + const notification = update.notification; + + if (notification.type === "permission") { + const { requestId } = notification; + const pendingPermission = + agent.pendingPermissions.get(requestId) ?? null; + + if (!pendingPermission) { + rejectWithError( + new Error( + `Permission ${requestId} no longer pending for agent ${agentId}` + ) + ); + return; + } + + resolvePending(formatPending(requestId, pendingPermission)); + return; + } + + if (notification.type === "status") { + const status = notification.status; + + // If agent transitions out of processing without requesting permission, return null + if (status !== "processing") { + settled = true; + cleanup(); + resolve(null); + return; + } + } + }); + } catch (error) { + rejectWithError( + error instanceof Error ? error : new Error(String(error)) + ); + return; + } + + if (abortSignal?.aborted) { + onAbort(); + } + }); + } + /** * Gracefully shutdown all agents * Waits for processing agents to finish, then terminates all processes diff --git a/packages/server/src/server/acp/agent-types.ts b/packages/server/src/server/acp/agent-types.ts index 00312eaf2..1e24f1ea0 100644 --- a/packages/server/src/server/acp/agent-types.ts +++ b/packages/server/src/server/acp/agent-types.ts @@ -20,6 +20,11 @@ export interface AgentTypeDefinition { } const CLAUDE_MODES: AgentModeDefinition[] = [ + { + id: "default", + name: "Default", + description: "Default Claude Code mode with standard permissions", + }, { id: "plan", name: "Plan", diff --git a/packages/server/src/server/acp/mcp-server.ts b/packages/server/src/server/acp/mcp-server.ts index ad2041861..71a364759 100644 --- a/packages/server/src/server/acp/mcp-server.ts +++ b/packages/server/src/server/acp/mcp-server.ts @@ -5,6 +5,10 @@ import { resolve } from "path"; import { AgentManager } from "./agent-manager.js"; import type { AgentNotification } from "./types.js"; import { curateAgentActivity } from "./activity-curator.js"; +import { + listAgentTypeDefinitions, + type AgentType, +} from "./agent-types.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -50,6 +54,17 @@ export async function createAgentMcpServer( ): Promise { const { agentManager } = options; + const agentTypeDefinitions = listAgentTypeDefinitions(); + if (agentTypeDefinitions.length === 0) { + throw new Error("[Agent MCP] No agent types configured"); + } + + const agentTypeIds = agentTypeDefinitions.map( + (definition) => definition.id + ) as [AgentType, ...AgentType[]]; + + const AgentTypeEnum = z.enum(agentTypeIds); + const server = new McpServer({ name: "agent-mcp", version: "1.0.0", @@ -61,25 +76,25 @@ export async function createAgentMcpServer( { title: "Create Coding Agent", description: - "Creates a new Claude Code agent via ACP. The agent runs as a separate process and can execute coding tasks autonomously in a specified directory. Returns immediately with agent ID and status. If an initial prompt is provided, the agent will start working on it automatically. Optionally create a git worktree for isolated development.", + "Creates a new Claude Code or Codex agent via ACP. The agent runs as a separate process and can execute coding tasks autonomously in a specified directory. Returns immediately with agent ID, type, and status. If an initial prompt is provided, the agent will start working on it automatically. Optionally create a git worktree for isolated development.", inputSchema: { cwd: z .string() .describe( "REQUIRED: Working directory for the agent. Can be absolute path, tilde-prefixed path, or relative path (e.g., '~/dev/project', '/path/to/repo', './subdir')." ), + agentType: AgentTypeEnum.optional().describe( + "Optional: Agent implementation to spawn. 'claude' provides full Claude Code capabilities with session persistence. 'codex' uses Zed Codex ACP with fast startup and explicit permission modes. Defaults to 'claude'." + ), initialPrompt: z .string() .optional() .describe( "Optional initial task or prompt for the agent to start working on immediately after creation. If provided, agent will begin processing this task right away." ), - initialMode: z - .string() - .optional() - .describe( - "Initial session mode for the agent (e.g., 'ask', 'code', 'architect'). If not specified, the agent will use its default mode. The available modes depend on the specific agent implementation." - ), + initialMode: z.string().optional().describe( + "Optional: Initial session mode for the agent. Mode is validated at runtime based on agent type. Claude Code supports 'default', 'plan', and 'bypassPermissions'. Codex supports 'read-only', 'auto', and 'full-access'. Defaults to the agent type's default mode." + ), worktreeName: z .string() .optional() @@ -89,6 +104,7 @@ export async function createAgentMcpServer( }, outputSchema: { agentId: z.string().describe("Unique identifier for the created agent"), + type: AgentTypeEnum.describe("Agent implementation that was created"), status: z .string() .describe( @@ -99,11 +115,11 @@ export async function createAgentMcpServer( availableModes: z.array(z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), + description: z.string().nullable().optional(), })).nullable().describe("Available session modes for this agent"), }, }, - async ({ cwd, initialPrompt, initialMode, worktreeName }) => { + async ({ cwd, agentType, initialPrompt, initialMode, worktreeName }) => { // Expand and resolve the working directory let resolvedCwd = expandPath(cwd); @@ -119,9 +135,11 @@ export async function createAgentMcpServer( resolvedCwd = worktreeConfig.worktreePath; } + const resolvedType: AgentType = agentType ?? "claude"; + const agentId = await agentManager.createAgent({ cwd: resolvedCwd, - type: "claude", + type: resolvedType, initialPrompt, initialMode, }); @@ -132,6 +150,7 @@ export async function createAgentMcpServer( const result = { agentId, + type: resolvedType, status, cwd: resolvedCwd, currentModeId: currentModeId ?? null, @@ -145,13 +164,86 @@ export async function createAgentMcpServer( } ); + // Tool: wait_for_agent + server.registerTool( + "wait_for_agent", + { + title: "Wait For Agent", + description: + "Wait until the agent requests permission or finishes its task. Returns the agent's activity and permission request (if any). Use this to monitor agent progress without polling.", + inputSchema: { + agentId: z + .string() + .describe("Agent ID to wait on (typically the result of create_coding_agent)"), + }, + outputSchema: { + agentId: z.string(), + status: z + .string() + .describe("Agent status after waiting completed"), + permission: z.object({ + agentId: z.string(), + requestId: z.string(), + sessionId: z.string(), + toolCall: z.any(), + options: z.array( + z.object({ + kind: z.string(), + name: z.string(), + optionId: z.string(), + }) + ), + }).nullable().describe("Permission request if agent requested one, null if agent finished without requesting"), + activity: z.object({ + format: z + .literal("curated") + .describe("Activity format: curated text for easy reading"), + updateCount: z + .number() + .describe("Total number of updates recorded for the agent"), + currentModeId: z + .string() + .nullable() + .describe("Current session mode"), + content: z + .string() + .describe("Curated activity transcript including the latest updates"), + }), + }, + }, + async ({ agentId }, { signal }) => { + const permission = await agentManager.waitForPermissionRequest(agentId, { + signal, + }); + const updates = agentManager.getAgentUpdates(agentId); + const curatedText = curateAgentActivity(updates); + const currentModeId = agentManager.getCurrentMode(agentId); + const status = agentManager.getAgentStatus(agentId); + + return { + content: [], + structuredContent: { + agentId, + status, + permission, + activity: { + format: "curated" as const, + updateCount: updates.length, + currentModeId, + content: curatedText, + }, + }, + }; + } + ); + // Tool: send_agent_prompt server.registerTool( "send_agent_prompt", { title: "Send Agent Prompt", description: - "Sends a task or prompt to an existing agent. By default, returns immediately without waiting (non-blocking). The agent will process the prompt in the background. Use get_agent_status or get_agent_activity to check progress. Optionally specify maxWait to wait for completion, or sessionMode to switch modes before sending.", + "Sends a task or prompt to an existing agent. Returns immediately without waiting (non-blocking). The agent will process the prompt in the background. Use wait_for_agent, get_agent_status, or get_agent_activity to monitor progress. Optionally switch session modes before sending.", inputSchema: { agentId: z.string().describe("Agent ID returned from create_coding_agent"), prompt: z @@ -159,35 +251,25 @@ export async function createAgentMcpServer( .describe( "The task, instruction, or feedback to send to the agent. Be specific about what you want the agent to accomplish." ), - sessionMode: z - .string() - .optional() - .describe( - "Optional: Session mode to set before sending the prompt (e.g., 'plan', 'code', 'default'). If specified, the agent's mode will be changed before processing the prompt." - ), - maxWait: z - .number() - .optional() - .describe( - "Optional: Maximum milliseconds to wait for agent to complete. If not provided, returns immediately (non-blocking). If agent doesn't finish within this time, returns with didComplete=false and agent continues processing in background." - ), + sessionMode: z.string().optional().describe( + "Optional: Session mode to set before sending the prompt. Mode is validated at runtime based on agent type. Claude Code supports 'default', 'plan', and 'bypassPermissions'. Codex offers 'read-only', 'auto', and 'full-access' for progressively broader permissions." + ), }, outputSchema: { success: z.boolean().describe("Whether the prompt was sent successfully"), - didComplete: z.boolean().describe("Whether the agent completed within maxWait time (always false if maxWait not specified)"), - stopReason: z.string().nullable().describe("Reason agent stopped if it completed: 'end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'"), + status: z + .string() + .describe("Agent status immediately after enqueuing the prompt (usually 'processing')"), }, }, - async ({ agentId, prompt, sessionMode, maxWait }) => { - const response = await agentManager.sendPrompt(agentId, prompt, { - maxWait, + async ({ agentId, prompt, sessionMode }) => { + await agentManager.sendPrompt(agentId, prompt, { sessionMode, }); const result = { success: true, - didComplete: response.didComplete, - stopReason: response.stopReason ?? null, + status: agentManager.getAgentStatus(agentId), }; return { @@ -215,14 +297,14 @@ export async function createAgentMcpServer( status: z.string(), createdAt: z.string(), lastActivityAt: z.string(), - type: z.enum(["claude", "codex"]), + type: AgentTypeEnum, sessionId: z.string().nullable(), error: z.string().nullable(), currentModeId: z.string().nullable(), availableModes: z.array(z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), + description: z.string().nullable().optional(), })).nullable(), }) .describe("Detailed agent information"), @@ -264,14 +346,14 @@ export async function createAgentMcpServer( status: z.string(), createdAt: z.string(), lastActivityAt: z.string(), - type: z.enum(["claude", "codex"]), + type: AgentTypeEnum, sessionId: z.string().nullable(), error: z.string().nullable(), currentModeId: z.string().nullable(), availableModes: z.array(z.object({ id: z.string(), name: z.string(), - description: z.string().optional(), + description: z.string().nullable().optional(), })).nullable(), }) ), @@ -435,14 +517,12 @@ export async function createAgentMcpServer( { title: "Set Agent Session Mode", description: - "Change the agent's session mode (e.g., from 'ask' to 'code', or 'architect' to 'code'). Each mode affects the agent's behavior - 'ask' mode requests permission before changes, 'code' mode writes code directly, 'architect' mode plans without implementation. The available modes depend on the specific agent. Use get_agent_status or list_agents to see available modes for each agent.", + "Change the agent's session mode. Claude Code supports 'plan' for step-by-step approvals and 'bypassPermissions' to auto-approve actions. Codex offers 'read-only', 'auto', and 'full-access' to control how freely it can modify the workspace or access the system. Use get_agent_status or list_agents to see which modes are currently available for each agent.", inputSchema: { agentId: z.string().describe("Agent ID to configure"), - modeId: z - .string() - .describe( - "The session mode to set (e.g., 'ask', 'code', 'architect'). Must be one of the agent's available modes." - ), + modeId: z.string().describe( + "The session mode to set. Mode is validated at runtime based on the agent's available modes. Check agent's availableModes from get_agent_status or list_agents to see valid options for the specific agent." + ), }, outputSchema: { success: z.boolean().describe("Whether the mode change succeeded"), diff --git a/test-worktree-agent.ts b/test-worktree-agent.ts index 6a5d996c7..addf59209 100644 --- a/test-worktree-agent.ts +++ b/test-worktree-agent.ts @@ -88,13 +88,20 @@ async function testWorktreeAgent() { // Step 6: Send a prompt to verify it's working in the worktree console.log("Step 6: Sending test prompt to agent..."); - const { didComplete } = await agentManager.sendPrompt( + await agentManager.sendPrompt( agentId, "Run pwd and git status to verify you're in a worktree. Reply with the output.", - { maxWait: 30000 } ); - console.log(` Prompt completed: ${didComplete}`); + let status = agentManager.getAgentStatus(agentId); + let attempts = 0; + while (status === "processing" && attempts < 60) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + status = agentManager.getAgentStatus(agentId); + attempts++; + } + + console.log(` Agent status after prompt: ${status}`); // Get agent updates to see the response const updates = agentManager.getAgentUpdates(agentId); diff --git a/test-worktree-e2e.ts b/test-worktree-e2e.ts index 47b2f3331..6ada7c685 100644 --- a/test-worktree-e2e.ts +++ b/test-worktree-e2e.ts @@ -116,13 +116,20 @@ async function testWorktreeE2E() { // Step 7: Test agent can run commands in worktree console.log("Step 7: Testing agent execution in worktree..."); - const { didComplete } = await agentManager.sendPrompt( + await agentManager.sendPrompt( createdAgentId, "Run 'pwd' and 'git branch --show-current'. Just show me the output.", - { maxWait: 30000 } ); - console.log(` Prompt completed: ${didComplete}`); + let status = agentManager.getAgentStatus(createdAgentId); + let attempts = 0; + while (status === "processing" && attempts < 60) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + status = agentManager.getAgentStatus(createdAgentId); + attempts++; + } + + console.log(` Agent status after prompt: ${status}`); const updates = agentManager.getAgentUpdates(createdAgentId); const messageChunks = updates