refactor(agent): add wait_for_agent tool and simplify prompt sending API

This commit is contained in:
Mohamed Boudra
2025-11-03 11:47:15 +01:00
parent 1d22fe46e7
commit a118fd3315
12 changed files with 592 additions and 146 deletions
-48
View File
@@ -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": "<agent-id>",
"requestId": "<optional-correlation-id>"
}
```
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": "<agent-id>",
"info": { /* same shape as session_state agents */ },
"updates": [
{
"agentId": "<agent-id>",
"timestamp": "2024-01-01T00:00:00.000Z",
"notification": { /* AgentNotification */ }
}
],
"requestId": "<optional-correlation-id>"
}
}
```
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.
+124
View File
@@ -8302,6 +8302,119 @@
"node": ">=10.0.0" "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": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -12070,6 +12183,15 @@
"node": ">=10" "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": { "node_modules/expo-constants": {
"version": "18.0.10", "version": "18.0.10",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz",
@@ -21185,6 +21307,7 @@
"expo-audio": "~1.0.13", "expo-audio": "~1.0.13",
"expo-av": "^16.0.7", "expo-av": "^16.0.7",
"expo-build-properties": "^1.0.9", "expo-build-properties": "^1.0.9",
"expo-clipboard": "~6.0.3",
"expo-constants": "~18.0.9", "expo-constants": "~18.0.9",
"expo-dev-client": "^6.0.15", "expo-dev-client": "^6.0.15",
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
@@ -21237,6 +21360,7 @@
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0", "@openrouter/ai-sdk-provider": "^1.2.0",
"@zed-industries/codex-acp": "^0.3.9",
"ai": "^5.0.76", "ai": "^5.0.76",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",
+1
View File
@@ -24,6 +24,7 @@
"expo-audio": "~1.0.13", "expo-audio": "~1.0.13",
"expo-av": "^16.0.7", "expo-av": "^16.0.7",
"expo-build-properties": "^1.0.9", "expo-build-properties": "^1.0.9",
"expo-clipboard": "~6.0.3",
"expo-constants": "~18.0.9", "expo-constants": "~18.0.9",
"expo-dev-client": "^6.0.15", "expo-dev-client": "^6.0.15",
"expo-file-system": "~19.0.17", "expo-file-system": "~19.0.17",
+66 -3
View File
@@ -1,5 +1,5 @@
import { View, Text, Pressable, Animated } from "react-native"; 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 Markdown from "react-native-markdown-display";
import { import {
Circle, Circle,
@@ -21,6 +21,7 @@ import {
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme"; import { baseColors, theme } from "@/styles/theme";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
interface UserMessageProps { interface UserMessageProps {
message: string; message: string;
@@ -47,17 +48,79 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
lineHeight: 24, 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({ export const UserMessage = memo(function UserMessage({
message, message,
timestamp, timestamp,
}: UserMessageProps) { }: UserMessageProps) {
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ( return (
<View style={userMessageStylesheet.container}> <View style={userMessageStylesheet.container}>
<View style={userMessageStylesheet.bubble}> <Pressable
accessibilityRole="button"
accessibilityLabel="Copy message"
accessibilityHint="Long press to copy this message"
delayLongPress={250}
onLongPress={handleLongPress}
style={({ pressed }) => [
userMessageStylesheet.bubble,
pressed ? userMessageStylesheet.bubblePressed : null,
]}
>
<Text style={userMessageStylesheet.text}>{message}</Text> <Text style={userMessageStylesheet.text}>{message}</Text>
</View> </Pressable>
{copied && (
<View style={userMessageStylesheet.copiedTagContainer}>
<Text style={userMessageStylesheet.copiedTagText}>
Copied to clipboard
</Text>
</View>
)}
</View> </View>
); );
}); });
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+30 -13
View File
@@ -335,7 +335,7 @@ present_artifact({
}) })
``` ```
## 5. Claude Code Integration ## 5. Agent Integrations
### Your Role: Orchestrator ### Your Role: Orchestrator
@@ -354,27 +354,43 @@ Load the agent list before any agent interaction. Always.
- Quick commands → Execute directly - Quick commands → Execute directly
- Active agent context → Send prompt to that agent - 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 ### Creating Agents
**Creation requires confirmation. Always ask first.** **Creation requires confirmation. Always ask first.**
```javascript ```javascript
// After confirmation // Claude Code with planning
create_coding_agent({ create_coding_agent({
cwd: "~/dev/voice-dev", cwd: "~/dev/voice-dev",
agentType: "claude",
initialPrompt: "add dark mode toggle to settings page", 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:** If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
- `"bypassPermissions"` - Auto-approve (fastest, default for most tasks)
- `"plan"` - Shows plan before executing
- `"default"` - Asks permission per action
### Working with Agents ### Working with Agents
@@ -395,11 +411,11 @@ send_agent_prompt({
maxWait: 60000 // Wait up to 60 seconds 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({ send_agent_prompt({
agentId: "abc123", agentId: "abc123",
prompt: "implement user registration", 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?" // You: "Create agent in ~/dev/project for authentication?"
// User: "yes" // User: "yes"
// 3. Create with initialPrompt + mode // 3. Create with type + mode
create_coding_agent({ create_coding_agent({
cwd: "~/dev/project", cwd: "~/dev/project",
agentType: "claude",
initialPrompt: "add authentication", initialPrompt: "add authentication",
initialMode: "bypassPermissions" initialMode: "plan"
}) })
// 4. Monitor or send follow-up tasks // 4. Monitor or send follow-up tasks
@@ -96,6 +96,73 @@ describe("AgentManager", () => {
} }
}, 120000); }, 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 () => { it("should not fail when sending '.' after plan permission request", async () => {
const manager = new AgentManager(); const manager = new AgentManager();
let permissionRequest: RequestPermissionRequest | null = null; let permissionRequest: RequestPermissionRequest | null = null;
+158 -36
View File
@@ -379,14 +379,13 @@ export class AgentManager {
* Send a prompt to an agent * Send a prompt to an agent
* @param agentId - Agent ID * @param agentId - Agent ID
* @param prompt - The prompt text or ContentBlock array * @param prompt - The prompt text or ContentBlock array
* @param options - Optional settings: maxWait (ms), sessionMode to set before sending, messageId for deduplication * @param options - Optional settings: sessionMode to set before sending, messageId for deduplication
* @returns Object with didComplete boolean indicating if agent finished within maxWait time
*/ */
async sendPrompt( async sendPrompt(
agentId: string, agentId: string,
prompt: string | ContentBlock[], prompt: string | ContentBlock[],
options?: { maxWait?: number; sessionMode?: string; messageId?: string } options?: { sessionMode?: string; messageId?: string }
): Promise<{ didComplete: boolean; stopReason?: string }> { ): Promise<void> {
const agent = this.agents.get(agentId); const agent = this.agents.get(agentId);
if (!agent) { if (!agent) {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
@@ -577,38 +576,9 @@ export class AgentManager {
); );
}); });
const maxWait = options?.maxWait; console.log(
`[Agent ${agentId}] Prompt sent (non-blocking, use get_agent_status to check completion)`
// 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<never>((_, 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;
}
} }
/** /**
@@ -1701,6 +1671,158 @@ export class AgentManager {
return allPermissions; 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<typeof formatPending>) => {
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 * Gracefully shutdown all agents
* Waits for processing agents to finish, then terminates all processes * Waits for processing agents to finish, then terminates all processes
@@ -20,6 +20,11 @@ export interface AgentTypeDefinition {
} }
const CLAUDE_MODES: AgentModeDefinition[] = [ const CLAUDE_MODES: AgentModeDefinition[] = [
{
id: "default",
name: "Default",
description: "Default Claude Code mode with standard permissions",
},
{ {
id: "plan", id: "plan",
name: "Plan", name: "Plan",
+120 -40
View File
@@ -5,6 +5,10 @@ import { resolve } from "path";
import { AgentManager } from "./agent-manager.js"; import { AgentManager } from "./agent-manager.js";
import type { AgentNotification } from "./types.js"; import type { AgentNotification } from "./types.js";
import { curateAgentActivity } from "./activity-curator.js"; import { curateAgentActivity } from "./activity-curator.js";
import {
listAgentTypeDefinitions,
type AgentType,
} from "./agent-types.js";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
@@ -50,6 +54,17 @@ export async function createAgentMcpServer(
): Promise<McpServer> { ): Promise<McpServer> {
const { agentManager } = options; 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({ const server = new McpServer({
name: "agent-mcp", name: "agent-mcp",
version: "1.0.0", version: "1.0.0",
@@ -61,25 +76,25 @@ export async function createAgentMcpServer(
{ {
title: "Create Coding Agent", title: "Create Coding Agent",
description: 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: { inputSchema: {
cwd: z cwd: z
.string() .string()
.describe( .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')." "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 initialPrompt: z
.string() .string()
.optional() .optional()
.describe( .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." "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 initialMode: z.string().optional().describe(
.string() "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."
.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."
),
worktreeName: z worktreeName: z
.string() .string()
.optional() .optional()
@@ -89,6 +104,7 @@ export async function createAgentMcpServer(
}, },
outputSchema: { outputSchema: {
agentId: z.string().describe("Unique identifier for the created agent"), agentId: z.string().describe("Unique identifier for the created agent"),
type: AgentTypeEnum.describe("Agent implementation that was created"),
status: z status: z
.string() .string()
.describe( .describe(
@@ -99,11 +115,11 @@ export async function createAgentMcpServer(
availableModes: z.array(z.object({ availableModes: z.array(z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
description: z.string().optional(), description: z.string().nullable().optional(),
})).nullable().describe("Available session modes for this agent"), })).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 // Expand and resolve the working directory
let resolvedCwd = expandPath(cwd); let resolvedCwd = expandPath(cwd);
@@ -119,9 +135,11 @@ export async function createAgentMcpServer(
resolvedCwd = worktreeConfig.worktreePath; resolvedCwd = worktreeConfig.worktreePath;
} }
const resolvedType: AgentType = agentType ?? "claude";
const agentId = await agentManager.createAgent({ const agentId = await agentManager.createAgent({
cwd: resolvedCwd, cwd: resolvedCwd,
type: "claude", type: resolvedType,
initialPrompt, initialPrompt,
initialMode, initialMode,
}); });
@@ -132,6 +150,7 @@ export async function createAgentMcpServer(
const result = { const result = {
agentId, agentId,
type: resolvedType,
status, status,
cwd: resolvedCwd, cwd: resolvedCwd,
currentModeId: currentModeId ?? null, 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 // Tool: send_agent_prompt
server.registerTool( server.registerTool(
"send_agent_prompt", "send_agent_prompt",
{ {
title: "Send Agent Prompt", title: "Send Agent Prompt",
description: 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: { inputSchema: {
agentId: z.string().describe("Agent ID returned from create_coding_agent"), agentId: z.string().describe("Agent ID returned from create_coding_agent"),
prompt: z prompt: z
@@ -159,35 +251,25 @@ export async function createAgentMcpServer(
.describe( .describe(
"The task, instruction, or feedback to send to the agent. Be specific about what you want the agent to accomplish." "The task, instruction, or feedback to send to the agent. Be specific about what you want the agent to accomplish."
), ),
sessionMode: z sessionMode: z.string().optional().describe(
.string() "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."
.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."
),
}, },
outputSchema: { outputSchema: {
success: z.boolean().describe("Whether the prompt was sent successfully"), 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)"), status: z
stopReason: z.string().nullable().describe("Reason agent stopped if it completed: 'end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'"), .string()
.describe("Agent status immediately after enqueuing the prompt (usually 'processing')"),
}, },
}, },
async ({ agentId, prompt, sessionMode, maxWait }) => { async ({ agentId, prompt, sessionMode }) => {
const response = await agentManager.sendPrompt(agentId, prompt, { await agentManager.sendPrompt(agentId, prompt, {
maxWait,
sessionMode, sessionMode,
}); });
const result = { const result = {
success: true, success: true,
didComplete: response.didComplete, status: agentManager.getAgentStatus(agentId),
stopReason: response.stopReason ?? null,
}; };
return { return {
@@ -215,14 +297,14 @@ export async function createAgentMcpServer(
status: z.string(), status: z.string(),
createdAt: z.string(), createdAt: z.string(),
lastActivityAt: z.string(), lastActivityAt: z.string(),
type: z.enum(["claude", "codex"]), type: AgentTypeEnum,
sessionId: z.string().nullable(), sessionId: z.string().nullable(),
error: z.string().nullable(), error: z.string().nullable(),
currentModeId: z.string().nullable(), currentModeId: z.string().nullable(),
availableModes: z.array(z.object({ availableModes: z.array(z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
description: z.string().optional(), description: z.string().nullable().optional(),
})).nullable(), })).nullable(),
}) })
.describe("Detailed agent information"), .describe("Detailed agent information"),
@@ -264,14 +346,14 @@ export async function createAgentMcpServer(
status: z.string(), status: z.string(),
createdAt: z.string(), createdAt: z.string(),
lastActivityAt: z.string(), lastActivityAt: z.string(),
type: z.enum(["claude", "codex"]), type: AgentTypeEnum,
sessionId: z.string().nullable(), sessionId: z.string().nullable(),
error: z.string().nullable(), error: z.string().nullable(),
currentModeId: z.string().nullable(), currentModeId: z.string().nullable(),
availableModes: z.array(z.object({ availableModes: z.array(z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
description: z.string().optional(), description: z.string().nullable().optional(),
})).nullable(), })).nullable(),
}) })
), ),
@@ -435,14 +517,12 @@ export async function createAgentMcpServer(
{ {
title: "Set Agent Session Mode", title: "Set Agent Session Mode",
description: 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: { inputSchema: {
agentId: z.string().describe("Agent ID to configure"), agentId: z.string().describe("Agent ID to configure"),
modeId: z modeId: z.string().describe(
.string() "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."
.describe( ),
"The session mode to set (e.g., 'ask', 'code', 'architect'). Must be one of the agent's available modes."
),
}, },
outputSchema: { outputSchema: {
success: z.boolean().describe("Whether the mode change succeeded"), success: z.boolean().describe("Whether the mode change succeeded"),
+10 -3
View File
@@ -88,13 +88,20 @@ async function testWorktreeAgent() {
// Step 6: Send a prompt to verify it's working in the worktree // Step 6: Send a prompt to verify it's working in the worktree
console.log("Step 6: Sending test prompt to agent..."); console.log("Step 6: Sending test prompt to agent...");
const { didComplete } = await agentManager.sendPrompt( await agentManager.sendPrompt(
agentId, agentId,
"Run pwd and git status to verify you're in a worktree. Reply with the output.", "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 // Get agent updates to see the response
const updates = agentManager.getAgentUpdates(agentId); const updates = agentManager.getAgentUpdates(agentId);
+10 -3
View File
@@ -116,13 +116,20 @@ async function testWorktreeE2E() {
// Step 7: Test agent can run commands in worktree // Step 7: Test agent can run commands in worktree
console.log("Step 7: Testing agent execution in worktree..."); console.log("Step 7: Testing agent execution in worktree...");
const { didComplete } = await agentManager.sendPrompt( await agentManager.sendPrompt(
createdAgentId, createdAgentId,
"Run 'pwd' and 'git branch --show-current'. Just show me the output.", "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 updates = agentManager.getAgentUpdates(createdAgentId);
const messageChunks = updates const messageChunks = updates