refactor(agent): add wait_for_agent tool and simplify prompt sending API
This commit is contained in:
@@ -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.
|
||||
Generated
+124
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</Pressable>
|
||||
{copied && (
|
||||
<View style={userMessageStylesheet.copiedTagContainer}>
|
||||
<Text style={userMessageStylesheet.copiedTagText}>
|
||||
Copied to clipboard
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
CLAUDE.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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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",
|
||||
|
||||
@@ -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,24 +76,24 @@ 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()
|
||||
@@ -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,13 +517,11 @@ 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: {
|
||||
|
||||
+10
-3
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user