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_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",
+1
View File
@@ -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",
+66 -3
View File
@@ -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<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 (
<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>
</View>
</Pressable>
{copied && (
<View style={userMessageStylesheet.copiedTagContainer}>
<Text style={userMessageStylesheet.copiedTagText}>
Copied to clipboard
</Text>
</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
@@ -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
@@ -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;
+158 -36
View File
@@ -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<void> {
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<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;
}
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<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
* Waits for processing agents to finish, then terminates all processes
@@ -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",
+120 -40
View File
@@ -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<McpServer> {
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"),
+10 -3
View File
@@ -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);
+10 -3
View File
@@ -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