diff --git a/ORCHESTRATOR-AGENT-SPEC.md b/ORCHESTRATOR-AGENT-SPEC.md deleted file mode 100644 index 0fc9cf55f..000000000 --- a/ORCHESTRATOR-AGENT-SPEC.md +++ /dev/null @@ -1,181 +0,0 @@ -# Voice Dev: Orchestrator + Agent Chat Architecture Spec - -## Paradigm - -### Two Interaction Models - -**1. Orchestrator (Realtime Voice)** -- Top-level conversational agent -- Controls everything: creates agents, checks status, manages projects -- Realtime voice mode with TTS -- App-level session (not project-specific) - -**2. Agent Chat (Text + Voice Notes)** -- Direct interaction with individual coding agents -- Text messages + voice notes (transcribed to text) -- No realtime, no TTS -- Per-agent conversation history - -### Key Rules - -1. **Realtime always routes to orchestrator** - regardless of which view you're in -2. **Non-realtime routes based on view**: - - Orchestrator view → orchestrator session - - Agent view → specific agent -3. **Input area is identical everywhere** - same UI, different routing -4. **TTS is implied by message type** - no flags needed - -## Message Architecture - -### Separation of Concerns - -**Realtime Messages (Orchestrator Only)** -- `audio_chunk` (realtime) → triggers TTS response -- Implies: speech-to-speech interaction -- Server handles with full orchestrator Session - -**Non-Realtime Messages** -- `user_text` (orchestrator) → text to orchestrator, no TTS -- `user_audio_note` (orchestrator) → transcribe → text to orchestrator, no TTS -- `send_agent_message` (agent) → text to agent via ACP, no TTS -- `send_agent_audio` (agent) → transcribe → text to agent, no TTS - -**Outbound (Existing)** -- Orchestrator: `assistant_chunk`, `activity_log`, `audio_output` (TTS) -- Agents: `agent_update` (session notifications from ACP) - -## Message Types - -### New Inbound Types - -```typescript -// To agent (text) -{ - type: "send_agent_message", - agentId: string, - text: string -} - -// To agent (voice note) -{ - type: "send_agent_audio", - agentId: string, - audio: string, // base64 - format: string, - isLast: boolean -} - -// Separate audio_chunk into realtime context -{ - type: "realtime_audio_chunk", // rename from audio_chunk - audio: string, - format: string, - isLast: boolean -} -``` - -### Existing Types (Clarified) - -```typescript -// To orchestrator (text) -{ - type: "user_text", - text: string - // NO disableTTS flag - never has TTS in non-realtime context -} -``` - -## UI Architecture - -### Unified Input Area - -**Same everywhere, routes differently:** -- Text input -- Voice note button (push-to-talk, sends when released) -- Realtime toggle - -**Routing Logic:** -``` -if (realtimeMode) { - → orchestrator (speech-to-speech) -} else if (viewMode === "agent") { - → agent[activeAgentId] (text only) -} else { - → orchestrator (text only) -} -``` - -### Views - -**Orchestrator View (index.tsx)** -- Current chat UI (unchanged) -- Conversation with orchestrator -- Realtime UI when enabled - -**Agent View (agent-stream-view.tsx)** -- Enhanced to match orchestrator chat quality -- User messages + agent responses (parsed from session updates) -- Tool calls, thoughts, plans (inline activity) -- Same input area (routed to agent) -- Shows realtime UI if enabled (but messages still go to orchestrator) - -### Navigation - -- Header: connection status, conversation selector, settings, "New Agent" button -- ActiveProcesses: horizontal list of agents (click to open agent view) -- "Back to Chat" button when in agent view - -## Implementation Phases - -### Phase 1: Message Routing Foundation - -**Server** -- Rename `audio_chunk` → `realtime_audio_chunk` for clarity -- Add `send_agent_message` and `send_agent_audio` message types -- Route non-realtime messages to `agentManager.sendPrompt()` -- Handle audio transcription for agent audio notes - -**App** -- Add message routing logic based on `viewMode` and `isRealtimeMode` -- Update WebSocket to handle new message types -- Add per-agent message state: `Map` - -### Phase 2: Agent Chat Interface - -**App** -- Parse `agent_update` messages into chat messages - - Extract text chunks (user_message_chunk, agent_message_chunk) - - Extract thoughts (agent_thought_chunk) - - Merge tool calls with updates -- Enhance agent-stream-view to chat quality: - - Render user messages - - Render agent responses (streaming) - - Render activity inline (thoughts, tools, plans) - - Remove its own input area (uses shared one) - -### Phase 3: Agent Creation - -**Server** -- Ensure `createAgent` supports mode selection (already exists) - -**App** -- Create agent creation modal - - Directory input/picker - - Mode selector dropdown - - Validate directory exists (optional) -- "New Agent" button in header -- Auto-switch to agent view after creation - -### Phase 4: Polish - -**App** -- Ensure realtime UI shows in both views -- Clear visual indication of routing (subtle badge/label?) -- Smooth transitions between views -- Handle edge cases (agent killed while viewing, etc.) - -## Open Questions - -1. Should we show a subtle indicator in the input area showing where messages will go? -2. How to handle agent errors during chat (failed, killed)? -3. Should agent creation modal remember last used directory? diff --git a/codex-resume-investigation.md b/codex-resume-investigation.md deleted file mode 100644 index 9067b4426..000000000 --- a/codex-resume-investigation.md +++ /dev/null @@ -1,29 +0,0 @@ -# Codex ACP Session Persistence & Resume Investigation - -## Summary -- PR [#66](https://github.com/zed-industries/codex-acp/pull/66) adds real session persistence to `@zed-industries/codex-acp`: CLI flags/env vars enable it, and each session is persisted in `${CODEX_HOME}/sessions/manifest.jsonl` with rollout paths, MCP servers, cwd, approval/mode settings, and timestamps. `loadSession` pulls that manifest entry, restores MCP config, and calls `ConversationManager::resume_conversation_from_rollout` with the saved rollout JSONL. Internal state resumes correctly. -- The ACP protocol surface still only returns mode/model selectors during `loadSession` (`codex-acp/src/conversation.rs:1585-1650`). Historical chat updates are **not** replayed: `ConversationActor::handle_load` never emits prior `SessionUpdate`s, and the event dispatcher drops any replay stream because there’s no active submission entry to consume it (`codex-acp/src/conversation.rs:1830-1840`). Clients are expected to have stored the transcript themselves. -- Our voice-dev server currently treats Codex as non-resumable (`supportsSessionPersistence: false` in `packages/server/src/server/acp/agent-types.ts`). `AgentManager` only attempts `loadSession` for Claude agents, so Codex sessions that the app starts get lost once the daemon dies. Agents launched outside the app aren’t tracked anywhere either. -- For CloudCode we solved this by forking the ACP package to replay history and by persisting agent metadata (session ids, rollout paths) in `agents.json`. Codex’s manifest already contains the same data; we can read it after each `newSession` and store it alongside the agent entry. On restart we can resume by calling `/session/load` with that data and replaying the rollout ourselves to rebuild UI history. - -## Key Details -- **CLI & Manifest (PR #66)** - - `codex-acp/src/cli.rs` parses `--session-persist`, `--session-persist=`, `--no-session-persist`, and the env vars `CODEX_SESSION_PERSIST`/`CODEX_SESSION_DIR`. - - `SessionPersistCli` feeds into `SessionPersistenceSettings::resolve`, defaulting to `${CODEX_HOME}/sessions` (`codex-acp/src/session_store.rs:55-94`). - - `SessionRecord` captures session id, conversation id, rollout path, cwd, MCP server definitions (including HTTP headers), model/mode ids, approval/sandbox policies, reasoning effort, and timestamps (`codex-acp/src/session_store.rs:21-90`). `SessionStore` writes JSONL manifest entries and exposes `record_session`, `update_model`, `update_mode`, etc. -- **Resume Flow** - - When a new session is created, Codex ACP records a manifest entry before returning (`codex-acp/src/codex_agent.rs:373-414`). - - On resume, Codex ACP reads the manifest, clones the saved MCP config/cwd, feeds it to `ConversationManager::resume_conversation_from_rollout`, and updates the manifest (`codex-acp/src/codex_agent.rs:415-498`). - - `ConversationManager` loads the rollout history via `RolloutRecorder::get_rollout_history` and spawns Codex with that history (`codex-rs/core/src/conversation_manager.rs:128-177`), so Codex internally has all previous turns. -- **History Replay Gap** - - `ConversationActor::handle_load` simply returns `LoadSessionResponse { modes, models }`; no previous `SessionUpdate`s are sent (`codex-acp/src/conversation.rs:1585-1650`). - - The conversation event loop only routes events to active submissions stored in `self.submissions`; replaying the rollout would immediately warn “unknown submission ID” and drop everything (`codex-acp/src/conversation.rs:1830-1840`). Result: clients must retain chat logs themselves. -- **Voice-dev State** - - `AgentManager` only considers Claude resumable because `supportsSessionPersistence` is false for Codex (`packages/server/src/server/acp/agent-types.ts:33-67`). - - `AgentPersistence` writes `agents.json` for app-started agents but currently stores only Claude’s session ids; Codex sessions disappear once the process exits (`packages/server/src/server/acp/agent-persistence.ts:1-89`). - -## Next Steps (not implemented yet) -1. Install/run Codex ACP from PR #66 (or the author’s fork) with `--session-persist` so every session we start writes a manifest entry. -2. Extend our agent persistence to capture Codex session metadata (session id + rollout path + cwd + MCP servers) by reading Codex’s manifest immediately after `newSession` succeeds. -3. Flip Codex’s `supportsSessionPersistence` flag and teach `AgentManager` to call `/session/load` for persisted Codex agents, replaying history ourselves by parsing the saved rollout (mirroring our Claude fork). -4. Expose resume UI/UX for Codex agents started from the app; later we can explore listing Codex’s manifest directly for sessions started outside voice-dev. diff --git a/dummy.txt b/dummy.txt deleted file mode 100644 index e43fdb915..000000000 --- a/dummy.txt +++ /dev/null @@ -1 +0,0 @@ -This is a dummy file. diff --git a/package-lock.json b/package-lock.json index fb6889556..16d5ec3bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ ], "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.37", - "@boudra/claude-code-acp": "^0.8.3", "@openai/codex-sdk": "^0.58.0" }, "devDependencies": { @@ -35,15 +34,6 @@ } } }, - "node_modules/@agentclientprotocol/sdk": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.4.9.tgz", - "integrity": "sha512-ExwH828LaTGoTTjxuw49l+fwOLA+Yx0+qkWn1TcHMOsY5mVI9CUfkj7ZDhv2klgZ7mJeT+lxX/Dn/KINv1AkNQ==", - "license": "Apache-2.0", - "dependencies": { - "zod": "^3.0.0" - } - }, "node_modules/@ai-sdk/gateway": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz", @@ -126,26 +116,6 @@ "zod": "^3.24.1" } }, - "node_modules/@anthropic-ai/claude-code": { - "version": "2.0.24", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.0.24.tgz", - "integrity": "sha512-6f/AXoTi3SmFYZl42l6L8brdPSkL+MDQWesRBJwgZy3enNI0LaVn1j/6RxQ7toPKnIyChCN0r6hZi61N8znzzQ==", - "license": "SEE LICENSE IN README.md", - "bin": { - "claude": "cli.js" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1623,322 +1593,6 @@ "node": ">=6.9.0" } }, - "node_modules/@boudra/claude-code-acp": { - "version": "0.8.3", - "resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.3/b3bc5721e2af340be3b530ab1dea79734ee2d716", - "integrity": "sha512-5OPbb15TmUkj+D/alWKOCfVPcSrkULf99zSEeDTtWW8nGgOuTWuRrNApHsINOSfMrbpTfreNWQsMEteevZDuPA==", - "license": "Apache-2.0", - "dependencies": { - "@agentclientprotocol/sdk": "0.4.9", - "@anthropic-ai/claude-agent-sdk": "0.1.23", - "@anthropic-ai/claude-code": "2.0.24", - "@modelcontextprotocol/sdk": "1.20.1", - "diff": "8.0.2", - "express": "5.1.0", - "uuid": "13.0.0" - }, - "bin": { - "claude-code-acp": "dist/index.js" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.23.tgz", - "integrity": "sha512-DktXOjzS2hOuuj2Zpo7fEooONfMa5bm09pt1/Vt4vn30YugELoezn/ZQ/TG5uSQ7+Zl/ElxAvi4vGDorj1Tirg==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - }, - "peerDependencies": { - "zod": "^3.24.1" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", - "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.6", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@boudra/claude-code-acp/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/@boudra/expo-two-way-audio": { "version": "0.1.4", "resolved": "https://npm.pkg.github.com/download/@boudra/expo-two-way-audio/0.1.4/6b9f97bb0a8fb57950eb1f7078b2baf6337dfd0c", @@ -10341,15 +9995,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -21274,13 +20919,10 @@ "name": "@voice-dev/server", "version": "0.1.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.4.9", "@ai-sdk/openai": "^2.0.52", - "@boudra/claude-code-acp": "^0.8.5", "@deepgram/sdk": "^3.4.0", "@modelcontextprotocol/sdk": "^1.20.1", "@openrouter/ai-sdk-provider": "^1.2.0", - "@zed-industries/codex-acp": "^0.4.0", "ai": "^5.0.76", "dotenv": "^17.2.3", "express": "^4.18.2", @@ -21304,99 +20946,6 @@ "vitest": "^3.2.4" } }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.23.tgz", - "integrity": "sha512-DktXOjzS2hOuuj2Zpo7fEooONfMa5bm09pt1/Vt4vn30YugELoezn/ZQ/TG5uSQ7+Zl/ElxAvi4vGDorj1Tirg==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - }, - "peerDependencies": { - "zod": "^3.24.1" - } - }, - "packages/server/node_modules/@boudra/claude-code-acp": { - "version": "0.8.5", - "resolved": "https://npm.pkg.github.com/download/@boudra/claude-code-acp/0.8.5/f4bc31a9496d5ec72b306e2f891eeaaef38aeaf5", - "integrity": "sha512-ZGa3gNtlGnwUlPZUgVS0+hys9BPYztHJlE16EvWX8pdBsD4TxMlH/WflNKQ3s7isovTJuAljRrCUF0YXH3FD0g==", - "license": "Apache-2.0", - "dependencies": { - "@agentclientprotocol/sdk": "0.4.9", - "@anthropic-ai/claude-agent-sdk": "0.1.23", - "@anthropic-ai/claude-code": "2.0.24", - "@modelcontextprotocol/sdk": "1.20.1", - "diff": "8.0.2", - "express": "5.1.0", - "uuid": "13.0.0" - }, - "bin": { - "claude-code-acp": "dist/index.js" - } - }, - "packages/server/node_modules/@boudra/claude-code-acp/node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "packages/server/node_modules/@boudra/claude-code-acp/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "packages/server/node_modules/@modelcontextprotocol/sdk": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", @@ -21462,119 +21011,6 @@ "url": "https://opencollective.com/express" } }, - "packages/server/node_modules/@zed-industries/codex-acp": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.4.0.tgz", - "integrity": "sha512-1X121qtuxYeD/BBvxDkMEKJ5W67oE11mHUvMMEwlalrBQP2MlxkcDGpKoTNnjQIHTRnN+Gd4jN67bgw2P48tPg==", - "license": "Apache-2.0", - "bin": { - "codex-acp": "bin/codex-acp.js" - }, - "optionalDependencies": { - "@zed-industries/codex-acp-darwin-arm64": "0.4.0", - "@zed-industries/codex-acp-darwin-x64": "0.4.0", - "@zed-industries/codex-acp-linux-arm64": "0.4.0", - "@zed-industries/codex-acp-linux-x64": "0.4.0", - "@zed-industries/codex-acp-windows-arm64": "0.4.0", - "@zed-industries/codex-acp-windows-x64": "0.4.0" - } - }, - "packages/server/node_modules/@zed-industries/codex-acp-darwin-arm64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-arm64/-/codex-acp-darwin-arm64-0.4.0.tgz", - "integrity": "sha512-SY54IJdDI1N2qoh0YM1WeH4j3IGEG7NHTZ6PI2Rj/znmB+Oh6bbxV3XemQbcKvqK2aSE+/kl5JKgvIIePEgdAA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "codex-acp-darwin-arm64": "bin/codex-acp" - } - }, - "packages/server/node_modules/@zed-industries/codex-acp-darwin-x64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-darwin-x64/-/codex-acp-darwin-x64-0.4.0.tgz", - "integrity": "sha512-zUV5qAgKPatGJmM1diZqNN5GvTl3pgF8cC5eVhGiwLgSMuw+eUsH9Ilwq5j+WRoTIRRfPnGF/QcGysGyBVvHOQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "codex-acp-darwin-x64": "bin/codex-acp" - } - }, - "packages/server/node_modules/@zed-industries/codex-acp-linux-arm64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-arm64/-/codex-acp-linux-arm64-0.4.0.tgz", - "integrity": "sha512-kUAxIYIXAkzRrYN5+j+LI+ggNLA5WQbHM3kn3RRsXVWBEQj+viehQsxJYyVjL2SRYLnMFl52wfehkErwb+Xf1Q==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "codex-acp-linux-arm64": "bin/codex-acp" - } - }, - "packages/server/node_modules/@zed-industries/codex-acp-linux-x64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-linux-x64/-/codex-acp-linux-x64-0.4.0.tgz", - "integrity": "sha512-MmeXc5o7BbKih4MtbAxIiJ6pOnMCr+xk+7KDaFdodNxQ9jWRoHpL1qyc1vd8pVpUK9L1GKBqkm3vJn3QEwU5Sw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "codex-acp-linux-x64": "bin/codex-acp" - } - }, - "packages/server/node_modules/@zed-industries/codex-acp-windows-arm64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-arm64/-/codex-acp-windows-arm64-0.4.0.tgz", - "integrity": "sha512-xf96JmssYnlh7m6vFcceBGVy11pBmWtPhPSjuZKyiPOR8dd5MGNeokILdJ9uJbDlJv/p5zBovXHX2m42xuIdhg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "windows" - ], - "bin": { - "codex-acp-windows-arm64": "bin/codex-acp.exe" - } - }, - "packages/server/node_modules/@zed-industries/codex-acp-windows-x64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@zed-industries/codex-acp-windows-x64/-/codex-acp-windows-x64-0.4.0.tgz", - "integrity": "sha512-dch3puZuS9g5XkeYRsCBH9v7jBVfhoCtkggY2oLs7sj9cFisgE3lebWfYhfB/0ibXoKSmePNoLwxoql8RiFpZg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "windows" - ], - "bin": { - "codex-acp-windows-x64": "bin/codex-acp.exe" - } - }, "packages/server/node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index 502e178f9..20e0f8e0c 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.37", - "@boudra/claude-code-acp": "^0.8.3", "@openai/codex-sdk": "^0.58.0" } } diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index 59fcdc53b..1ccc46611 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -231,8 +231,8 @@ export default function AgentScreen() { agent={agent} streamItems={streamItems} pendingPermissions={agentPermissions} - onPermissionResponse={(requestId, optionId) => - respondToPermission(requestId, id!, agent.sessionId || "", [optionId]) + onPermissionResponse={(agentId, requestId, response) => + respondToPermission(agentId, requestId, response) } /> )} diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index 24746c0da..016fe68f5 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -176,7 +176,7 @@ export default function FileExplorerScreen() { return ( - + {shouldShowPreview ? ( diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx index 0412a0c3b..027355d3e 100644 --- a/packages/app/src/components/active-processes.tsx +++ b/packages/app/src/components/active-processes.tsx @@ -1,15 +1,9 @@ -import { View, Text, Pressable, ScrollView } from 'react-native'; -import { StyleSheet } from 'react-native-unistyles'; -import type { AgentStatus } from '@server/server/acp/types'; +import { View, Text, Pressable, ScrollView } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { Agent } from "@/contexts/session-context"; export interface ActiveProcessesProps { - agents: Array<{ - id: string; - status: AgentStatus; - type: 'claude'; - currentModeId?: string; - availableModes?: Array<{ id: string; name: string; description?: string | null }>; - }>; + agents: Agent[]; commands: Array<{ id: string; name: string; @@ -18,46 +12,44 @@ export interface ActiveProcessesProps { isDead: boolean; exitCode: number | null; }>; - viewMode: 'orchestrator' | 'agent'; + viewMode: "orchestrator" | "agent"; activeAgentId: string | null; onSelectAgent: (id: string) => void; onSelectOrchestrator: () => void; } -function getAgentStatusColor(status: AgentStatus): string { +function getAgentStatusColor(status: Agent["status"]): string { switch (status) { - case 'initializing': - return '#f59e0b'; // orange - case 'ready': - return '#3b82f6'; // blue - case 'processing': - return '#fbbf24'; // yellow - case 'completed': - return '#22c55e'; // green - case 'failed': - return '#ef4444'; // red - case 'killed': - return '#9ca3af'; // gray + case "initializing": + return "#f59e0b"; + case "idle": + return "#22c55e"; + case "running": + return "#3b82f6"; + case "error": + return "#ef4444"; + case "closed": + return "#9ca3af"; default: - return '#9ca3af'; + return "#9ca3af"; } } -function getModeName(modeId?: string, availableModes?: Array<{ id: string; name: string }>): string { - if (!modeId) return 'unknown'; +function getModeName(modeId?: string, availableModes?: Agent["availableModes"]): string { + if (!modeId) return "unknown"; const mode = availableModes?.find((m) => m.id === modeId); - return mode?.name || modeId; + return mode?.label || modeId; } function getModeColor(modeId?: string): string { - if (!modeId) return '#9ca3af'; // gray + if (!modeId) return "#9ca3af"; // gray // Color based on common mode types - if (modeId.includes('ask')) return '#f59e0b'; // orange - asks permission - if (modeId.includes('code')) return '#22c55e'; // green - writes code - if (modeId.includes('architect') || modeId.includes('plan')) return '#3b82f6'; // blue - plans + if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission + if (modeId.includes("code")) return "#22c55e"; // green - writes code + if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans - return '#9ca3af'; // gray - unknown + return "#9ca3af"; // gray - unknown } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 8025fb0fa..be05b8336 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -2,51 +2,13 @@ import { View, Text, Pressable, ScrollView } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { Agent } from "@/contexts/session-context"; -import type { AgentStatus } from "@server/server/acp/types"; import { formatTimeAgo } from "@/utils/time"; +import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; interface AgentListProps { agents: Map; } -function getStatusColor(status: AgentStatus): string { - switch (status) { - case "initializing": - return "#FFA500"; - case "ready": - return "#2563EB"; - case "processing": - return "#FACC15"; - case "completed": - return "#10B981"; - case "failed": - return "#EF4444"; - case "killed": - return "#6B7280"; - default: - return "#6B7280"; - } -} - -function getStatusLabel(status: AgentStatus): string { - switch (status) { - case "initializing": - return "Initializing"; - case "ready": - return "Ready"; - case "processing": - return "Processing"; - case "completed": - return "Completed"; - case "failed": - return "Failed"; - case "killed": - return "Killed"; - default: - return "Unknown"; - } -} - export function AgentList({ agents }: AgentListProps) { const { theme } = useUnistyles(); @@ -62,8 +24,8 @@ export function AgentList({ agents }: AgentListProps) { return ( {agentArray.map((agent) => { - const statusColor = getStatusColor(agent.status); - const statusLabel = getStatusLabel(agent.status); + const statusColor = getAgentStatusColor(agent.status); + const statusLabel = getAgentStatusLabel(agent.status); const timeAgo = formatTimeAgo(agent.lastActivityAt); return ( diff --git a/packages/app/src/components/agent-sidebar.tsx b/packages/app/src/components/agent-sidebar.tsx index f1c92f3a1..826ed6a51 100644 --- a/packages/app/src/components/agent-sidebar.tsx +++ b/packages/app/src/components/agent-sidebar.tsx @@ -13,14 +13,8 @@ import Animated, { Easing, type SharedValue, } from "react-native-reanimated"; -import type { AgentStatus } from "@server/server/acp/types"; - -interface Agent { - id: string; - status: AgentStatus; - title?: string; - cwd: string; -} +import type { Agent } from "@/contexts/session-context"; +import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; interface AgentSidebarProps { isOpen: boolean; @@ -32,44 +26,6 @@ interface AgentSidebarProps { edgeSwipeTranslateX?: SharedValue | null; } -function getStatusColor(status: AgentStatus): string { - switch (status) { - case "initializing": - return "#FFA500"; - case "ready": - return "#2563EB"; - case "processing": - return "#FACC15"; - case "completed": - return "#10B981"; - case "failed": - return "#EF4444"; - case "killed": - return "#6B7280"; - default: - return "#6B7280"; - } -} - -function getStatusLabel(status: AgentStatus): string { - switch (status) { - case "initializing": - return "Initializing"; - case "ready": - return "Ready"; - case "processing": - return "Processing"; - case "completed": - return "Completed"; - case "failed": - return "Failed"; - case "killed": - return "Killed"; - default: - return "Unknown"; - } -} - export function AgentSidebar({ isOpen, agents, @@ -221,8 +177,8 @@ export function AgentSidebar({ {agents.map((agent) => { const isActive = agent.id === activeAgentId; - const statusColor = getStatusColor(agent.status); - const statusLabel = getStatusLabel(agent.status); + const statusColor = getAgentStatusColor(agent.status); + const statusLabel = getAgentStatusLabel(agent.status); return ( - {agent.availableModes?.find((m) => m.id === agent.currentModeId)?.name || + {agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label || agent.currentModeId || "default"} diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index ae13814b2..849692a2b 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -26,6 +26,7 @@ import { import { ToolCallBottomSheet } from "./tool-call-bottom-sheet"; import type { StreamItem } from "@/types/stream"; import type { SelectedToolCall, PendingPermission } from "@/types/shared"; +import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSession } from "@/contexts/session-context"; @@ -34,7 +35,7 @@ export interface AgentStreamViewProps { agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; - onPermissionResponse: (requestId: string, optionId: string) => void; + onPermissionResponse: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; } export function AgentStreamView({ @@ -232,30 +233,18 @@ export function AgentStreamView({ case "tool_call": { const { payload } = item; - // Extract data based on source - if (payload.source === "acp") { + if (payload.source === "agent") { const data = payload.data; - // Map ACP status to display status - const toolStatus = - data.status === "pending" || data.status === "in_progress" - ? ("executing" as const) - : data.status === "completed" - ? ("completed" as const) - : ("failed" as const); - return ( handleOpenToolCallDetails({ payload })} /> ); } - // Orchestrator tool call const data = payload.data; return ( + ); + default: return null; } @@ -318,7 +314,7 @@ export function AgentStreamView({ {pendingPermissionItems.map((permission) => ( @@ -411,75 +407,17 @@ function PermissionRequestCard({ permission, onResponse, }: { - permission: { - requestId: string; - toolCall: any; - options: Array<{ - kind: string; - name: string; - optionId: string; - }>; - }; - onResponse: (requestId: string, optionId: string) => void; + permission: PendingPermission; + onResponse: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; }) { const { theme } = useUnistyles(); - // Determine permission type and content based on toolCall - const getPermissionInfo = () => { - const rawInput = permission.toolCall?.rawInput || {}; - const toolCallId = permission.toolCall?.toolCallId || ""; - - console.log("[PermissionCard] Tool call details:", { - toolCallId, - rawInputKeys: Object.keys(rawInput), - rawInput, - }); - - // Check if this is a plan (ExitPlanMode) - if (rawInput.plan) { - return { - title: "Plan Ready for Review", - content: rawInput.plan, - type: "plan" as const, - }; - } - - // Check if this is a file operation (Write, Edit, etc.) - if (rawInput.file_path) { - const operation = toolCallId.includes("Write") ? "Create" : "Edit"; - const fileContent = rawInput.content || rawInput.new_string || ""; - const preview = - fileContent.length > 500 - ? fileContent.slice(0, 500) + "\n\n... (truncated)" - : fileContent; - - return { - title: `${operation} File Permission`, - content: `File: ${rawInput.file_path}\n\n${preview || "(empty file)"}`, - type: "file" as const, - }; - } - - // Check if this is a command (Bash) - if (rawInput.command) { - return { - title: "Run Command Permission", - content: `Command: ${rawInput.command}\n\nDescription: ${ - rawInput.description || "No description" - }`, - type: "command" as const, - }; - } - - // Fallback - show whatever is in rawInput - return { - title: "Permission Required", - content: JSON.stringify(rawInput, null, 2), - type: "unknown" as const, - }; - }; - - const permissionInfo = getPermissionInfo(); + const { request } = permission; + const title = request.title ?? request.name ?? "Permission Required"; + const description = request.description ?? ""; + const inputPreview = request.input + ? JSON.stringify(request.input, null, 2) + : null; return ( - - {permissionInfo.title} + + {title} - {permissionInfo.content && ( + {description ? ( + + {description} + + ) : null} + + {inputPreview && ( - - {permissionInfo.content} + + {inputPreview} )} @@ -525,29 +462,45 @@ function PermissionRequestCard({ - {permission.options.map((option) => ( - + onResponse(permission.agentId, request.id, { behavior: "allow" }) + } + > + onResponse(permission.requestId, option.optionId)} > - - {option.name} - - - ))} + Allow + + + + onResponse(permission.agentId, request.id, { + behavior: "deny", + message: "Denied by user", + }) + } + > + + Deny + + ); diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 0e2acea5b..615b2d488 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import type { ReactElement } from "react"; import { View, Text, @@ -28,30 +29,24 @@ import { useSession } from "@/contexts/session-context"; import { useRouter } from "expo-router"; import { generateMessageId } from "@/types/stream"; import { - listAgentTypeDefinitions, - type AgentType, - type AgentTypeDefinition, - type AgentModeDefinition, -} from "@server/server/acp/agent-types"; + AGENT_PROVIDER_DEFINITIONS, + type AgentProviderDefinition, +} from "@server/server/agent/provider-manifest"; +import type { AgentProvider, AgentMode, AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; interface CreateAgentModalProps { isVisible: boolean; onClose: () => void; } -const agentTypeDefinitions = listAgentTypeDefinitions(); +const providerDefinitions = AGENT_PROVIDER_DEFINITIONS; +const providerDefinitionMap = new Map( + providerDefinitions.map((definition) => [definition.id, definition]) +); -const agentTypeDefinitionMap: Record = - {} as Record; -for (const definition of agentTypeDefinitions) { - agentTypeDefinitionMap[definition.id] = definition; -} - -const fallbackDefinition = agentTypeDefinitionMap.claude ?? agentTypeDefinitions[0]; -const DEFAULT_AGENT_TYPE: AgentType = fallbackDefinition - ? fallbackDefinition.id - : "claude"; -const DEFAULT_MODE_FOR_DEFAULT_AGENT = fallbackDefinition?.defaultModeId ?? ""; +const fallbackDefinition = providerDefinitions[0]; +const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude"; +const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? ""; const BACKDROP_OPACITY = 0.55; export function CreateAgentModal({ @@ -71,10 +66,10 @@ export function CreateAgentModal({ const [isMounted, setIsMounted] = useState(isVisible); const [workingDir, setWorkingDir] = useState(""); - const [selectedAgentType, setSelectedAgentType] = useState( - DEFAULT_AGENT_TYPE + const [selectedProvider, setSelectedProvider] = useState( + DEFAULT_PROVIDER ); - const [selectedMode, setSelectedMode] = useState(DEFAULT_MODE_FOR_DEFAULT_AGENT); + const [selectedMode, setSelectedMode] = useState(DEFAULT_MODE_FOR_DEFAULT_PROVIDER); const [worktreeName, setWorktreeName] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -82,8 +77,8 @@ export function CreateAgentModal({ const pendingNavigationAgentIdRef = useRef(null); - const agentDefinition = agentTypeDefinitionMap[selectedAgentType]; - const modeOptions = agentDefinition?.availableModes ?? []; + const agentDefinition = providerDefinitionMap.get(selectedProvider); + const modeOptions = agentDefinition?.modes ?? []; useEffect(() => { if (!agentDefinition) { @@ -109,8 +104,8 @@ export function CreateAgentModal({ const resetFormState = useCallback(() => { setWorkingDir(""); setWorktreeName(""); - setSelectedAgentType(DEFAULT_AGENT_TYPE); - setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_AGENT); + setSelectedProvider(DEFAULT_PROVIDER); + setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER); setErrorMessage(""); setIsLoading(false); setPendingRequestId(null); @@ -293,14 +288,17 @@ export function CreateAgentModal({ setPendingRequestId(requestId); setErrorMessage(""); - const modeId = - modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + + const config: AgentSessionConfig = { + provider: selectedProvider, + cwd: trimmedPath, + ...(modeId ? { modeId } : {}), + }; try { createAgent({ - cwd: trimmedPath, - agentType: selectedAgentType, - initialMode: modeId, + config, worktreeName: worktree || undefined, requestId, }); @@ -314,8 +312,8 @@ export function CreateAgentModal({ workingDir, worktreeName, selectedMode, - selectedAgentType, modeOptions, + selectedProvider, isLoading, validateWorktreeName, addRecentPath, @@ -327,20 +325,22 @@ export function CreateAgentModal({ return; } - const unsubscribe = ws.on("agent_created", (message) => { - if (message.type !== "agent_created") { + const unsubscribe = ws.on("status", (message) => { + if (message.type !== "status") { return; } - - const { agentId, requestId } = message.payload; - - if (requestId === pendingRequestId) { - console.log("[CreateAgentModal] Agent created:", agentId); - setIsLoading(false); - setPendingRequestId(null); - pendingNavigationAgentIdRef.current = agentId; - handleClose(); + const payload = message.payload as { status: string; agentId?: string; requestId?: string }; + if (payload.status !== "agent_created") { + return; } + if (payload.requestId !== pendingRequestId || !payload.agentId) { + return; + } + console.log("[CreateAgentModal] Agent created:", payload.agentId); + setIsLoading(false); + setPendingRequestId(null); + pendingNavigationAgentIdRef.current = payload.agentId; + handleClose(); }); return () => { @@ -427,11 +427,11 @@ export function CreateAgentModal({ ]} > void; } -function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose }: ModalHeaderProps): JSX.Element { +function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose }: ModalHeaderProps): ReactElement { return ( Create New Agent - + ); @@ -521,7 +521,7 @@ function WorkingDirectorySection({ errorMessage, recentPaths, onChangeWorkingDir, -}: WorkingDirectorySectionProps): JSX.Element { +}: WorkingDirectorySectionProps): ReactElement { return ( Working Directory @@ -557,26 +557,26 @@ function WorkingDirectorySection({ } interface AssistantSelectorProps { - agentTypeDefinitions: AgentTypeDefinition[]; - selectedAgentType: AgentType; + providerDefinitions: AgentProviderDefinition[]; + selectedProvider: AgentProvider; disabled: boolean; isStacked: boolean; - onSelect: (agentType: AgentType) => void; + onSelect: (provider: AgentProvider) => void; } function AssistantSelector({ - agentTypeDefinitions, - selectedAgentType, + providerDefinitions, + selectedProvider, disabled, isStacked, onSelect, -}: AssistantSelectorProps): JSX.Element { +}: AssistantSelectorProps): ReactElement { return ( Assistant - {agentTypeDefinitions.map((definition) => { - const isSelected = selectedAgentType === definition.id; + {providerDefinitions.map((definition) => { + const isSelected = selectedProvider === definition.id; return ( Permissions @@ -652,7 +652,7 @@ function ModeSelector({ {isSelected ? : null} - {mode.name} + {mode.label} {mode.description ? {mode.description} : null} @@ -671,7 +671,7 @@ interface WorktreeSectionProps { onChange: (value: string) => void; } -function WorktreeSection({ value, isLoading, onChange }: WorktreeSectionProps): JSX.Element { +function WorktreeSection({ value, isLoading, onChange }: WorktreeSectionProps): ReactElement { return ( Worktree Name (Optional) @@ -742,9 +742,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - closeIcon: { - color: theme.colors.mutedForeground, - }, scroll: { flex: 1, }, diff --git a/packages/app/src/components/mode-selector-modal.tsx b/packages/app/src/components/mode-selector-modal.tsx index c4b6085e3..67f910412 100644 --- a/packages/app/src/components/mode-selector-modal.tsx +++ b/packages/app/src/components/mode-selector-modal.tsx @@ -46,7 +46,7 @@ export function ModeSelectorModal({ isActive && styles.modeNameActive, ]} > - {mode.name} + {mode.label} {mode.description && ( { - if (item.type === "content" && item.content.type === "text") { - return [item.content.text]; - } - return []; - }) - .join("\n"); - - return { - toolName: data.kind ?? "Unknown Tool", - args: data.rawInput, - result: content, - error: undefined, // ACP doesn't have a separate error field - }; - } else { - // Orchestrator tool call + if (payload.source === "agent") { const data = payload.data; return { - toolName: data.toolName, - args: data.arguments, - result: data.result, - error: data.error, + toolName: `${data.server}/${data.tool}`, + args: data.raw, + result: undefined, + error: undefined, }; } + + const data = payload.data; + return { + toolName: data.toolName, + args: data.arguments, + result: data.result, + error: data.error, + }; }, [selectedToolCall]); const editEntries = useMemo(() => { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 2b870840c..dbc24df7a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,16 +1,28 @@ import { createContext, useContext, useState, useRef, ReactNode, useCallback, useEffect } from "react"; import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket"; import { useAudioPlayer } from "@/hooks/use-audio-player"; -import { reduceStreamUpdate, generateMessageId, type StreamItem } from "@/types/stream"; +import { reduceStreamUpdate, generateMessageId, hydrateStreamState, type StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { ActivityLogPayload, + AgentSnapshotPayload, + AgentStreamEventPayload, SessionInboundMessage, WSInboundMessage, } from "@server/server/messages"; -import type { AgentStatus, AgentUpdate, AgentNotification } from "@server/server/acp/types"; -import type { AgentType } from "@server/server/acp/agent-types"; -import { parseSessionUpdate } from "@/types/agent-activity"; +import type { + AgentLifecycleStatus, +} from "@server/server/agent/agent-manager"; +import type { + AgentPermissionResponse, + AgentSessionConfig, + AgentProvider, + AgentPermissionRequest, + AgentMode, + AgentCapabilityFlags, + AgentUsage, + AgentPersistenceHandle, +} from "@server/server/agent/agent-sdk-types"; import { ScrollView } from "react-native"; import * as FileSystem from 'expo-file-system'; @@ -56,18 +68,19 @@ export type MessageEntry = export interface Agent { id: string; - status: AgentStatus; + provider: AgentProvider; + status: AgentLifecycleStatus; createdAt: Date; + updatedAt: Date; lastActivityAt: Date; - type: AgentType; sessionId: string | null; - error: string | null; + capabilities: AgentCapabilityFlags; currentModeId: string | null; - availableModes: Array<{ - id: string; - name: string; - description?: string | null; - }> | null; + availableModes: AgentMode[]; + pendingPermissions: AgentPermissionRequest[]; + persistence: AgentPersistenceHandle | null; + lastUsage?: AgentUsage; + lastError?: string | null; title: string | null; cwd: string; } @@ -131,6 +144,29 @@ const createExplorerState = (): AgentFileExplorerState => ({ currentPath: ".", }); +function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent { + const createdAt = new Date(snapshot.createdAt); + const updatedAt = new Date(snapshot.updatedAt); + return { + id: snapshot.id, + provider: snapshot.provider, + status: snapshot.status as AgentLifecycleStatus, + createdAt, + updatedAt, + lastActivityAt: updatedAt, + sessionId: snapshot.sessionId, + capabilities: snapshot.capabilities, + currentModeId: snapshot.currentModeId, + availableModes: snapshot.availableModes ?? [], + pendingPermissions: snapshot.pendingPermissions ?? [], + persistence: snapshot.persistence ?? null, + lastUsage: snapshot.lastUsage, + lastError: snapshot.lastError ?? null, + title: snapshot.title ?? null, + cwd: snapshot.cwd, + }; +} + interface SessionContextValue { // WebSocket @@ -158,8 +194,6 @@ interface SessionContextValue { setAgents: (agents: Map | ((prev: Map) => Map)) => void; commands: Map; setCommands: (commands: Map | ((prev: Map) => Map)) => void; - agentUpdates: Map; - setAgentUpdates: (updates: Map | ((prev: Map) => Map)) => void; // Permissions pendingPermissions: Map; @@ -178,15 +212,9 @@ interface SessionContextValue { initializeAgent: (params: { agentId: string; requestId?: string }) => void; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise; sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise; - createAgent: (options: { - cwd: string; - agentType: AgentType; - initialMode?: string; - worktreeName?: string; - requestId?: string; - }) => void; + createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void; setAgentMode: (agentId: string, modeId: string) => void; - respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void; + respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; } const SessionContext = createContext(null); @@ -224,7 +252,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const [agents, setAgents] = useState>(new Map()); const [commands, setCommands] = useState>(new Map()); - const [agentUpdates, setAgentUpdates] = useState>(new Map()); const [pendingPermissions, setPendingPermissions] = useState>(new Map()); const [gitDiffs, setGitDiffs] = useState>(new Map()); const [fileExplorer, setFileExplorer] = useState>(new Map()); @@ -250,212 +277,151 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { console.log("[Session] Session state:", agentsList.length, "agents,", commandsList.length, "commands"); - setAgents( - new Map( - agentsList.map((agentInfo) => { - const createdAt = new Date(agentInfo.createdAt); - const lastActivityAt = agentInfo.lastActivityAt - ? new Date(agentInfo.lastActivityAt) - : createdAt; - return [ - agentInfo.id, - { - id: agentInfo.id, - status: agentInfo.status as AgentStatus, - type: agentInfo.type, - createdAt, - lastActivityAt, - title: agentInfo.title ?? null, - cwd: agentInfo.cwd, - sessionId: agentInfo.sessionId ?? null, - error: agentInfo.error ?? null, - currentModeId: agentInfo.currentModeId ?? null, - availableModes: agentInfo.availableModes ?? null, - } satisfies Agent, - ]; - }) - ) - ); + const nextAgents = new Map(); + const nextPermissions = new Map(); + for (const snapshot of agentsList) { + const agent = normalizeAgentSnapshot(snapshot); + nextAgents.set(agent.id, agent); + for (const request of agent.pendingPermissions) { + nextPermissions.set(request.id, { agentId: agent.id, request }); + } + } + + setAgents(nextAgents); + setPendingPermissions(nextPermissions); setCommands(new Map(commandsList.map((c) => [c.id, c as Command]))); - setAgentStreamState(new Map()); - setAgentUpdates(new Map()); + setAgentStreamState(() => { + const next = new Map(); + for (const snapshot of agentsList) { + next.set(snapshot.id, []); + } + return next; + }); setInitializingAgents(new Map()); }); - // Agent created - const unsubAgentCreated = ws.on("agent_created", (message) => { - if (message.type !== "agent_created") return; - const { agentId, status, type, currentModeId, availableModes, title, cwd } = message.payload; + const unsubAgentState = ws.on("agent_state", (message) => { + if (message.type !== "agent_state") return; + const snapshot = message.payload; + const agent = normalizeAgentSnapshot(snapshot); - console.log("[Session] Agent created:", agentId); - - const now = new Date(); - const agent: Agent = { - id: agentId, - status: status as AgentStatus, - type, - createdAt: now, - lastActivityAt: now, - title: title ?? null, - cwd, - sessionId: null, - error: null, - currentModeId: currentModeId ?? null, - availableModes: availableModes ?? null, - }; - - setAgents((prev) => new Map(prev).set(agentId, agent)); - setAgentStreamState((prev) => new Map(prev).set(agentId, [])); - }); - - // Agent initialized - receive full history on demand - const unsubAgentInitialized = ws.on("agent_initialized", (message) => { - if (message.type !== "agent_initialized") return; - const { agentId, info, updates } = message.payload; - - console.log( - "[Session] Agent initialized:", - agentId, - "updates:", - updates.length - ); + console.log("[Session] Agent state update:", agent.id, agent.status); setAgents((prev) => { const next = new Map(prev); - const createdAt = new Date(info.createdAt); - const lastActivityAt = info.lastActivityAt - ? new Date(info.lastActivityAt) - : createdAt; - const existing = next.get(agentId); - - const normalizedAgent: Agent = { - id: info.id, - status: info.status as AgentStatus, - type: info.type, - createdAt, - lastActivityAt, - title: info.title ?? null, - cwd: info.cwd, - sessionId: info.sessionId ?? null, - error: info.error ?? null, - currentModeId: info.currentModeId ?? null, - availableModes: info.availableModes ?? null, - }; - - next.set( - agentId, - existing - ? { - ...existing, - ...normalizedAgent, - } - : normalizedAgent - ); + next.set(agent.id, agent); return next; }); - const normalizedUpdates: AgentUpdate[] = updates.map((update) => ({ - agentId: update.agentId, - timestamp: new Date(update.timestamp), - notification: update.notification, - })); + setPendingPermissions((prev) => { + const next = new Map(prev); + for (const [key, pending] of Array.from(next.entries())) { + if (pending.agentId === agent.id) { + next.delete(key); + } + } + for (const request of agent.pendingPermissions) { + next.set(request.id, { agentId: agent.id, request }); + } + return next; + }); + }); - setAgentUpdates((prev) => new Map(prev).set(agentId, normalizedUpdates)); + const unsubAgentStream = ws.on("agent_stream", (message) => { + if (message.type !== "agent_stream") return; + const { agentId, event, timestamp } = message.payload; + const parsedTimestamp = new Date(timestamp); setAgentStreamState((prev) => { - const reconstructedStream = normalizedUpdates.reduce( - (acc, update) => reduceStreamUpdate(acc, update.notification, update.timestamp), - [] + const currentStream = prev.get(agentId) || []; + const newStream = reduceStreamUpdate( + currentStream, + event as AgentStreamEventPayload, + parsedTimestamp ); - return new Map(prev).set(agentId, reconstructedStream); + const next = new Map(prev); + next.set(agentId, newStream); + return next; + }); + + setAgents((prev) => { + const existing = prev.get(agentId); + if (!existing) { + return prev; + } + const next = new Map(prev); + next.set(agentId, { + ...existing, + lastActivityAt: parsedTimestamp, + updatedAt: parsedTimestamp, + }); + return next; + }); + }); + + const unsubAgentStreamSnapshot = ws.on("agent_stream_snapshot", (message) => { + if (message.type !== "agent_stream_snapshot") return; + const { agentId, events } = message.payload; + + const hydrated = hydrateStreamState( + events.map(({ event, timestamp }) => ({ + event: event as AgentStreamEventPayload, + timestamp: new Date(timestamp), + })) + ); + + setAgentStreamState((prev) => { + const next = new Map(prev); + next.set(agentId, hydrated); + return next; }); setInitializingAgents((prev) => { + if (!prev.has(agentId)) { + return prev; + } const next = new Map(prev); next.set(agentId, false); return next; }); }); - // Agent status update (mode changes, title changes, etc.) - const unsubAgentStatus = ws.on("agent_status", (message) => { - if (message.type !== "agent_status") return; - const { agentId, info } = message.payload; - - console.log("[Session] Agent status update:", agentId, "mode:", info.currentModeId); - - setAgents((prev) => { - const existingAgent = prev.get(agentId); - if (!existingAgent) return prev; - - const lastActivityAt = info.lastActivityAt - ? new Date(info.lastActivityAt) - : existingAgent.lastActivityAt; - - const updatedAgent: Agent = { - ...existingAgent, - status: info.status as AgentStatus, - sessionId: info.sessionId ?? null, - error: info.error ?? null, - currentModeId: info.currentModeId ?? null, - availableModes: info.availableModes ?? null, - title: info.title ?? null, - cwd: info.cwd, - lastActivityAt, - }; - - return new Map(prev).set(agentId, updatedAgent); - }); - }); - - // Agent update - const unsubAgentUpdate = ws.on("agent_update", (message) => { - if (message.type !== "agent_update") return; - const { agentId, notification, timestamp: rawTimestamp } = message.payload; - const timestamp = new Date(rawTimestamp); - - const update: AgentUpdate = { - agentId, - timestamp, - notification, - }; - - setAgentUpdates((prev) => { - const agentHistory = prev.get(agentId) || []; - return new Map(prev).set(agentId, [...agentHistory, update]); - }); - - // Update stream state using reducer - setAgentStreamState((prev) => { - const currentStream = prev.get(agentId) || []; - const newStream = reduceStreamUpdate(currentStream, notification, timestamp); - return new Map(prev).set(agentId, newStream); - }); + const unsubStatus = ws.on("status", (message) => { + if (message.type !== "status") return; + const status = message.payload.status; + if (status === "agent_initialized" && "agentId" in message.payload) { + const agentId = (message.payload as any).agentId as string | undefined; + if (agentId) { + setInitializingAgents((prev) => { + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + } + } }); // Permission request const unsubPermissionRequest = ws.on("agent_permission_request", (message) => { if (message.type !== "agent_permission_request") return; - const { agentId, requestId, sessionId, toolCall, options } = message.payload; + const { agentId, request } = message.payload; - console.log("[Session] Permission request:", requestId, "for agent:", agentId); + console.log("[Session] Permission request:", request.id, "for agent:", agentId); - setPendingPermissions((prev) => new Map(prev).set(requestId, { - agentId, - requestId, - sessionId, - toolCall, - options, - })); + setPendingPermissions((prev) => { + const next = new Map(prev); + next.set(request.id, { agentId, request }); + return next; + }); }); // Permission resolved - remove from pending const unsubPermissionResolved = ws.on("agent_permission_resolved", (message) => { if (message.type !== "agent_permission_resolved") return; - const { requestId, agentId, optionId } = message.payload; + const { requestId, agentId } = message.payload; - console.log("[Session] Permission resolved:", requestId, "for agent:", agentId, "with option:", optionId); + console.log("[Session] Permission resolved:", requestId, "for agent:", agentId); setPendingPermissions((prev) => { const next = new Map(prev); @@ -724,10 +690,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { return () => { unsubSessionState(); - unsubAgentCreated(); - unsubAgentInitialized(); - unsubAgentStatus(); - unsubAgentUpdate(); + unsubAgentState(); + unsubAgentStream(); + unsubAgentStreamSnapshot(); + unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); unsubAudioOutput(); @@ -746,6 +712,12 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { return next; }); + setAgentStreamState((prev) => { + const next = new Map(prev); + next.set(agentId, []); + return next; + }); + const msg: WSInboundMessage = { type: "session", message: { @@ -764,25 +736,14 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { // Optimistically add user message to stream setAgentStreamState((prev) => { const currentStream = prev.get(agentId) || []; - - // Create AgentNotification structure that matches server format - const notification: AgentNotification = { - type: 'session', - notification: { - sessionId: '', - update: { - sessionUpdate: "user_message_chunk", - content: { type: 'text', text: message }, - messageId, - }, - }, + const nextItem: StreamItem = { + kind: "user_message", + id: messageId, + text: message, + timestamp: new Date(), }; - - // Use reduceStreamUpdate to properly create the StreamItem - const newStream = reduceStreamUpdate(currentStream, notification, new Date()); - const updated = new Map(prev); - updated.set(agentId, newStream); + updated.set(agentId, [...currentStream, nextItem]); return updated; }); @@ -858,12 +819,14 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { } }, [ws]); - const createAgent = useCallback((options: { cwd: string; agentType: AgentType; initialMode?: string; worktreeName?: string; requestId?: string }) => { + const createAgent = useCallback(({ config, worktreeName, requestId }: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => { const msg: WSInboundMessage = { type: "session", message: { type: "create_agent_request", - ...options, + config, + ...(worktreeName ? { worktreeName } : {}), + ...(requestId ? { requestId } : {}), }, }; ws.send(msg); @@ -881,25 +844,17 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); - const respondToPermission = useCallback(( - requestId: string, - agentId: string, - sessionId: string, - selectedOptionIds: string[] - ) => { + const respondToPermission = useCallback((agentId: string, requestId: string, response: AgentPermissionResponse) => { const msg: WSInboundMessage = { type: "session", message: { type: "agent_permission_response", agentId, requestId, - optionId: selectedOptionIds[0], + response, }, }; ws.send(msg); - - // Don't remove from pending here - wait for server confirmation via agent_permission_resolved - // This ensures UI stays in sync whether permission is accepted via UI or MCP }, [ws]); const setVoiceDetectionFlags = useCallback((isDetecting: boolean, isSpeaking: boolean) => { @@ -978,8 +933,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { setAgents, commands, setCommands, - agentUpdates, - setAgentUpdates, pendingPermissions, setPendingPermissions, gitDiffs, diff --git a/packages/app/src/types/agent-activity.ts b/packages/app/src/types/agent-activity.ts index 62c11cee4..d97512bc1 100644 --- a/packages/app/src/types/agent-activity.ts +++ b/packages/app/src/types/agent-activity.ts @@ -1,6 +1,4 @@ -import type { SessionNotification } from '@agentclientprotocol/sdk'; - -/** +/** * Discriminated union types for session updates */ export type SessionUpdate = @@ -119,86 +117,6 @@ export interface MergedToolCall { endTimestamp: Date; } -/** - * Parse SessionNotification into typed SessionUpdate - */ -export function parseSessionUpdate(notification: SessionNotification): SessionUpdate | null { - const update = (notification as any).update; - - if (!update || !update.sessionUpdate) { - return null; - } - - const kind = update.sessionUpdate; - - switch (kind) { - case 'user_message_chunk': - return { - kind: 'user_message_chunk', - content: update.content, - }; - - case 'agent_message_chunk': - return { - kind: 'agent_message_chunk', - content: update.content, - }; - - case 'agent_thought_chunk': - return { - kind: 'agent_thought_chunk', - content: update.content, - }; - - case 'tool_call': - return { - kind: 'tool_call', - toolCallId: update.toolCallId, - title: update.title, - status: update.status, - toolKind: update.kind, - rawInput: update.rawInput, - rawOutput: update.rawOutput, - content: update.content, - locations: update.locations, - }; - - case 'tool_call_update': - return { - kind: 'tool_call_update', - toolCallId: update.toolCallId, - title: update.title, - status: update.status, - toolKind: update.kind, - rawInput: update.rawInput, - rawOutput: update.rawOutput, - content: update.content, - locations: update.locations, - }; - - case 'plan': - return { - kind: 'plan', - entries: update.entries, - }; - - case 'available_commands_update': - return { - kind: 'available_commands_update', - availableCommands: update.availableCommands, - }; - - case 'current_mode_update': - return { - kind: 'current_mode_update', - currentModeId: update.currentModeId, - }; - - default: - return null; - } -} - /** * Group consecutive text chunks into messages and merge tool calls by ID */ diff --git a/packages/app/src/types/shared.ts b/packages/app/src/types/shared.ts index 7665ae832..cd3a077da 100644 --- a/packages/app/src/types/shared.ts +++ b/packages/app/src/types/shared.ts @@ -1,5 +1,5 @@ -import type { RequestPermissionRequest } from '@agentclientprotocol/sdk'; -import type { ToolCallPayload } from './stream'; +import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; +import type { ToolCallPayload } from "./stream"; /** * Generic tool call selection for bottom sheet @@ -15,10 +15,7 @@ export interface SelectedToolCall { */ export interface PendingPermission { agentId: string; - requestId: string; - sessionId: string; - toolCall: RequestPermissionRequest['toolCall']; - options: RequestPermissionRequest['options']; + request: AgentPermissionRequest; } // Agent info interface is provided by SessionContext's Agent type diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 5f80b71ed..18d21cfc4 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1,16 +1,15 @@ -import type { AgentNotification } from '@server/server/acp/types'; -import type { SessionNotification } from '@agentclientprotocol/sdk'; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import type { AgentStreamEventPayload } from "@server/server/messages"; /** * Simple hash function for deterministic ID generation - * Uses a basic string hash algorithm for consistency across runs */ function simpleHash(str: string): string { let hash = 0; - for (let i = 0; i < str.length; i++) { + for (let i = 0; i < str.length; i += 1) { const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32bit integer } return Math.abs(hash).toString(36); } @@ -22,414 +21,352 @@ export function generateMessageId(): string { return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } -/** - * Derive deterministic ID for user message based on timestamp and content - * If messageId is provided from server, use that instead - */ -function deriveUserMessageId(text: string, timestamp: Date, messageId?: string): string { - if (messageId) { - return messageId; - } - return `user_${timestamp.getTime()}_${simpleHash(text)}`; +function createTimelineId(prefix: string, text: string, timestamp: Date): string { + return `${prefix}_${timestamp.getTime()}_${simpleHash(text)}`; } -/** - * Derive deterministic ID for assistant message - * Now uses stable messageId from server if available - */ -function deriveAssistantMessageId(messageId: string | undefined, timestamp: Date): string { - if (messageId) { - return `assistant_${messageId}`; - } - // Fallback for messages without messageId (shouldn't happen with enriched updates) - return `assistant_${timestamp.getTime()}`; -} - -/** - * Derive deterministic ID for thought - * Now uses stable messageId from server if available - */ -function deriveThoughtId(messageId: string | undefined, timestamp: Date): string { - if (messageId) { - return `thought_${messageId}`; - } - // Fallback for thoughts without messageId (shouldn't happen with enriched updates) - return `thought_${timestamp.getTime()}`; -} - -/** - * Unified stream item types for both orchestrator and agent views - */ export type StreamItem = | UserMessageItem | AssistantMessageItem | ThoughtItem | ToolCallItem - | PlanItem - | ActivityLogItem - | ArtifactItem; + | ActivityLogItem; export interface UserMessageItem { - kind: 'user_message'; + kind: "user_message"; id: string; text: string; timestamp: Date; } export interface AssistantMessageItem { - kind: 'assistant_message'; + kind: "assistant_message"; id: string; text: string; timestamp: Date; } export interface ThoughtItem { - kind: 'thought'; + kind: "thought"; id: string; text: string; timestamp: Date; } -// Extract the actual ACP tool_call type from SDK -type ACPToolCall = Extract; - -// Orchestrator tool call data (from activity_log messages) interface OrchestratorToolCallData { toolCallId: string; toolName: string; arguments: unknown; result?: unknown; error?: unknown; - status: 'executing' | 'completed' | 'failed'; + status: "executing" | "completed" | "failed"; } -// Tagged union for tool call sources -export type ToolCallPayload = - | { source: 'acp'; data: ACPToolCall } - | { source: 'orchestrator'; data: OrchestratorToolCallData }; +export interface AgentToolCallData { + provider: AgentProvider; + server: string; + tool: string; + status: string; + raw?: unknown; +} + +export type ToolCallPayload = + | { source: "agent"; data: AgentToolCallData } + | { source: "orchestrator"; data: OrchestratorToolCallData }; -// Simplified ToolCallItem with payload export interface ToolCallItem { - kind: 'tool_call'; + kind: "tool_call"; id: string; timestamp: Date; payload: ToolCallPayload; } -export interface PlanItem { - kind: 'plan'; - id: string; - entries: Array<{ - content: string; - status: 'pending' | 'in_progress' | 'completed'; - priority: 'high' | 'medium' | 'low'; - }>; - timestamp: Date; -} +type ActivityLogType = "system" | "info" | "success" | "error"; export interface ActivityLogItem { - kind: 'activity_log'; + kind: "activity_log"; id: string; - activityType: 'system' | 'transcript' | 'assistant' | 'tool_call' | 'tool_result' | 'error'; + timestamp: Date; + activityType: ActivityLogType; message: string; metadata?: Record; - timestamp: Date; } -export interface ArtifactItem { - kind: 'artifact'; - id: string; - artifactId: string; - artifactType: 'markdown' | 'diff' | 'image' | 'code'; - title: string; - timestamp: Date; -} +type FileChangeEntry = { path: string; kind: string }; +type TodoEntry = { text: string; completed: boolean }; -// Extract ACP types for tool calls -type ACPToolCallUpdate = Extract; - -/** - * Parsed notification types (internal representation before converting to StreamItem) - */ -type ParsedNotification = - | { kind: 'user_message_chunk'; text: string; messageId?: string } - | { kind: 'agent_message_chunk'; text: string; messageId?: string } - | { kind: 'agent_thought_chunk'; text: string; messageId?: string } - | { kind: 'tool_call'; data: ACPToolCall } - | { kind: 'tool_call_update'; data: ACPToolCallUpdate } - | { kind: 'plan'; entries: Array<{ content: string; status: string; priority: string }> } - | { kind: 'current_mode_update'; currentModeId: string } - | { kind: 'available_commands_update'; availableCommands: Array<{ name: string; description: string }> } - | null; - -/** - * Parse AgentNotification into typed ParsedNotification - * Only processes session notifications, ignoring permission and status notifications - */ -function parseNotification(notification: AgentNotification): ParsedNotification { - // Only process session notifications (discriminated union) - if (notification.type !== 'session') { - return null; - } - - const update = notification.notification.update; - - if (!update || !update.sessionUpdate) { - return null; - } - - const kind = update.sessionUpdate; - - switch (kind) { - case 'user_message_chunk': { - const content = update.content; - const text = content && content.type === 'text' ? content.text : ''; - const messageId = 'messageId' in update ? update.messageId : undefined; - return { - kind: 'user_message_chunk', - text, - messageId, - }; - } - - case 'agent_message_chunk': { - const content = update.content; - const text = content && content.type === 'text' ? content.text : ''; - const messageId = 'messageId' in update ? update.messageId : undefined; - return { - kind: 'agent_message_chunk', - text, - messageId, - }; - } - - case 'agent_thought_chunk': { - const content = update.content; - const text = content && content.type === 'text' ? content.text : ''; - const messageId = 'messageId' in update ? update.messageId : undefined; - return { - kind: 'agent_thought_chunk', - text, - messageId, - }; - } - - case 'tool_call': { - return { - kind: 'tool_call' as const, - data: update as ACPToolCall, - }; - } - - case 'tool_call_update': { - return { - kind: 'tool_call_update' as const, - data: update as ACPToolCallUpdate, - }; - } - - case 'plan': - return { - kind: 'plan', - entries: update.entries, - }; - - case 'current_mode_update': - return { - kind: 'current_mode_update', - currentModeId: update.currentModeId, - }; - - case 'available_commands_update': - return { - kind: 'available_commands_update', - availableCommands: update.availableCommands, - }; - - default: - return null; - } -} - -/** - * Core reducer function that processes stream updates one at a time - * - * This function handles: - * - Message chunks (accumulates text into single message) - * - Tool call updates (finds and merges with existing) - * - New items (appends to stream) - * - * Can be used for: - * - Hydration: updates.reduce(reduceStreamUpdate, []) - * - Real-time: setState(prev => reduceStreamUpdate(prev, update)) - */ -export function reduceStreamUpdate( - state: StreamItem[], - notification: AgentNotification, - timestamp: Date -): StreamItem[] { - const parsed = parseNotification(notification); - - if (!parsed) { +function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { + const trimmed = text.trim(); + if (!trimmed) { return state; } - // User message chunks - always create new message (they're complete from server) - if (parsed.kind === 'user_message_chunk') { - const id = deriveUserMessageId(parsed.text, timestamp, parsed.messageId); - - // Idempotency check - if this exact message already exists, don't add it again - if (state.some(item => item.id === id)) { - return state; - } - - return [...state, { - kind: 'user_message', - id, - text: parsed.text, + const last = state[state.length - 1]; + if (last && last.kind === "assistant_message") { + const updated: AssistantMessageItem = { + ...last, + text: `${last.text}${trimmed}`, timestamp, - }]; - } - - // Agent message chunks - accumulate into message with matching ID or create new - if (parsed.kind === 'agent_message_chunk') { - const id = deriveAssistantMessageId(parsed.messageId, timestamp); - - // Find existing message with this ID - const existingIndex = state.findIndex( - item => item.kind === 'assistant_message' && item.id === id - ); - - if (existingIndex >= 0) { - // Append to existing message - const existing = state[existingIndex] as AssistantMessageItem; - return state.map((item, idx) => - idx === existingIndex - ? { ...existing, text: existing.text + parsed.text } - : item - ); - } - - // Create new message with stable ID - return [...state, { - kind: 'assistant_message', - id, - text: parsed.text, - timestamp, - }]; - } - - // Thought chunks - accumulate into thought with matching ID or create new - if (parsed.kind === 'agent_thought_chunk') { - const id = deriveThoughtId(parsed.messageId, timestamp); - - // Find existing thought with this ID - const existingIndex = state.findIndex( - item => item.kind === 'thought' && item.id === id - ); - - if (existingIndex >= 0) { - // Append to existing thought - const existing = state[existingIndex] as ThoughtItem; - return state.map((item, idx) => - idx === existingIndex - ? { ...existing, text: existing.text + parsed.text } - : item - ); - } - - // Create new thought with stable ID - return [...state, { - kind: 'thought', - id, - text: parsed.text, - timestamp, - }]; - } - - // Tool call updates - find existing and merge - if (parsed.kind === 'tool_call_update') { - return state.map(item => { - if (item.kind === 'tool_call' && - item.payload.source === 'acp' && - item.payload.data.toolCallId === parsed.data.toolCallId) { - // Merge the update data with existing ACP data - const updatedData = { ...item.payload.data }; - - // Apply non-null updates from parsed.data - Object.entries(parsed.data).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - (updatedData as any)[key] = value; - } - }); - - return { - ...item, - payload: { source: 'acp' as const, data: updatedData }, - timestamp, - }; - } - return item; - }); - } - - // New tool call - check if exists first (server may send multiple notifications for same tool) - if (parsed.kind === 'tool_call') { - const existingIndex = state.findIndex( - item => item.kind === 'tool_call' && - item.payload.source === 'acp' && - item.payload.data.toolCallId === parsed.data.toolCallId - ); - - if (existingIndex >= 0) { - // Update existing tool call - return state.map((item, idx) => { - if (idx === existingIndex && item.kind === 'tool_call') { - return { - ...item, - payload: { source: 'acp' as const, data: parsed.data }, - timestamp, - }; - } - return item; - }); - } - - // Create new tool call - const newToolCall: ToolCallItem = { - kind: 'tool_call', - id: parsed.data.toolCallId, - timestamp, - payload: { source: 'acp', data: parsed.data }, }; - - return [...state, newToolCall]; + return [...state.slice(0, -1), updated]; } - // Plan - replace existing or create new (uses constant ID since only one plan exists) - if (parsed.kind === 'plan') { - const withoutPlan = state.filter(item => item.kind !== 'plan'); - return [...withoutPlan, { - kind: 'plan', - id: 'plan_latest', - entries: parsed.entries as any, + const item: AssistantMessageItem = { + kind: "assistant_message", + id: createTimelineId("assistant", trimmed, timestamp), + text: trimmed, + timestamp, + }; + return [...state, item]; +} + +function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { + const trimmed = text.trim(); + if (!trimmed) { + return state; + } + + const last = state[state.length - 1]; + if (last && last.kind === "thought") { + const updated: ThoughtItem = { + ...last, + text: `${last.text}${trimmed}`, timestamp, - }]; + }; + return [...state.slice(0, -1), updated]; } - // Mode updates and other types don't create stream items - // They're handled separately in state management - return state; + const item: ThoughtItem = { + kind: "thought", + id: createTimelineId("thought", trimmed, timestamp), + text: trimmed, + timestamp, + }; + return [...state, item]; +} + +function appendAgentToolCall( + state: StreamItem[], + data: AgentToolCallData, + timestamp: Date +): StreamItem[] { + const status = data.status; + const id = createTimelineId( + "tool", + `${data.provider}:${data.server}:${data.tool}:${status}`, + timestamp + ); + + const normalizedStatus: "executing" | "completed" | "failed" = + status === "completed" + ? "completed" + : status === "failed" + ? "failed" + : "executing"; + + const item: ToolCallItem = { + kind: "tool_call", + id, + timestamp, + payload: { + source: "agent", + data: { ...data, status: normalizedStatus }, + }, + }; + + // Replace existing entry with same ID if present + const index = state.findIndex((entry) => entry.id === id); + if (index >= 0) { + const next = [...state]; + next[index] = item; + return next; + } + + return [...state, item]; +} + +function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] { + const index = state.findIndex((existing) => existing.id === entry.id); + if (index >= 0) { + const next = [...state]; + next[index] = entry; + return next; + } + return [...state, entry]; +} + +function toHumanLabel(value?: string): string { + if (!value) { + return ""; + } + return value + .replace(/[_-]+/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function describeCommandStatus(status?: string): ActivityLogType { + if (!status) { + return "info"; + } + const normalized = status.toLowerCase(); + if ( + normalized.includes("fail") || + normalized.includes("error") || + normalized.includes("deny") || + normalized.includes("reject") + ) { + return "error"; + } + if ( + normalized.includes("success") || + normalized.includes("complete") || + normalized.includes("granted") || + normalized.includes("applied") + ) { + return "success"; + } + return "info"; +} + +function formatCommandMessage(command: string, status?: string): string { + const label = status ? toHumanLabel(status) : null; + const header = label ? `Command (${label})` : "Command"; + return `${header}\n${command}`; +} + +function formatFileChangeMessage(files: FileChangeEntry[]): string { + if (!files.length) { + return "File changes"; + } + const header = files.length === 1 ? "File change" : `${files.length} file changes`; + const entries = files.map((file) => { + const kindLabel = file.kind ? toHumanLabel(file.kind) : ""; + return `• ${kindLabel ? `[${kindLabel}] ` : ""}${file.path}`; + }); + return [header, ...entries].join("\n"); +} + +function formatWebSearchMessage(query: string): string { + return `Web search\n"${query.trim()}"`; +} + +function formatTodoMessage(items: TodoEntry[]): string { + if (!items.length) { + return "Todo list"; + } + const header = "Todo list"; + const entries = items.map((item) => `• [${item.completed ? "x" : " "}] ${item.text}`); + return [header, ...entries].join("\n"); +} + +function formatErrorMessage(message: string): string { + return `Agent error\n${message}`; } /** - * Hydrate stream state from batch of notifications + * Reduce a single AgentManager stream event into the UI timeline + */ +export function reduceStreamUpdate( + state: StreamItem[], + event: AgentStreamEventPayload, + timestamp: Date +): StreamItem[] { + switch (event.type) { + case "timeline": { + const item = event.item; + switch (item.type) { + case "assistant_message": + return appendAssistantMessage(state, item.text, timestamp); + case "reasoning": + return appendThought(state, item.text, timestamp); + case "mcp_tool": + return appendAgentToolCall( + state, + { + provider: event.provider, + server: item.server, + tool: item.tool, + status: item.status, + raw: item.raw, + }, + timestamp + ); + case "command": { + const activity: ActivityLogItem = { + kind: "activity_log", + id: createTimelineId("command", `${item.command}:${item.status ?? ""}`, timestamp), + timestamp, + activityType: describeCommandStatus(item.status), + message: formatCommandMessage(item.command, item.status), + metadata: item.raw ? { raw: item.raw } : undefined, + }; + return appendActivityLog(state, activity); + } + case "file_change": { + const files = (item.files ?? []) as FileChangeEntry[]; + const activity: ActivityLogItem = { + kind: "activity_log", + id: createTimelineId("file_change", JSON.stringify(files), timestamp), + timestamp, + activityType: "info", + message: formatFileChangeMessage(files), + metadata: files.length ? { files } : undefined, + }; + return appendActivityLog(state, activity); + } + case "web_search": { + const activity: ActivityLogItem = { + kind: "activity_log", + id: createTimelineId("web_search", item.query ?? "", timestamp), + timestamp, + activityType: "info", + message: formatWebSearchMessage(item.query ?? ""), + metadata: item.raw ? { raw: item.raw } : { query: item.query }, + }; + return appendActivityLog(state, activity); + } + case "todo": { + const items = (item.items ?? []) as TodoEntry[]; + const activity: ActivityLogItem = { + kind: "activity_log", + id: createTimelineId("todo", JSON.stringify(items), timestamp), + timestamp, + activityType: "system", + message: formatTodoMessage(items), + metadata: items.length ? { items } : undefined, + }; + return appendActivityLog(state, activity); + } + case "error": { + const activity: ActivityLogItem = { + kind: "activity_log", + id: createTimelineId("error", item.message ?? "", timestamp), + timestamp, + activityType: "error", + message: formatErrorMessage(item.message ?? "Unknown error"), + metadata: item.raw ? { raw: item.raw } : undefined, + }; + return appendActivityLog(state, activity); + } + default: + return state; + } + } + default: + return state; + } +} + +/** + * Hydrate stream state from a batch of AgentManager stream events */ export function hydrateStreamState( - notifications: Array<{ timestamp: Date; notification: AgentNotification }> + events: Array<{ event: AgentStreamEventPayload; timestamp: Date }> ): StreamItem[] { - return notifications.reduce( - (state, { notification, timestamp }) => reduceStreamUpdate(state, notification, timestamp), - [] as StreamItem[] - ); + return events.reduce((state, { event, timestamp }) => { + return reduceStreamUpdate(state, event, timestamp); + }, []); } diff --git a/packages/app/src/utils/agent-status.ts b/packages/app/src/utils/agent-status.ts new file mode 100644 index 000000000..dc20e0fcf --- /dev/null +++ b/packages/app/src/utils/agent-status.ts @@ -0,0 +1,27 @@ +import type { Agent } from "@/contexts/session-context"; + +type AgentStatus = Agent["status"]; + +const STATUS_COLOR_MAP: Record = { + initializing: "#f59e0b", + idle: "#22c55e", + running: "#3b82f6", + error: "#ef4444", + closed: "#6b7280", +}; + +const STATUS_LABEL_MAP: Record = { + initializing: "Initializing", + idle: "Idle", + running: "Running", + error: "Error", + closed: "Closed", +}; + +export function getAgentStatusColor(status: AgentStatus): string { + return STATUS_COLOR_MAP[status] ?? "#9ca3af"; +} + +export function getAgentStatusLabel(status: AgentStatus): string { + return STATUS_LABEL_MAP[status] ?? "Unknown"; +} diff --git a/packages/server/package.json b/packages/server/package.json index 31807e540..0d5c0c282 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,13 +17,10 @@ "test:e2e:mobile": "playwright test --project='Mobile Chrome'" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.4.9", "@ai-sdk/openai": "^2.0.52", - "@boudra/claude-code-acp": "^0.8.5", "@deepgram/sdk": "^3.4.0", "@modelcontextprotocol/sdk": "^1.20.1", "@openrouter/ai-sdk-provider": "^1.2.0", - "@zed-industries/codex-acp": "^0.4.0", "ai": "^5.0.76", "dotenv": "^17.2.3", "express": "^4.18.2", diff --git a/packages/server/src/server/acp/IMPLEMENTATION.md b/packages/server/src/server/acp/IMPLEMENTATION.md deleted file mode 100644 index 2ee461859..000000000 --- a/packages/server/src/server/acp/IMPLEMENTATION.md +++ /dev/null @@ -1,220 +0,0 @@ -# Phase 1 Implementation Summary - -## What Was Built - -Phase 1 of ACP integration is complete. This provides core infrastructure for managing Claude Code agents without integrating with the rest of the application. - -## Files Created - -### Core Implementation -1. **types.ts** (53 lines) - - `AgentStatus` type: "initializing" | "ready" | "processing" | "completed" | "failed" | "killed" - - `AgentInfo` interface: agent metadata - - `AgentUpdate` interface: wraps ACP SessionNotification - - `CreateAgentOptions` interface - - `AgentUpdateCallback` type - -2. **agent-manager.ts** (348 lines) - - `AgentManager` class: main orchestrator - - `ACPClient` class: handles ACP callbacks - - Full lifecycle management (create, prompt, cancel, kill) - - Update subscription system - - Process management and cleanup - - Error handling - -### Test Scripts -3. **test-agent.ts** (91 lines) - - Full test with detailed update logging - - Tests basic agent creation and prompt execution - - 30-second timeout with status checking - -4. **test-simple.ts** (64 lines) - - Quick test with minimal output - - Filters updates to show only relevant info - - Good for rapid iteration - -5. **test-concurrent.ts** (81 lines) - - Tests multiple agents running simultaneously - - Tests cancellation functionality - - Verifies cleanup - -### Documentation -6. **README.md** - User guide with examples -7. **IMPLEMENTATION.md** - This file - -## API Reference - -### AgentManager Class - -```typescript -class AgentManager { - // Lifecycle - createAgent(options?: CreateAgentOptions): Promise - sendPrompt(agentId: string, prompt: string): Promise - cancelAgent(agentId: string): Promise - killAgent(agentId: string): Promise - - // Status/Monitoring - getAgentStatus(agentId: string): AgentStatus - listAgents(): AgentInfo[] - - // Updates - subscribeToUpdates(agentId: string, callback: AgentUpdateCallback): () => void -} -``` - -### CreateAgentOptions - -```typescript -interface CreateAgentOptions { - plan?: string; // Optional initial prompt to send - cwd?: string; // Working directory (defaults to process.cwd()) -} -``` - -## Test Results - -All tests pass successfully: - -### test-simple.ts -``` -✓ Agent creation -✓ Update subscription -✓ Prompt execution -✓ Tool calls detected (2) -✓ Message streaming (353 chars) -✓ Cleanup -``` - -### test-concurrent.ts -``` -✓ Multiple agent creation -✓ Concurrent operation -✓ Update isolation (20 updates vs 13 updates) -✓ Cancellation -✓ Cleanup verification (0 remaining agents) -``` - -### TypeScript Compilation -``` -✓ No type errors -✓ All files compile cleanly -``` - -## How It Works - -### 1. Agent Spawning -```typescript -const agentProcess = spawn("npx", ["@zed-industries/claude-code-acp"], { - stdio: ["pipe", "pipe", "pipe"], - cwd, -}); -``` - -### 2. ACP Connection -```typescript -const input = Writable.toWeb(agentProcess.stdin); -const output = Readable.toWeb(agentProcess.stdout); -const stream = ndJsonStream(input, output); -const connection = new ClientSideConnection(() => client, stream); -``` - -### 3. Initialization -```typescript -await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true } - } -}); - -const sessionResponse = await connection.newSession({ - cwd, - mcpServers: [] -}); -``` - -### 4. Update Handling -```typescript -class ACPClient { - async sessionUpdate(params: any) { - this.onUpdate(this.agentId, params.update); - } -} -``` - -Subscribers receive updates via callback: -```typescript -manager.subscribeToUpdates(agentId, (update) => { - // update.notification contains SessionNotification - // update.agentId identifies which agent - // update.timestamp shows when it occurred -}); -``` - -## Key Design Decisions - -### 1. Managed Agent State -Each agent tracked with: -- Unique ID (UUID) -- Status (lifecycle stage) -- Child process reference -- ACP connection -- Session ID -- Subscriber list - -### 2. Auto-Approval -Phase 1 auto-approves all permissions to keep things simple. Phase 2 will add proper permission handling. - -### 3. Callback-Based Updates -Updates use callbacks rather than events/observables for simplicity and flexibility. Subscribers manage their own errors. - -### 4. Process Cleanup -Graceful shutdown with SIGTERM, then SIGKILL after 5 seconds if needed. Prevents orphaned processes. - -### 5. Status Transitions -``` -initializing → ready → processing → (completed | failed) - ↓ - killed (from any state) -``` - -## Integration Points for Phase 2 - -The agent manager is designed to integrate with: - -1. **Session class**: Add agents to conversation sessions -2. **MCP tools**: Expose agent control as MCP functions -3. **WebSocket**: Stream updates to UI in real-time -4. **Persistence**: Save/restore agent state -5. **File system**: Connect read/write callbacks to actual files -6. **Permissions**: Add user confirmation for sensitive operations - -## Dependencies Installed - -```json -{ - "@agentclientprotocol/sdk": "^0.4.9" -} -``` - -## Known Limitations - -1. No integration with existing Session/WebSocket -2. File read/write return empty/mock data -3. Auto-approves all permissions -4. No state persistence -5. No authentication -6. Verbose logging (designed for debugging) - -## Performance Characteristics - -- Agent spawn time: ~2-3 seconds -- First prompt response: ~3-5 seconds -- Update latency: <100ms (streaming) -- Memory per agent: ~50-100MB -- Concurrent agents: Tested with 2, should support many more - -## Next Phase - -See the main requirements document for Phase 2 tasks. This implementation provides a solid foundation for integration with the voice assistant's session and UI layers. diff --git a/packages/server/src/server/acp/PHASE2-INTEGRATION.md b/packages/server/src/server/acp/PHASE2-INTEGRATION.md deleted file mode 100644 index 83f35f274..000000000 --- a/packages/server/src/server/acp/PHASE2-INTEGRATION.md +++ /dev/null @@ -1,293 +0,0 @@ -# Phase 2: ACP Integration - Session and MCP Tools - -This document describes the Phase 2 implementation that integrates AgentManager with the Session and MCP tools infrastructure. - -## Overview - -Phase 2 enables the orchestrator LLM to create and control Claude Code agents via function calls. Agents run as separate processes and can execute coding tasks autonomously, with their updates streamed back to the UI via WebSocket. - -## Architecture - -### Components - -1. **Agent MCP Server** (`mcp-server.ts`) - - Exposes 6 tools to the LLM for agent management - - Each Session gets its own AgentManager instance - - Uses in-memory transport to connect to Session's MCP client - -2. **WebSocket Messages** (`messages.ts`) - - Three new message types: `agent_created`, `agent_update`, `agent_status` - - Agent updates flow directly to WebSocket (NOT through LLM) - - Enables real-time UI feedback for agent work - -3. **Session Integration** (`session.ts`) - - Each session owns an AgentManager - - Initializes agent MCP server on startup - - Subscribes to agent updates and forwards to WebSocket - - Cleans up all agents on session end - -4. **getAllTools** (`llm-openai.ts`) - - Updated to accept agent tools parameter - - Merges terminal, agent, Playwright, and manual tools - - Per-session tool sets enable isolation - -## Tools Available to LLM - -### `create_coding_agent` - -Creates a new Claude Code agent. - -**Input:** -- `plan` (optional): Initial task for the agent - -**Output:** -- `agentId`: Unique identifier for the agent -- `status`: Current agent status - -**Example:** -```typescript -{ - plan: "Refactor the authentication module to use JWT tokens" -} -``` - -### `send_agent_prompt` - -Sends a task or instruction to an agent. - -**Input:** -- `agentId`: Agent to send prompt to -- `prompt`: Task or instruction - -**Output:** -- `success`: Whether prompt was sent - -**Example:** -```typescript -{ - agentId: "abc-123", - prompt: "Add unit tests for the new functions" -} -``` - -### `get_agent_status` - -Gets current status and info about an agent. - -**Input:** -- `agentId`: Agent to query - -**Output:** -- `status`: Current status -- `info`: Detailed agent information - -### `list_agents` - -Lists all active agents in this session. - -**Input:** None - -**Output:** -- `agents`: Array of agent info objects - -### `cancel_agent` - -Cancels an agent's current task (keeps agent alive). - -**Input:** -- `agentId`: Agent to cancel - -**Output:** -- `success`: Whether cancellation succeeded - -### `kill_agent` - -Terminates an agent completely. - -**Input:** -- `agentId`: Agent to kill - -**Output:** -- `success`: Whether kill succeeded - -## Data Flow - -### Agent Creation Flow - -1. LLM calls `create_coding_agent` tool -2. Agent MCP server creates agent via AgentManager -3. AgentManager spawns `npx @zed-industries/claude-code-acp` process -4. Session intercepts tool result, sees `agentId` -5. Session subscribes to agent updates -6. Session emits `agent_created` message to WebSocket - -### Agent Update Flow - -1. Agent process sends ACP session updates -2. AgentManager receives updates via ACP callback -3. AgentManager notifies subscribers (including Session) -4. Session forwards update to WebSocket as `agent_update` message -5. UI receives update and can display progress - -**Important:** Agent updates bypass the LLM and go directly to the UI. This prevents the LLM from being overwhelmed with agent activity while still providing real-time feedback to the user. - -### Agent Cleanup Flow - -1. Session cleanup is triggered (client disconnect, timeout, etc.) -2. Session iterates through all agents -3. Calls `AgentManager.killAgent()` for each -4. Unsubscribes from all agent updates -5. Closes agent MCP client - -## Session Lifecycle - -```typescript -// On Session creation -constructor() { - this.agentManager = new AgentManager() - this.initializeAgentMcp() // Async initialization -} - -// During LLM processing -async processWithLLM() { - // Wait for agent MCP to initialize - while (!this.agentTools && timeout) { await sleep(100) } - - // Get all tools (includes agent tools) - const allTools = await getAllTools(this.terminalTools, this.agentTools) - - // Stream with tools available - await streamText({ tools: allTools, ... }) -} - -// On create_coding_agent tool result -onChunk({ type: "tool-result", toolName: "create_coding_agent" }) { - const agentId = result.structuredContent.agentId - this.subscribeToAgent(agentId) // Start receiving updates - this.emit({ type: "agent_created", ... }) -} - -// On Session cleanup -async cleanup() { - // Kill all agents - for (const agent of this.agentManager.listAgents()) { - await this.agentManager.killAgent(agent.id) - } - - // Unsubscribe from all updates - for (const unsubscribe of this.agentUpdateUnsubscribers.values()) { - unsubscribe() - } - - // Close MCP clients - await this.agentMcpClient.close() -} -``` - -## Testing - -### Integration Test - -Run the integration test to verify the complete flow: - -```bash -npx tsx src/server/acp/test-integration.ts -``` - -The test: -1. Creates AgentManager -2. Sets up Agent MCP server -3. Connects MCP client -4. Verifies all 6 tools are present -5. Creates an agent -6. Subscribes to agent updates -7. Tests agent operations (status, prompt, cancel) -8. Kills agent and verifies cleanup - -### Manual Testing - -Once Phase 3 (UI) is complete, you can test via the voice interface: - -1. Start the server: `npm run dev` -2. Connect via browser -3. Say: "Create a coding agent to refactor the auth module" -4. LLM will call `create_coding_agent` tool -5. Watch agent updates in the UI as it works -6. Say: "Check the agent status" -7. LLM will call `get_agent_status` tool - -## Key Design Decisions - -### Per-Session AgentManager - -Each Session gets its own AgentManager instance. This ensures: -- Agent isolation between conversations -- Clean cleanup when session ends -- No shared state between users - -### Direct Update Streaming - -Agent updates go directly to WebSocket, not through LLM: -- Prevents LLM prompt pollution -- Enables real-time UI feedback -- Reduces token usage -- Allows parallel agent and LLM work - -### Tool Result Interception - -Session intercepts `create_coding_agent` tool results to: -- Automatically subscribe to new agents -- Emit `agent_created` message -- No manual tracking needed by LLM - -### Graceful Initialization - -Agent MCP initializes asynchronously: -- Session creation doesn't block on initialization -- LLM waits for tools before first call -- 5 second timeout with clear error -- Allows fast session creation - -## Files Modified - -### New Files -- `src/server/acp/mcp-server.ts` - Agent MCP server with 6 tools -- `src/server/acp/test-integration.ts` - Integration test script -- `src/server/acp/PHASE2-INTEGRATION.md` - This documentation - -### Modified Files -- `src/server/messages.ts` - Added 3 agent message types -- `src/server/session.ts` - Integrated AgentManager -- `src/server/agent/llm-openai.ts` - Updated getAllTools signature - -## Next Steps: Phase 3 - -Phase 3 will add UI components to: -- Display agent creation notifications -- Stream agent updates in real-time -- Show agent status and progress -- Allow manual agent control (cancel, kill) -- Display agent work artifacts - -## Troubleshooting - -### "Agent MCP failed to initialize" - -Check: -- `@zed-industries/claude-code-acp` is installed -- Node version is compatible -- No firewall blocking spawned processes - -### "Agent not found" - -Likely causes: -- Agent was killed or crashed -- Using wrong agentId -- Session was cleaned up - -### Agent updates not received - -Check: -- Subscription was created after agent creation -- WebSocket connection is active -- Agent process is still running diff --git a/packages/server/src/server/acp/README.md b/packages/server/src/server/acp/README.md deleted file mode 100644 index b050a4c0b..000000000 --- a/packages/server/src/server/acp/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# ACP Agent Manager - -Phase 1 implementation of Agent Client Protocol (ACP) integration for managing Claude Code agents. - -## Overview - -This module provides core infrastructure for spawning, managing, and communicating with Claude Code agents via the ACP SDK. It does NOT integrate with the rest of the application yet - that's for Phase 2. - -## Files - -- `types.ts` - TypeScript types and interfaces -- `agent-manager.ts` - Core AgentManager class -- `test-agent.ts` - Full test script with detailed logging -- `test-simple.ts` - Quick test with minimal output -- `test-concurrent.ts` - Concurrent agents and cancellation test - -## Usage - -### Basic Example - -```typescript -import { AgentManager } from "./acp/agent-manager.js"; - -const manager = new AgentManager(); - -// Create an agent -const agentId = await manager.createAgent({ - cwd: process.cwd(), - plan: "List files in current directory", // Optional initial prompt -}); - -// Subscribe to updates -manager.subscribeToUpdates(agentId, (update) => { - console.log("Update:", update.notification); -}); - -// Send a prompt -await manager.sendPrompt(agentId, "Do something"); - -// Check status -const status = manager.getAgentStatus(agentId); // "ready" | "processing" | etc - -// Cancel current task -await manager.cancelAgent(agentId); - -// Kill the agent -await manager.killAgent(agentId); -``` - -### Multiple Agents - -```typescript -const agent1 = await manager.createAgent({ cwd: "/path/1" }); -const agent2 = await manager.createAgent({ cwd: "/path/2" }); - -// Send different prompts -await manager.sendPrompt(agent1, "Task 1"); -await manager.sendPrompt(agent2, "Task 2"); - -// List all agents -const agents = manager.listAgents(); -agents.forEach((agent) => { - console.log(`${agent.id}: ${agent.status}`); -}); -``` - -## Agent Lifecycle - -1. **initializing** - Agent process starting, ACP connection being established -2. **ready** - Agent is ready to receive prompts -3. **processing** - Agent is working on a task -4. **completed** - Task finished successfully -5. **failed** - Agent encountered an error -6. **killed** - Agent was terminated - -## Session Updates - -The agent sends various types of updates via the `subscribeToUpdates` callback: - -- `sessionUpdate: "agent_message_chunk"` - Streaming response text -- `sessionUpdate: "tool_call"` - Agent is calling a tool (terminal, file read, etc) -- `sessionUpdate: "tool_call_update"` - Tool call status update -- `sessionUpdate: "available_commands_update"` - List of available slash commands -- And more... - -See the ACP SDK documentation for full list of notification types. - -## Testing - -Run the test scripts to verify everything works: - -```bash -# Full test with detailed logging -npx tsx src/server/acp/test-agent.ts - -# Quick test with minimal output -npx tsx src/server/acp/test-simple.ts - -# Test concurrent agents and cancellation -npx tsx src/server/acp/test-concurrent.ts -``` - -## Implementation Details - -### Process Management - -- Spawns `npx @zed-industries/claude-code-acp` as subprocess -- Uses stdio for ACP communication (stdin/stdout) -- Stderr captured for debugging -- Automatic cleanup on kill (SIGTERM, then SIGKILL after 5s) - -### ACP Connection - -- Uses `ClientSideConnection` from `@agentclientprotocol/sdk` -- Implements required callbacks: `sessionUpdate`, `requestPermission`, `readTextFile`, `writeTextFile` -- Auto-approves permissions for now (Phase 1 only) - -### Update Streaming - -- All session updates forwarded to subscribers via callback -- Multiple subscribers supported per agent -- Updates include timestamp and agent ID -- Subscribers handle their own error recovery - -## Known Limitations (Phase 1) - -- No integration with Session or WebSocket -- No MCP tools exposed yet -- Auto-approves all permissions (no user confirmation) -- File read/write callbacks return empty/mock data -- No persistence of agent state -- No authentication - -## Next Steps (Phase 2) - -- Integrate with Session class -- Add MCP tools for agent control -- Expose via WebSocket to UI -- Add proper permission handling -- Connect file read/write to actual filesystem -- Add conversation persistence diff --git a/packages/server/src/server/acp/TEST-RESULTS.md b/packages/server/src/server/acp/TEST-RESULTS.md deleted file mode 100644 index 6e7d84760..000000000 --- a/packages/server/src/server/acp/TEST-RESULTS.md +++ /dev/null @@ -1,218 +0,0 @@ -# Comprehensive ACP Agent Test Results - -## Overview - -This document summarizes the comprehensive test suite for the ACP (Agent Client Protocol) agent functionality. All tests use a **REAL Claude Code agent** via the `@zed-industries/claude-code-acp` package - no mocking. - -## Test File - -`/Users/moboudra/dev/voice-dev/packages/voice-assistant/src/server/acp/test-comprehensive.ts` - -Run with: -```bash -npx tsx src/server/acp/test-comprehensive.ts -``` - -## Test Results Summary - -### ✅ ALL TESTS PASSED - -| Test | Status | Description | -|------|--------|-------------| -| TEST 1 | ✅ PASSED | Directory Control and Initial Prompt | -| TEST 2 | ✅ PASSED | Permission Mode - Auto Approve | -| TEST 3 | ✅ PASSED | Permission Mode - Reject All | -| TEST 4 | ✅ PASSED | Multiple Prompts | -| TEST 5 | ✅ PASSED | Update Streaming | -| TEST 6 | ✅ PASSED | State Management | - -## Test Details - -### TEST 1: Directory Control and Initial Prompt - -**Validates:** -- Agent runs in specified directory -- Initial prompt executes successfully -- Tool calls are captured (pwd command) -- Message chunks stream correctly -- Output contains expected directory path - -**Results:** -- ✓ Agent created successfully -- ✓ Agent completed processing -- ✓ Output contains correct directory path -- ✓ Tool calls detected: 2 -- ✓ Status transitions: processing → completed - ---- - -### TEST 2: Permission Mode - Auto Approve - -**Validates:** -- File write operations are automatically approved -- Files are actually created on disk -- File content matches expectations -- Permission requests are handled correctly - -**Results:** -- ✓ Agent created successfully -- ✓ File created with content: "hello from auto approve" -- ✓ File content is correct -- ✓ Auto-approve mode working as expected - ---- - -### TEST 3: Permission Mode - Reject All - -**Validates:** -- File operations are blocked when permissions rejected -- Files are NOT created on disk -- Agent indicates permission denial in response -- Agent completes even when permissions denied - -**Results:** -- ✓ Agent created successfully -- ✓ File was correctly blocked from creation -- ✓ Agent indicated permission denial in response -- ✓ Reject-all mode working as expected - ---- - -### TEST 4: Multiple Prompts - -**Validates:** -- Agent can handle multiple sequential prompts -- Each prompt gets its own response -- Updates for each prompt are tracked separately -- Agent status transitions correctly between prompts - -**Results:** -- ✓ Agent created successfully -- ✓ First prompt completed (echo 'first') -- ✓ Second prompt completed (echo 'second') -- ✓ Both prompts executed successfully -- ✓ Separate update streams for each prompt - ---- - -### TEST 5: Update Streaming - -**Validates:** -- All update types stream correctly: - - `agent_message_chunk` - text response chunks - - `tool_call` - tool invocations - - `available_commands_update` - available slash commands - - `status_change` - agent status changes -- Message chunks can be reconstructed into coherent text -- Tool call details are captured - -**Results:** -- ✓ Total updates: 91 -- ✓ Message chunks: 79 (reconstructed to 623 chars) -- ✓ Tool calls: 3 (Bash, list files, read file) -- ✓ Message reconstruction successful -- ✓ All update types captured correctly - ---- - -### TEST 6: State Management - -**Validates:** -- Agent status accurately reflects actual state -- No drift between reported status and real state -- Status transitions are tracked correctly -- Agent cleanup (kill) works properly -- Agent is removed from manager after kill - -**Results:** -- ✓ Agent completed processing -- ✓ Final status is correct: completed -- ✓ Agent correctly removed after kill -- ⚠ Status polling warnings (expected - updates happen faster than polling interval) - ---- - -## Key Features Validated - -### ✅ Core Functionality -- Agent creation with custom `cwd` -- Initial prompt execution -- Sequential prompt handling -- Agent lifecycle (create → process → complete → kill) - -### ✅ Permission Modes -- `auto_approve` - All file operations approved automatically -- `reject_all` - All file operations blocked -- `ask_user` - Falls back to auto-approve (UI prompt not implemented yet) - -### ✅ Update Streaming -- Real-time message chunk streaming -- Tool call notifications -- Tool result tracking -- Status change notifications -- Available commands updates - -### ✅ File Operations -- Read file capability (via `readTextFile` callback) -- Write file capability (via `writeTextFile` callback) -- Permission request handling -- Actual filesystem changes verified - -### ✅ State Management -- Completion detection (2-second timeout after last update) -- Status tracking: initializing → ready → processing → completed -- Error handling and failed states -- Proper cleanup on kill - ---- - -## Implementation Notes - -### Completion Detection - -The ACP protocol does not have an explicit "completion" message. Completion is detected using a heuristic approach: -- After each update, a 2-second timer is set -- If no more updates arrive within 2 seconds, the agent is marked as "completed" -- This works reliably for typical agent operations - -### Update Structure - -ACP session notifications have different structures: -- Status updates: `{ type: "sessionUpdate", sessionUpdate: { status: "..." } }` -- Message chunks: `{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "..." } }` -- Tool calls: `{ sessionUpdate: "tool_call", title: "...", ... }` -- Commands: `{ availableCommands: [...] }` - -### File Operations - -File read/write operations are handled via ACP client callbacks: -- `readTextFile(params)` - Called when agent requests file read -- `writeTextFile(params)` - Called when agent requests file write -- Permissions checked before callback execution -- Actual filesystem I/O performed in callbacks - ---- - -## Future Improvements - -1. **User Permission Prompts**: Implement `ask_user` mode with actual UI prompts -2. **Explicit Completion Events**: Work with ACP spec to add explicit completion notifications -3. **Error Recovery**: Test agent behavior with network errors, timeouts, etc. -4. **Performance Tests**: Measure agent response times, update throughput -5. **Concurrent Agents**: Test multiple agents running simultaneously - ---- - -## Conclusion - -The ACP agent integration is **production-ready** with all core features working correctly: - -- ✅ Real Claude Code agent integration -- ✅ Directory control -- ✅ Permission management -- ✅ Update streaming -- ✅ File operations -- ✅ State management -- ✅ Multi-prompt support - -All 6 comprehensive tests pass successfully with real agent execution. diff --git a/packages/server/src/server/acp/activity-curator.ts b/packages/server/src/server/acp/activity-curator.ts deleted file mode 100644 index 70c87675b..000000000 --- a/packages/server/src/server/acp/activity-curator.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { AgentUpdate } from "./types.js"; - -/** - * Convert agent activity updates into chronological text format - */ -export function curateAgentActivity(updates: AgentUpdate[]): string { - const lines: string[] = []; - let messageBuffer = ""; - let thoughtBuffer = ""; - - for (const update of updates) { - // Only process session notifications - if (update.notification.type !== 'session') { - continue; - } - - const sessionUpdate = update.notification.notification.update; - const updateType = sessionUpdate.sessionUpdate; - - switch (updateType) { - case "agent_message_chunk": { - const chunk = sessionUpdate as Extract; - if (chunk.content?.type === "text" && chunk.content?.text) { - messageBuffer += chunk.content.text; - } - break; - } - - case "agent_thought_chunk": { - const chunk = sessionUpdate as Extract; - if (chunk.content?.type === "text" && chunk.content?.text) { - thoughtBuffer += chunk.content.text; - } - break; - } - - case "tool_call": - case "tool_call_update": { - // Flush buffered content - if (messageBuffer.trim()) { - lines.push(messageBuffer.trim()); - messageBuffer = ""; - } - if (thoughtBuffer.trim()) { - lines.push(`[Thought: ${thoughtBuffer.trim()}]`); - thoughtBuffer = ""; - } - - const toolUpdate = sessionUpdate as Extract; - const title = toolUpdate.title || toolUpdate.kind || "Tool"; - const status = toolUpdate.status || "unknown"; - - lines.push(`\n[${title}] ${status}`); - - if (toolUpdate.rawInput && Object.keys(toolUpdate.rawInput).length > 0) { - lines.push(`Input: ${JSON.stringify(toolUpdate.rawInput)}`); - } - if (toolUpdate.rawOutput && Object.keys(toolUpdate.rawOutput).length > 0) { - lines.push(`Output: ${JSON.stringify(toolUpdate.rawOutput)}`); - } - break; - } - - case "plan": { - // Flush buffered content - if (messageBuffer.trim()) { - lines.push(messageBuffer.trim()); - messageBuffer = ""; - } - if (thoughtBuffer.trim()) { - lines.push(`[Thought: ${thoughtBuffer.trim()}]`); - thoughtBuffer = ""; - } - - const planUpdate = sessionUpdate as Extract; - lines.push("\n[Plan]"); - for (const entry of planUpdate.entries) { - lines.push(`- [${entry.status}] ${entry.content}`); - } - break; - } - - case "user_message_chunk": { - const chunk = sessionUpdate as Extract; - if (chunk.content?.type === "text" && chunk.content?.text) { - lines.push(`User: ${chunk.content.text}`); - } - break; - } - } - } - - // Flush remaining buffered content - if (messageBuffer.trim()) { - lines.push(messageBuffer.trim()); - } - if (thoughtBuffer.trim()) { - lines.push(`[Thought: ${thoughtBuffer.trim()}]`); - } - - return lines.join("\n\n") || "No activity to display."; -} diff --git a/packages/server/src/server/acp/agent-manager.test.ts b/packages/server/src/server/acp/agent-manager.test.ts deleted file mode 100644 index 4a1c38bf3..000000000 --- a/packages/server/src/server/acp/agent-manager.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest"; -import { AgentManager } from "./agent-manager.js"; -import type { AgentUpdate, AgentNotification } from "./types.js"; -import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; -import { mkdtemp, rm, readFile } from "fs/promises"; -import { tmpdir } from "os"; -import { join } from "path"; - -describe("AgentManager", () => { - let tmpDir: string; - const createdAgents: Array<{ manager: AgentManager; agentId: string }> = []; - - beforeAll(async () => { - tmpDir = await mkdtemp(join(tmpdir(), "acp-test-")); - }); - - afterEach(async () => { - // Clean up all agents created during tests - for (const { manager, agentId } of createdAgents) { - try { - await manager.deleteAgent(agentId); - } catch (error) { - console.error(`Failed to delete agent ${agentId}:`, error); - } - } - createdAgents.length = 0; - }); - - afterAll(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it("should create file after accepting plan", async () => { - const manager = new AgentManager(); - let permissionRequest: RequestPermissionRequest | null = null; - let requestId: string | null = null; - const testFile = join(tmpDir, "test.txt"); - - const agentId = await manager.createAgent({ - cwd: tmpDir, - type: "claude", - initialMode: "plan", - }); - createdAgents.push({ manager, agentId }); - - const unsubscribe = manager.subscribeToUpdates( - agentId, - (update: AgentUpdate) => { - const notification: AgentNotification = update.notification; - if (notification.type === "permission") { - permissionRequest = notification.request; - requestId = notification.requestId; - } - } - ); - - try { - await manager.sendPrompt( - agentId, - "Create a file called test.txt with the content 'hello world'" - ); - - let attempts = 0; - while (!permissionRequest && attempts < 40) { - await new Promise((resolve) => setTimeout(resolve, 500)); - attempts++; - } - - expect(permissionRequest).toBeDefined(); - expect(requestId).toBeDefined(); - - const acceptOption = permissionRequest!.options.find( - (o) => o.kind === "allow_once" || o.kind === "allow_always" - ); - expect(acceptOption).toBeDefined(); - - manager.respondToPermission(agentId, requestId!, acceptOption!.optionId); - - let status = manager.getAgentStatus(agentId); - attempts = 0; - while (status !== "completed" && status !== "failed" && attempts < 60) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - status = manager.getAgentStatus(agentId); - attempts++; - } - - expect(status).toBe("completed"); - - const content = await readFile(testFile, "utf-8"); - expect(content).toContain("hello world"); - - const agentInfo = manager.listAgents().find((a) => a.id === agentId); - expect(agentInfo?.currentModeId).toBe("acceptEdits"); - } finally { - unsubscribe(); - } - }, 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; - let requestId: string | null = null; - - const agentId = await manager.createAgent({ - cwd: tmpDir, - type: "claude", - initialMode: "plan", - }); - createdAgents.push({ manager, agentId }); - - const unsubscribe = manager.subscribeToUpdates( - agentId, - (update: AgentUpdate) => { - const notification: AgentNotification = update.notification; - if (notification.type === "permission") { - permissionRequest = notification.request; - requestId = notification.requestId; - } - } - ); - - try { - await manager.sendPrompt( - agentId, - "Create a file called test.txt with the content 'hello world'" - ); - - let attempts = 0; - while (!permissionRequest && attempts < 40) { - await new Promise((resolve) => setTimeout(resolve, 500)); - attempts++; - } - - expect(permissionRequest).toBeDefined(); - expect(requestId).toBeDefined(); - - console.log("Permission request received, now sending '.' message instead of responding"); - - // Instead of responding to permission, send a "." message - await manager.sendPrompt(agentId, "."); - - // Wait for agent to finish processing - let status = manager.getAgentStatus(agentId); - let waitAttempts = 0; - while (status === "processing" && waitAttempts < 60) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - status = manager.getAgentStatus(agentId); - waitAttempts++; - } - - const agentInfo = manager.listAgents().find((a) => a.id === agentId); - const updates = manager.getAgentUpdates(agentId); - const sessionUpdates = updates.filter(u => u.notification.type === "session"); - - console.log("Agent status after '.' message:", status); - console.log("Agent error:", agentInfo?.error); - console.log("Session updates count:", sessionUpdates.length); - console.log("Last few updates:", updates.slice(-5).map(u => ({ - type: u.notification.type, - timestamp: u.timestamp, - }))); - - // The agent should NOT be in failed state - expect(status).not.toBe("failed"); - - // The agent should be in a usable state (ready, completed, or processing) - expect(["ready", "completed", "processing"]).toContain(status); - - // There should be no error - expect(agentInfo?.error).toBeNull(); - } finally { - unsubscribe(); - } - }, 120000); - - describe("persistence", () => { - it("should load persisted agent and send new prompt", async () => { - const manager = new AgentManager(); - const agentId = await manager.createAgent({ - cwd: tmpDir, - type: "claude", - }); - createdAgents.push({ manager, agentId }); - - await manager.sendPrompt(agentId, "echo 'first message'"); - - let status = manager.getAgentStatus(agentId); - let attempts = 0; - while (status !== "completed" && status !== "failed" && attempts < 60) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - status = manager.getAgentStatus(agentId); - attempts++; - } - - expect(status).toBe("completed"); - - const agentBeforeKill = manager - .listAgents() - .find((a) => a.id === agentId); - const claudeSessionId = manager.getClaudeSessionId(agentId); - console.log("Before kill - ACP Session ID:", agentBeforeKill?.sessionId); - console.log("Before kill - Claude Session ID:", claudeSessionId); - console.log("Working directory:", tmpDir); - - await manager.killAgent(agentId); - - const newManager = new AgentManager(); - await newManager.initialize(); - - // Update the tracking to use the new manager instance - const trackingIndex = createdAgents.findIndex( - (a) => a.agentId === agentId - ); - if (trackingIndex >= 0) { - createdAgents[trackingIndex] = { manager: newManager, agentId }; - } - - const agents = newManager.listAgents(); - const loadedAgent = agents.find((a) => a.id === agentId); - const loadedClaudeSessionId = newManager.getClaudeSessionId(agentId); - - console.log("After reload - ACP Session ID:", loadedAgent?.sessionId); - console.log("After reload - Claude Session ID:", loadedClaudeSessionId); - - expect(loadedAgent).toBeDefined(); - expect(loadedAgent?.id).toBe(agentId); - expect(loadedAgent?.cwd).toBe(tmpDir); - - await newManager.initializeAgentAndGetHistory(agentId); - await new Promise((resolve) => setTimeout(resolve, 2000)); - const persistedUpdates = newManager.getAgentUpdates(agentId); - - console.log( - "Persisted updates:", - JSON.stringify(persistedUpdates, null, 2) - ); - - await newManager.sendPrompt(agentId, "echo 'second message'"); - - status = newManager.getAgentStatus(agentId); - attempts = 0; - while (status !== "completed" && status !== "failed" && attempts < 60) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - status = newManager.getAgentStatus(agentId); - attempts++; - } - - expect(status).toBe("completed"); - - const finalUpdates = newManager.getAgentUpdates(agentId); - expect(finalUpdates.length).toBeGreaterThan(0); - - console.log("Final updates:", finalUpdates); - }, 120000); - }); -}); diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts deleted file mode 100644 index 36298ee0c..000000000 --- a/packages/server/src/server/acp/agent-manager.ts +++ /dev/null @@ -1,1931 +0,0 @@ -import { spawn } from "child_process"; -import { Writable, Readable } from "stream"; -import { access, constants, mkdir } from "fs/promises"; -import os from "os"; -import path from "path"; -import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Client, - type SessionNotification, - type RequestPermissionRequest, - type RequestPermissionResponse, - type ReadTextFileRequest, - type ReadTextFileResponse, - type WriteTextFileRequest, - type WriteTextFileResponse, - type ContentBlock, -} from "@agentclientprotocol/sdk"; -import { v4 as uuidv4 } from "uuid"; -import { expandTilde } from "../terminal-mcp/tmux.js"; -import { - getAgentModes, - getAgentTypeDefinition, -} from "./agent-types.js"; -import type { - AgentStatus, - AgentInfo, - AgentUpdate, - CreateAgentOptions, - AgentUpdateCallback, - SessionMode, - EnrichedSessionNotification, - EnrichedSessionUpdate, - AgentRuntime, - ManagedAgentState, -} from "./types.js"; -import { - AgentPersistence, - type AgentOptions, - type PersistedAgent, -} from "./agent-persistence.js"; - -interface PendingPermission { - requestId: string; - sessionId: string; - params: RequestPermissionRequest; - resolve: (response: RequestPermissionResponse) => void; - reject: (error: Error) => void; -} - -interface ManagedAgent { - id: string; - cwd: string; - createdAt: Date; - lastActivityAt: Date; - title: string; - options: AgentOptions; - subscribers: Set; - updates: AgentUpdate[]; - pendingPermissions: Map; - currentAssistantMessageId: string | null; - currentThoughtId: string | null; - titleGenerationTriggered: boolean; - pendingSessionMode: string | null; - state: ManagedAgentState; -} - -type UpdateAgentCallback = ( - agentId: string, - updateFn: (agent: ManagedAgent) => boolean | void | Promise, - options?: { sessionId: string | null } -) => Promise; - -/** - * Get the status from an agent's state - */ -function getAgentStatusFromState(state: ManagedAgentState): AgentStatus { - return state.type; -} - -/** - * Get the error message from an agent's state - */ -function getAgentError(state: ManagedAgentState): string | undefined { - if (state.type === "failed") { - return state.lastError; - } - if (state.type === "uninitialized" && state.lastError) { - return state.lastError; - } - return undefined; -} - -function normalizeModes(modes?: SessionMode[] | null): SessionMode[] { - if (!modes) { - return []; - } - return modes.map((mode) => ({ - id: mode.id, - name: mode.name, - description: mode.description ?? null, - })); -} - -function getStaticModes(type: AgentOptions["type"]): SessionMode[] { - const staticModes = getAgentModes(type); - return staticModes.map((mode) => ({ - id: mode.id, - name: mode.name, - description: mode.description ?? null, - })); -} - -function buildAvailableModes( - type: AgentOptions["type"], - runtimeModes?: SessionMode[] | null -): SessionMode[] | null { - const runtimeList = normalizeModes(runtimeModes); - if (runtimeList.length > 0) { - return runtimeList; - } - - const staticModes = getStaticModes(type); - return staticModes.length > 0 ? staticModes : null; -} - -function resolveModeSelection({ - type, - requestedModeId, - runtimeModes, -}: { - type: AgentOptions["type"]; - requestedModeId?: string | null; - runtimeModes?: SessionMode[] | null; -}): { - availableModes: SessionMode[] | null; - modeId: string | null; - wasAdjusted: boolean; -} { - const availableModes = buildAvailableModes(type, runtimeModes); - - if (!availableModes || availableModes.length === 0) { - return { - availableModes: null, - modeId: null, - wasAdjusted: Boolean(requestedModeId), - }; - } - - if (requestedModeId) { - const match = availableModes.find((mode) => mode.id === requestedModeId); - if (match) { - return { - availableModes, - modeId: requestedModeId, - wasAdjusted: false, - }; - } - } - - const definition = getAgentTypeDefinition(type); - const fallbackModeId = - definition.defaultModeId ?? (availableModes[0]?.id ?? null); - - return { - availableModes, - modeId: fallbackModeId, - wasAdjusted: Boolean(requestedModeId), - }; -} - -/** - * Client implementation for ACP callbacks - */ -class ACPClient implements Client { - constructor( - private agentId: string, - private onUpdate: (agentId: string, update: SessionNotification) => void, - private onPermissionRequest: ( - agentId: string, - params: RequestPermissionRequest - ) => Promise, - private updateAgent: UpdateAgentCallback - ) {} - - async requestPermission( - params: RequestPermissionRequest - ): Promise { - console.log(`[Agent ${this.agentId}] Permission requested:`, params); - - // Forward to agent manager which will handle the permission flow - return this.onPermissionRequest(this.agentId, params); - } - - async sessionUpdate(params: SessionNotification): Promise { - // Check if this update contains a Claude session ID - const claudeSessionId = params._meta?.claudeSessionId as string | undefined; - if (claudeSessionId) { - await this.updateAgent(this.agentId, (agent) => { - if (agent.options.type !== "claude") { - return false; - } - if (agent.options.sessionId === claudeSessionId) { - return false; - } - - agent.options = { - ...agent.options, - sessionId: claudeSessionId, - }; - - return true; - }); - } - this.onUpdate(this.agentId, params); - } - - async readTextFile( - params: ReadTextFileRequest - ): Promise { - console.log(`[Agent ${this.agentId}] Read text file:`, params.path); - const fs = await import("fs/promises"); - try { - const content = await fs.readFile(params.path, "utf-8"); - return { content }; - } catch (error) { - console.error(`[Agent ${this.agentId}] Failed to read file:`, error); - return { content: "" }; - } - } - - async writeTextFile( - params: WriteTextFileRequest - ): Promise { - console.log(`[Agent ${this.agentId}] Write text file:`, params.path); - const fs = await import("fs/promises"); - try { - await fs.writeFile(params.path, params.content, "utf-8"); - return {}; - } catch (error) { - console.error(`[Agent ${this.agentId}] Failed to write file:`, error); - throw error; - } - } -} - -/** - * Manages Claude Code agents via ACP - */ -export class AgentManager { - private agents = new Map(); - private persistence = new AgentPersistence(); - private codexHomeDir: string; - private codexSessionDir: string; - - constructor() { - const defaultCodexHome = path.join(os.homedir(), ".voice-dev", "codex"); - this.codexHomeDir = process.env.CODEX_HOME ?? defaultCodexHome; - this.codexSessionDir = - process.env.CODEX_SESSION_DIR ?? - path.join(this.codexHomeDir, "sessions"); - } - - /** - * Initialize the agent manager and load persisted agents - * Agents are loaded as uninitialized and will be started lazily on first use - */ - async initialize(): Promise { - console.log("[AgentManager] Initializing and loading persisted agents..."); - const persistedAgents = await this.persistence.load(); - - for (const persistedAgent of persistedAgents) { - try { - console.log( - `[AgentManager] Loading agent ${persistedAgent.id} as uninitialized` - ); - const createdAt = new Date(persistedAgent.createdAt); - const lastActivityAt = persistedAgent.lastActivityAt - ? new Date(persistedAgent.lastActivityAt) - : createdAt; - const agent: ManagedAgent = { - id: persistedAgent.id, - cwd: expandTilde(persistedAgent.cwd), - createdAt, - lastActivityAt, - title: persistedAgent.title, - options: persistedAgent.options, - subscribers: new Set(), - updates: [], - pendingPermissions: new Map(), - currentAssistantMessageId: null, - currentThoughtId: null, - titleGenerationTriggered: true, - pendingSessionMode: null, - state: { - type: "uninitialized", - persistedSessionId: persistedAgent.sessionId, - }, - }; - this.agents.set(persistedAgent.id, agent); - } catch (error) { - console.error( - `[AgentManager] Failed to load agent ${persistedAgent.id}:`, - error - ); - } - } - - console.log( - `[AgentManager] Loaded ${this.agents.size} agents as uninitialized` - ); - } - - /** - * Create a new agent - * Creates an uninitialized agent record that will lazily start on first use - */ - async createAgent(options: CreateAgentOptions): Promise { - const agentId = uuidv4(); - const cwd = expandTilde(options.cwd); - - // Validate that the working directory exists - try { - await access(cwd, constants.R_OK | constants.X_OK); - } catch (error) { - throw new Error( - `Working directory does not exist or is not accessible: ${cwd}` - ); - } - - const createdAt = new Date(); - const agentOptions: AgentOptions = - options.type === "claude" - ? { - type: "claude", - sessionId: null, - } - : { - type: "codex", - }; - - const modeSelection = resolveModeSelection({ - type: options.type, - requestedModeId: options.initialMode ?? null, - }); - - if (options.initialMode && modeSelection.wasAdjusted) { - console.warn( - `[AgentManager] Invalid initial mode '${options.initialMode}' for agent type '${options.type}'. Falling back to '${modeSelection.modeId ?? "none"}'.` - ); - } - - const agent: ManagedAgent = { - id: agentId, - cwd, - createdAt, - lastActivityAt: createdAt, - title: "", - options: agentOptions, - subscribers: new Set(), - updates: [], - pendingPermissions: new Map(), - currentAssistantMessageId: null, - currentThoughtId: null, - titleGenerationTriggered: false, - pendingSessionMode: options.initialPrompt ? null : modeSelection.modeId, - state: { - type: "uninitialized", - persistedSessionId: null, - }, - }; - - this.agents.set(agentId, agent); - - await this.updateAgent(agentId, () => undefined); - - this.notifySubscribers(agentId); - - if (options.initialPrompt) { - console.log(`[Agent ${agentId}] Sending initial prompt after creation`); - await this.sendPrompt(agentId, options.initialPrompt, { - sessionMode: options.initialMode, - }); - } - - return agentId; - } - - /** - * Send a prompt to an agent - * @param agentId - Agent ID - * @param prompt - The prompt text or ContentBlock array - * @param options - Optional settings: sessionMode to set before sending, messageId for deduplication - */ - async sendPrompt( - agentId: string, - prompt: string | ContentBlock[], - options?: { sessionMode?: string; messageId?: string } - ): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - // Ensure agent is initialized - await this.ensureInitialized(agentId); - - const status = getAgentStatusFromState(agent.state); - if (status === "killed" || status === "failed") { - throw new Error(`Agent ${agentId} is ${status}`); - } - - // Auto-cancel if agent is currently processing - if (status === "processing") { - console.log( - `[Agent ${agentId}] Auto-cancelling current task before new prompt` - ); - try { - await this.cancelAgent(agentId); - await new Promise((resolve) => setTimeout(resolve, 100)); - } catch (error) { - console.warn( - `[Agent ${agentId}] Cancel failed, continuing with new prompt:`, - error - ); - } - } - - // Clear any pending permissions since we're starting a new turn - if (agent.pendingPermissions.size > 0) { - console.log( - `[Agent ${agentId}] Clearing ${agent.pendingPermissions.size} pending permission(s)` - ); - - // Reject all pending permission promises with cancellation - for (const [requestId, permission] of agent.pendingPermissions) { - permission.resolve({ - outcome: { - outcome: "cancelled" as const, - }, - }); - - // Emit permission_resolved notification so UI updates - const agentUpdate: AgentUpdate = { - agentId, - timestamp: new Date(), - notification: { - type: "permission_resolved", - requestId, - agentId, - optionId: "cancelled", - }, - }; - - agent.updates.push(agentUpdate); - - for (const subscriber of agent.subscribers) { - try { - subscriber(agentUpdate); - } catch (error) { - console.error(`[Agent ${agentId}] Subscriber error:`, error); - } - } - } - - agent.pendingPermissions.clear(); - } - - // Get runtime (guaranteed to exist after ensureInitialized) - if ( - agent.state.type !== "ready" && - agent.state.type !== "processing" && - agent.state.type !== "completed" - ) { - throw new Error( - `Agent ${agentId} is not ready (state: ${agent.state.type})` - ); - } - - const runtime = agent.state.runtime; - - // Reset message IDs for new turn - agent.currentAssistantMessageId = null; - agent.currentThoughtId = null; - - // Set session mode if specified - if (options?.sessionMode) { - await this.setSessionMode(agentId, options.sessionMode); - } - - // Convert prompt to ContentBlock array if it's a string - const contentBlocks: ContentBlock[] = - typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt; - - // Extract text for user message notification (use first text block) - const firstTextBlock = contentBlocks.find((block) => block.type === "text"); - const userMessageText = - firstTextBlock && "text" in firstTextBlock - ? firstTextBlock.text - : "[message with attachments]"; - - // Emit user message notification - const userMessageUpdate: AgentUpdate = { - agentId, - timestamp: new Date(), - notification: { - type: "session", - notification: { - sessionId: runtime.sessionId, - update: { - sessionUpdate: "user_message_chunk", - content: { - type: "text", - text: userMessageText, - }, - ...(options?.messageId ? { messageId: options.messageId } : {}), - }, - }, - }, - }; - - agent.updates.push(userMessageUpdate); - - for (const subscriber of agent.subscribers) { - try { - subscriber(userMessageUpdate); - } catch (error) { - console.error(`[Agent ${agentId}] Subscriber error:`, error); - } - } - - // Update state to processing - agent.state = { type: "processing", runtime }; - this.notifySubscribers(agentId); - - // Start the prompt with ContentBlock array - const promptPromise = runtime.connection.prompt({ - sessionId: runtime.sessionId, - prompt: contentBlocks, - }); - - // Handle completion in background - promptPromise - .then((response) => { - console.log( - `[Agent ${agentId}] Prompt completed with stopReason: ${response.stopReason}` - ); - - const agent = this.agents.get(agentId); - if (!agent || agent.state.type !== "processing") return; - - if (response.stopReason === "end_turn") { - agent.state = { - type: "completed", - runtime: agent.state.runtime, - stopReason: response.stopReason, - }; - } else if (response.stopReason === "refusal") { - console.warn( - `[Agent ${agentId}] Agent refused to process the prompt`, - response - ); - agent.state = { - type: "completed", - runtime: agent.state.runtime, - stopReason: response.stopReason, - }; - } else if (response.stopReason === "cancelled") { - agent.state = { type: "ready", runtime: agent.state.runtime }; - } else { - agent.state = { - type: "completed", - runtime: agent.state.runtime, - stopReason: response.stopReason, - }; - } - - this.notifySubscribers(agentId); - }) - .catch((error) => { - console.error(`[Agent ${agentId}] Prompt failed:`, error); - this.handleAgentError( - agentId, - `Prompt failed: ${ - error instanceof Error ? error.message : String(error) - }` - ); - }); - - console.log( - `[Agent ${agentId}] Prompt sent (non-blocking, use get_agent_status to check completion)` - ); - } - - /** - * Cancel an agent's current task - */ - async cancelAgent(agentId: string): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const runtime = this.getRuntime(agent); - if (!runtime) { - throw new Error(`Agent ${agentId} has no active runtime to cancel`); - } - - if (agent.state.type !== "processing") { - console.log( - `[Agent ${agentId}] Cancel called but agent is in state ${agent.state.type}; skipping` - ); - return; - } - - try { - await runtime.connection.cancel({ - sessionId: runtime.sessionId, - }); - agent.state = { type: "ready", runtime }; - this.notifySubscribers(agentId); - } catch (error) { - console.error(`[Agent ${agentId}] Cancel failed:`, error); - throw error; - } - } - - /** - * Kill an agent - * Terminates the process but keeps it in persistence for resumption - */ - async killAgent(agentId: string): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const runtime = this.getRuntime(agent); - - // Persist current state before killing - if (runtime) { - await this.updateAgent(agentId, () => true); - } - - agent.state = { type: "killed" }; - - // Notify subscribers BEFORE removing from manager - // This ensures subscribers can still query agent info - this.notifySubscribers(agentId); - - // Kill the process - if (runtime) { - runtime.process.kill("SIGTERM"); - } - - // Wait a bit, then force kill if still alive - if (runtime) { - setTimeout(() => { - if (!runtime.process.killed) { - console.log(`[Agent ${agentId}] Force killing process`); - runtime.process.kill("SIGKILL"); - } - }, 5000); - } - - // Remove from manager after a small delay to allow status updates to propagate - setTimeout(() => { - this.agents.delete(agentId); - console.log(`[Agent ${agentId}] Removed from manager`); - }, 100); - } - - /** - * Delete an agent completely - * Kills the process and removes it from persistence - */ - async deleteAgent(agentId: string): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - // Kill the agent process - await this.killAgent(agentId); - - // Remove from persistence - await this.persistence.remove(agentId); - - console.log(`[Agent ${agentId}] Deleted from persistence`); - } - - /** - * Get the status of an agent - */ - getAgentStatus(agentId: string): AgentStatus { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - return getAgentStatusFromState(agent.state); - } - - /** - * List all agents - */ - listAgents(): AgentInfo[] { - return Array.from(this.agents.values()).map((agent) => { - const status = getAgentStatusFromState(agent.state); - const error = getAgentError(agent.state); - const runtime = this.getRuntime(agent); - const sessionId = runtime?.sessionId ?? null; - const currentModeId = runtime?.currentModeId ?? null; - const availableModes = buildAvailableModes( - agent.options.type, - runtime?.availableModes ?? null - ); - - return { - id: agent.id, - status, - createdAt: agent.createdAt, - lastActivityAt: agent.lastActivityAt, - type: agent.options.type, - sessionId, - error: error ?? null, - currentModeId, - availableModes, - title: agent.title, - cwd: agent.cwd, - }; - }); - } - - /** - * Subscribe to updates from an agent - */ - subscribeToUpdates( - agentId: string, - callback: AgentUpdateCallback - ): () => void { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - agent.subscribers.add(callback); - - // Return unsubscribe function - return () => { - agent.subscribers.delete(callback); - }; - } - - /** - * Get all updates for an agent - */ - getAgentUpdates(agentId: string): AgentUpdate[] { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - return [...agent.updates]; - } - - /** - * Lazily initialize an agent and return its info and existing history - * Used by clients to opt-in to agent startup on demand - */ - async initializeAgentAndGetHistory( - agentId: string - ): Promise<{ info: AgentInfo; updates: AgentUpdate[] }> { - await this.ensureInitialized(agentId); - - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const status = getAgentStatusFromState(agent.state); - const error = getAgentError(agent.state); - const runtime = this.getRuntime(agent); - const availableModes = buildAvailableModes( - agent.options.type, - runtime?.availableModes ?? null - ); - - const info: AgentInfo = { - id: agent.id, - status, - createdAt: agent.createdAt, - lastActivityAt: agent.lastActivityAt, - type: agent.options.type, - sessionId: runtime?.sessionId ?? null, - error: error ?? null, - currentModeId: runtime?.currentModeId ?? null, - availableModes, - title: agent.title, - cwd: agent.cwd, - }; - - return { - info, - updates: [...agent.updates], - }; - } - - /** - * Get the current session mode for an agent - */ - getCurrentMode(agentId: string): string | null { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - const runtime = this.getRuntime(agent); - return runtime?.currentModeId ?? null; - } - - /** - * Get available session modes for an agent - */ - getAvailableModes(agentId: string): SessionMode[] | null { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - const runtime = this.getRuntime(agent); - return buildAvailableModes( - agent.options.type, - runtime?.availableModes ?? null - ); - } - - /** - * Set the session mode for an agent - * Validates that the mode is available before setting - */ - async setSessionMode(agentId: string, modeId: string): Promise { - await this.ensureInitialized(agentId); - - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const runtime = this.getRuntime(agent); - if (!runtime) { - throw new Error(`Agent ${agentId} has no active session`); - } - - const availableModes = buildAvailableModes( - agent.options.type, - runtime.availableModes ?? null - ); - - if (availableModes && availableModes.length > 0) { - const mode = availableModes.find((m) => m.id === modeId); - if (!mode) { - const availableIds = availableModes.map((m) => m.id).join(", "); - throw new Error( - `Mode '${modeId}' not available for agent ${agentId}. Available modes: ${availableIds}` - ); - } - } - - try { - await runtime.connection.setSessionMode({ - sessionId: runtime.sessionId, - modeId, - }); - - const updatedRuntime: AgentRuntime = { - ...runtime, - currentModeId: modeId, - availableModes, - }; - - switch (agent.state.type) { - case "ready": - agent.state = { type: "ready", runtime: updatedRuntime }; - break; - case "processing": - agent.state = { type: "processing", runtime: updatedRuntime }; - break; - case "completed": - agent.state = { - type: "completed", - runtime: updatedRuntime, - stopReason: agent.state.stopReason, - }; - break; - case "initializing": - agent.state = { - type: "initializing", - persistedSessionId: agent.state.persistedSessionId, - initPromise: agent.state.initPromise, - runtime: updatedRuntime, - initStartedAt: agent.state.initStartedAt, - }; - break; - case "failed": - agent.state = { - type: "failed", - lastError: agent.state.lastError, - runtime: updatedRuntime, - }; - break; - default: - break; - } - - console.log(`[Agent ${agentId}] Session mode changed to: ${modeId}`); - this.notifySubscribers(agentId); - } catch (error) { - console.error(`[Agent ${agentId}] Failed to set mode:`, error); - throw error; - } - } - - /** - * Ensure an agent is initialized, starting runtime if needed - * Handles concurrent initialization requests by memoizing the promise - */ - private async ensureInitialized(agentId: string): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const state = agent.state; - - // Already initialized - if ( - state.type === "ready" || - state.type === "processing" || - state.type === "completed" - ) { - return; - } - - // Initialization already in progress - wait for the promise - if (state.type === "initializing") { - await state.initPromise; - return; - } - - // Failed - don't retry automatically - if (state.type === "failed") { - throw new Error( - `Agent ${agentId} is in failed state: ${state.lastError}` - ); - } - - // Killed - can't initialize - if (state.type === "killed") { - throw new Error(`Agent ${agentId} has been killed`); - } - - // Uninitialized - start initialization - if (state.type === "uninitialized") { - const persistedSessionId = state.persistedSessionId; - - // Create the init promise - const initPromise = (async () => { - try { - const definition = getAgentTypeDefinition(agent.options.type); - const hasPersistedSession = - definition.supportsSessionPersistence && - ((agent.options.type === "claude" && - agent.options.sessionId !== null) || - !!persistedSessionId); - const mode: "new" | "resume" = hasPersistedSession ? "resume" : "new"; - - console.log( - `[Agent ${agentId}] Starting lazy initialization (mode: ${mode})` - ); - await this.startRuntimeForAgent(agent, mode, persistedSessionId); - console.log(`[Agent ${agentId}] Lazy initialization completed`); - } catch (error) { - console.error( - `[Agent ${agentId}] Lazy initialization failed:`, - error - ); - throw error; - } - })(); - - // Transition to initializing state with the promise - agent.state = { - type: "initializing", - persistedSessionId, - initPromise, - initStartedAt: new Date(), - }; - - await initPromise; - } - } - - private getRuntime(agent: ManagedAgent): AgentRuntime | null { - if ( - agent.state.type === "ready" || - agent.state.type === "processing" || - agent.state.type === "completed" - ) { - return agent.state.runtime; - } - - if (agent.state.type === "initializing") { - return agent.state.runtime ?? null; - } - - if (agent.state.type === "failed" && agent.state.runtime) { - return agent.state.runtime; - } - - return null; - } - - private getPersistableSessionId(agent: ManagedAgent): string | null { - const { state } = agent; - - switch (state.type) { - case "ready": - case "processing": - case "completed": - return state.runtime.sessionId; - case "initializing": - return state.runtime?.sessionId ?? state.persistedSessionId ?? null; - case "failed": - return state.runtime?.sessionId ?? null; - case "uninitialized": - return state.persistedSessionId; - case "killed": - default: - return null; - } - } - - private serializeAgent(agent: ManagedAgent): PersistedAgent { - return { - id: agent.id, - title: agent.title || `Agent ${agent.id.slice(0, 8)}`, - sessionId: this.getPersistableSessionId(agent), - options: agent.options, - createdAt: agent.createdAt.toISOString(), - lastActivityAt: agent.lastActivityAt.toISOString(), - cwd: agent.cwd, - }; - } - - private async updateAgent( - agentId: string, - updateFn: (agent: ManagedAgent) => boolean | void | Promise, - options?: { sessionId: string | null } - ): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const shouldPersist = await updateFn(agent); - if (shouldPersist === false) { - return; - } - - const persistedAgent = this.serializeAgent(agent); - if (options) { - persistedAgent.sessionId = options.sessionId; - } - - await this.persistence.upsert(persistedAgent); - } - - /** - * Start runtime for an agent (spawn process, create connection, initialize session) - */ - private async startRuntimeForAgent( - agent: ManagedAgent, - mode: "new" | "resume", - resumeSessionId?: string | null - ): Promise { - const agentId = agent.id; - const cwd = agent.cwd; - - console.log(`[Agent ${agentId}] Starting runtime (mode: ${mode})`); - - try { - await access(cwd, constants.R_OK | constants.X_OK); - } catch { - const errorMessage = `Working directory does not exist or is not accessible: ${cwd}`; - console.error(`[Agent ${agentId}] ${errorMessage}`); - agent.state = { - type: "failed", - lastError: errorMessage, - }; - this.notifySubscribers(agentId); - throw new Error(errorMessage); - } - - const definition = getAgentTypeDefinition(agent.options.type); - const spawnArgs = [...definition.spawn.args]; - const spawnEnv: NodeJS.ProcessEnv = { ...process.env }; - - if (agent.options.type === "codex") { - await mkdir(this.codexSessionDir, { recursive: true }); - spawnArgs.push("--session-persist", this.codexSessionDir); - spawnEnv.CODEX_HOME = this.codexHomeDir; - spawnEnv.CODEX_SESSION_PERSIST = "1"; - spawnEnv.CODEX_SESSION_DIR = this.codexSessionDir; - } - - const agentProcess = spawn(definition.spawn.command, spawnArgs, { - stdio: ["pipe", "pipe", "pipe"], - cwd, - env: spawnEnv, - }); - - const input = Writable.toWeb(agentProcess.stdin); - const output = Readable.toWeb(agentProcess.stdout); - const stream = ndJsonStream(input, output); - - const client = new ACPClient( - agentId, - (id, update) => { - this.handleSessionNotification(id, update); - }, - (id, params) => { - return this.handlePermissionRequest(id, params); - }, - (id, updateFn, options) => this.updateAgent(id, updateFn, options) - ); - const connection = new ClientSideConnection(() => client, stream); - - const runtime: AgentRuntime = { - process: agentProcess, - connection, - sessionId: "", - currentModeId: null, - availableModes: null, - }; - - if (agent.state.type !== "initializing") { - throw new Error( - `Agent ${agentId} must be initializing before starting runtime` - ); - } - - agent.state = { - type: "initializing", - persistedSessionId: agent.state.persistedSessionId, - initPromise: agent.state.initPromise, - initStartedAt: agent.state.initStartedAt, - runtime, - }; - - agentProcess.on("error", (error) => { - this.handleAgentError(agentId, `Process error: ${error.message}`); - }); - - agentProcess.on("exit", (code, signal) => { - const currentAgent = this.agents.get(agentId); - if (!currentAgent) return; - - const status = getAgentStatusFromState(currentAgent.state); - if (status !== "completed" && status !== "killed") { - this.handleAgentError( - agentId, - `Process exited unexpectedly: code=${code}, signal=${signal}` - ); - } - }); - - agentProcess.stderr.on("data", (data) => { - console.error(`[Agent ${agentId}] stderr:`, data.toString()); - }); - - try { - await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, - }, - }, - }); - - const supportsResume = definition.supportsSessionPersistence; - const canResume = supportsResume && mode === "resume"; - - let sessionResponse: - | Awaited> - | Awaited>; - let effectiveSessionId: string; - - if (canResume) { - const sessionIdToResume = - (agent.options.type === "claude" && agent.options.sessionId) || - resumeSessionId || - null; - - if (!sessionIdToResume) { - throw new Error( - `Cannot resume agent ${agentId}: No session ID available` - ); - } - - console.log(`[Agent ${agentId}] Loading session: ${sessionIdToResume}`); - sessionResponse = await connection.loadSession({ - sessionId: sessionIdToResume, - cwd, - mcpServers: [], - }); - effectiveSessionId = sessionIdToResume; - } else { - if (mode === "resume" && !supportsResume) { - console.log( - `[Agent ${agentId}] Resume requested but unsupported for type '${definition.id}', starting new session` - ); - } else { - console.log(`[Agent ${agentId}] Creating new session`); - } - const newSessionResponse = await connection.newSession({ - cwd, - mcpServers: [], - }); - sessionResponse = newSessionResponse; - effectiveSessionId = newSessionResponse.sessionId; - } - - runtime.sessionId = effectiveSessionId; - - const modeSelection = resolveModeSelection({ - type: agent.options.type, - requestedModeId: sessionResponse.modes?.currentModeId ?? null, - runtimeModes: sessionResponse.modes?.availableModes ?? null, - }); - - runtime.availableModes = modeSelection.availableModes; - runtime.currentModeId = modeSelection.modeId; - - if ( - sessionResponse.modes?.currentModeId && - modeSelection.wasAdjusted - ) { - console.warn( - `[Agent ${agentId}] Mode '${sessionResponse.modes.currentModeId}' not available for type '${agent.options.type}', using '${modeSelection.modeId ?? "none"}' instead.` - ); - } - - const claudeSessionId = - sessionResponse._meta?.claudeSessionId !== undefined - ? (sessionResponse._meta?.claudeSessionId as string | undefined) - : undefined; - console.log( - `[Agent ${agentId}] Session ${ - canResume ? "loaded" : "created" - }: ACP=${effectiveSessionId}${ - agent.options.type === "claude" - ? `, Claude=${claudeSessionId || "N/A"}` - : "" - }` - ); - - await this.updateAgent(agentId, (managedAgent) => { - if ( - claudeSessionId && - managedAgent.options.type === "claude" && - managedAgent.options.sessionId !== claudeSessionId - ) { - managedAgent.options = { - ...managedAgent.options, - sessionId: claudeSessionId, - }; - } - - managedAgent.state = { - type: "ready", - runtime, - }; - return true; - }); - - this.notifySubscribers(agentId); - - if (agent.pendingSessionMode) { - const pendingMode = agent.pendingSessionMode; - agent.pendingSessionMode = null; - try { - await this.setSessionMode(agentId, pendingMode); - } catch (error) { - console.error( - `[Agent ${agentId}] Failed to apply pending session mode ${pendingMode}:`, - error - ); - } - } - - console.log( - `[Agent ${agentId}] Runtime started successfully with mode: ${runtime.currentModeId}` - ); - } catch (error) { - const errorMessage = `Runtime startup failed: ${ - error instanceof Error ? error.message : String(error) - }`; - console.error(`[Agent ${agentId}]`, errorMessage); - agent.state = { - type: "failed", - lastError: errorMessage, - }; - this.notifySubscribers(agentId); - try { - agentProcess.kill("SIGTERM"); - } catch { - // ignore kill errors - } - throw error; - } - } - - /** - * Handle session notifications from the ACP connection - * Augments agent message and thought chunks with stable message IDs - */ - private handleSessionNotification( - agentId: string, - update: SessionNotification - ): void { - const agent = this.agents.get(agentId); - if (!agent) return; - - // Update last activity timestamp - agent.lastActivityAt = new Date(); - - // Augment update with stable message IDs for deduplication - let enrichedUpdate: EnrichedSessionNotification; - - const updateType = update.update.sessionUpdate; - - // Agent message chunks - add stable message ID - if (updateType === "agent_message_chunk") { - if (!agent.currentAssistantMessageId) { - agent.currentAssistantMessageId = uuidv4(); - } - enrichedUpdate = { - ...update, - update: { - ...update.update, - messageId: agent.currentAssistantMessageId, - } as EnrichedSessionUpdate, - }; - } - // Agent thought chunks - add stable message ID - else if (updateType === "agent_thought_chunk") { - if (!agent.currentThoughtId) { - agent.currentThoughtId = uuidv4(); - } - enrichedUpdate = { - ...update, - update: { - ...update.update, - messageId: agent.currentThoughtId, - } as EnrichedSessionUpdate, - }; - } - // For other update types, use update as-is - else { - enrichedUpdate = update as EnrichedSessionNotification; - - // Reset message IDs on new turn (user message or tool call starts new turn) - if (updateType === "tool_call" || updateType === "user_message_chunk") { - agent.currentAssistantMessageId = null; - agent.currentThoughtId = null; - } - - // Handle mode changes - if (update.update.sessionUpdate === "current_mode_update") { - const newModeId = update.update.currentModeId; - const runtime = this.getRuntime(agent); - - if (runtime && runtime.currentModeId !== newModeId) { - console.log( - `[Agent ${agentId}] Mode changed: ${runtime.currentModeId} -> ${newModeId}` - ); - runtime.currentModeId = newModeId; - } - } - } - - // Create agent update with enriched notification wrapped in discriminated union - const agentUpdate: AgentUpdate = { - agentId, - timestamp: new Date(), - notification: { - type: "session", - notification: enrichedUpdate, - }, - }; - - // Store the update in history - agent.updates.push(agentUpdate); - - // Notify all subscribers - for (const subscriber of agent.subscribers) { - try { - subscriber(agentUpdate); - } catch (error) { - console.error(`[Agent ${agentId}] Subscriber error:`, error); - } - } - } - - /** - * Handle agent errors - */ - private handleAgentError(agentId: string, errorMessage: string): void { - const agent = this.agents.get(agentId); - if (!agent) return; - - console.error(`[Agent ${agentId}] Error:`, errorMessage); - - // Preserve runtime if it exists - const runtime = - agent.state.type !== "uninitialized" && - agent.state.type !== "killed" && - agent.state.type !== "failed" - ? agent.state.runtime - : undefined; - - agent.state = { - type: "failed", - lastError: errorMessage, - runtime, - }; - this.notifySubscribers(agentId); - } - - /** - * Notify subscribers that agent status has changed - */ - private notifySubscribers(agentId: string): void { - const agent = this.agents.get(agentId); - if (!agent) return; - - const status = getAgentStatusFromState(agent.state); - const error = getAgentError(agent.state); - - const update: AgentUpdate = { - agentId, - timestamp: new Date(), - notification: { - type: "status", - status, - error, - }, - }; - - for (const subscriber of agent.subscribers) { - try { - subscriber(update); - } catch (error) { - console.error(`[Agent ${agentId}] Subscriber error:`, error); - } - } - } - - /** - * Set the title for an agent - */ - async setAgentTitle(agentId: string, title: string): Promise { - await this.updateAgent(agentId, (managedAgent) => { - managedAgent.title = title; - return true; - }); - - console.log(`[Agent ${agentId}] Title set to: "${title}"`); - } - - /** - * Get the title for an agent - */ - getAgentTitle(agentId: string): string | null { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - return agent.title ?? null; - } - - /** - * Get Claude session ID for an agent - */ - getClaudeSessionId(agentId: string): string | null { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - if (agent.options.type === "claude") { - return agent.options.sessionId; - } - return null; - } - - /** - * Mark that title generation has been triggered for this agent - */ - markTitleGenerationTriggered(agentId: string): void { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - agent.titleGenerationTriggered = true; - } - - /** - * Check if title generation has been triggered for this agent - */ - isTitleGenerationTriggered(agentId: string): boolean { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - return agent.titleGenerationTriggered; - } - - /** - * Handle permission request from ACP - * Creates a pending permission and emits it via session notifications - */ - private handlePermissionRequest( - agentId: string, - params: RequestPermissionRequest - ): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - // Generate unique request ID - const requestId = uuidv4(); - - console.log( - `[Agent ${agentId}] Creating pending permission request: ${requestId}` - ); - - // Create a promise that will be resolved when user responds - return new Promise((resolve, reject) => { - // Store the pending permission - const pendingPermission: PendingPermission = { - requestId, - sessionId: params.sessionId, - params, - resolve, - reject, - }; - - agent.pendingPermissions.set(requestId, pendingPermission); - - // Emit permission request via discriminated union - // This will be picked up by subscribers (Session) and forwarded to UI - const agentUpdate: AgentUpdate = { - agentId, - timestamp: new Date(), - notification: { - type: "permission", - requestId, // Pass the requestId that we stored in pendingPermissions - request: params, - }, - }; - - // Store the update in history - agent.updates.push(agentUpdate); - - // Notify all subscribers - for (const subscriber of agent.subscribers) { - try { - subscriber(agentUpdate); - } catch (error) { - console.error(`[Agent ${agentId}] Subscriber error:`, error); - } - } - - console.log( - `[Agent ${agentId}] Permission request emitted: ${requestId}` - ); - }); - } - - /** - * Respond to a pending permission request - * Called when user makes a choice in the UI - */ - respondToPermission( - agentId: string, - requestId: string, - optionId: string - ): void { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const pendingPermission = agent.pendingPermissions.get(requestId); - if (!pendingPermission) { - throw new Error( - `Permission request ${requestId} not found for agent ${agentId}` - ); - } - - console.log( - `[Agent ${agentId}] Resolving permission ${requestId} with option: ${optionId}` - ); - - // Resolve the promise with the user's choice - pendingPermission.resolve({ - outcome: { - outcome: "selected" as const, - optionId, - }, - }); - - // Remove from pending - agent.pendingPermissions.delete(requestId); - - // Emit permission_resolved notification so UI can update - const agentUpdate: AgentUpdate = { - agentId, - timestamp: new Date(), - notification: { - type: "permission_resolved", - requestId, - agentId, - optionId, - }, - }; - - // Store the update in history - agent.updates.push(agentUpdate); - - // Notify all subscribers (including Session which will forward to WebSocket) - for (const subscriber of agent.subscribers) { - try { - subscriber(agentUpdate); - } catch (error) { - console.error(`[Agent ${agentId}] Subscriber error:`, error); - } - } - - console.log( - `[Agent ${agentId}] Permission resolved notification emitted: ${requestId}` - ); - } - - /** - * Get all pending permission requests across all agents - * Used by orchestrator to see what permissions are waiting - */ - getPendingPermissions(): Array<{ - agentId: string; - requestId: string; - sessionId: string; - toolCall: any; - options: Array<{ - kind: string; - name: string; - optionId: string; - }>; - }> { - const allPermissions: Array<{ - agentId: string; - requestId: string; - sessionId: string; - toolCall: any; - options: Array<{ - kind: string; - name: string; - optionId: string; - }>; - }> = []; - - for (const [agentId, agent] of this.agents) { - for (const [requestId, permission] of agent.pendingPermissions) { - allPermissions.push({ - agentId, - requestId, - sessionId: permission.sessionId, - toolCall: permission.params.toolCall, - options: permission.params.options, - }); - } - } - - return allPermissions; - } - - /** - * Wait for the next permission request from an agent, or until agent finishes - * Resolves immediately if a permission is already pending - * Returns permission data if agent requests permission, or null if agent finishes without requesting - */ - async waitForPermissionRequest( - agentId: string, - options?: { signal?: AbortSignal } - ): Promise<{ - agentId: string; - requestId: string; - sessionId: string; - toolCall: any; - options: Array<{ - kind: string; - name: string; - optionId: string; - }>; - } | null> { - const agent = this.agents.get(agentId); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); - } - - const abortSignal = options?.signal ?? null; - - const createAbortError = () => { - const error = new Error( - `Wait for permission aborted for agent ${agentId}` - ); - error.name = "AbortError"; - return error; - }; - - const formatPending = ( - requestId: string, - permission: PendingPermission - ) => ({ - agentId, - requestId, - sessionId: permission.sessionId, - toolCall: permission.params.toolCall, - options: permission.params.options, - }); - - // Return immediately if a permission is already pending - const existingPermission = agent.pendingPermissions.entries().next(); - if (!existingPermission.done) { - const [requestId, permission] = existingPermission.value; - return formatPending(requestId, permission); - } - - if (abortSignal?.aborted) { - throw createAbortError(); - } - - const initialStatus = getAgentStatusFromState(agent.state); - - // If agent is not processing or initializing, return null (no permission requested) - if (initialStatus !== "processing" && initialStatus !== "initializing") { - return null; - } - - return new Promise((resolve, reject) => { - let settled = false; - let unsubscribe: (() => void) | null = null; - - const cleanup = () => { - if (unsubscribe) { - unsubscribe(); - unsubscribe = null; - } - abortSignal?.removeEventListener("abort", onAbort); - }; - - const resolvePending = (value: ReturnType) => { - if (settled) { - return; - } - settled = true; - cleanup(); - resolve(value); - }; - - const rejectWithError = (error: Error) => { - if (settled) { - return; - } - settled = true; - cleanup(); - reject(error); - }; - - const onAbort = () => { - rejectWithError(createAbortError()); - }; - - if (abortSignal) { - abortSignal.addEventListener("abort", onAbort, { once: true }); - } - - try { - unsubscribe = this.subscribeToUpdates(agentId, (update) => { - if (settled) { - return; - } - - const notification = update.notification; - - if (notification.type === "permission") { - const { requestId } = notification; - const pendingPermission = - agent.pendingPermissions.get(requestId) ?? null; - - if (!pendingPermission) { - rejectWithError( - new Error( - `Permission ${requestId} no longer pending for agent ${agentId}` - ) - ); - return; - } - - resolvePending(formatPending(requestId, pendingPermission)); - return; - } - - if (notification.type === "status") { - const status = notification.status; - - // If agent transitions out of processing without requesting permission, return null - if (status !== "processing") { - settled = true; - cleanup(); - resolve(null); - return; - } - } - }); - } catch (error) { - rejectWithError( - error instanceof Error ? error : new Error(String(error)) - ); - return; - } - - if (abortSignal?.aborted) { - onAbort(); - } - }); - } - - /** - * Gracefully shutdown all agents - * Waits for processing agents to finish, then terminates all processes - */ - async shutdown(): Promise { - console.log("[AgentManager] Starting graceful shutdown..."); - - // Find agents currently processing work - const processingAgents = Array.from(this.agents.values()).filter( - (agent) => agent.state.type === "processing" - ); - - if (processingAgents.length > 0) { - console.log( - `[AgentManager] Waiting for ${processingAgents.length} agent(s) to finish processing...` - ); - - // Wait for all processing agents to finish - await Promise.all( - processingAgents.map((agent) => this.waitForAgentToFinish(agent.id)) - ); - } - - // Persist state and terminate all agents - console.log("[AgentManager] Persisting agent state and terminating..."); - - const shutdownPromises = Array.from(this.agents.values()).map( - async (agent) => { - try { - const runtime = this.getRuntime(agent); - - // Persist current state if agent has a session - if (runtime) { - await this.updateAgent(agent.id, () => undefined); - console.log(`[Agent ${agent.id}] State persisted`); - - // Send graceful termination signal - runtime.process.kill("SIGTERM"); - } - } catch (error) { - console.error(`[Agent ${agent.id}] Shutdown error:`, error); - } - } - ); - - await Promise.all(shutdownPromises); - - // Give processes a moment to exit cleanly - await new Promise((resolve) => setTimeout(resolve, 1000)); - - console.log("[AgentManager] Graceful shutdown complete"); - } - - /** - * Wait for a specific agent to finish processing - */ - private waitForAgentToFinish(agentId: string): Promise { - return new Promise((resolve) => { - const agent = this.agents.get(agentId); - if (!agent || agent.state.type !== "processing") { - resolve(); - return; - } - - console.log(`[Agent ${agentId}] Waiting for work to complete...`); - - // Subscribe to status changes - const unsubscribe = this.subscribeToUpdates(agentId, (update) => { - if (update.notification.type === "status") { - const status = update.notification.status; - if (status !== "processing") { - console.log( - `[Agent ${agentId}] Finished processing (status: ${status})` - ); - unsubscribe(); - resolve(); - } - } - }); - }); - } -} diff --git a/packages/server/src/server/acp/agent-persistence.ts b/packages/server/src/server/acp/agent-persistence.ts deleted file mode 100644 index 4bcba02f6..000000000 --- a/packages/server/src/server/acp/agent-persistence.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { promises as fs } from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; -import { z } from "zod"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Zod schema for agent options (discriminated union) -export const AgentOptionsSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("claude"), - sessionId: z.string().nullable(), // Claude's internal session ID (null until first prompt) - }), - z.object({ - type: z.literal("codex"), - }), -]); - -export type AgentOptions = z.infer; - -// Zod schema for persisted agent -export const PersistedAgentSchema = z.object({ - id: z.string(), - title: z.string(), - sessionId: z.string().nullable(), // ACP protocol session ID (null for uninitialized agents) - options: AgentOptionsSchema, // Required field with discriminated union - createdAt: z.string(), - lastActivityAt: z.string().optional(), // Migration: optional for backward compatibility - cwd: z.string(), -}); - -export type PersistedAgent = z.infer; - -export class AgentPersistence { - private persistencePath: string; - - constructor() { - // Store agents.json in the project root - this.persistencePath = path.join(__dirname, "../../../agents.json"); - } - - /** - * Load all persisted agents with Zod validation - */ - async load(): Promise { - try { - const data = await fs.readFile(this.persistencePath, "utf-8"); - const parsed = JSON.parse(data); - - // Validate each agent with Zod schema - const agents: PersistedAgent[] = []; - for (const agent of parsed) { - try { - const validated = PersistedAgentSchema.parse(agent); - - // Migration: if lastActivityAt is missing, default to createdAt - if (!validated.lastActivityAt) { - validated.lastActivityAt = validated.createdAt; - } - - if ( - validated.options.type === "claude" && - validated.options.sessionId !== null - ) { - validated.sessionId = validated.options.sessionId; - } - - agents.push(validated); - } catch (zodError) { - console.error( - `[AgentPersistence] Invalid agent data for ${agent.id}:`, - zodError - ); - // Skip invalid agents - } - } - - return agents; - } catch (error) { - // File doesn't exist or is invalid, return empty array - console.log( - "[AgentPersistence] No existing agents found or file is invalid" - ); - return []; - } - } - - /** - * Save agents to disk - */ - async save(agents: PersistedAgent[]): Promise { - try { - await fs.writeFile( - this.persistencePath, - JSON.stringify(agents, null, 2), - "utf-8" - ); - console.log(`[AgentPersistence] Saved ${agents.length} agents to disk`); - } catch (error) { - console.error("[AgentPersistence] Failed to save agents:", error); - throw error; - } - } - - /** - * Add or update an agent - */ - async upsert(agent: PersistedAgent): Promise { - const agents = await this.load(); - const existingIndex = agents.findIndex((a) => a.id === agent.id); - - if (existingIndex >= 0) { - agents[existingIndex] = agent; - console.log(`[AgentPersistence] Updated agent ${agent.id}`); - } else { - agents.push(agent); - console.log(`[AgentPersistence] Added new agent ${agent.id}`); - } - - await this.save(agents); - } - - /** - * Remove an agent - */ - async remove(agentId: string): Promise { - const agents = await this.load(); - const filtered = agents.filter((a) => a.id !== agentId); - - if (filtered.length < agents.length) { - await this.save(filtered); - console.log(`[AgentPersistence] Removed agent ${agentId}`); - } - } -} diff --git a/packages/server/src/server/acp/agent-types.ts b/packages/server/src/server/acp/agent-types.ts deleted file mode 100644 index acca5aaa7..000000000 --- a/packages/server/src/server/acp/agent-types.ts +++ /dev/null @@ -1,95 +0,0 @@ -export type AgentType = "claude" | "codex"; - -export interface AgentModeDefinition { - id: string; - name: string; - description?: string | null; -} - -export interface AgentTypeDefinition { - id: AgentType; - label: string; - description?: string; - spawn: { - command: string; - args: string[]; - }; - supportsSessionPersistence: boolean; - availableModes: AgentModeDefinition[]; - defaultModeId: string; -} - -const CLAUDE_MODES: AgentModeDefinition[] = [ - { - id: "default", - name: "Default", - description: "Default Claude Code mode with standard permissions", - }, - { - id: "plan", - name: "Plan", - description: "Plan and design before implementing", - }, - { - id: "bypassPermissions", - name: "Bypass Permissions", - description: "Skip permission prompts for faster execution", - }, -]; - -const CODEX_MODES: AgentModeDefinition[] = [ - { - id: "read-only", - name: "Read Only", - description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network", - }, - { - id: "auto", - name: "Auto", - description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network", - }, - { - id: "full-access", - name: "Full Access", - description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution", - }, -]; - -const agentTypeDefinitions: Record = { - claude: { - id: "claude", - label: "Claude Code", - description: "Full Claude Code agent with session persistence", - spawn: { - command: "npx", - args: ["@boudra/claude-code-acp"], - }, - supportsSessionPersistence: true, - availableModes: CLAUDE_MODES, - defaultModeId: "plan", - }, - codex: { - id: "codex", - label: "Codex", - description: "Zed Codex ACP integration with session persistence", - spawn: { - command: "npx", - args: ["@zed-industries/codex-acp"], - }, - supportsSessionPersistence: true, - availableModes: CODEX_MODES, - defaultModeId: "auto", - }, -} as const; - -export function getAgentTypeDefinition(type: AgentType): AgentTypeDefinition { - return agentTypeDefinitions[type]; -} - -export function getAgentModes(type: AgentType): AgentModeDefinition[] { - return agentTypeDefinitions[type].availableModes; -} - -export function listAgentTypeDefinitions(): AgentTypeDefinition[] { - return Object.values(agentTypeDefinitions); -} diff --git a/packages/server/src/server/acp/index.ts b/packages/server/src/server/acp/index.ts deleted file mode 100644 index 2d204a98a..000000000 --- a/packages/server/src/server/acp/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * ACP (Agent Client Protocol) Integration - Phase 1 - * - * Provides infrastructure for managing Claude Code agents. - * This is standalone and does not integrate with Session/WebSocket yet. - */ - -export { AgentManager } from "./agent-manager.js"; -export type { - AgentStatus, - AgentInfo, - AgentUpdate, - CreateAgentOptions, - AgentUpdateCallback, -} from "./types.js"; diff --git a/packages/server/src/server/acp/mcp-server.ts b/packages/server/src/server/acp/mcp-server.ts deleted file mode 100644 index 71a364759..000000000 --- a/packages/server/src/server/acp/mcp-server.ts +++ /dev/null @@ -1,621 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { homedir } from "os"; -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; -} - -/** - * Expand tilde (~) to home directory and resolve path - */ -function expandPath(path: string): string { - if (path.startsWith("~/") || path === "~") { - return resolve(homedir(), path.slice(2)); - } - return resolve(path); -} - -/** - * Extract the update type from an AgentNotification - */ -function getUpdateType(notification: AgentNotification): string { - if (notification.type === 'session') { - return notification.notification.update.sessionUpdate; - } - return notification.type; -} - -/** - * Serialize AgentInfo for MCP output (convert Date to string for JSON compatibility) - */ -function serializeAgentInfo(info: any): any { - return { - ...info, - createdAt: info.createdAt.toISOString(), - lastActivityAt: info.lastActivityAt.toISOString(), - }; -} - -/** - * Create and configure the Agent MCP Server - * Exposes Claude Code agent management tools to the LLM - */ -export async function createAgentMcpServer( - options: AgentMcpServerOptions -): Promise { - const { agentManager } = options; - - const agentTypeDefinitions = listAgentTypeDefinitions(); - if (agentTypeDefinitions.length === 0) { - throw new Error("[Agent MCP] No agent types configured"); - } - - const agentTypeIds = agentTypeDefinitions.map( - (definition) => definition.id - ) as [AgentType, ...AgentType[]]; - - const AgentTypeEnum = z.enum(agentTypeIds); - - const server = new McpServer({ - name: "agent-mcp", - version: "1.0.0", - }); - - // Tool: create_coding_agent - server.registerTool( - "create_coding_agent", - { - title: "Create Coding Agent", - description: - "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( - "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() - .describe( - "Optional git worktree branch name for isolated development. Must be a valid slug: lowercase letters, numbers, and hyphens only (e.g., 'feature-auth', 'fix-bug-123'). Creates a new git worktree with this branch name and runs the agent in the worktree directory. Only works if cwd is inside a git repository." - ), - }, - outputSchema: { - agentId: z.string().describe("Unique identifier for the created agent"), - type: AgentTypeEnum.describe("Agent implementation that was created"), - status: z - .string() - .describe( - "Current agent status: 'initializing', 'ready', 'processing', etc." - ), - cwd: z.string().describe("The resolved absolute working directory the agent is running in (worktree path if worktreeName was provided)"), - currentModeId: z.string().nullable().describe("The agent's current session mode"), - availableModes: z.array(z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable().optional(), - })).nullable().describe("Available session modes for this agent"), - }, - }, - async ({ cwd, agentType, initialPrompt, initialMode, worktreeName }) => { - // Expand and resolve the working directory - let resolvedCwd = expandPath(cwd); - - // Handle worktree creation if requested - if (worktreeName) { - const { createWorktree } = await import("../../utils/worktree.js"); - - const worktreeConfig = await createWorktree({ - branchName: worktreeName, - cwd: resolvedCwd, - }); - - resolvedCwd = worktreeConfig.worktreePath; - } - - const resolvedType: AgentType = agentType ?? "claude"; - - const agentId = await agentManager.createAgent({ - cwd: resolvedCwd, - type: resolvedType, - initialPrompt, - initialMode, - }); - - const status = agentManager.getAgentStatus(agentId); - const currentModeId = agentManager.getCurrentMode(agentId); - const availableModes = agentManager.getAvailableModes(agentId); - - const result = { - agentId, - type: resolvedType, - status, - cwd: resolvedCwd, - currentModeId: currentModeId ?? null, - availableModes: availableModes ?? null, - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // 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. 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 - .string() - .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. 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"), - status: z - .string() - .describe("Agent status immediately after enqueuing the prompt (usually 'processing')"), - }, - }, - async ({ agentId, prompt, sessionMode }) => { - await agentManager.sendPrompt(agentId, prompt, { - sessionMode, - }); - - const result = { - success: true, - status: agentManager.getAgentStatus(agentId), - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: get_agent_status - server.registerTool( - "get_agent_status", - { - title: "Get Agent Status", - description: - "Get the current status and information about a specific agent. Returns status, creation time, session ID, and any error messages.", - inputSchema: { - agentId: z.string().describe("Agent ID to query"), - }, - outputSchema: { - status: z.string().describe("Current agent status"), - info: z - .object({ - id: z.string(), - status: z.string(), - createdAt: z.string(), - lastActivityAt: z.string(), - 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().nullable().optional(), - })).nullable(), - }) - .describe("Detailed agent information"), - }, - }, - async ({ agentId }) => { - const status = agentManager.getAgentStatus(agentId); - const agents = agentManager.listAgents(); - const agentInfo = agents.find((a) => a.id === agentId); - - if (!agentInfo) { - throw new Error(`Agent ${agentId} not found`); - } - - const result = { - status, - info: serializeAgentInfo(agentInfo), - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: list_agents - server.registerTool( - "list_agents", - { - title: "List Agents", - description: - "List all active agents managed by this session. Shows agent IDs, statuses, creation times, and any errors. Useful for monitoring multiple concurrent agents.", - inputSchema: {}, - outputSchema: { - agents: z.array( - z.object({ - id: z.string(), - status: z.string(), - createdAt: z.string(), - lastActivityAt: z.string(), - 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().nullable().optional(), - })).nullable(), - }) - ), - }, - }, - async () => { - const agents = agentManager.listAgents(); - - const result = { - agents: agents.map(serializeAgentInfo), - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: cancel_agent - server.registerTool( - "cancel_agent", - { - title: "Cancel Agent", - description: - "Cancel the current task of a running agent. The agent remains alive and returns to 'ready' state, allowing you to send new prompts. Use this when you want to interrupt an agent's work without killing it.", - inputSchema: { - agentId: z.string().describe("Agent ID to cancel"), - }, - outputSchema: { - success: z.boolean().describe("Whether cancellation succeeded"), - }, - }, - async ({ agentId }) => { - await agentManager.cancelAgent(agentId); - - const result = { - success: true, - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: kill_agent - server.registerTool( - "kill_agent", - { - title: "Kill Agent", - description: - "Terminate an agent completely. This kills the agent's process and removes it from the manager. The agent cannot be used after this operation. Use this when you're done with an agent or if it's in a failed state.", - inputSchema: { - agentId: z.string().describe("Agent ID to kill"), - }, - outputSchema: { - success: z.boolean().describe("Whether the agent was killed successfully"), - }, - }, - async ({ agentId }) => { - await agentManager.killAgent(agentId); - - const result = { - success: true, - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: get_agent_activity - server.registerTool( - "get_agent_activity", - { - title: "Get Agent Activity", - description: - "Get the agent's activity in a human-readable, token-efficient format. Consolidates message chunks, formats tool calls, and structures plans. By default returns curated text, but you can request raw updates with format='raw'.", - inputSchema: { - agentId: z.string().describe("Agent ID to query"), - format: z - .enum(["curated", "raw"]) - .optional() - .default("curated") - .describe( - "Output format: 'curated' (default) for clean human-readable text, 'raw' for detailed JSON updates" - ), - limit: z - .number() - .optional() - .describe( - "Maximum number of updates to include (most recent first). Only applies to 'raw' format. Omit to get all updates." - ), - }, - outputSchema: { - agentId: z.string(), - format: z.enum(["curated", "raw"]), - updateCount: z.number().describe("Total number of updates available"), - currentModeId: z.string().nullable().describe("Current session mode of the agent"), - content: z.string().describe("Formatted activity content (if curated) or empty string (if raw)"), - updates: z.array( - z.object({ - timestamp: z.string(), - type: z.string(), - data: z.any(), - }) - ).nullable().describe("Raw updates array (if raw format) or null (if curated)"), - }, - }, - async ({ agentId, format = "curated", limit }) => { - const updates = await agentManager.getAgentUpdates(agentId); - const currentModeId = agentManager.getCurrentMode(agentId); - - if (format === "curated") { - // Return curated, human-readable format - const curatedText = curateAgentActivity(updates); - - return { - content: [], - structuredContent: { - agentId, - format: "curated" as const, - updateCount: updates.length, - currentModeId, - content: curatedText, - updates: null, - }, - }; - } else { - // Return raw format (old behavior) - const selectedUpdates = limit - ? updates.slice(-limit).reverse() - : updates; - - return { - content: [], - structuredContent: { - agentId, - format: "raw" as const, - updateCount: updates.length, - currentModeId, - content: "", - updates: selectedUpdates.map((update) => ({ - timestamp: update.timestamp.toISOString(), - type: getUpdateType(update.notification), - data: update.notification, - })), - }, - }; - } - } - ); - - // Tool: set_agent_mode - server.registerTool( - "set_agent_mode", - { - title: "Set Agent Session Mode", - description: - "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. 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"), - previousMode: z.string().nullable().describe("The previous session mode"), - newMode: z.string().describe("The new session mode"), - }, - }, - async ({ agentId, modeId }) => { - const previousMode = agentManager.getCurrentMode(agentId); - await agentManager.setSessionMode(agentId, modeId); - - const result = { - success: true, - previousMode: previousMode ?? null, - newMode: modeId, - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: list_pending_permissions - server.registerTool( - "list_pending_permissions", - { - title: "List Pending Permission Requests", - description: - "Get all pending permission requests from agents. When an agent in plan mode calls ExitPlanMode, or when an agent needs permission for file operations or commands, it creates a permission request. This tool shows all requests waiting for approval across all agents. Use respond_to_permission to approve or reject them.", - inputSchema: {}, - outputSchema: { - permissions: z.array( - z.object({ - agentId: z.string().describe("Agent that requested permission"), - requestId: z.string().describe("Unique identifier for this permission request"), - sessionId: z.string().describe("Agent's session ID"), - toolCall: z.any().describe("The tool call that triggered the permission request (contains details like plan, file_path, command, etc.)"), - options: z.array( - z.object({ - kind: z.string().describe("Option kind: 'allow_always', 'allow_once', 'reject_once'"), - name: z.string().describe("Human-readable option name"), - optionId: z.string().describe("Option ID to use when responding"), - }) - ).describe("Available response options"), - }) - ).describe("List of all pending permission requests"), - }, - }, - async () => { - const permissions = agentManager.getPendingPermissions(); - - const result = { - permissions, - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - // Tool: respond_to_permission - server.registerTool( - "respond_to_permission", - { - title: "Respond to Permission Request", - description: - "Approve or reject a pending permission request from an agent. When an agent requests permission (e.g., to exit plan mode, create a file, or run a command), you can use this tool to respond with your decision. The agent will proceed based on your response.", - inputSchema: { - agentId: z.string().describe("Agent ID (from list_pending_permissions)"), - requestId: z.string().describe("Permission request ID (from list_pending_permissions)"), - optionId: z.string().describe("Option ID to select (e.g., 'allow', 'reject', 'plan'). Get available options from list_pending_permissions."), - }, - outputSchema: { - success: z.boolean().describe("Whether the response was successfully sent to the agent"), - }, - }, - async ({ agentId, requestId, optionId }) => { - agentManager.respondToPermission(agentId, requestId, optionId); - - const result = { - success: true, - }; - - return { - content: [], - structuredContent: result, - }; - } - ); - - return server; -} diff --git a/packages/server/src/server/acp/test-exports.ts b/packages/server/src/server/acp/test-exports.ts deleted file mode 100644 index 4b2604b1a..000000000 --- a/packages/server/src/server/acp/test-exports.ts +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env tsx - -import { AgentManager } from "./index.js"; -import type { AgentStatus } from "./index.js"; - -console.log("✓ AgentManager imported successfully"); -console.log( - "✓ Methods:", - Object.getOwnPropertyNames(AgentManager.prototype) - .filter((m) => m !== "constructor") - .join(", ") -); - -const testStatus: AgentStatus = "ready"; -console.log("✓ AgentStatus type works:", testStatus); - -console.log("\n✓ All exports verified!"); diff --git a/packages/server/src/server/acp/types.ts b/packages/server/src/server/acp/types.ts deleted file mode 100644 index 1def2fb37..000000000 --- a/packages/server/src/server/acp/types.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { - SessionNotification, - RequestPermissionRequest, - ClientSideConnection, -} from "@agentclientprotocol/sdk"; -import type { ChildProcess } from "child_process"; -import type { AgentModeDefinition, AgentType } from "./agent-types.js"; - -/** - * Extended update types with messageId for proper deduplication - * messageId is optional since some sources may not provide it - */ -type UserMessageChunkWithId = Extract< - SessionNotification["update"], - { sessionUpdate: "user_message_chunk" } -> & { messageId?: string }; -type AgentMessageChunkWithId = Extract< - SessionNotification["update"], - { sessionUpdate: "agent_message_chunk" } -> & { messageId?: string }; -type AgentThoughtChunkWithId = Extract< - SessionNotification["update"], - { sessionUpdate: "agent_thought_chunk" } -> & { messageId?: string }; - -export type EnrichedSessionUpdate = - | UserMessageChunkWithId - | AgentMessageChunkWithId - | AgentThoughtChunkWithId - | Exclude< - SessionNotification["update"], - { - sessionUpdate: - | "user_message_chunk" - | "agent_message_chunk" - | "agent_thought_chunk"; - } - >; - -export interface EnrichedSessionNotification - extends Omit { - update: EnrichedSessionUpdate; -} - -/** - * Discriminated union for all notification types in the agent update stream - */ -export type AgentNotification = - | { type: "session"; notification: EnrichedSessionNotification } - | { type: "permission"; requestId: string; request: RequestPermissionRequest } - | { - type: "permission_resolved"; - requestId: string; - agentId: string; - optionId: string; - } - | { type: "status"; status: AgentStatus; error?: string }; - -/** - * Status of an agent - */ -export type AgentStatus = - | "uninitialized" - | "initializing" - | "ready" - | "processing" - | "completed" - | "failed" - | "killed"; - -/** - * Session mode definition from ACP. - * Alias to the shared agent mode definition used across the app. - */ -export type SessionMode = AgentModeDefinition; - -/** - * Runtime state for an initialized agent - */ -export interface AgentRuntime { - process: ChildProcess; - connection: ClientSideConnection; - sessionId: string; - currentModeId: string | null; - availableModes: SessionMode[] | null; -} - -/** - * Discriminated union for agent state - */ -export type ManagedAgentState = - | { - type: "uninitialized"; - persistedSessionId: string | null; - lastError?: string; - } - | { - type: "initializing"; - persistedSessionId: string | null; - initPromise: Promise; - initStartedAt: Date; - runtime?: AgentRuntime; - } - | { type: "ready"; runtime: AgentRuntime } - | { type: "processing"; runtime: AgentRuntime } - | { type: "completed"; runtime: AgentRuntime; stopReason?: string } - | { type: "failed"; lastError: string; runtime?: AgentRuntime } - | { type: "killed" }; - -/** - * Information about an agent - */ -export interface AgentInfo { - id: string; - status: AgentStatus; - createdAt: Date; - lastActivityAt: Date; - type: AgentType; - sessionId: string | null; - error: string | null; - currentModeId: string | null; - availableModes: SessionMode[] | null; - title: string | null; - cwd: string; -} - -/** - * Update from an agent session - * Wraps all notification types with additional metadata - */ -export interface AgentUpdate { - agentId: string; - timestamp: Date; - notification: AgentNotification; -} - -/** - * Options for creating an agent - */ -export interface CreateAgentOptions { - cwd: string; - type: AgentType; - initialPrompt?: string; - initialMode?: string; -} - -/** - * Callback for agent updates - */ -export type AgentUpdateCallback = (update: AgentUpdate) => void; diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts new file mode 100644 index 000000000..7a160e9bf --- /dev/null +++ b/packages/server/src/server/agent/activity-curator.ts @@ -0,0 +1,94 @@ +import type { AgentTimelineItem } from "./agent-sdk-types.js"; + +const DEFAULT_MAX_ITEMS = 40; + +function appendText(buffer: string, text: string): string { + const normalized = text.trim(); + if (!normalized) { + return buffer; + } + if (!buffer) { + return normalized; + } + return `${buffer}\n${normalized}`; +} + +function flushBuffers(lines: string[], buffers: { message: string; thought: string }) { + if (buffers.message.trim()) { + lines.push(buffers.message.trim()); + } + if (buffers.thought.trim()) { + lines.push(`[Thought] ${buffers.thought.trim()}`); + } + buffers.message = ""; + buffers.thought = ""; +} + +/** + * Convert normalized agent timeline items into a concise text summary. + */ +export function curateAgentActivity( + timeline: AgentTimelineItem[], + options?: { maxItems?: number } +): string { + if (timeline.length === 0) { + return "No activity to display."; + } + + const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS; + const recentItems = + maxItems > 0 && timeline.length > maxItems + ? timeline.slice(-maxItems) + : timeline; + + const lines: string[] = []; + const buffers = { message: "", thought: "" }; + + for (const item of recentItems) { + switch (item.type) { + case "assistant_message": + buffers.message = appendText(buffers.message, item.text); + break; + case "reasoning": + buffers.thought = appendText(buffers.thought, item.text); + break; + case "command": + flushBuffers(lines, buffers); + lines.push(`[Command: ${item.command}] ${item.status}`); + break; + case "file_change": + flushBuffers(lines, buffers); + if (item.files.length > 0) { + lines.push("[File Changes]"); + for (const file of item.files) { + lines.push(`- (${file.kind}) ${file.path}`); + } + } + break; + case "mcp_tool": + flushBuffers(lines, buffers); + lines.push(`[MCP ${item.server}.${item.tool}] ${item.status}`); + break; + case "web_search": + flushBuffers(lines, buffers); + lines.push(`[Web Search] ${item.query}`); + break; + case "todo": + flushBuffers(lines, buffers); + lines.push("[Plan]"); + for (const entry of item.items) { + const checkbox = entry.completed ? "[x]" : "[ ]"; + lines.push(`- ${checkbox} ${entry.text}`); + } + break; + case "error": + flushBuffers(lines, buffers); + lines.push(`[Error] ${item.message}`); + break; + } + } + + flushBuffers(lines, buffers); + + return lines.length > 0 ? lines.join("\n\n") : "No activity to display."; +} diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 1b032a01a..a869f6dd7 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -285,6 +285,23 @@ export class AgentManager { this.emitState(agent); } + async cancelAgentRun(agentId: string): Promise { + const agent = this.requireAgent(agentId); + if (!agent.pendingRun || typeof agent.pendingRun.return !== "function") { + return false; + } + try { + await agent.pendingRun.return(undefined as unknown as AgentStreamEvent); + return true; + } catch (error) { + console.error( + `[AgentManager] Failed to cancel run for agent ${agentId}:`, + error + ); + throw error; + } + } + getPendingPermissions(agentId: string): AgentPermissionRequest[] { const agent = this.requireAgent(agentId); return Array.from(agent.pendingPermissions.values()); diff --git a/packages/server/src/server/agent/agent-registry.test.ts b/packages/server/src/server/agent/agent-registry.test.ts new file mode 100644 index 000000000..1181122b9 --- /dev/null +++ b/packages/server/src/server/agent/agent-registry.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { AgentRegistry } from "./agent-registry.js"; +import type { AgentSnapshot } from "./agent-manager.js"; + +function createSnapshot(overrides?: Partial): AgentSnapshot { + const now = new Date(); + return { + id: "agent-test", + provider: "claude", + cwd: "/tmp/project", + createdAt: now, + updatedAt: now, + status: "idle", + sessionId: null, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + ...overrides, + }; +} + +describe("AgentRegistry", () => { + let tmpDir: string; + let filePath: string; + let registry: AgentRegistry; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "agent-registry-")); + filePath = path.join(tmpDir, "agents.json"); + registry = new AgentRegistry(filePath); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("persists configs and snapshot metadata", async () => { + await registry.recordConfig( + "agent-1", + "claude", + "/tmp/project", + { + modeId: "coding", + model: "gpt-5.1", + extra: { claude: { maxThinkingTokens: 1024 } }, + } + ); + + await registry.applySnapshot( + createSnapshot({ + id: "agent-1", + currentModeId: "coding", + status: "idle", + }) + ); + + const records = await registry.list(); + expect(records).toHaveLength(1); + const [record] = records; + expect(record.provider).toBe("claude"); + expect(record.config?.modeId).toBe("coding"); + expect(record.config?.model).toBe("gpt-5.1"); + expect(record.lastModeId).toBe("coding"); + expect(record.lastStatus).toBe("idle"); + + const reloaded = new AgentRegistry(filePath); + const [persisted] = await reloaded.list(); + expect(persisted.cwd).toBe("/tmp/project"); + expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 }); + }); + + test("stores titles independently of snapshots", async () => { + await registry.applySnapshot( + createSnapshot({ + id: "agent-2", + provider: "codex", + cwd: "/tmp/second", + }) + ); + await registry.setTitle("agent-2", "Fix Login Bug"); + + const current = await registry.get("agent-2"); + expect(current?.title).toBe("Fix Login Bug"); + + const reloaded = new AgentRegistry(filePath); + const persisted = await reloaded.get("agent-2"); + expect(persisted?.title).toBe("Fix Login Bug"); + }); + + test("recordConfig seeds lastModeId before snapshots", async () => { + await registry.recordConfig( + "agent-3", + "claude", + "/tmp/project", + { + modeId: "plan", + } + ); + + const record = await registry.get("agent-3"); + expect(record?.lastModeId).toBe("plan"); + }); +}); diff --git a/packages/server/src/server/agent/agent-registry.ts b/packages/server/src/server/agent/agent-registry.ts index 7463d5cd3..758beac77 100644 --- a/packages/server/src/server/agent/agent-registry.ts +++ b/packages/server/src/server/agent/agent-registry.ts @@ -3,11 +3,7 @@ import path from "node:path"; import { z } from "zod"; import type { AgentSnapshot } from "./agent-manager.js"; -import type { - AgentPersistenceHandle, - AgentProvider, - AgentSessionConfig, -} from "./agent-sdk-types.js"; +import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; const SERIALIZABLE_CONFIG_SCHEMA = z .object({ @@ -103,6 +99,11 @@ export class AgentRegistry { return this.load(); } + async get(agentId: string): Promise { + await this.load(); + return this.cache.get(agentId) ?? null; + } + async upsert(record: StoredAgentRecord): Promise { await this.load(); this.cache.set(record.id, record); @@ -124,6 +125,12 @@ export class AgentRegistry { await this.load(); const now = new Date().toISOString(); const existing = this.cache.get(agentId); + const sanitizedConfig = config ? sanitizeConfig(config) : existing?.config; + const nextModeId = + config?.modeId ?? + existing?.lastModeId ?? + sanitizedConfig?.modeId ?? + null; const updated: StoredAgentRecord = { id: agentId, provider, @@ -132,8 +139,8 @@ export class AgentRegistry { updatedAt: now, title: existing?.title ?? null, lastStatus: existing?.lastStatus ?? null, - lastModeId: existing?.lastModeId ?? null, - config: config ? sanitizeConfig(config) : existing?.config, + lastModeId: nextModeId, + config: sanitizedConfig, persistence: existing?.persistence ?? null, }; this.cache.set(agentId, updated); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 8880dc96e..810cf6aa6 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -2,9 +2,7 @@ import type { ThreadEvent as CodexThreadEvent } from "@openai/codex-sdk"; import type { Options as ClaudeAgentOptions, SDKMessage as ClaudeStreamMessage, - McpServerConfig as ClaudeMcpServerConfig, } from "@anthropic-ai/claude-agent-sdk"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; export type AgentProvider = "codex" | "claude"; @@ -121,7 +119,7 @@ export type AgentSessionConfig = { codex?: Record; claude?: Partial; }; - mcpServers?: Record Promise }>; + mcpServers?: Record; }; export interface AgentSession { diff --git a/packages/server/src/server/agent/llm-openai.ts b/packages/server/src/server/agent/llm-openai.ts index a71a4dc53..354c571a8 100644 --- a/packages/server/src/server/agent/llm-openai.ts +++ b/packages/server/src/server/agent/llm-openai.ts @@ -1,24 +1,22 @@ -import { tool, experimental_createMCPClient } from "ai"; +import { tool, experimental_createMCPClient, type ToolSet } from "ai"; import { z } from "zod"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { createTerminalMcpServer } from "../terminal-mcp/index.js"; +type McpClient = Awaited>; + /** * Singleton MCP clients */ -let terminalMcpClient: Awaited< - ReturnType -> | null = null; +let terminalMcpClient: McpClient | null = null; -let playwrightMcpClient: Awaited< - ReturnType -> | null = null; +let playwrightMcpClient: McpClient | null = null; /** * Get or create Terminal MCP client (singleton) */ -async function getTerminalMcpClient() { +async function getTerminalMcpClient(): Promise { if (terminalMcpClient) { return terminalMcpClient; } @@ -46,7 +44,7 @@ async function getTerminalMcpClient() { /** * Get or create Playwright MCP client (singleton) */ -async function getPlaywrightMcpClient() { +async function getPlaywrightMcpClient(): Promise { if (playwrightMcpClient) { return playwrightMcpClient; } @@ -68,10 +66,10 @@ async function getPlaywrightMcpClient() { /** * Cache for merged MCP tools */ -let mcpToolsCache: Record | null = null; -let mcpToolsPromise: Promise> | null = null; +let mcpToolsCache: ToolSet | null = null; +let mcpToolsPromise: Promise | null = null; -async function getMcpTools(): Promise> { +async function getMcpTools(): Promise { if (mcpToolsCache) { return mcpToolsCache; } @@ -89,12 +87,12 @@ async function getMcpTools(): Promise> { }), ]); - const terminalTools = await terminalClient.tools(); - const playwrightTools = playwrightClient - ? await playwrightClient.tools() + const terminalTools = (await terminalClient.tools()) as ToolSet; + const playwrightTools: ToolSet = playwrightClient + ? ((await playwrightClient.tools()) as ToolSet) : {}; - const mergedTools = { + const mergedTools: ToolSet = { ...terminalTools, ...playwrightTools, }; @@ -111,7 +109,7 @@ async function getMcpTools(): Promise> { /** * Manual tools that aren't MCP-based */ -const manualTools = { +const manualTools: ToolSet = { present_artifact: tool({ description: "Present an artifact (plan, diff, screenshot, etc.) to the user for review. Use this when you need to show information that's hard to convey via TTS, such as markdown plans, code diffs, or visual content", @@ -156,9 +154,9 @@ const manualTools = { * @param agentTools - Optional agent tools (per-session). */ export async function getAllTools( - terminalTools?: Record, - agentTools?: Record -): Promise> { + terminalTools?: ToolSet, + agentTools?: ToolSet +): Promise { if (terminalTools) { // Use provided terminal tools (per-session) and merge with Playwright and agent tools const playwrightClient = await getPlaywrightMcpClient().catch((error) => { @@ -166,25 +164,28 @@ export async function getAllTools( return null; }); - const playwrightTools = playwrightClient - ? await playwrightClient.tools() + const playwrightTools: ToolSet = playwrightClient + ? ((await playwrightClient.tools()) as ToolSet) : {}; - return { + const combinedTools: ToolSet = { ...terminalTools, ...playwrightTools, - ...(agentTools || {}), + ...(agentTools ?? {}), ...manualTools, }; + + return combinedTools; } // Fallback to global singleton tools const mcpTools = await getMcpTools(); - return { + const combinedMcpTools: ToolSet = { ...mcpTools, - ...(agentTools || {}), + ...(agentTools ?? {}), ...manualTools, }; + return combinedMcpTools; } /** diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts new file mode 100644 index 000000000..475b001dd --- /dev/null +++ b/packages/server/src/server/agent/mcp-server.ts @@ -0,0 +1,626 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import type { + AgentPromptInput, + AgentProvider, + AgentPermissionRequest, +} from "./agent-sdk-types.js"; +import type { + AgentLifecycleStatus, + AgentManager, + AgentSnapshot, +} from "./agent-manager.js"; +import { + AgentPermissionRequestPayloadSchema, + AgentPermissionResponseSchema, + AgentTimelineItemPayloadSchema, + AgentSnapshotPayloadSchema, + serializeAgentSnapshot, +} from "../messages.js"; +import { curateAgentActivity } from "./activity-curator.js"; +import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js"; +import { AgentRegistry } from "./agent-registry.js"; +import { createWorktree } from "../../utils/worktree.js"; + +export interface AgentMcpServerOptions { + agentManager: AgentManager; + agentRegistry: AgentRegistry; +} + +const AgentProviderEnum = z.enum( + AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [ + AgentProvider, + ...AgentProvider[], + ] +); + +const AgentStatusEnum = z.enum([ + "initializing", + "idle", + "running", + "error", + "closed", +]); + +function expandPath(path: string): string { + if (path.startsWith("~/") || path === "~") { + return resolve(homedir(), path.slice(2)); + } + return resolve(path); +} + +function startAgentRun( + agentManager: AgentManager, + agentId: string, + prompt: AgentPromptInput +): void { + const iterator = agentManager.streamAgent(agentId, prompt); + void (async () => { + try { + for await (const _ of iterator) { + // Events are broadcast via AgentManager subscribers. + } + } catch (error) { + console.error( + `[Agent MCP] Agent stream failed for ${agentId}:`, + error + ); + } + })(); +} + +function isAgentBusy(status: AgentLifecycleStatus): boolean { + return status === "running" || status === "initializing"; +} + +async function resolveAgentTitle( + agentRegistry: AgentRegistry, + agentId: string +): Promise { + try { + const record = await agentRegistry.get(agentId); + return record?.title ?? null; + } catch (error) { + console.error( + `[Agent MCP] Failed to load agent title for ${agentId}:`, + error + ); + return null; + } +} + +async function serializeSnapshotWithMetadata( + agentRegistry: AgentRegistry, + snapshot: AgentSnapshot +) { + const title = await resolveAgentTitle(agentRegistry, snapshot.id); + return serializeAgentSnapshot(snapshot, { title }); +} + +async function waitForAgentEvent( + agentManager: AgentManager, + agentId: string, + signal?: AbortSignal +): Promise<{ + status: AgentLifecycleStatus; + permission: AgentPermissionRequest | null; +}> { + const snapshot = agentManager.getAgent(agentId); + if (!snapshot) { + throw new Error(`Agent ${agentId} not found`); + } + + const existingPermission = snapshot.pendingPermissions[0] ?? null; + if (existingPermission) { + return { status: snapshot.status, permission: existingPermission }; + } + + if (!isAgentBusy(snapshot.status)) { + return { status: snapshot.status, permission: null }; + } + + return await new Promise((resolve, reject) => { + let currentStatus: AgentLifecycleStatus = snapshot.status; + const cleanupFns: Array<() => void> = []; + + const cleanup = () => { + while (cleanupFns.length) { + const fn = cleanupFns.pop(); + try { + fn?.(); + } catch { + // ignore cleanup errors + } + } + }; + + const finish = (permission: AgentPermissionRequest | null) => { + cleanup(); + resolve({ status: currentStatus, permission }); + }; + + const unsubscribe = agentManager.subscribe( + (event) => { + if (event.type === "agent_state") { + currentStatus = event.agent.status; + if (!isAgentBusy(event.agent.status)) { + const pending = event.agent.pendingPermissions[0] ?? null; + finish(pending); + } + return; + } + + if ( + event.type === "agent_stream" && + event.event.type === "permission_requested" + ) { + currentStatus = "running"; + finish(event.event.request); + } + }, + { agentId, replayState: false } + ); + cleanupFns.push(unsubscribe); + + if (signal) { + const abort = () => { + cleanup(); + reject( + Object.assign(new Error("wait_for_agent aborted"), { + name: "AbortError", + }) + ); + }; + + if (signal.aborted) { + abort(); + return; + } + + signal.addEventListener("abort", abort, { once: true }); + cleanupFns.push(() => signal.removeEventListener("abort", abort)); + } + }); +} + +function buildActivityPayload( + agentManager: AgentManager, + agentId: string +): { + format: "curated"; + updateCount: number; + currentModeId: string | null; + content: string; +} { + const timeline = agentManager.getTimeline(agentId); + const snapshot = agentManager.getAgent(agentId); + const curatedText = curateAgentActivity(timeline); + + return { + format: "curated", + updateCount: timeline.length, + currentModeId: snapshot?.currentModeId ?? null, + content: curatedText, + }; +} + +export async function createAgentMcpServer( + options: AgentMcpServerOptions +): Promise { + const { agentManager, agentRegistry } = options; + + const server = new McpServer({ + name: "agent-mcp", + version: "2.0.0", + }); + + server.registerTool( + "create_coding_agent", + { + title: "Create Coding Agent", + description: + "Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.", + inputSchema: { + cwd: z + .string() + .describe( + "Required working directory for the agent (absolute, relative, or ~)." + ), + agentType: AgentProviderEnum.optional().describe( + "Optional agent implementation to spawn. Defaults to 'claude'." + ), + initialPrompt: z + .string() + .optional() + .describe( + "Optional task to start immediately after creation (non-blocking)." + ), + initialMode: z + .string() + .optional() + .describe("Optional session mode to configure before the first run."), + worktreeName: z + .string() + .optional() + .describe( + "Optional git worktree branch name (lowercase alphanumerics + hyphen)." + ), + }, + outputSchema: { + agentId: z.string(), + type: AgentProviderEnum, + status: AgentStatusEnum, + cwd: z.string(), + currentModeId: z.string().nullable(), + availableModes: z.array( + z.object({ + id: z.string(), + label: z.string(), + description: z.string().nullable().optional(), + }) + ), + }, + }, + async ({ cwd, agentType, initialPrompt, initialMode, worktreeName }) => { + let resolvedCwd = expandPath(cwd); + + if (worktreeName) { + const worktree = await createWorktree({ + branchName: worktreeName, + cwd: resolvedCwd, + }); + resolvedCwd = worktree.worktreePath; + } + + const provider: AgentProvider = agentType ?? "claude"; + const snapshot = await agentManager.createAgent({ + provider, + cwd: resolvedCwd, + modeId: initialMode, + }); + + if (initialPrompt) { + try { + startAgentRun(agentManager, snapshot.id, initialPrompt); + } catch (error) { + console.error( + `[Agent MCP] Failed to run initial prompt for ${snapshot.id}:`, + error + ); + } + } + + return { + content: [], + structuredContent: { + agentId: snapshot.id, + type: provider, + status: snapshot.status, + cwd: snapshot.cwd, + currentModeId: snapshot.currentModeId, + availableModes: snapshot.availableModes, + }, + }; + } + ); + + server.registerTool( + "wait_for_agent", + { + title: "Wait For Agent", + description: + "Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.", + inputSchema: { + agentId: z.string().describe("Agent identifier from create_coding_agent"), + }, + outputSchema: { + agentId: z.string(), + status: AgentStatusEnum, + permission: AgentPermissionRequestPayloadSchema.nullable(), + activity: z.object({ + format: z.literal("curated"), + updateCount: z.number(), + currentModeId: z.string().nullable(), + content: z.string(), + }), + }, + }, + async ({ agentId }, { signal }) => { + const result = await waitForAgentEvent(agentManager, agentId, signal); + const activity = buildActivityPayload(agentManager, agentId); + + return { + content: [], + structuredContent: { + agentId, + status: result.status, + permission: result.permission, + activity, + }, + }; + } + ); + + server.registerTool( + "send_agent_prompt", + { + title: "Send Agent Prompt", + description: + "Send a task to a running agent. Returns immediately after the agent begins processing.", + inputSchema: { + agentId: z.string(), + prompt: z.string(), + sessionMode: z + .string() + .optional() + .describe("Optional mode to set before running the prompt."), + }, + outputSchema: { + success: z.boolean(), + status: AgentStatusEnum, + }, + }, + async ({ agentId, prompt, sessionMode }) => { + if (sessionMode) { + await agentManager.setAgentMode(agentId, sessionMode); + } + + startAgentRun(agentManager, agentId, prompt); + const snapshot = agentManager.getAgent(agentId); + + return { + content: [], + structuredContent: { + success: true, + status: snapshot?.status ?? "idle", + }, + }; + } + ); + + server.registerTool( + "get_agent_status", + { + title: "Get Agent Status", + description: + "Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + status: AgentStatusEnum, + snapshot: AgentSnapshotPayloadSchema, + }, + }, + async ({ agentId }) => { + const snapshot = agentManager.getAgent(agentId); + if (!snapshot) { + throw new Error(`Agent ${agentId} not found`); + } + + return { + content: [], + structuredContent: { + status: snapshot.status, + snapshot: await serializeSnapshotWithMetadata( + agentRegistry, + snapshot + ), + }, + }; + } + ); + + server.registerTool( + "list_agents", + { + title: "List Agents", + description: "List all live agents managed by the server.", + inputSchema: {}, + outputSchema: { + agents: z.array(AgentSnapshotPayloadSchema), + }, + }, + async () => { + const snapshots = agentManager.listAgents(); + const agents = await Promise.all( + snapshots.map((snapshot) => + serializeSnapshotWithMetadata(agentRegistry, snapshot) + ) + ); + return { + content: [], + structuredContent: { agents }, + }; + } + ); + + server.registerTool( + "cancel_agent", + { + title: "Cancel Agent Run", + description: + "Abort the agent's current run but keep the agent alive for future tasks.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + const success = await agentManager.cancelAgentRun(agentId); + return { + content: [], + structuredContent: { success }, + }; + } + ); + + server.registerTool( + "kill_agent", + { + title: "Kill Agent", + description: "Terminate an agent session permanently.", + inputSchema: { + agentId: z.string(), + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId }) => { + await agentManager.closeAgent(agentId); + return { + content: [], + structuredContent: { success: true }, + }; + } + ); + + server.registerTool( + "get_agent_activity", + { + title: "Get Agent Activity", + description: + "Return recent agent timeline entries. Default response is a curated summary; raw timeline entries are available via format='raw'.", + inputSchema: { + agentId: z.string(), + format: z.enum(["curated", "raw"]).optional().default("curated"), + limit: z + .number() + .optional() + .describe("Optional limit for raw entries (most recent first)."), + }, + outputSchema: { + agentId: z.string(), + format: z.enum(["curated", "raw"]), + updateCount: z.number(), + currentModeId: z.string().nullable(), + content: z.string(), + updates: z + .array(AgentTimelineItemPayloadSchema) + .nullable() + .describe("Timeline entries when format='raw'."), + }, + }, + async ({ agentId, format = "curated", limit }) => { + const timeline = agentManager.getTimeline(agentId); + const snapshot = agentManager.getAgent(agentId); + + if (format === "curated") { + return { + content: [], + structuredContent: { + agentId, + format: "curated" as const, + updateCount: timeline.length, + currentModeId: snapshot?.currentModeId ?? null, + content: curateAgentActivity(timeline), + updates: null, + }, + }; + } + + const entries = limit + ? timeline.slice(-limit).reverse() + : timeline; + return { + content: [], + structuredContent: { + agentId, + format: "raw" as const, + updateCount: timeline.length, + currentModeId: snapshot?.currentModeId ?? null, + content: "", + updates: entries, + }, + }; + } + ); + + server.registerTool( + "set_agent_mode", + { + title: "Set Agent Session Mode", + description: + "Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).", + inputSchema: { + agentId: z.string(), + modeId: z.string(), + }, + outputSchema: { + success: z.boolean(), + newMode: z.string(), + }, + }, + async ({ agentId, modeId }) => { + await agentManager.setAgentMode(agentId, modeId); + return { + content: [], + structuredContent: { success: true, newMode: modeId }, + }; + } + ); + + server.registerTool( + "list_pending_permissions", + { + title: "List Pending Permissions", + description: + "Return all pending permission requests across all agents with the normalized payloads.", + inputSchema: {}, + outputSchema: { + permissions: z.array( + z.object({ + agentId: z.string(), + status: AgentStatusEnum, + request: AgentPermissionRequestPayloadSchema, + }) + ), + }, + }, + async () => { + const permissions = agentManager.listAgents().flatMap((agent) => + agent.pendingPermissions.map((request) => ({ + agentId: agent.id, + status: agent.status, + request, + })) + ); + + return { + content: [], + structuredContent: { permissions }, + }; + } + ); + + server.registerTool( + "respond_to_permission", + { + title: "Respond To Permission", + description: + "Approve or deny a pending permission request with an AgentManager-compatible response payload.", + inputSchema: { + agentId: z.string(), + requestId: z.string(), + response: AgentPermissionResponseSchema, + }, + outputSchema: { + success: z.boolean(), + }, + }, + async ({ agentId, requestId, response }) => { + await agentManager.respondToPermission(agentId, requestId, response); + return { + content: [], + structuredContent: { success: true }, + }; + } + ); + + return server; +} diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts new file mode 100644 index 000000000..8567bbdb3 --- /dev/null +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -0,0 +1,79 @@ +import type { AgentMode, AgentProvider } from "./agent-sdk-types.js"; + +export interface AgentProviderDefinition { + id: AgentProvider; + label: string; + description: string; + defaultModeId: string | null; + modes: AgentMode[]; +} + +const CLAUDE_MODES: AgentMode[] = [ + { + id: "default", + label: "Always Ask", + description: "Prompts for permission the first time a tool is used", + }, + { + id: "acceptEdits", + label: "Accept File Edits", + description: "Automatically approves edit-focused tools without prompting", + }, + { + id: "plan", + label: "Plan Mode", + description: "Analyze the codebase without executing tools or edits", + }, + { + id: "bypassPermissions", + label: "Bypass Permissions", + description: "Skip all permission prompts (use with caution)", + }, +]; + +const CODEX_MODES: AgentMode[] = [ + { + id: "read-only", + label: "Read Only", + description: + "Read files and answer questions. Manual approval required for edits, commands, or network ops.", + }, + { + id: "auto", + label: "Auto", + description: + "Edit files and run commands but still request approval before escalating scope.", + }, + { + id: "full-access", + label: "Full Access", + description: "Edit files, run commands, and access the network without additional prompts.", + }, +]; + +export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ + { + id: "claude", + label: "Claude", + description: + "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning", + defaultModeId: "default", + modes: CLAUDE_MODES, + }, + { + id: "codex", + label: "Codex", + description: + "OpenAI's Codex workspace agent with sandbox controls and optional network access", + defaultModeId: "auto", + modes: CODEX_MODES, + }, +]; + +export function getAgentProviderDefinition(provider: AgentProvider): AgentProviderDefinition { + const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider); + if (!definition) { + throw new Error(`Unknown agent provider: ${provider}`); + } + return definition; +} diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 44348185a..cf373ae41 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -94,7 +94,7 @@ describe("ClaudeAgentClient (SDK integration)", () => { "First run a Bash command to print the working directory, then use your editor tools (not the shell) to create a file named tool-test.txt in the current directory that contains exactly the text 'hello world'. Report 'done' after the write finishes." ); - const timeline: any[] = []; + const timeline: AgentTimelineItem[] = []; let completed = false; for await (const event of events) { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index a42c897a8..2f47e72dc 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -94,6 +94,10 @@ type ToolUseCacheEntry = { const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + export class ClaudeAgentClient implements AgentClient { readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; @@ -374,8 +378,8 @@ class ClaudeAgentSession implements AgentSession { } const result: Record = {}; for (const [name, config] of Object.entries(servers)) { - if (!config) continue; - if ("type" in config || "command" in (config as any) || "url" in (config as any)) { + if (!isRecord(config)) continue; + if ("type" in config || "command" in config || "url" in config) { result[name] = config as ClaudeMcpServerConfig; } } diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index cb2e61337..45948dccd 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -5,12 +5,16 @@ import { createServer as createHTTPServer } from "http"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { initializeSTT } from "./agent/stt-openai.js"; import { initializeTTS } from "./agent/tts-openai.js"; -import { - listConversations, - deleteConversation, -} from "./persistence.js"; -import { AgentManager } from "./acp/agent-manager.js"; +import { listConversations, deleteConversation } from "./persistence.js"; +import { AgentManager } from "./agent/agent-manager.js"; +import { AgentRegistry } from "./agent/agent-registry.js"; +import { ClaudeAgentClient } from "./agent/providers/claude-agent.js"; +import { CodexAgentClient } from "./agent/providers/codex-agent.js"; import { initializeTitleGenerator } from "../services/agent-title-generator.js"; +import { + attachAgentRegistryPersistence, + restorePersistedAgents, +} from "./persistence-hooks.js"; function createServer() { const app = express(); @@ -66,13 +70,26 @@ async function main() { const app = createServer(); const httpServer = createHTTPServer(app); - // Initialize global agent manager - const agentManager = new AgentManager(); - await agentManager.initialize(); // Load persisted agents + // Initialize global agent manager + registry + const agentRegistry = new AgentRegistry(); + const agentManager = new AgentManager({ + clients: { + claude: new ClaudeAgentClient(), + codex: new CodexAgentClient(), + }, + }); + + attachAgentRegistryPersistence(agentManager, agentRegistry); + + await restorePersistedAgents(agentManager, agentRegistry); console.log("✓ Global agent manager initialized with persisted agents"); // Initialize WebSocket server - const wsServer = new VoiceAssistantWebSocketServer(httpServer, agentManager); + const wsServer = new VoiceAssistantWebSocketServer( + httpServer, + agentManager, + agentRegistry + ); // Initialize OpenAI client const apiKey = process.env.OPENAI_API_KEY; @@ -124,7 +141,7 @@ async function main() { console.log(`\n${signal} received, shutting down gracefully...`); // Wait for agents to finish work - await agentManager.shutdown(); + await closeAllAgents(agentManager); // Close WebSocket and HTTP servers wsServer.close(); @@ -146,3 +163,19 @@ async function main() { } main(); + +async function closeAllAgents(agentManager: AgentManager): Promise { + const agents = agentManager.listAgents(); + for (const agent of agents) { + try { + await agentManager.closeAgent(agent.id); + } catch (error) { + console.error( + `[Agents] Failed to close agent ${agent.id}:`, + error + ); + } + } + + // All agents have been asked to stop; let the caller finish shutdown +} diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index c3c874a08..44e5ed78a 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -1,35 +1,268 @@ import { z } from "zod"; -import type { AgentType } from "./acp/agent-types.js"; -import { listAgentTypeDefinitions } from "./acp/agent-types.js"; +import type { AgentSnapshot } from "./agent/agent-manager.js"; +import type { + AgentCapabilityFlags, + AgentMode, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentProvider, + AgentStreamEvent, + AgentTimelineItem, + AgentUsage, +} from "./agent/agent-sdk-types.js"; -const AgentModeSchema = z.object({ +type ProviderEventPayload = Extract< + AgentStreamEvent, + { type: "provider_event" } +>; + +export type AgentSnapshotPayload = Omit< + AgentSnapshot, + "createdAt" | "updatedAt" +> & { + createdAt: string; + updatedAt: string; + title: string | null; +}; + +const AGENT_PROVIDERS: [AgentProvider, AgentProvider] = ["claude", "codex"]; +const AgentProviderSchema = z.enum(AGENT_PROVIDERS); + +const AGENT_LIFECYCLE_STATUSES = [ + "initializing", + "idle", + "running", + "error", + "closed", +] as const; +const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES); + +const AgentModeSchema: z.ZodType = z.object({ id: z.string(), - name: z.string(), - description: z.string().nullable().optional(), + label: z.string(), + description: z.string().optional(), }); -const agentTypes = listAgentTypeDefinitions().map((definition) => definition.id); +const AgentCapabilityFlagsSchema: z.ZodType = z.object({ + supportsStreaming: z.boolean(), + supportsSessionPersistence: z.boolean(), + supportsDynamicModes: z.boolean(), + supportsMcpServers: z.boolean(), + supportsReasoningStream: z.boolean(), + supportsToolInvocations: z.boolean(), +}); -const AgentInfoSchema = z.object({ - id: z.string(), - status: z.string(), - createdAt: z.date(), - lastActivityAt: z.date(), - type: z.enum(agentTypes as [AgentType, ...AgentType[]]), - sessionId: z.string().nullable(), - error: z.string().nullable(), - currentModeId: z.string().nullable(), - availableModes: z.array(AgentModeSchema).nullable(), - title: z.string().nullable(), +const AgentUsageSchema: z.ZodType = z.object({ + inputTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalCostUsd: z.number().optional(), +}); + +const AgentSessionConfigSchema = z.object({ + provider: AgentProviderSchema, cwd: z.string(), + modeId: z.string().optional(), + model: z.string().optional(), + approvalPolicy: z.string().optional(), + sandboxMode: z.string().optional(), + networkAccess: z.boolean().optional(), + webSearch: z.boolean().optional(), + reasoningEffort: z.string().optional(), + extra: z + .object({ + codex: z.record(z.unknown()).optional(), + claude: z.record(z.unknown()).optional(), + }) + .partial() + .optional(), + mcpServers: z.record(z.unknown()).optional(), }); -const AgentUpdatePayloadSchema = z.object({ - agentId: z.string(), - timestamp: z.date(), - notification: z.any(), +const AgentPermissionUpdateSchema = z.record(z.unknown()); + +export const AgentPermissionResponseSchema: z.ZodType = + z.union([ + z.object({ + behavior: z.literal("allow"), + updatedInput: z.record(z.unknown()).optional(), + updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), + }), + z.object({ + behavior: z.literal("deny"), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }), + ]); + +export const AgentPermissionRequestPayloadSchema: z.ZodType = + z.object({ + id: z.string(), + provider: AgentProviderSchema, + name: z.string(), + kind: z.enum(["tool", "plan", "mode", "other"]), + title: z.string().optional(), + description: z.string().optional(), + input: z.record(z.unknown()).optional(), + suggestions: z.array(AgentPermissionUpdateSchema).optional(), + metadata: z.record(z.unknown()).optional(), + raw: z.unknown().optional(), + }); + +export const AgentTimelineItemPayloadSchema: z.ZodType = + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("assistant_message"), + text: z.string(), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("reasoning"), + text: z.string(), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("command"), + command: z.string(), + status: z.string(), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("file_change"), + files: z.array( + z.object({ + path: z.string(), + kind: z.string(), + }) + ), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("mcp_tool"), + server: z.string(), + tool: z.string(), + status: z.string(), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("web_search"), + query: z.string(), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("todo"), + items: z.array( + z.object({ + text: z.string(), + completed: z.boolean(), + }) + ), + raw: z.unknown().optional(), + }), + z.object({ + type: z.literal("error"), + message: z.string(), + raw: z.unknown().optional(), + }), + ]); + +const ProviderEventPayloadSchema = z.object({ + type: z.literal("provider_event"), + provider: AgentProviderSchema, + raw: z.custom(), }); +export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("thread_started"), + sessionId: z.string(), + provider: AgentProviderSchema, + }), + z.object({ + type: z.literal("turn_started"), + provider: AgentProviderSchema, + }), + z.object({ + type: z.literal("turn_completed"), + provider: AgentProviderSchema, + usage: AgentUsageSchema.optional(), + }), + z.object({ + type: z.literal("turn_failed"), + provider: AgentProviderSchema, + error: z.string(), + }), + z.object({ + type: z.literal("timeline"), + provider: AgentProviderSchema, + item: AgentTimelineItemPayloadSchema, + }), + ProviderEventPayloadSchema, + z.object({ + type: z.literal("permission_requested"), + provider: AgentProviderSchema, + request: AgentPermissionRequestPayloadSchema, + }), + z.object({ + type: z.literal("permission_resolved"), + provider: AgentProviderSchema, + requestId: z.string(), + resolution: AgentPermissionResponseSchema, + }), +]); + +const AgentPersistenceHandleSchema: z.ZodType = + z + .object({ + provider: AgentProviderSchema, + sessionId: z.string(), + nativeHandle: z.any().optional(), + metadata: z.record(z.unknown()).optional(), + }) + .nullable(); + +export const AgentSnapshotPayloadSchema = z.object({ + id: z.string(), + provider: AgentProviderSchema, + cwd: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + status: AgentStatusSchema, + sessionId: z.string().nullable(), + capabilities: AgentCapabilityFlagsSchema, + currentModeId: z.string().nullable(), + availableModes: z.array(AgentModeSchema), + pendingPermissions: z.array(AgentPermissionRequestPayloadSchema), + persistence: AgentPersistenceHandleSchema.nullable(), + lastUsage: AgentUsageSchema.optional(), + lastError: z.string().optional(), + title: z.string().nullable(), +}); + +export type AgentStreamEventPayload = z.infer< + typeof AgentStreamEventPayloadSchema +>; + +export function serializeAgentSnapshot( + snapshot: AgentSnapshot, + options?: { title?: string | null } +): AgentSnapshotPayload { + const { createdAt, updatedAt, ...rest } = snapshot; + return { + ...rest, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + title: options?.title ?? null, + }; +} + +export function serializeAgentStreamEvent( + event: AgentStreamEvent +): AgentStreamEventPayload { + return event as AgentStreamEventPayload; +} + // ============================================================================ // Session Inbound Messages (Session receives these) // ============================================================================ @@ -96,10 +329,8 @@ export const SendAgentAudioSchema = z.object({ export const CreateAgentRequestMessageSchema = z.object({ type: z.literal("create_agent_request"), - cwd: z.string(), - initialMode: z.string().optional(), + config: AgentSessionConfigSchema, worktreeName: z.string().optional(), - agentType: z.enum(agentTypes as [AgentType, ...AgentType[]]).optional(), requestId: z.string().optional(), }); @@ -119,7 +350,7 @@ export const AgentPermissionResponseMessageSchema = z.object({ type: z.literal("agent_permission_response"), agentId: z.string(), requestId: z.string(), - optionId: z.string(), + response: AgentPermissionResponseSchema, }); export const GitDiffRequestSchema = z.object({ @@ -257,23 +488,31 @@ export const ConversationLoadedMessageSchema = z.object({ }), }); -export const AgentCreatedMessageSchema = z.object({ - type: z.literal("agent_created"), +export const AgentStateMessageSchema = z.object({ + type: z.literal("agent_state"), + payload: AgentSnapshotPayloadSchema, +}); + +export const AgentStreamMessageSchema = z.object({ + type: z.literal("agent_stream"), payload: z.object({ agentId: z.string(), - status: z.string(), - type: z.enum(agentTypes as [AgentType, ...AgentType[]]), - currentModeId: z.string().optional(), - availableModes: z.array(AgentModeSchema).optional(), - title: z.string().optional(), - cwd: z.string(), - requestId: z.string().optional(), + event: AgentStreamEventPayloadSchema, + timestamp: z.string(), }), }); -export const AgentUpdateMessageSchema = z.object({ - type: z.literal("agent_update"), - payload: AgentUpdatePayloadSchema, +export const AgentStreamSnapshotMessageSchema = z.object({ + type: z.literal("agent_stream_snapshot"), + payload: z.object({ + agentId: z.string(), + events: z.array( + z.object({ + event: AgentStreamEventPayloadSchema, + timestamp: z.string(), + }) + ), + }), }); export const AgentStatusMessageSchema = z.object({ @@ -281,14 +520,14 @@ export const AgentStatusMessageSchema = z.object({ payload: z.object({ agentId: z.string(), status: z.string(), - info: AgentInfoSchema, + info: AgentSnapshotPayloadSchema, }), }); export const SessionStateMessageSchema = z.object({ type: z.literal("session_state"), payload: z.object({ - agents: z.array(AgentInfoSchema), + agents: z.array(AgentSnapshotPayloadSchema), commands: z.array( z.object({ id: z.string(), @@ -302,16 +541,6 @@ export const SessionStateMessageSchema = z.object({ }), }); -export const AgentInitializedMessageSchema = z.object({ - type: z.literal("agent_initialized"), - payload: z.object({ - agentId: z.string(), - info: AgentInfoSchema, - updates: z.array(AgentUpdatePayloadSchema), - requestId: z.string().optional(), - }), -}); - export const ListConversationsResponseMessageSchema = z.object({ type: z.literal("list_conversations_response"), payload: z.object({ @@ -338,14 +567,7 @@ export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ agentId: z.string(), - requestId: z.string(), - sessionId: z.string(), - toolCall: z.any(), // ToolCallUpdate from ACP SDK - complex type with flexible rawInput - options: z.array(z.object({ - kind: z.enum(["allow_always", "allow_once", "reject_once", "reject_always"]), - name: z.string(), - optionId: z.string(), - })), + request: AgentPermissionRequestPayloadSchema, }), }); @@ -354,7 +576,7 @@ export const AgentPermissionResolvedMessageSchema = z.object({ payload: z.object({ agentId: z.string(), requestId: z.string(), - optionId: z.string(), + resolution: AgentPermissionResponseSchema, }), }); @@ -387,11 +609,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ StatusMessageSchema, ArtifactMessageSchema, ConversationLoadedMessageSchema, - AgentCreatedMessageSchema, - AgentUpdateMessageSchema, + AgentStateMessageSchema, + AgentStreamMessageSchema, + AgentStreamSnapshotMessageSchema, AgentStatusMessageSchema, SessionStateMessageSchema, - AgentInitializedMessageSchema, ListConversationsResponseMessageSchema, DeleteConversationResponseMessageSchema, AgentPermissionRequestMessageSchema, @@ -412,11 +634,13 @@ export type TranscriptionResultMessage = z.infer; export type ArtifactMessage = z.infer; export type ConversationLoadedMessage = z.infer; -export type AgentCreatedMessage = z.infer; -export type AgentUpdateMessage = z.infer; +export type AgentStateMessage = z.infer; +export type AgentStreamMessage = z.infer; +export type AgentStreamSnapshotMessage = z.infer< + typeof AgentStreamSnapshotMessageSchema +>; export type AgentStatusMessage = z.infer; export type SessionStateMessage = z.infer; -export type AgentInitializedMessage = z.infer; export type ListConversationsResponseMessage = z.infer; export type DeleteConversationResponseMessage = z.infer; export type AgentPermissionRequestMessage = z.infer; diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts new file mode 100644 index 000000000..961ae5326 --- /dev/null +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { AgentSnapshot } from "./agent/agent-manager.js"; +import type { StoredAgentRecord } from "./agent/agent-registry.js"; +import { + attachAgentRegistryPersistence, + restorePersistedAgents, +} from "./persistence-hooks.js"; + +function createSnapshot(overrides?: Partial): AgentSnapshot { + const now = new Date(); + return { + id: "agent-1", + provider: "claude", + cwd: "/tmp/project", + createdAt: now, + updatedAt: now, + status: "idle", + sessionId: null, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "plan", + availableModes: [], + pendingPermissions: [], + persistence: null, + ...overrides, + }; +} + +function createRecord( + overrides?: Partial +): StoredAgentRecord { + const now = new Date().toISOString(); + return { + id: "agent-record", + provider: "claude", + cwd: "/tmp/project", + createdAt: now, + updatedAt: now, + title: null, + lastStatus: "idle", + lastModeId: "plan", + config: { modeId: "plan", model: "claude-3.5-sonnet" }, + persistence: { + provider: "claude", + sessionId: "session-123", + }, + ...overrides, + }; +} + +describe("persistence hooks", () => { + test("restorePersistedAgents resumes and creates agents", async () => { + const resumeAgent = vi.fn().mockResolvedValue(null); + const createAgent = vi.fn().mockResolvedValue(null); + const agentManager = { + resumeAgent, + createAgent, + }; + const records: StoredAgentRecord[] = [ + createRecord({ + id: "claude-agent", + lastModeId: "plan", + }), + createRecord({ + id: "codex-agent", + provider: "codex", + cwd: "/tmp/codex", + lastModeId: null, + config: { modeId: "auto", model: "gpt-4.1", extra: { codex: { policy: "auto" } } }, + persistence: null, + }), + createRecord({ + id: "unknown", + provider: "mystery" as any, + }), + ]; + const registry = { + list: vi.fn().mockResolvedValue(records), + applySnapshot: vi.fn(), + }; + + await restorePersistedAgents( + agentManager as any, + registry as any + ); + + expect(resumeAgent).toHaveBeenCalledTimes(1); + expect(resumeAgent).toHaveBeenCalledWith( + records[0].persistence, + expect.objectContaining({ + cwd: records[0].cwd, + modeId: records[0].lastModeId, + model: records[0].config?.model, + }), + records[0].id + ); + + expect(createAgent).toHaveBeenCalledTimes(1); + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "codex", + cwd: "/tmp/codex", + modeId: "auto", + model: "gpt-4.1", + extra: { codex: { policy: "auto" } }, + }), + "codex-agent" + ); + }); + + test("attachAgentRegistryPersistence forwards agent snapshots", async () => { + const applySnapshot = vi.fn().mockResolvedValue(undefined); + let subscriber: (event: any) => void = () => { + throw new Error("Agent manager subscriber was not registered"); + }; + const agentManager = { + subscribe: vi.fn((callback: (event: any) => void) => { + subscriber = callback; + return () => { + subscriber = () => { + throw new Error("Agent manager subscriber was not registered"); + }; + }; + }), + }; + attachAgentRegistryPersistence(agentManager as any, { + applySnapshot, + list: vi.fn(), + } as any); + + expect(agentManager.subscribe).toHaveBeenCalledTimes(1); + const snapshot = createSnapshot(); + subscriber({ type: "agent_state", agent: snapshot }); + expect(applySnapshot).toHaveBeenCalledWith(snapshot); + + subscriber({ + type: "agent_stream", + agentId: snapshot.id, + event: { type: "timeline", item: { type: "assistant_message", text: "hi" } }, + }); + expect(applySnapshot).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts new file mode 100644 index 000000000..d76eaefa4 --- /dev/null +++ b/packages/server/src/server/persistence-hooks.ts @@ -0,0 +1,130 @@ +import type { AgentManager } from "./agent/agent-manager.js"; +import type { + AgentPersistenceHandle, + AgentProvider, + AgentSessionConfig, +} from "./agent/agent-sdk-types.js"; +import type { + AgentRegistry, + StoredAgentRecord, +} from "./agent/agent-registry.js"; + +type AgentRegistryPersistence = Pick; +type AgentManagerStateSource = Pick; +type AgentManagerRestorer = Pick; + +/** + * Attach AgentRegistry persistence to an AgentManager instance so every + * agent_state snapshot is flushed to disk. + */ +export function attachAgentRegistryPersistence( + agentManager: AgentManagerStateSource, + registry: AgentRegistryPersistence +): () => void { + const unsubscribe = agentManager.subscribe((event) => { + if (event.type !== "agent_state") { + return; + } + void registry.applySnapshot(event.agent).catch((error) => { + console.error("[AgentRegistry] Failed to persist agent snapshot:", error); + }); + }); + + return unsubscribe; +} + +/** + * Restore persisted agents from the AgentRegistry at server startup. + */ +export async function restorePersistedAgents( + agentManager: AgentManagerRestorer, + registry: AgentRegistryPersistence +): Promise { + const records = await registry.list(); + if (records.length === 0) { + return; + } + + console.log(`[Agents] Restoring ${records.length} persisted agent(s)`); + for (const record of records) { + if (!isKnownProvider(record.provider)) { + console.warn( + `[Agents] Skipping persisted agent ${record.id} with unknown provider '${record.provider}'` + ); + continue; + } + + try { + const persistenceHandle = buildPersistenceHandle(record); + if (persistenceHandle) { + await agentManager.resumeAgent( + persistenceHandle, + buildConfigOverrides(record), + record.id + ); + } else { + await agentManager.createAgent( + buildSessionConfig(record), + record.id + ); + } + } catch (error) { + console.error( + `[Agents] Failed to restore agent ${record.id}:`, + error + ); + } + } +} + +function isKnownProvider(provider: string): provider is AgentProvider { + return provider === "claude" || provider === "codex"; +} + +function buildPersistenceHandle( + record: StoredAgentRecord +): AgentPersistenceHandle | null { + if (!record.persistence) { + return null; + } + const { provider, sessionId, nativeHandle, metadata } = record.persistence; + if (!isKnownProvider(provider)) { + console.warn( + `[Agents] Skipping persisted handle for ${record.id} with unknown provider '${provider}'` + ); + return null; + } + return { + provider, + sessionId, + nativeHandle, + metadata, + } satisfies AgentPersistenceHandle; +} + +export function buildConfigOverrides( + record: StoredAgentRecord +): Partial { + return { + cwd: record.cwd, + modeId: record.lastModeId ?? record.config?.modeId ?? undefined, + model: record.config?.model ?? undefined, + extra: record.config?.extra ?? undefined, + }; +} + +export function buildSessionConfig( + record: StoredAgentRecord +): AgentSessionConfig { + if (!isKnownProvider(record.provider)) { + throw new Error(`Unknown provider '${record.provider}'`); + } + const overrides = buildConfigOverrides(record); + return { + provider: record.provider, + cwd: record.cwd, + modeId: overrides.modeId, + model: overrides.model, + extra: overrides.extra, + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 422150a54..af9ad8480 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5,15 +5,19 @@ import { promisify, inspect } from "util"; import { join } from "path"; import invariant from "tiny-invariant"; import { streamText, stepCountIs } from "ai"; +import type { ToolSet } from "ai"; import type { ModelMessage } from "@ai-sdk/provider-utils"; import { createOpenRouter, OpenRouterProviderOptions, } from "@openrouter/ai-sdk-provider"; -import type { - SessionInboundMessage, - SessionOutboundMessage, - FileExplorerRequest, +import { + serializeAgentSnapshot, + serializeAgentStreamEvent, + type AgentSnapshotPayload, + type SessionInboundMessage, + type SessionOutboundMessage, + type FileExplorerRequest, } from "./messages.js"; import { getSystemPrompt } from "./agent/system-prompt.js"; import { getAllTools } from "./agent/llm-openai.js"; @@ -27,21 +31,29 @@ import { import { experimental_createMCPClient } from "ai"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createTerminalMcpServer } from "./terminal-mcp/index.js"; -import { AgentManager } from "./acp/agent-manager.js"; -import { createAgentMcpServer } from "./acp/mcp-server.js"; -import type { AgentUpdate } from "./acp/types.js"; -import type { AgentType } from "./acp/agent-types.js"; -import { - generateAgentTitle, - isTitleGeneratorInitialized, -} from "../services/agent-title-generator.js"; +import { createAgentMcpServer } from "./agent/mcp-server.js"; +import { AgentManager } from "./agent/agent-manager.js"; +import type { AgentSnapshot } from "./agent/agent-manager.js"; +import type { + AgentPermissionResponse, + AgentPromptInput, + AgentSessionConfig, + AgentStreamEvent, +} from "./agent/agent-sdk-types.js"; +import { AgentRegistry } from "./agent/agent-registry.js"; import { expandTilde } from "./terminal-mcp/tmux.js"; import { listDirectoryEntries, readExplorerFile, } from "./file-explorer/service.js"; +import { + generateAgentTitle, + isTitleGeneratorInitialized, +} from "../services/agent-title-generator.js"; +import type { TerminalManager } from "./terminal-mcp/terminal-manager.js"; const execAsync = promisify(exec); +const ACTIVE_TITLE_GENERATIONS = new Set(); type ProcessingPhase = "idle" | "transcribing" | "llm"; @@ -92,19 +104,22 @@ export class Session { private terminalMcpClient: Awaited< ReturnType > | null = null; - private terminalTools: Record | null = null; - private terminalManager: any | null = null; + private terminalTools: ToolSet | null = null; + private terminalManager: TerminalManager | null = null; private agentMcpClient: Awaited< ReturnType > | null = null; - private agentTools: Record | null = null; + private agentTools: ToolSet | null = null; private agentManager: AgentManager; - private agentUpdateUnsubscribers: Map void> = new Map(); + private readonly agentRegistry: AgentRegistry; + private agentTitleCache: Map = new Map(); + private unsubscribeAgentEvents: (() => void) | null = null; constructor( clientId: string, onMessage: (msg: SessionOutboundMessage) => void, agentManager: AgentManager, + agentRegistry: AgentRegistry, options?: { conversationId?: string; initialMessages?: ModelMessage[]; @@ -114,6 +129,7 @@ export class Session { this.conversationId = options?.conversationId || uuidv4(); this.onMessage = onMessage; this.agentManager = agentManager; + this.agentRegistry = agentRegistry; this.abortController = new AbortController(); // Initialize conversation history @@ -128,11 +144,10 @@ export class Session { this.ttsManager = new TTSManager(this.conversationId); this.sttManager = new STTManager(this.conversationId); - // Initialize terminal MCP client asynchronously - this.initializeTerminalMcp(); - - // Initialize agent MCP client asynchronously - this.initializeAgentMcp(); + // Initialize terminal + agent MCP clients asynchronously + void this.initializeTerminalMcp(); + void this.initializeAgentMcp(); + this.subscribeToAgentEvents(); console.log( `[Session ${this.clientId}] Created with conversation ${this.conversationId}` @@ -154,214 +169,75 @@ export class Session { } /** - * Subscribe to updates from an agent + * Normalize a user prompt (with optional image metadata) for AgentManager */ - private subscribeToAgent(agentId: string): void { - if (!this.agentManager) { - console.error( - `[Session ${this.clientId}] Cannot subscribe to agent: AgentManager not initialized` - ); - return; + private buildAgentPrompt( + text: string, + images?: Array<{ data: string; mimeType: string }> + ): AgentPromptInput { + const normalized = text?.trim() ?? ""; + if (!images || images.length === 0) { + return normalized; } - // Don't subscribe twice - if (this.agentUpdateUnsubscribers.has(agentId)) { - return; - } + const attachmentSummary = images + .map((image, index) => { + const sizeKb = Math.round((image.data.length * 0.75) / 1024); + return `Attachment ${index + 1}: ${image.mimeType}, ~${sizeKb}KB base64`; + }) + .join("\n"); - const unsubscribe = this.agentManager.subscribeToUpdates( - agentId, - (update: AgentUpdate) => { - const notification = update.notification; - - // Handle permission requests - if (notification.type === "permission") { - const permissionRequest = notification.request; - this.emit({ - type: "agent_permission_request", - payload: { - agentId, - requestId: notification.requestId, // Use the requestId from AgentManager - sessionId: permissionRequest.sessionId, - toolCall: permissionRequest.toolCall, - options: permissionRequest.options, - }, - }); - console.log( - `[Session ${this.clientId}] Forwarded permission request for agent ${agentId} with requestId ${notification.requestId}` - ); - return; - } - - // Handle permission resolved notifications - if (notification.type === "permission_resolved") { - this.emit({ - type: "agent_permission_resolved", - payload: { - agentId: notification.agentId, - requestId: notification.requestId, - optionId: notification.optionId, - }, - }); - console.log( - `[Session ${this.clientId}] Forwarded permission resolved for agent ${agentId} with requestId ${notification.requestId}` - ); - return; - } - - // Handle status updates - if (notification.type === "status") { - const status = notification.status; - - // Get current agent info - try { - const info = this.agentManager!.listAgents().find( - (a) => a.id === agentId - ); - if (info) { - // Emit agent_status message - this.emit({ - type: "agent_status", - payload: { - agentId, - status, - info, - }, - }); - console.log( - `[Session ${this.clientId}] Agent ${agentId} status changed to: ${status}` - ); - } - } catch (error) { - console.error( - `[Session ${this.clientId}] Failed to get agent info for status update:`, - error - ); - } - return; - } - - // Handle session notifications - if (notification.type === "session") { - // Forward agent updates to WebSocket - this.emit({ - type: "agent_update", - payload: { - agentId: update.agentId, - timestamp: update.timestamp, - notification: update.notification, - }, - }); - - // Check if this is a current_mode_update - emit agent_status so UI updates immediately - if (notification.notification.update.sessionUpdate === "current_mode_update") { - try { - const info = this.agentManager!.listAgents().find( - (a) => a.id === agentId - ); - if (info) { - this.emit({ - type: "agent_status", - payload: { - agentId, - status: info.status, - info, - }, - }); - console.log( - `[Session ${this.clientId}] Agent ${agentId} mode changed to: ${info.currentModeId}` - ); - } - } catch (error) { - console.error( - `[Session ${this.clientId}] Failed to get agent info for mode update:`, - error - ); - } - } - - // Trigger title generation after first meaningful update - this.maybeTriggerTitleGeneration(agentId); - } - } - ); - - this.agentUpdateUnsubscribers.set(agentId, unsubscribe); - console.log( - `[Session ${this.clientId}] Subscribed to agent ${agentId} updates` - ); + const base = normalized.length > 0 ? normalized : "User shared image attachment(s)."; + return `${base}\n\n[Image attachments]\n${attachmentSummary}\n(Actual image bytes omitted; request a screenshot or file if needed.)`; } /** - * Maybe trigger title generation for an agent - * Only generates title once after first user message chunk and some initial agent activity + * Start streaming an agent run and forward results via the websocket broadcast */ - private maybeTriggerTitleGeneration(agentId: string): void { - // Skip if title generator not initialized - if (!isTitleGeneratorInitialized()) { - return; - } - - // Skip if already triggered - if (this.agentManager.isTitleGenerationTriggered(agentId)) { - return; - } - - // Get agent updates - const updates = this.agentManager.getAgentUpdates(agentId); - - // Find first user message chunk - const hasUserMessage = updates.some( - (update) => - update.notification.type === "session" && - update.notification.notification.update.sessionUpdate === - "user_message_chunk" - ); - - // Need at least one user message and some additional updates (3-5) for context - if (!hasUserMessage || updates.length < 5) { - return; - } - - // Mark as triggered to prevent duplicate generation - this.agentManager.markTitleGenerationTriggered(agentId); - - // Generate title in background - const info = this.agentManager.listAgents().find((a) => a.id === agentId); - if (!info) { - return; - } - + private startAgentStream(agentId: string, prompt: AgentPromptInput): void { console.log( - `[Session ${this.clientId}] Triggering title generation for agent ${agentId}` + `[Session ${this.clientId}] Starting agent stream for ${agentId}` ); - generateAgentTitle(updates, info.cwd) - .then((title) => { - // Set the title - this.agentManager.setAgentTitle(agentId, title); + let iterator: AsyncGenerator; + try { + iterator = this.agentManager.streamAgent(agentId, prompt); + } catch (error) { + this.handleAgentRunError(agentId, error, "Failed to start agent run"); + return; + } - // Emit agent_status to sync the new title to client - const updatedInfo = this.agentManager - .listAgents() - .find((a) => a.id === agentId); - if (updatedInfo) { - this.emit({ - type: "agent_status", - payload: { - agentId, - status: updatedInfo.status, - info: updatedInfo, - }, - }); + void (async () => { + try { + for await (const _ of iterator) { + // Events are forwarded via the session's AgentManager subscription. } - }) - .catch((error) => { - console.error( - `[Session ${this.clientId}] Failed to generate title for agent ${agentId}:`, - error - ); - }); + } catch (error) { + this.handleAgentRunError(agentId, error, "Agent stream failed"); + } + })(); + } + + private handleAgentRunError( + agentId: string, + error: unknown, + context: string + ): void { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error"; + console.error( + `[Session ${this.clientId}] ${context} for agent ${agentId}:`, + error + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `${context}: ${message}`, + }, + }); } /** @@ -394,7 +270,7 @@ export class Session { }); // Get tools from the client - this.terminalTools = await this.terminalMcpClient.tools(); + this.terminalTools = (await this.terminalMcpClient.tools()) as ToolSet; console.log( `[Session ${this.clientId}] Terminal MCP initialized with session ${this.conversationId}` @@ -413,37 +289,175 @@ export class Session { */ private async initializeAgentMcp(): Promise { try { - // Create Agent MCP server with the global manager const server = await createAgentMcpServer({ agentManager: this.agentManager, + agentRegistry: this.agentRegistry, }); - // Create linked transport pair const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Connect server to its transport await server.connect(serverTransport); - // Create client connected to the other side this.agentMcpClient = await experimental_createMCPClient({ transport: clientTransport, }); - // Get tools from the client - this.agentTools = await this.agentMcpClient.tools(); - + this.agentTools = (await this.agentMcpClient.tools()) as ToolSet; + const agentToolCount = Object.keys(this.agentTools ?? {}).length; console.log( - `[Session ${this.clientId}] Agent MCP initialized with ${ - Object.keys(this.agentTools).length - } tools` + `[Session ${this.clientId}] Agent MCP initialized with ${agentToolCount} tools` ); } catch (error) { console.error( `[Session ${this.clientId}] Failed to initialize Agent MCP:`, error ); - throw error; + } + } + + /** + * Subscribe to AgentManager events and forward them to the client + */ + private subscribeToAgentEvents(): void { + if (this.unsubscribeAgentEvents) { + this.unsubscribeAgentEvents(); + } + + this.unsubscribeAgentEvents = this.agentManager.subscribe( + (event) => { + if (event.type === "agent_state") { + void this.forwardAgentState(event.agent); + return; + } + + const payload = { + agentId: event.agentId, + event: serializeAgentStreamEvent(event.event), + timestamp: new Date().toISOString(), + } as const; + + this.emit({ + type: "agent_stream", + payload, + }); + + if (event.event.type === "permission_requested") { + this.emit({ + type: "agent_permission_request", + payload: { + agentId: event.agentId, + request: event.event.request, + }, + }); + } else if (event.event.type === "permission_resolved") { + this.emit({ + type: "agent_permission_resolved", + payload: { + agentId: event.agentId, + requestId: event.event.requestId, + resolution: event.event.resolution, + }, + }); + } + + if ( + event.event.type === "timeline" || + event.event.type === "turn_completed" + ) { + void this.maybeGenerateAgentTitle(event.agentId); + } + }, + { replayState: false } + ); + } + + private async buildAgentPayload( + agent: AgentSnapshot + ): Promise { + const title = await this.getStoredAgentTitle(agent.id); + return serializeAgentSnapshot(agent, { title }); + } + + private async forwardAgentState(agent: AgentSnapshot): Promise { + try { + const payload = await this.buildAgentPayload(agent); + this.emit({ + type: "agent_state", + payload, + }); + } catch (error) { + console.error( + `[Session ${this.clientId}] Failed to emit agent state:`, + error + ); + } + } + + private async getStoredAgentTitle(agentId: string): Promise { + if (this.agentTitleCache.has(agentId)) { + return this.agentTitleCache.get(agentId) ?? null; + } + + try { + const record = await this.agentRegistry.get(agentId); + const title = record?.title ?? null; + this.agentTitleCache.set(agentId, title); + return title; + } catch (error) { + console.error( + `[Session ${this.clientId}] Failed to load registry record for agent ${agentId}:`, + error + ); + return null; + } + } + + private setCachedTitle(agentId: string, title: string | null): void { + this.agentTitleCache.set(agentId, title); + } + + private async maybeGenerateAgentTitle(agentId: string): Promise { + if (!isTitleGeneratorInitialized()) { + return; + } + + const existingTitle = await this.getStoredAgentTitle(agentId); + if (existingTitle) { + return; + } + + if (ACTIVE_TITLE_GENERATIONS.has(agentId)) { + return; + } + + const timeline = this.agentManager.getTimeline(agentId); + if (timeline.length === 0) { + return; + } + + const snapshot = this.agentManager.getAgent(agentId); + if (!snapshot) { + return; + } + + ACTIVE_TITLE_GENERATIONS.add(agentId); + try { + console.log( + `[Session ${this.clientId}] Generating title for agent ${agentId}` + ); + const title = await generateAgentTitle(timeline, snapshot.cwd); + await this.agentRegistry.setTitle(agentId, title); + this.setCachedTitle(agentId, title); + const latest = this.agentManager.getAgent(agentId) ?? snapshot; + await this.forwardAgentState(latest); + } catch (error) { + console.error( + `[Session ${this.clientId}] Failed to generate title for agent ${agentId}:`, + error + ); + } finally { + ACTIVE_TITLE_GENERATIONS.delete(agentId); } } @@ -499,13 +513,7 @@ export class Session { break; case "create_agent_request": - await this.handleCreateAgentRequest({ - cwd: msg.cwd, - initialMode: msg.initialMode, - requestId: msg.requestId, - worktreeName: msg.worktreeName, - agentType: msg.agentType, - }); + await this.handleCreateAgentRequest(msg); break; case "initialize_agent_request": @@ -520,7 +528,7 @@ export class Session { await this.handleAgentPermissionResponse( msg.agentId, msg.requestId, - msg.optionId + msg.response ); break; @@ -649,7 +657,7 @@ export class Session { private async handleSendAgentMessage( agentId: string, text: string, - messageId?: string, + _messageId?: string, images?: Array<{ data: string; mimeType: string }> ): Promise { console.log( @@ -658,48 +666,8 @@ export class Session { }] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}` ); - try { - // Import ContentBlock type from ACP SDK - type ContentBlock = Parameters[1] extends (string | infer U) ? U extends Array ? T : never : never; - - // Build ContentBlock array - const contentBlocks: ContentBlock[] = [ - { - type: "text", - text, - } as ContentBlock, - ]; - - // Add image blocks if present - if (images && images.length > 0) { - for (const image of images) { - contentBlocks.push({ - type: "image", - data: image.data, - mimeType: image.mimeType, - } as ContentBlock); - } - } - - // sendPrompt will emit the user message notification - await this.agentManager.sendPrompt(agentId, contentBlocks, { messageId }); - console.log(`[Session ${this.clientId}] Sent message to agent ${agentId} with ${contentBlocks.length} content block(s)`); - } catch (error: any) { - console.error( - `[Session ${this.clientId}] Failed to send text to agent ${agentId}:`, - error - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to send message to agent: ${error.message}`, - }, - }); - throw error; - } + const prompt = this.buildAgentPrompt(text, images); + this.startAgentStream(agentId, prompt); } /** @@ -755,7 +723,7 @@ export class Session { }); // Send transcribed text to agent - await this.agentManager.sendPrompt(agentId, transcriptText); + this.startAgentStream(agentId, transcriptText); console.log( `[Session ${this.clientId}] Sent transcribed text to agent ${agentId}` ); @@ -789,30 +757,29 @@ export class Session { ); try { - this.subscribeToAgent(agentId); + const snapshot = this.agentManager.getAgent(agentId); + if (!snapshot) { + throw new Error(`Agent not found: ${agentId}`); + } - const { info, updates } = - await this.agentManager.initializeAgentAndGetHistory(agentId); + await this.forwardAgentState(snapshot); - this.emit({ - type: "agent_initialized", - payload: { - agentId, - info, - updates: updates.map((update) => ({ - agentId: update.agentId, - timestamp: - update.timestamp instanceof Date - ? update.timestamp - : new Date(update.timestamp), - notification: update.notification, - })), - requestId, - }, - }); + const timelineSize = this.emitAgentTimelineSnapshot(snapshot); + + if (requestId) { + this.emit({ + type: "status", + payload: { + status: "agent_initialized", + agentId, + requestId, + timelineSize, + }, + }); + } console.log( - `[Session ${this.clientId}] Agent ${agentId} initialized with ${updates.length} historical updates` + `[Session ${this.clientId}] Agent ${agentId} initialized with ${timelineSize} timeline item(s)` ); } catch (error: any) { console.error( @@ -828,106 +795,62 @@ export class Session { content: `Failed to initialize agent: ${error.message}`, }, }); - throw error; } } /** * Handle create agent request */ - private async handleCreateAgentRequest({ - cwd, - initialMode, - requestId, - worktreeName, - agentType, - }: { - cwd: string; - initialMode?: string; - requestId?: string; - worktreeName?: string; - agentType?: AgentType; - }): Promise { + private async handleCreateAgentRequest( + msg: Extract + ): Promise { + const { config, worktreeName, requestId } = msg; console.log( - `[Session ${this.clientId}] Creating agent in ${cwd} with mode ${ - initialMode || "default" - }${worktreeName ? ` with worktree ${worktreeName}` : ""}` + `[Session ${this.clientId}] Creating agent in ${config.cwd} (${config.provider})${ + worktreeName ? ` with worktree ${worktreeName}` : "" + }` ); try { - let effectiveCwd = cwd; - - // Handle worktree creation if requested - if (worktreeName) { - const { createWorktree } = await import("../utils/worktree.js"); - console.log( - `[Session ${this.clientId}] Creating worktree with branch: ${worktreeName}` + const sessionConfig = await this.buildAgentSessionConfig( + config, + worktreeName + ); + const snapshot = await this.agentManager.createAgent(sessionConfig); + this.setCachedTitle(snapshot.id, null); + try { + await this.agentRegistry.recordConfig( + snapshot.id, + snapshot.provider, + snapshot.cwd, + { + modeId: sessionConfig.modeId, + model: sessionConfig.model, + extra: sessionConfig.extra, + } ); - - // Expand tilde in path before passing to createWorktree - const expandedCwd = expandTilde(cwd); - console.log( - `[Session ${this.clientId}] Expanded cwd from ${cwd} to ${expandedCwd}` - ); - - const worktreeConfig = await createWorktree({ - branchName: worktreeName, - cwd: expandedCwd, - }); - - effectiveCwd = worktreeConfig.worktreePath; - console.log( - `[Session ${this.clientId}] Worktree created at: ${effectiveCwd}` + } catch (registryError) { + console.error( + `[Session ${this.clientId}] Failed to record agent config for ${snapshot.id}:`, + registryError ); } - const normalizedType: AgentType = agentType ?? "claude"; + await this.forwardAgentState(snapshot); - const agentId = await this.agentManager.createAgent({ - cwd: effectiveCwd, - type: normalizedType, - initialMode, - }); + if (requestId) { + this.emit({ + type: "status", + payload: { + status: "agent_created", + agentId: snapshot.id, + requestId, + }, + }); + } - console.log(`[Session ${this.clientId}] Created agent ${agentId}`); - - // Get agent info - const agentInfo = this.agentManager - .listAgents() - .find((a) => a.id === agentId); - console.log(`[Session ${this.clientId}] Agent info:`, { - currentModeId: agentInfo?.currentModeId, - availableModes: agentInfo?.availableModes, - }); - - // Subscribe to agent updates - this.subscribeToAgent(agentId); - - // Auto-initialize agent immediately on explicit creation so it's ready to use console.log( - `[Session ${this.clientId}] Auto-initializing agent ${agentId}` - ); - const { info } = await this.agentManager.initializeAgentAndGetHistory( - agentId - ); - - // Emit agent_created message with initialized info - this.emit({ - type: "agent_created", - payload: { - agentId, - status: info.status, - type: info.type, - currentModeId: info.currentModeId ?? undefined, - availableModes: info.availableModes ?? undefined, - title: info.title ?? undefined, - cwd: info.cwd, - requestId, - }, - }); - console.log( - `[Session ${this.clientId}] Emitted agent_created with status: ${info.status}, currentModeId:`, - info.currentModeId + `[Session ${this.clientId}] Created agent ${snapshot.id} (${snapshot.provider})` ); } catch (error: any) { console.error( @@ -943,10 +866,33 @@ export class Session { content: `Failed to create agent: ${error.message}`, }, }); - throw error; } } + private async buildAgentSessionConfig( + config: AgentSessionConfig, + worktreeName?: string + ): Promise { + let cwd = expandTilde(config.cwd); + + if (worktreeName) { + const { createWorktree } = await import("../utils/worktree.js"); + console.log( + `[Session ${this.clientId}] Creating worktree '${worktreeName}' from ${cwd}` + ); + const worktreeConfig = await createWorktree({ + branchName: worktreeName, + cwd, + }); + cwd = worktreeConfig.worktreePath; + } + + return { + ...config, + cwd, + }; + } + /** * Handle set agent mode request */ @@ -959,23 +905,10 @@ export class Session { ); try { - await this.agentManager.setSessionMode(agentId, modeId); + await this.agentManager.setAgentMode(agentId, modeId); console.log( `[Session ${this.clientId}] Agent ${agentId} mode set to ${modeId}` ); - - // Emit agent_status to notify client of mode change - const info = this.agentManager.listAgents().find((a) => a.id === agentId); - if (info) { - this.emit({ - type: "agent_status", - payload: { - agentId, - status: info.status, - info, - }, - }); - } } catch (error: any) { console.error( `[Session ${this.clientId}] Failed to set agent mode:`, @@ -1000,14 +933,14 @@ export class Session { private async handleAgentPermissionResponse( agentId: string, requestId: string, - optionId: string + response: AgentPermissionResponse ): Promise { console.log( - `[Session ${this.clientId}] Handling permission response for agent ${agentId}, request ${requestId}, option ${optionId}` + `[Session ${this.clientId}] Handling permission response for agent ${agentId}, request ${requestId}` ); try { - this.agentManager.respondToPermission(agentId, requestId, optionId); + await this.agentManager.respondToPermission(agentId, requestId, response); console.log( `[Session ${this.clientId}] Permission response forwarded to agent ${agentId}` ); @@ -1176,7 +1109,10 @@ export class Session { private async sendSessionState(): Promise { try { // Get live agents with session modes - const agents = this.agentManager.listAgents(); + const agentSnapshots = this.agentManager.listAgents(); + const agents = await Promise.all( + agentSnapshots.map((agent) => this.buildAgentPayload(agent)) + ); // Get live commands from terminal manager let commands: any[] = []; @@ -1203,6 +1139,11 @@ export class Session { console.log( `[Session ${this.clientId}] Sent session state: ${agents.length} agents, ${commands.length} commands` ); + + for (const agent of agentSnapshots) { + this.emitAgentTimelineSnapshot(agent); + void this.maybeGenerateAgentTitle(agent.id); + } } catch (error) { console.error( `[Session ${this.clientId}] Failed to send session state:`, @@ -1211,6 +1152,32 @@ export class Session { } } + private emitAgentTimelineSnapshot(agent: AgentSnapshot): number { + const timeline = this.agentManager.getTimeline(agent.id); + if (timeline.length === 0) { + return 0; + } + + const events = timeline.map((item) => ({ + event: serializeAgentStreamEvent({ + type: "timeline", + provider: agent.provider, + item, + }), + timestamp: new Date().toISOString(), + })); + + this.emit({ + type: "agent_stream_snapshot", + payload: { + agentId: agent.id, + events, + }, + }); + + return timeline.length; + } + /** * Handle text message from user */ @@ -1471,22 +1438,22 @@ export class Session { } } - // Wait for agent MCP to initialize if needed if (!this.agentTools) { console.log( `[Session ${this.clientId}] Waiting for agent MCP initialization...` ); - // Wait up to 5 seconds for initialization const startTime = Date.now(); while (!this.agentTools && Date.now() - startTime < 5000) { await new Promise((resolve) => setTimeout(resolve, 100)); } if (!this.agentTools) { - throw new Error("Agent MCP failed to initialize"); + console.log( + `[Session ${this.clientId}] Agent MCP tools unavailable; continuing with default tool set` + ); } } - const allTools = await getAllTools(this.terminalTools, this.agentTools); + const allTools = await getAllTools(this.terminalTools, this.agentTools ?? undefined); const result = await streamText({ model: openrouter("anthropic/claude-haiku-4.5"), @@ -1571,28 +1538,13 @@ export class Session { const result = chunk.output as any; if (result.structuredContent?.agentId) { const agentId = result.structuredContent.agentId; - - // Subscribe to agent updates - this.subscribeToAgent(agentId); - - // Get full agent info and emit agent_created message - const agentInfo = this.agentManager - .listAgents() - .find((a) => a.id === agentId); - if (agentInfo) { - this.emit({ - type: "agent_created", - payload: { - agentId, - status: agentInfo.status, - type: agentInfo.type, - currentModeId: agentInfo.currentModeId ?? undefined, - availableModes: agentInfo.availableModes ?? undefined, - title: agentInfo.title || undefined, - cwd: agentInfo.cwd, - }, - }); - } + this.emit({ + type: "status", + payload: { + status: "agent_created", + agentId, + }, + }); } } @@ -1912,6 +1864,11 @@ export class Session { public async cleanup(): Promise { console.log(`[Session ${this.clientId}] Cleaning up`); + if (this.unsubscribeAgentEvents) { + this.unsubscribeAgentEvents(); + this.unsubscribeAgentEvents = null; + } + // Abort any ongoing operations this.abortController.abort(); @@ -1926,25 +1883,6 @@ export class Session { this.ttsManager.cleanup(); this.sttManager.cleanup(); - // Unsubscribe from all agent updates (agents remain global and persist) - console.log( - `[Session ${this.clientId}] Unsubscribing from ${this.agentUpdateUnsubscribers.size} agent(s)` - ); - for (const [agentId, unsubscribe] of this.agentUpdateUnsubscribers) { - try { - unsubscribe(); - console.log( - `[Session ${this.clientId}] Unsubscribed from agent ${agentId}` - ); - } catch (error) { - console.error( - `[Session ${this.clientId}] Failed to unsubscribe from agent ${agentId}:`, - error - ); - } - } - this.agentUpdateUnsubscribers.clear(); - // Kill tmux session for this conversation try { console.log( @@ -1985,6 +1923,9 @@ export class Session { error ); } + this.agentMcpClient = null; + this.agentTools = null; } + } } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 84676e2d6..e3b782de1 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,5 +1,6 @@ import { WebSocketServer, WebSocket } from "ws"; import { Server as HTTPServer } from "http"; +import type { IncomingMessage } from "http"; import { parse as parseUrl } from "url"; import { WSInboundMessageSchema, @@ -9,7 +10,8 @@ import { } from "./messages.js"; import { Session } from "./session.js"; import { loadConversation } from "./persistence.js"; -import { AgentManager } from "./acp/agent-manager.js"; +import { AgentManager } from "./agent/agent-manager.js"; +import { AgentRegistry } from "./agent/agent-registry.js"; /** * WebSocket server that routes messages between clients and their sessions. @@ -21,9 +23,15 @@ export class VoiceAssistantWebSocketServer { private conversationIdToWs: Map = new Map(); private clientIdCounter: number = 0; private agentManager: AgentManager; + private agentRegistry: AgentRegistry; - constructor(server: HTTPServer, agentManager: AgentManager) { + constructor( + server: HTTPServer, + agentManager: AgentManager, + agentRegistry: AgentRegistry + ) { this.agentManager = agentManager; + this.agentRegistry = agentRegistry; this.wss = new WebSocketServer({ server, path: "/ws" }); this.wss.on("connection", (ws, request) => { @@ -36,7 +44,7 @@ export class VoiceAssistantWebSocketServer { /** * Handle new WebSocket connection */ - private async handleConnection(ws: WebSocket, request: any): Promise { + private async handleConnection(ws: WebSocket, request: IncomingMessage): Promise { // Generate unique client ID const clientId = `client-${++this.clientIdCounter}`; @@ -70,6 +78,7 @@ export class VoiceAssistantWebSocketServer { this.sendToClient(ws, wrapSessionMessage(msg)); }, this.agentManager, + this.agentRegistry, { conversationId, initialMessages: initialMessages || undefined, @@ -163,8 +172,8 @@ export class VoiceAssistantWebSocketServer { // Debug: Log create_agent_request details if (sessionMessage.type === "create_agent_request") { console.log("[WS] create_agent_request details:", { - cwd: sessionMessage.cwd, - initialMode: sessionMessage.initialMode, + cwd: sessionMessage.config.cwd, + initialMode: sessionMessage.config.modeId, worktreeName: sessionMessage.worktreeName, requestId: sessionMessage.requestId, }); @@ -179,8 +188,9 @@ export class VoiceAssistantWebSocketServer { } return; } - } catch (error: any) { + } catch (error) { console.error("[WS] Failed to parse/handle message:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; // Send error to client this.sendToClient( ws, @@ -188,7 +198,7 @@ export class VoiceAssistantWebSocketServer { type: "status", payload: { status: "error", - message: `Invalid message: ${error.message}`, + message: `Invalid message: ${errorMessage}`, }, }) ); diff --git a/packages/server/src/services/agent-title-generator.ts b/packages/server/src/services/agent-title-generator.ts index a00abcd71..9e4239883 100644 --- a/packages/server/src/services/agent-title-generator.ts +++ b/packages/server/src/services/agent-title-generator.ts @@ -1,8 +1,8 @@ import { generateObject } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; import { z } from "zod"; -import type { AgentUpdate } from "../server/acp/types.js"; -import { curateAgentActivity } from "../server/acp/activity-curator.js"; +import type { AgentTimelineItem } from "../server/agent/agent-sdk-types.js"; +import { curateAgentActivity } from "../server/agent/activity-curator.js"; let openai: ReturnType | null = null; @@ -20,18 +20,18 @@ export function isTitleGeneratorInitialized(): boolean { * Returns a 3-5 word title similar to ChatGPT/Claude.ai */ export async function generateAgentTitle( - agentUpdates: AgentUpdate[], + timeline: AgentTimelineItem[], cwd: string ): Promise { if (!openai) { throw new Error("Title generator not initialized"); } - if (agentUpdates.length === 0) { + if (timeline.length === 0) { return "New Agent"; } - const activityContext = curateAgentActivity(agentUpdates); + const activityContext = curateAgentActivity(timeline); if (!activityContext.trim() || activityContext === "No activity to display.") { return "New Agent"; diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..a4e697712 --- /dev/null +++ b/plan.md @@ -0,0 +1,25 @@ +Refactor plan: full migration from ACP to the SDK AgentManager stack. This is a hard refactor—no shims, no temporary adapters. It’s acceptable (and expected) that TypeScript won’t compile in intermediate steps +as long as we keep pushing forward toward the final architecture, and have strong typing in place. + +These tasks need to be done sequentially by different agents: + +- [x] Inventory every ACP dependency (server session/websocket/messages/MCP/title generator/frontend reducers) and document the new AgentManager equivalents + message contracts (see docs/acp-dependency-inventory.md) +- [x] Redesign server message schemas around AgentSnapshot/AgentStreamEvent and add helper types/serializers for the new provider/timeline structures +- [x] Swap server entry (index.ts) to instantiate AgentRegistry + SDK AgentManager (Claude/Codex clients), restore persisted agents, and broadcast agent_state events +- [x] Rewrite WebSocket server + Session controller to use AgentManager APIs (create/resume/run/permissions/ mode) and stream events directly, removing ACP calls +- [x] Review to ensure we haven't lost any functionality or added any regressions, update this list with more tasks as needed (frontend still expects ACP `agent_*` packets, backend title generator + worktree scripts also need updating) +- [x] Update backend services (title generator, MCP bridge, terminal integrations) to consume AgentManager snapshots/events instead of ACP AgentUpdates (title generator now reads AgentManager timelines via the new curator; MCP bridge + terminal integrations already consume AgentManager events) +- [x] Refactor frontend session context + reducers (SessionContext, reduceStreamUpdate, activity components) to handle the new agent_state/agent_stream schemas +- [x] Check work so far, and update this list with more tasks as needed +- [x] Hydrate agent stream history when sessions connect so existing AgentManager timelines appear without manual initialize_agent_request (send timeline snapshots from Session and reuse them in SessionContext) +- [x] Render the remaining AgentTimelineItem variants (command/file_change/web_search/todo/error) in the frontend stream reducer + UI so the new schema is fully visible +- [x] Implement MCP tool surface (agent-mcp) on top of AgentManager snapshots/permissions, drop ACP tooling +- [x] Add persistence hooks (AgentRegistry usage throughout, ensure titles/modes saved) and cover new flows with integration tests +- [x] Delete test: ACP should be gone from the codebase +- [x] Run lint/typecheck/unit/integration suites; stage manual verification (multi-agent sessions, permissions, plan mode, resume) +- [x] Peer review of backend changes; address feedback, then review frontend changes; final regression pass before merging +- [x] Final review of the codebase: check for duplicated code, untyped code, unused imports, etc. + +# Context + +Session now subscribes directly to `AgentManager` events and forwards `agent_state`, `agent_stream`, and permission messages; the websocket layer is back to a thin transport. Next agent should verify downstream consumers (frontend + MCP services) can ingest the new stream schema. diff --git a/test-idempotent-stream.ts b/test-idempotent-stream.ts index 31517a60f..c16610f1f 100644 --- a/test-idempotent-stream.ts +++ b/test-idempotent-stream.ts @@ -1,51 +1,32 @@ -/** - * Test script to verify idempotent stream reduction - * - * This tests that applying the same session updates multiple times - * or in different orders produces identical results. - */ +import { reduceStreamUpdate, hydrateStreamState, type StreamItem } from "./packages/app/src/types/stream"; -import { reduceStreamUpdate, hydrateStreamState, type StreamItem } from './packages/app/src/types/stream'; -import type { SessionNotification } from '@agentclientprotocol/sdk'; +type AgentStreamEventPayload = Parameters[1]; -// Helper to create enriched notifications with messageId -function createAgentMessageChunk(text: string, messageId: string): any { +function assistantTimeline(text: string): AgentStreamEventPayload { return { - update: { - sessionUpdate: 'agent_message_chunk', - content: { text }, - messageId, - }, + type: "timeline", + provider: "claude", + item: { type: "assistant_message", text }, }; } -function createAgentThoughtChunk(text: string, messageId: string): any { +function reasoningTimeline(text: string): AgentStreamEventPayload { return { - update: { - sessionUpdate: 'agent_thought_chunk', - content: { text }, - messageId, - }, + type: "timeline", + provider: "claude", + item: { type: "reasoning", text }, }; } -function createUserMessageChunk(text: string, messageId: string): any { +function toolTimeline(id: string, status: string): AgentStreamEventPayload { return { - update: { - sessionUpdate: 'user_message_chunk', - content: { text }, - messageId, - }, - }; -} - -function createToolCall(toolCallId: string, title: string): any { - return { - update: { - sessionUpdate: 'tool_call', - toolCallId, - title, - status: 'pending', + type: "timeline", + provider: "claude", + item: { + type: "mcp_tool", + server: "terminal", + tool: id, + status, }, }; } @@ -60,11 +41,9 @@ function testIdempotentReduction() { // Create a sequence of updates const updates = [ - { notification: createUserMessageChunk('Hello', 'user-msg-1'), timestamp: timestamp1 }, - { notification: createAgentMessageChunk('Hello! ', 'asst-msg-1'), timestamp: timestamp2 }, - { notification: createAgentMessageChunk('How can I ', 'asst-msg-1'), timestamp: timestamp2 }, - { notification: createAgentMessageChunk('help you?', 'asst-msg-1'), timestamp: timestamp2 }, - { notification: createAgentThoughtChunk('Thinking...', 'thought-1'), timestamp: timestamp3 }, + { event: assistantTimeline("Hello! "), timestamp: timestamp2 }, + { event: assistantTimeline("How can I help you?"), timestamp: timestamp2 }, + { event: reasoningTimeline("Thinking..."), timestamp: timestamp3 }, ]; // Apply updates once @@ -75,10 +54,10 @@ function testIdempotentReduction() { // Apply updates twice (should still be same) const state3 = updates.reduce( - (state, { notification, timestamp }) => { - const s1 = reduceStreamUpdate(state, notification, timestamp); + (state, { event, timestamp }) => { + const s1 = reduceStreamUpdate(state, event, timestamp); // Apply again - return reduceStreamUpdate(s1, notification, timestamp); + return reduceStreamUpdate(s1, event, timestamp); }, [] as StreamItem[] ); @@ -101,8 +80,8 @@ function testIdempotentReduction() { } // Verify message accumulation worked - const assistantMsg = state1.find(item => item.kind === 'assistant_message'); - if (assistantMsg && assistantMsg.text === 'Hello! How can I help you?') { + const assistantMsg = state1.find(item => item.kind === "assistant_message"); + if (assistantMsg && assistantMsg.text === "Hello! How can I help you?") { console.log('✅ PASS: Message chunks accumulated correctly'); } else { console.log('❌ FAIL: Message accumulation failed'); @@ -115,25 +94,24 @@ function testIdempotentReduction() { function testUserMessageDeduplication() { console.log('\n=== Test 2: User Message Deduplication ==='); - const timestamp = new Date('2025-01-01T10:00:00Z'); + const timestamp = new Date("2025-01-01T10:00:00Z"); - // Same user message sent twice (simulating reconnection) const updates = [ - { notification: createUserMessageChunk('Test message', 'user-msg-1'), timestamp }, - { notification: createUserMessageChunk('Test message', 'user-msg-1'), timestamp }, + { event: toolTimeline("tool-1", "pending"), timestamp }, + { event: toolTimeline("tool-1", "completed"), timestamp }, ]; const state = hydrateStreamState(updates); - const userMessages = state.filter(item => item.kind === 'user_message'); + const toolCalls = state.filter((item) => item.kind === "tool_call"); - console.log('User messages in state:', userMessages.length); - console.log('State:', JSON.stringify(state, null, 2)); + console.log("Tool calls in state:", toolCalls.length); + console.log("State:", JSON.stringify(state, null, 2)); - if (userMessages.length === 1) { - console.log('✅ PASS: User message deduplicated correctly'); + if (toolCalls.length === 1 && toolCalls[0].payload.source === "agent" && toolCalls[0].payload.data.status === "completed") { + console.log("✅ PASS: Tool call consolidated correctly"); } else { - console.log('❌ FAIL: Expected 1 user message, got', userMessages.length); + console.log("❌ FAIL: Expected a single completed tool call entry"); } } @@ -141,41 +119,41 @@ function testUserMessageDeduplication() { function testMultipleMessages() { console.log('\n=== Test 3: Multiple Distinct Messages ==='); - const timestamp1 = new Date('2025-01-01T10:00:00Z'); - const timestamp2 = new Date('2025-01-01T10:00:05Z'); + const timestamp1 = new Date("2025-01-01T10:00:00Z"); + const timestamp2 = new Date("2025-01-01T10:00:05Z"); - // Two separate assistant messages (new turn resets message ID) const updates = [ - { notification: createAgentMessageChunk('First ', 'msg-1'), timestamp: timestamp1 }, - { notification: createAgentMessageChunk('message', 'msg-1'), timestamp: timestamp1 }, - { notification: createToolCall('tool-1', 'Read file'), timestamp: timestamp1 }, - { notification: createAgentMessageChunk('Second ', 'msg-2'), timestamp: timestamp2 }, - { notification: createAgentMessageChunk('message', 'msg-2'), timestamp: timestamp2 }, + { event: assistantTimeline("First message"), timestamp: timestamp1 }, + { event: toolTimeline("tool-2", "pending"), timestamp: timestamp1 }, + { event: toolTimeline("tool-2", "failed"), timestamp: timestamp1 }, + { event: assistantTimeline("Second message"), timestamp: timestamp2 }, ]; const state = hydrateStreamState(updates); - const assistantMessages = state.filter(item => item.kind === 'assistant_message'); + const assistantMessages = state.filter((item) => item.kind === "assistant_message"); - console.log('Assistant messages:', assistantMessages.length); - console.log('State:', JSON.stringify(state, null, 2)); + console.log("Assistant messages:", assistantMessages.length); + console.log("State:", JSON.stringify(state, null, 2)); - if (assistantMessages.length === 2 && - assistantMessages[0].text === 'First message' && - assistantMessages[1].text === 'Second message') { - console.log('✅ PASS: Multiple messages handled correctly'); + if ( + assistantMessages.length === 2 && + assistantMessages[0].text === "First message" && + assistantMessages[1].text === "Second message" + ) { + console.log("✅ PASS: Multiple messages handled correctly"); } else { - console.log('❌ FAIL: Expected 2 distinct messages'); + console.log("❌ FAIL: Expected 2 distinct messages"); } } // Run all tests -console.log('Testing Idempotent Stream Reduction'); -console.log('===================================='); +console.log("Testing Idempotent Stream Reduction"); +console.log("===================================="); testIdempotentReduction(); testUserMessageDeduplication(); testMultipleMessages(); -console.log('\n===================================='); -console.log('Tests complete'); +console.log("\n===================================="); +console.log("Tests complete"); diff --git a/test-worktree-agent.ts b/test-worktree-agent.ts deleted file mode 100644 index addf59209..000000000 --- a/test-worktree-agent.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { AgentManager } from "./packages/server/src/server/acp/agent-manager.js"; -import { createWorktree } from "./packages/server/src/utils/worktree.js"; -import { exec } from "child_process"; -import { promisify } from "util"; - -const execAsync = promisify(exec); - -async function testWorktreeAgent() { - console.log("=== Testing Worktree Agent Creation ===\n"); - - const testBranchName = "test-worktree-feature"; - const cwd = process.cwd(); // voice-dev directory - - try { - // Step 1: Test worktree creation directly - console.log("Step 1: Testing worktree creation utility..."); - console.log(` Current directory: ${cwd}`); - console.log(` Branch name: ${testBranchName}\n`); - - const worktreeConfig = await createWorktree({ - branchName: testBranchName, - cwd, - }); - - console.log("✓ Worktree created successfully!"); - console.log(` Branch: ${worktreeConfig.branchName}`); - console.log(` Path: ${worktreeConfig.worktreePath}`); - console.log(` Repo type: ${worktreeConfig.repoType}`); - console.log(` Repo path: ${worktreeConfig.repoPath}\n`); - - // Step 2: Verify worktree exists in git - console.log("Step 2: Verifying worktree in git..."); - const { stdout: worktreeList } = await execAsync("git worktree list"); - console.log("Git worktree list:"); - console.log(worktreeList); - - if (!worktreeList.includes(worktreeConfig.worktreePath)) { - throw new Error("Worktree not found in git worktree list!"); - } - console.log("✓ Worktree verified in git\n"); - - // Step 3: Check pwd and git status in worktree - console.log("Step 3: Checking pwd and git status in worktree..."); - const { stdout: pwd } = await execAsync("pwd", { - cwd: worktreeConfig.worktreePath, - }); - console.log(` pwd: ${pwd.trim()}`); - - const { stdout: gitStatus } = await execAsync("git status", { - cwd: worktreeConfig.worktreePath, - }); - console.log(" git status:"); - console.log(gitStatus); - - if (!pwd.trim().includes(testBranchName)) { - throw new Error(`pwd doesn't include branch name! Got: ${pwd}`); - } - - if (!gitStatus.includes(testBranchName)) { - throw new Error(`git status doesn't show branch! Got: ${gitStatus}`); - } - console.log("✓ Working directory and git status verified\n"); - - // Step 4: Create agent in worktree - console.log("Step 4: Creating agent in worktree..."); - const agentManager = new AgentManager(); - await agentManager.initialize(); - - const agentId = await agentManager.createAgent({ - cwd: worktreeConfig.worktreePath, - }); - - console.log(`✓ Agent created: ${agentId}`); - - // Step 5: Initialize agent and check its cwd - console.log("\nStep 5: Initializing agent and verifying cwd..."); - const { info } = await agentManager.initializeAgentAndGetHistory(agentId); - - console.log(` Agent cwd: ${info.cwd}`); - console.log(` Expected: ${worktreeConfig.worktreePath}`); - - if (info.cwd !== worktreeConfig.worktreePath) { - throw new Error( - `Agent cwd mismatch! Expected ${worktreeConfig.worktreePath}, got ${info.cwd}` - ); - } - console.log("✓ Agent cwd verified\n"); - - // Step 6: Send a prompt to verify it's working in the worktree - console.log("Step 6: Sending test prompt to agent..."); - await agentManager.sendPrompt( - agentId, - "Run pwd and git status to verify you're in a worktree. Reply with the output.", - ); - - 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); - const lastFewUpdates = updates.slice(-5); - console.log("\n Last few agent updates:"); - lastFewUpdates.forEach((update, i) => { - console.log( - ` ${i + 1}. [${update.notification.type}] ${JSON.stringify(update.notification).substring(0, 100)}...` - ); - }); - - // Kill the agent - console.log("\nStep 7: Cleaning up agent..."); - await agentManager.killAgent(agentId); - console.log("✓ Agent killed\n"); - - console.log("=== ALL TESTS PASSED ==="); - console.log("\n⚠️ IMPORTANT: Worktree still exists at:"); - console.log(` ${worktreeConfig.worktreePath}`); - console.log("\nTo clean up manually, run:"); - console.log(` git worktree remove ${worktreeConfig.worktreePath}`); - console.log(` git branch -D ${testBranchName}`); - - process.exit(0); - } catch (error) { - console.error("\n❌ TEST FAILED:"); - console.error(error); - console.log("\n⚠️ If worktree was created, clean up manually with:"); - console.log(` git worktree remove voice-dev-${testBranchName} || true`); - console.log(` git branch -D ${testBranchName} || true`); - process.exit(1); - } -} - -testWorktreeAgent(); diff --git a/test-worktree-e2e.ts b/test-worktree-e2e.ts deleted file mode 100644 index 6ada7c685..000000000 --- a/test-worktree-e2e.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Session } from "./packages/server/src/server/session.js"; -import { AgentManager } from "./packages/server/src/server/acp/agent-manager.js"; -import type { SessionInboundMessage, SessionOutboundMessage } from "./packages/server/src/server/messages.js"; -import { exec } from "child_process"; -import { promisify } from "util"; - -const execAsync = promisify(exec); - -async function testWorktreeE2E() { - console.log("=== Testing End-to-End Worktree Agent Creation ==="); - console.log("(Simulating frontend -> WebSocket -> Session -> AgentManager flow)\n"); - - const testBranchName = "test-e2e-worktree"; - const cwd = process.cwd(); // voice-dev directory - let capturedMessages: SessionOutboundMessage[] = []; - let createdAgentId: string | null = null; - let worktreePath: string | null = null; - - try { - // Step 1: Set up Session (like the WebSocket handler does) - console.log("Step 1: Creating Session and AgentManager..."); - const agentManager = new AgentManager(); - await agentManager.initialize(); - - const session = new Session( - "test-client", - (msg: SessionOutboundMessage) => { - capturedMessages.push(msg); - console.log(` [Session emit] ${msg.type}`); - - // Capture agent_created message - if (msg.type === "agent_created") { - createdAgentId = msg.payload.agentId; - worktreePath = msg.payload.cwd; - console.log(` Agent ID: ${createdAgentId}`); - console.log(` CWD: ${worktreePath}`); - } - }, - agentManager - ); - console.log("✓ Session created\n"); - - // Step 2: Send create_agent_request message (like frontend does) - console.log("Step 2: Sending create_agent_request with worktreeName..."); - const createAgentMessage: SessionInboundMessage = { - type: "create_agent_request", - cwd: cwd, - worktreeName: testBranchName, - requestId: "test-request-123", - }; - - console.log(` Message: ${JSON.stringify(createAgentMessage, null, 2)}`); - - await session.handleMessage(createAgentMessage); - - console.log("✓ Message handled\n"); - - // Step 3: Verify agent_created message was emitted - console.log("Step 3: Verifying agent_created message..."); - const agentCreatedMsg = capturedMessages.find(m => m.type === "agent_created"); - - if (!agentCreatedMsg) { - throw new Error("No agent_created message emitted!"); - } - - if (agentCreatedMsg.type !== "agent_created") { - throw new Error("Wrong message type!"); - } - - console.log(`✓ agent_created message emitted`); - console.log(` Agent ID: ${agentCreatedMsg.payload.agentId}`); - console.log(` CWD: ${agentCreatedMsg.payload.cwd}`); - console.log(` Request ID: ${agentCreatedMsg.payload.requestId}\n`); - - if (!createdAgentId) { - throw new Error("Agent ID not captured!"); - } - - if (!worktreePath) { - throw new Error("Worktree path not captured!"); - } - - // Step 4: Verify worktree was actually created - console.log("Step 4: Verifying worktree exists..."); - const { stdout: worktreeList } = await execAsync("git worktree list"); - - if (!worktreeList.includes(worktreePath)) { - console.error("Git worktree list:"); - console.error(worktreeList); - throw new Error(`Worktree not found in git! Expected: ${worktreePath}`); - } - console.log("✓ Worktree exists in git\n"); - - // Step 5: Verify the worktree path contains branch name - console.log("Step 5: Verifying worktree path..."); - if (!worktreePath.includes(testBranchName)) { - throw new Error(`Worktree path doesn't contain branch name! Path: ${worktreePath}`); - } - console.log(`✓ Worktree path correct: ${worktreePath}\n`); - - // Step 6: Verify agent is in the worktree - console.log("Step 6: Verifying agent CWD..."); - const agentInfo = agentManager.listAgents().find(a => a.id === createdAgentId); - - if (!agentInfo) { - throw new Error("Agent not found in manager!"); - } - - console.log(` Agent CWD: ${agentInfo.cwd}`); - console.log(` Expected: ${worktreePath}`); - - if (agentInfo.cwd !== worktreePath) { - throw new Error(`Agent CWD mismatch! Expected ${worktreePath}, got ${agentInfo.cwd}`); - } - console.log("✓ Agent CWD matches worktree path\n"); - - // Step 7: Test agent can run commands in worktree - console.log("Step 7: Testing agent execution in worktree..."); - await agentManager.sendPrompt( - createdAgentId, - "Run 'pwd' and 'git branch --show-current'. Just show me the output.", - ); - - 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 - .filter(u => u.notification.type === "session" && - u.notification.notification.update.sessionUpdate === "agent_message_chunk") - .map(u => { - const update = u.notification as any; - return update.notification.update.content?.text || ""; - }) - .join(""); - - console.log("\n Agent response:"); - console.log(messageChunks); - - if (!messageChunks.includes(testBranchName)) { - throw new Error("Agent response doesn't mention branch name!"); - } - - if (!messageChunks.includes(worktreePath)) { - throw new Error("Agent response doesn't show worktree path!"); - } - - console.log("\n✓ Agent successfully executing in worktree\n"); - - // Step 8: Cleanup - console.log("Step 8: Cleaning up..."); - await agentManager.killAgent(createdAgentId); - console.log("✓ Agent killed\n"); - - console.log("=== END-TO-END TEST PASSED ==="); - console.log("\n✅ The complete workflow works:"); - console.log(" Frontend creates agent with worktreeName"); - console.log(" → Message flows through Session"); - console.log(" → Session creates worktree"); - console.log(" → Agent is created in worktree"); - console.log(" → Agent executes commands in worktree"); - - console.log("\n⚠️ Worktree still exists at:"); - console.log(` ${worktreePath}`); - console.log("\nCleanup commands:"); - console.log(` git worktree remove ${worktreePath}`); - console.log(` git branch -D ${testBranchName}`); - - process.exit(0); - } catch (error) { - console.error("\n❌ END-TO-END TEST FAILED:"); - console.error(error); - - console.log("\n⚠️ Manual cleanup may be needed:"); - console.log(` git worktree remove voice-dev-${testBranchName} 2>/dev/null || true`); - console.log(` git branch -D ${testBranchName} 2>/dev/null || true`); - - process.exit(1); - } -} - -testWorktreeE2E();