refactor(agent): consolidate agent system into unified AgentManager with provider-based architecture
Replace separate ACP and agent-types modules with unified AgentManager using provider pattern. Migrate Claude and Codex implementations to provider-specific clients. Streamline session-agent integration, remove old ACP notification types, and introduce agent timeline events. Delete obsolete documentation and test files. Add AgentRegistry for persistence and agent-stream-based communication pattern. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>
|
||||
sendPrompt(agentId: string, prompt: string): Promise<void>
|
||||
cancelAgent(agentId: string): Promise<void>
|
||||
killAgent(agentId: string): Promise<void>
|
||||
|
||||
// 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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<typeof sessionUpdate, { sessionUpdate: 'agent_message_chunk' }>;
|
||||
if (chunk.content?.type === "text" && chunk.content?.text) {
|
||||
messageBuffer += chunk.content.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent_thought_chunk": {
|
||||
const chunk = sessionUpdate as Extract<typeof sessionUpdate, { sessionUpdate: 'agent_thought_chunk' }>;
|
||||
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<typeof sessionUpdate, { sessionUpdate: 'tool_call' | 'tool_call_update' }>;
|
||||
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<typeof sessionUpdate, { sessionUpdate: 'plan' }>;
|
||||
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<typeof sessionUpdate, { sessionUpdate: 'user_message_chunk' }>;
|
||||
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.";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<typeof AgentOptionsSchema>;
|
||||
|
||||
// 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<typeof PersistedAgentSchema>;
|
||||
|
||||
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<PersistedAgent[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AgentType, AgentTypeDefinition> = {
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<McpServer> {
|
||||
const { agentManager } = options;
|
||||
|
||||
const agentTypeDefinitions = listAgentTypeDefinitions();
|
||||
if (agentTypeDefinitions.length === 0) {
|
||||
throw new Error("[Agent MCP] No agent types configured");
|
||||
}
|
||||
|
||||
const agentTypeIds = agentTypeDefinitions.map(
|
||||
(definition) => definition.id
|
||||
) as [AgentType, ...AgentType[]];
|
||||
|
||||
const AgentTypeEnum = z.enum(agentTypeIds);
|
||||
|
||||
const server = new McpServer({
|
||||
name: "agent-mcp",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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!");
|
||||
@@ -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<SessionNotification, "update"> {
|
||||
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<void>;
|
||||
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;
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -285,6 +285,23 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
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());
|
||||
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
claude?: Partial<ClaudeAgentOptions>;
|
||||
};
|
||||
mcpServers?: Record<string, ClaudeMcpServerConfig | McpServer | { handler: () => Promise<unknown> }>;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface AgentSession {
|
||||
|
||||
@@ -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<ReturnType<typeof experimental_createMCPClient>>;
|
||||
|
||||
/**
|
||||
* Singleton MCP clients
|
||||
*/
|
||||
let terminalMcpClient: Awaited<
|
||||
ReturnType<typeof experimental_createMCPClient>
|
||||
> | null = null;
|
||||
let terminalMcpClient: McpClient | null = null;
|
||||
|
||||
let playwrightMcpClient: Awaited<
|
||||
ReturnType<typeof experimental_createMCPClient>
|
||||
> | null = null;
|
||||
let playwrightMcpClient: McpClient | null = null;
|
||||
|
||||
/**
|
||||
* Get or create Terminal MCP client (singleton)
|
||||
*/
|
||||
async function getTerminalMcpClient() {
|
||||
async function getTerminalMcpClient(): Promise<McpClient> {
|
||||
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<McpClient> {
|
||||
if (playwrightMcpClient) {
|
||||
return playwrightMcpClient;
|
||||
}
|
||||
@@ -68,10 +66,10 @@ async function getPlaywrightMcpClient() {
|
||||
/**
|
||||
* Cache for merged MCP tools
|
||||
*/
|
||||
let mcpToolsCache: Record<string, any> | null = null;
|
||||
let mcpToolsPromise: Promise<Record<string, any>> | null = null;
|
||||
let mcpToolsCache: ToolSet | null = null;
|
||||
let mcpToolsPromise: Promise<ToolSet> | null = null;
|
||||
|
||||
async function getMcpTools(): Promise<Record<string, any>> {
|
||||
async function getMcpTools(): Promise<ToolSet> {
|
||||
if (mcpToolsCache) {
|
||||
return mcpToolsCache;
|
||||
}
|
||||
@@ -89,12 +87,12 @@ async function getMcpTools(): Promise<Record<string, any>> {
|
||||
}),
|
||||
]);
|
||||
|
||||
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<Record<string, any>> {
|
||||
/**
|
||||
* 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<string, any>,
|
||||
agentTools?: Record<string, any>
|
||||
): Promise<Record<string, any>> {
|
||||
terminalTools?: ToolSet,
|
||||
agentTools?: ToolSet
|
||||
): Promise<ToolSet> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<McpServer> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -94,6 +94,10 @@ type ToolUseCacheEntry = {
|
||||
|
||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, ClaudeMcpServerConfig> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<AgentMode> = 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<AgentCapabilityFlags> = 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<AgentUsage> = 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<AgentPermissionResponse> =
|
||||
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<AgentPermissionRequest> =
|
||||
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<AgentTimelineItem> =
|
||||
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<ProviderEventPayload["raw"]>(),
|
||||
});
|
||||
|
||||
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<AgentPersistenceHandle | null> =
|
||||
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<typeof TranscriptionResultMessa
|
||||
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
|
||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||
export type ConversationLoadedMessage = z.infer<typeof ConversationLoadedMessageSchema>;
|
||||
export type AgentCreatedMessage = z.infer<typeof AgentCreatedMessageSchema>;
|
||||
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
|
||||
export type AgentStateMessage = z.infer<typeof AgentStateMessageSchema>;
|
||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||
export type AgentStreamSnapshotMessage = z.infer<
|
||||
typeof AgentStreamSnapshotMessageSchema
|
||||
>;
|
||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||
export type SessionStateMessage = z.infer<typeof SessionStateMessageSchema>;
|
||||
export type AgentInitializedMessage = z.infer<typeof AgentInitializedMessageSchema>;
|
||||
export type ListConversationsResponseMessage = z.infer<typeof ListConversationsResponseMessageSchema>;
|
||||
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
|
||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
||||
|
||||
@@ -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>): 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>
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<AgentRegistry, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
type AgentManagerRestorer = Pick<AgentManager, "resumeAgent" | "createAgent">;
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<AgentSessionConfig> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, WebSocket> = 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<void> {
|
||||
private async handleConnection(ws: WebSocket, request: IncomingMessage): Promise<void> {
|
||||
// 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}`,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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<typeof createOpenAI> | 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<string> {
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user