feat: implement Claude Code backend and harness-aware UI

- Implement ClaudeCodeClient with subprocess JSON streaming to Claude CLI
- Implement ClaudeCodeBackend with Backend trait for mission execution
- Update mission runner to support both OpenCode and Claude Code backends
- Add harness tabs to Library Configs page (OpenCode/Claude Code)
- Add CLI path configuration for Claude Code in Settings
- Add comprehensive harness system documentation
This commit is contained in:
Thomas Marchand
2026-01-18 14:58:04 +00:00
parent 0303c5e6ae
commit 36fafe193e
8 changed files with 1376 additions and 44 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import useSWR from 'swr';
import { import {
getLibraryOpenCodeSettings, getLibraryOpenCodeSettings,
saveLibraryOpenCodeSettings, saveLibraryOpenCodeSettings,
@@ -10,8 +11,10 @@ import {
saveOpenAgentConfig, saveOpenAgentConfig,
listOpenCodeAgents, listOpenCodeAgents,
OpenAgentConfig, OpenAgentConfig,
listBackends,
getBackendConfig,
} from '@/lib/api'; } from '@/lib/api';
import { Save, Loader, AlertCircle, Check, RefreshCw, RotateCcw, Eye, EyeOff, AlertTriangle, X, GitBranch, Upload } from 'lucide-react'; import { Save, Loader, AlertCircle, Check, RefreshCw, RotateCcw, Eye, EyeOff, AlertTriangle, X, GitBranch, Upload, Info, FileCode, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ConfigCodeEditor } from '@/components/config-code-editor'; import { ConfigCodeEditor } from '@/components/config-code-editor';
import { useLibrary } from '@/contexts/library-context'; import { useLibrary } from '@/contexts/library-context';
@@ -39,6 +42,31 @@ export default function SettingsPage() {
refreshStatus, refreshStatus,
} = useLibrary(); } = useLibrary();
// Harness tab state
const [activeHarness, setActiveHarness] = useState<'opencode' | 'claudecode'>('opencode');
// Fetch backends and their config to show enabled harnesses
const { data: backends = [] } = useSWR('backends', listBackends, {
revalidateOnFocus: false,
fallbackData: [
{ id: 'opencode', name: 'OpenCode' },
{ id: 'claudecode', name: 'Claude Code' },
],
});
const { data: opencodeConfig } = useSWR('backend-opencode-config', () => getBackendConfig('opencode'), {
revalidateOnFocus: false,
});
const { data: claudecodeConfig } = useSWR('backend-claudecode-config', () => getBackendConfig('claudecode'), {
revalidateOnFocus: false,
});
// Filter to only enabled backends
const enabledBackends = backends.filter((b) => {
if (b.id === 'opencode') return opencodeConfig?.enabled !== false;
if (b.id === 'claudecode') return claudecodeConfig?.enabled !== false;
return true;
});
// OpenCode settings state // OpenCode settings state
const [settings, setSettings] = useState<string>(''); const [settings, setSettings] = useState<string>('');
const [originalSettings, setOriginalSettings] = useState<string>(''); const [originalSettings, setOriginalSettings] = useState<string>('');
@@ -407,7 +435,27 @@ export default function SettingsPage() {
</div> </div>
)} )}
{/* OpenCode Settings Section */} {/* Harness Tabs */}
<div className="flex items-center gap-2 mb-2">
{enabledBackends.map((backend) => (
<button
key={backend.id}
onClick={() => setActiveHarness(backend.id === 'claudecode' ? 'claudecode' : 'opencode')}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
activeHarness === backend.id
? 'bg-white/[0.08] border-white/[0.12] text-white'
: 'bg-white/[0.02] border-white/[0.06] text-white/50 hover:text-white/70'
)}
>
{backend.name}
</button>
))}
</div>
{activeHarness === 'opencode' ? (
<>
{/* OpenCode Settings Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -626,6 +674,125 @@ export default function SettingsPage() {
</select> </select>
</div> </div>
</div> </div>
</>
) : (
/* Claude Code Section */
<div className="space-y-6">
{/* Claude Code Info Card */}
<div className="p-6 rounded-xl bg-white/[0.02] border border-white/[0.06]">
<div className="flex items-start gap-4">
<div className="p-3 rounded-xl bg-indigo-500/10">
<Terminal className="h-6 w-6 text-indigo-400" />
</div>
<div className="flex-1 space-y-3">
<div>
<h2 className="text-lg font-medium text-white">Claude Code Configuration</h2>
<p className="text-sm text-white/50 mt-1">
Claude Code uses a workspace-centric configuration model that differs from OpenCode.
</p>
</div>
</div>
</div>
</div>
{/* How It Works */}
<div className="p-6 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-4">
<div className="flex items-center gap-2">
<Info className="h-5 w-5 text-blue-400" />
<h3 className="text-sm font-medium text-white">How Configuration Works</h3>
</div>
<div className="space-y-4 text-sm text-white/60">
<p>
Unlike OpenCode which uses a centralized <code className="text-amber-400 bg-white/[0.04] px-1.5 py-0.5 rounded">oh-my-opencode.json</code> configuration,
Claude Code generates configuration per-workspace from your Library.
</p>
<div className="space-y-2">
<p className="text-white/80 font-medium">Generated files in each workspace:</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li>
<code className="text-emerald-400 bg-white/[0.04] px-1.5 py-0.5 rounded">CLAUDE.md</code>
System prompt and context from Library skills
</li>
<li>
<code className="text-emerald-400 bg-white/[0.04] px-1.5 py-0.5 rounded">.claude/settings.local.json</code>
MCP servers and tool permissions
</li>
</ul>
</div>
<div className="space-y-2">
<p className="text-white/80 font-medium">Configuration sources from Library:</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li>
<code className="text-violet-400 bg-white/[0.04] px-1.5 py-0.5 rounded">skills/</code>
Markdown files become context in CLAUDE.md
</li>
<li>
<code className="text-violet-400 bg-white/[0.04] px-1.5 py-0.5 rounded">mcps/</code>
MCP server definitions for tool access
</li>
<li>
<code className="text-violet-400 bg-white/[0.04] px-1.5 py-0.5 rounded">tools/</code>
Custom tool definitions
</li>
</ul>
</div>
</div>
</div>
{/* Configuration Location */}
<div className="p-6 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-4">
<div className="flex items-center gap-2">
<FileCode className="h-5 w-5 text-emerald-400" />
<h3 className="text-sm font-medium text-white">Where to Configure</h3>
</div>
<div className="space-y-3 text-sm text-white/60">
<p>
To configure Claude Code behavior, edit your Library files:
</p>
<div className="grid gap-3">
<a
href="/config/skills"
className="flex items-center gap-3 p-3 rounded-lg bg-white/[0.02] border border-white/[0.06] hover:bg-white/[0.04] hover:border-white/[0.08] transition-colors"
>
<div className="p-2 rounded-lg bg-violet-500/10">
<FileCode className="h-4 w-4 text-violet-400" />
</div>
<div>
<p className="text-white/80 font-medium">Skills</p>
<p className="text-xs text-white/40">System prompts and context for Claude</p>
</div>
</a>
<a
href="/config/mcps"
className="flex items-center gap-3 p-3 rounded-lg bg-white/[0.02] border border-white/[0.06] hover:bg-white/[0.04] hover:border-white/[0.08] transition-colors"
>
<div className="p-2 rounded-lg bg-emerald-500/10">
<Terminal className="h-4 w-4 text-emerald-400" />
</div>
<div>
<p className="text-white/80 font-medium">MCP Servers</p>
<p className="text-xs text-white/40">Tool servers Claude can access</p>
</div>
</a>
</div>
</div>
</div>
{/* Backend Settings Link */}
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-400/80">
<p className="font-medium text-amber-400">Backend Settings</p>
<p className="mt-1">
To configure Claude Code API key, default model, or CLI path, visit the{' '}
<a href="/settings" className="underline hover:text-amber-300">Settings page</a> Backends Claude Code.
</p>
</div>
</div>
</div>
</div>
)}
{/* Commit Dialog */} {/* Commit Dialog */}
{showCommitDialog && ( {showCommitDialog && (

View File

@@ -116,6 +116,7 @@ export default function SettingsPage() {
const [claudeForm, setClaudeForm] = useState({ const [claudeForm, setClaudeForm] = useState({
api_key: '', api_key: '',
default_model: '', default_model: '',
cli_path: '',
api_key_configured: false, api_key_configured: false,
enabled: true, enabled: true,
}); });
@@ -231,6 +232,7 @@ export default function SettingsPage() {
setClaudeForm((prev) => ({ setClaudeForm((prev) => ({
...prev, ...prev,
default_model: typeof settings.default_model === 'string' ? settings.default_model : '', default_model: typeof settings.default_model === 'string' ? settings.default_model : '',
cli_path: typeof settings.cli_path === 'string' ? settings.cli_path : '',
api_key_configured: Boolean(settings.api_key_configured), api_key_configured: Boolean(settings.api_key_configured),
enabled: claudecodeBackendConfig.enabled, enabled: claudecodeBackendConfig.enabled,
})); }));
@@ -350,6 +352,7 @@ export default function SettingsPage() {
try { try {
const settings: Record<string, unknown> = { const settings: Record<string, unknown> = {
default_model: claudeForm.default_model || null, default_model: claudeForm.default_model || null,
cli_path: claudeForm.cli_path || null,
}; };
if (claudeForm.api_key) { if (claudeForm.api_key) {
settings.api_key = claudeForm.api_key; settings.api_key = claudeForm.api_key;
@@ -829,6 +832,21 @@ export default function SettingsPage() {
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50" className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/> />
</div> </div>
<div>
<label className="block text-xs text-white/60 mb-1.5">CLI Path</label>
<input
type="text"
value={claudeForm.cli_path || ''}
onChange={(e) =>
setClaudeForm((prev) => ({ ...prev, cli_path: e.target.value }))
}
placeholder="claude (uses PATH) or /path/to/claude"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/>
<p className="mt-1.5 text-xs text-white/30">
Path to the Claude CLI executable. Leave blank to use default from PATH.
</p>
</div>
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<button <button
onClick={handleSaveClaudeBackend} onClick={handleSaveClaudeBackend}

226
docs/HARNESS_SYSTEM.md Normal file
View File

@@ -0,0 +1,226 @@
# Harness System
Open Agent supports multiple execution backends (harnesses) for running agent missions. This document explains the harness architecture, configuration, and how to add new backends.
## Overview
A **harness** (also called a backend) is an execution engine that runs agent missions. Open Agent currently supports:
| Harness | Description | Configuration Model |
|---------|-------------|---------------------|
| **OpenCode** | OpenCode-based execution with custom agents | Centralized (`oh-my-opencode.json`) |
| **Claude Code** | Claude CLI subprocess execution | Workspace-centric (`CLAUDE.md`, `.claude/settings.local.json`) |
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Mission Runner │
│ (src/api/mission_runner.rs) │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend Trait │
│ (src/backend/mod.rs) │
│ - id() / name() │
│ - list_agents() │
│ - create_session() │
│ - send_message_streaming() │
└──────────────┬─────────────────────────────────┬────────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────────┐
│ OpenCodeBackend │ │ ClaudeCodeBackend │
│ (src/backend/opencode/) │ │ (src/backend/claudecode/) │
│ │ │ │
│ - HTTP/SSE to OpenCode │ │ - Subprocess to Claude CLI │
│ - oh-my-opencode agents │ │ - Built-in Claude agents │
└──────────────────────────┘ └──────────────────────────────────┘
```
## Backend Trait
All backends implement the `Backend` trait defined in `src/backend/mod.rs`:
```rust
#[async_trait]
pub trait Backend: Send + Sync {
fn id(&self) -> &str;
fn name(&self) -> &str;
async fn list_agents(&self) -> Result<Vec<AgentInfo>, Error>;
async fn create_session(&self, config: SessionConfig) -> Result<Session, Error>;
async fn send_message_streaming(
&self,
session: &Session,
message: &str,
) -> Result<(mpsc::Receiver<ExecutionEvent>, JoinHandle<()>), Error>;
}
```
### ExecutionEvent
Backends emit a unified event stream:
| Event | Description |
|-------|-------------|
| `Thinking { content }` | Agent reasoning/thinking text |
| `TextDelta { content }` | Streaming text delta |
| `ToolCall { id, name, args }` | Tool invocation |
| `ToolResult { id, name, result }` | Tool execution result |
| `MessageComplete { session_id }` | Message/turn complete |
| `Error { message }` | Error occurred |
## OpenCode Backend
OpenCode is the default backend that communicates with an OpenCode server via HTTP/SSE.
### Configuration
**Settings page** → Backends → OpenCode:
- **Base URL**: OpenCode server endpoint (default: `http://127.0.0.1:4096`)
- **Default Agent**: Pre-selected agent for new missions
- **Permissive Mode**: Auto-allow tool permissions
**Library page** → Configs → OpenCode tab:
- Edit `oh-my-opencode.json` for agent definitions, models, and plugins
- Configure agent visibility in mission dialogs
### Agents
OpenCode agents are defined in `oh-my-opencode.json`:
```json
{
"agents": {
"Sisyphus": {
"model": "anthropic/claude-opus-4-5"
},
"document-writer": {
"model": "google/gemini-3-flash-preview"
}
}
}
```
## Claude Code Backend
Claude Code executes missions via the Claude CLI subprocess with JSON streaming.
### Configuration
**Settings page** → Backends → Claude Code:
- **API Key**: Anthropic API key (stored in secrets vault)
- **Default Model**: Model for missions (e.g., `claude-sonnet-4-20250514`)
- **CLI Path**: Path to Claude CLI executable (default: `claude` from PATH)
### Workspace Configuration
Unlike OpenCode's centralized config, Claude Code generates configuration per-workspace from your Library:
| Generated File | Source | Purpose |
|----------------|--------|---------|
| `CLAUDE.md` | `skills/*.md` | System prompt and context |
| `.claude/settings.local.json` | `mcps/`, `tools/` | MCP servers and tool permissions |
### Agents
Claude Code has built-in agents:
| Agent | Description |
|-------|-------------|
| `general-purpose` | General-purpose coding agent |
| `Bash` | Shell command specialist |
| `Explore` | Codebase exploration |
| `Plan` | Implementation planning |
### CLI Protocol
Claude Code communicates via NDJSON streaming:
```bash
echo "prompt" | claude \
--print \
--output-format stream-json \
--verbose \
--include-partial-messages \
--dangerously-skip-permissions \
--model "claude-sonnet-4-20250514" \
--session-id "uuid"
```
Event types:
- `system` (init) → Session initialization
- `stream_event` → Streaming deltas
- `assistant` → Complete messages and tool calls
- `user` → Tool results
- `result` → Final completion
## Enabling/Disabling Backends
Backends can be enabled or disabled in Settings → Backends. Disabled backends:
- Don't appear in mission creation dialogs
- Don't appear in Library Configs tabs
- Cannot be selected for new missions
## Adding a New Backend
To add a new backend (e.g., Codex):
1. **Create backend module**: `src/backend/codex/mod.rs`
- Implement `Backend` trait
- Define event parsing and conversion
2. **Register in routes.rs**:
```rust
backend_registry.write().await.register(
crate::backend::codex::registry_entry()
);
```
3. **Add API endpoints** in `src/api/backends.rs`:
- GET/PUT config handlers
- Secrets management
4. **Update dashboard**:
- Add tab to Settings → Backends
- Add tab to Library → Configs
- Update mission creation dialog
## Mission Runner Integration
The mission runner (`src/api/mission_runner.rs`) selects the backend based on `backend_id`:
```rust
let result = match backend_id.as_str() {
"claudecode" => run_claudecode_turn(...).await,
"opencode" => run_opencode_turn(...).await,
_ => Err(anyhow!("Unknown backend")),
};
```
Each backend handles its own:
- Session management
- Message execution
- Event streaming
- Error handling
## Secrets Management
Backend API keys are stored in the secrets vault:
| Backend | Secret Key |
|---------|------------|
| Claude Code | `claudecode.api_key` |
| OpenCode | Configured via AI Providers |
Access via: `secrets.get_secret("claudecode", "api_key")`
## References
- Backend trait: `src/backend/mod.rs`
- OpenCode backend: `src/backend/opencode/`
- Claude Code backend: `src/backend/claudecode/`
- Mission runner: `src/api/mission_runner.rs`
- Backend API: `src/api/backends.rs`
- Workspace config generation: `src/workspace.rs`

View File

@@ -26,6 +26,7 @@ use uuid::Uuid;
use crate::agents::{AgentContext, AgentRef, TerminalReason}; use crate::agents::{AgentContext, AgentRef, TerminalReason};
use crate::config::Config; use crate::config::Config;
use crate::mcp::McpRegistry; use crate::mcp::McpRegistry;
use crate::secrets::SecretsStore;
use crate::workspace; use crate::workspace;
use super::auth::AuthUser; use super::auth::AuthUser;
@@ -658,6 +659,7 @@ pub struct ControlHub {
mcp: Arc<McpRegistry>, mcp: Arc<McpRegistry>,
workspaces: workspace::SharedWorkspaceStore, workspaces: workspace::SharedWorkspaceStore,
library: SharedLibrary, library: SharedLibrary,
secrets: Option<Arc<SecretsStore>>,
} }
impl ControlHub { impl ControlHub {
@@ -667,6 +669,7 @@ impl ControlHub {
mcp: Arc<McpRegistry>, mcp: Arc<McpRegistry>,
workspaces: workspace::SharedWorkspaceStore, workspaces: workspace::SharedWorkspaceStore,
library: SharedLibrary, library: SharedLibrary,
secrets: Option<Arc<SecretsStore>>,
) -> Self { ) -> Self {
Self { Self {
sessions: Arc::new(RwLock::new(HashMap::new())), sessions: Arc::new(RwLock::new(HashMap::new())),
@@ -675,6 +678,7 @@ impl ControlHub {
mcp, mcp,
workspaces, workspaces,
library, library,
secrets,
} }
} }
@@ -713,6 +717,7 @@ impl ControlHub {
Arc::clone(&self.workspaces), Arc::clone(&self.workspaces),
Arc::clone(&self.library), Arc::clone(&self.library),
mission_store, mission_store,
self.secrets.clone(),
); );
sessions.insert(user.id.clone(), state.clone()); sessions.insert(user.id.clone(), state.clone());
state state
@@ -1809,6 +1814,7 @@ fn spawn_control_session(
workspaces: workspace::SharedWorkspaceStore, workspaces: workspace::SharedWorkspaceStore,
library: SharedLibrary, library: SharedLibrary,
mission_store: Arc<dyn MissionStore>, mission_store: Arc<dyn MissionStore>,
secrets: Option<Arc<SecretsStore>>,
) -> ControlState { ) -> ControlState {
let (cmd_tx, cmd_rx) = mpsc::channel::<ControlCommand>(256); let (cmd_tx, cmd_rx) = mpsc::channel::<ControlCommand>(256);
let (events_tx, events_rx) = broadcast::channel::<AgentEvent>(1024); let (events_tx, events_rx) = broadcast::channel::<AgentEvent>(1024);
@@ -1860,6 +1866,7 @@ fn spawn_control_session(
current_tree, current_tree,
progress, progress,
mission_store, mission_store,
secrets,
)); ));
// Spawn background stale mission cleanup task (if enabled) // Spawn background stale mission cleanup task (if enabled)
@@ -1968,6 +1975,7 @@ async fn control_actor_loop(
current_tree: Arc<RwLock<Option<AgentTreeNode>>>, current_tree: Arc<RwLock<Option<AgentTreeNode>>>,
progress: Arc<RwLock<ExecutionProgress>>, progress: Arc<RwLock<ExecutionProgress>>,
mission_store: Arc<dyn MissionStore>, mission_store: Arc<dyn MissionStore>,
secrets: Option<Arc<SecretsStore>>,
) { ) {
// Queue stores (id, content, agent) for the current/primary mission // Queue stores (id, content, agent) for the current/primary mission
let mut queue: VecDeque<(Uuid, String, Option<String>)> = VecDeque::new(); let mut queue: VecDeque<(Uuid, String, Option<String>)> = VecDeque::new();
@@ -2304,6 +2312,7 @@ async fn control_actor_loop(
Arc::clone(&status), Arc::clone(&status),
mission_cmd_tx.clone(), mission_cmd_tx.clone(),
Arc::new(RwLock::new(Some(tid))), Arc::new(RwLock::new(Some(tid))),
secrets.clone(),
); );
} }
let _ = respond.send(was_running); let _ = respond.send(was_running);
@@ -2361,6 +2370,7 @@ async fn control_actor_loop(
Arc::clone(&status), Arc::clone(&status),
mission_cmd_tx.clone(), mission_cmd_tx.clone(),
Arc::new(RwLock::new(Some(tid))), Arc::new(RwLock::new(Some(tid))),
secrets.clone(),
); );
tracing::info!("Auto-started mission {} in parallel", tid); tracing::info!("Auto-started mission {} in parallel", tid);
parallel_runners.insert(tid, runner); parallel_runners.insert(tid, runner);
@@ -2722,6 +2732,7 @@ async fn control_actor_loop(
Arc::clone(&status), Arc::clone(&status),
mission_cmd_tx.clone(), mission_cmd_tx.clone(),
Arc::new(RwLock::new(Some(mission_id))), // Each runner tracks its own mission Arc::new(RwLock::new(Some(mission_id))), // Each runner tracks its own mission
secrets.clone(),
); );
if started { if started {

View File

@@ -19,8 +19,10 @@ use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason}; use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason};
use crate::backend::claudecode::client::{ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent};
use crate::config::Config; use crate::config::Config;
use crate::mcp::McpRegistry; use crate::mcp::McpRegistry;
use crate::secrets::SecretsStore;
use crate::task::{extract_deliverables, DeliverableSet}; use crate::task::{extract_deliverables, DeliverableSet};
use crate::workspace; use crate::workspace;
@@ -225,6 +227,7 @@ impl MissionRunner {
status: Arc<RwLock<ControlStatus>>, status: Arc<RwLock<ControlStatus>>,
mission_cmd_tx: mpsc::Sender<crate::tools::mission::MissionControlCommand>, mission_cmd_tx: mpsc::Sender<crate::tools::mission::MissionControlCommand>,
current_mission: Arc<RwLock<Option<Uuid>>>, current_mission: Arc<RwLock<Option<Uuid>>>,
secrets: Option<Arc<SecretsStore>>,
) -> bool { ) -> bool {
// Don't start if already running // Don't start if already running
if self.is_running() { if self.is_running() {
@@ -294,6 +297,7 @@ impl MissionRunner {
Some(workspace_id), Some(workspace_id),
backend_id, backend_id,
agent_override, agent_override,
secrets,
) )
.await; .await;
(msg_id, user_message, result) (msg_id, user_message, result)
@@ -397,10 +401,12 @@ async fn run_mission_turn(
workspace_id: Option<Uuid>, workspace_id: Option<Uuid>,
backend_id: String, backend_id: String,
agent_override: Option<String>, agent_override: Option<String>,
secrets: Option<Arc<SecretsStore>>,
) -> AgentResult { ) -> AgentResult {
let mut config = config; let mut config = config;
if let Some(agent) = agent_override { let effective_agent = agent_override.clone();
config.opencode_agent = Some(agent); if let Some(ref agent) = effective_agent {
config.opencode_agent = Some(agent.clone());
} }
tracing::info!( tracing::info!(
mission_id = %mission_id, mission_id = %mission_id,
@@ -500,31 +506,45 @@ async fn run_mission_turn(
} }
}; };
let mut ctx = AgentContext::new(config.clone(), mission_work_dir); // Execute based on backend
ctx.mission_control = mission_control; let result = match backend_id.as_str() {
ctx.control_events = Some(events_tx.clone()); "claudecode" => {
ctx.frontend_tool_hub = Some(tool_hub); run_claudecode_turn(
ctx.control_status = Some(status); &mission_work_dir,
ctx.cancel_token = Some(cancel); &user_message,
ctx.tree_snapshot = Some(tree_snapshot); config.default_model.as_deref(),
ctx.progress_snapshot = Some(progress_snapshot); effective_agent.as_deref(),
ctx.mission_id = Some(mission_id); mission_id,
ctx.mcp = Some(mcp); events_tx.clone(),
cancel,
secrets,
)
.await
}
"opencode" => {
let mut ctx = AgentContext::new(config.clone(), mission_work_dir);
ctx.mission_control = mission_control;
ctx.control_events = Some(events_tx.clone());
ctx.frontend_tool_hub = Some(tool_hub);
ctx.control_status = Some(status);
ctx.cancel_token = Some(cancel);
ctx.tree_snapshot = Some(tree_snapshot);
ctx.progress_snapshot = Some(progress_snapshot);
ctx.mission_id = Some(mission_id);
ctx.mcp = Some(mcp);
root_agent.execute(&mut task, &ctx).await
}
_ => {
let _ = events_tx.send(AgentEvent::Error {
message: format!("Unsupported backend: {}", backend_id),
mission_id: Some(mission_id),
resumable: true,
});
AgentResult::failure(format!("Unsupported backend: {}", backend_id), 0)
.with_terminal_reason(TerminalReason::LlmError)
}
};
if backend_id != "opencode" {
let _ = events_tx.send(AgentEvent::Error {
message: format!(
"Backend '{}' is not supported for in-app execution yet. Please use OpenCode or run Claude Code locally.",
backend_id
),
mission_id: Some(mission_id),
resumable: true,
});
return AgentResult::failure(format!("Unsupported backend: {}", backend_id), 0)
.with_terminal_reason(TerminalReason::LlmError);
}
let result = root_agent.execute(&mut task, &ctx).await;
tracing::info!( tracing::info!(
mission_id = %mission_id, mission_id = %mission_id,
success = result.success, success = result.success,
@@ -536,6 +556,239 @@ async fn run_mission_turn(
result result
} }
/// Execute a turn using Claude Code CLI backend.
async fn run_claudecode_turn(
work_dir: &std::path::Path,
message: &str,
model: Option<&str>,
agent: Option<&str>,
mission_id: Uuid,
events_tx: broadcast::Sender<AgentEvent>,
cancel: CancellationToken,
secrets: Option<Arc<SecretsStore>>,
) -> AgentResult {
use std::collections::HashMap;
// Get API key from secrets
let api_key = if let Some(ref store) = secrets {
match store.get_secret("claudecode", "api_key").await {
Ok(key) => Some(key),
Err(e) => {
tracing::warn!("Failed to get Claude API key from secrets: {}", e);
// Fall back to environment variable
std::env::var("ANTHROPIC_API_KEY").ok()
}
}
} else {
std::env::var("ANTHROPIC_API_KEY").ok()
};
// Determine CLI path
let cli_path = std::env::var("CLAUDE_CLI_PATH")
.unwrap_or_else(|_| "claude".to_string());
let config = ClaudeCodeConfig {
cli_path,
api_key,
default_model: model.map(|s| s.to_string()),
};
let client = ClaudeCodeClient::with_config(config);
let session_id = client.create_session_id();
tracing::info!(
mission_id = %mission_id,
session_id = %session_id,
work_dir = %work_dir.display(),
model = ?model,
agent = ?agent,
"Starting Claude Code execution"
);
// Execute the message
let (mut event_rx, process_handle) = match client
.execute_message(
work_dir.to_str().unwrap_or("."),
message,
model,
Some(&session_id),
agent,
)
.await
{
Ok(r) => r,
Err(e) => {
let err_msg = format!("Failed to start Claude CLI: {}", e);
tracing::error!("{}", err_msg);
let _ = events_tx.send(AgentEvent::Error {
message: err_msg.clone(),
mission_id: Some(mission_id),
resumable: true,
});
return AgentResult::failure(err_msg, 0)
.with_terminal_reason(TerminalReason::LlmError);
}
};
// Track tool calls for result mapping
let mut pending_tools: HashMap<String, String> = HashMap::new();
let mut total_cost_usd = 0.0f64;
let mut final_result = String::new();
let mut had_error = false;
// Process events until completion or cancellation
loop {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!(mission_id = %mission_id, "Claude Code execution cancelled");
// Process will be dropped and killed
return AgentResult::failure("Cancelled".to_string(), 0)
.with_terminal_reason(TerminalReason::Cancelled);
}
event = event_rx.recv() => {
match event {
Some(claude_event) => {
match claude_event {
ClaudeEvent::System(sys) => {
tracing::debug!(
"Claude session init: session_id={}, model={:?}",
sys.session_id, sys.model
);
}
ClaudeEvent::StreamEvent(wrapper) => {
match wrapper.event {
StreamEvent::ContentBlockDelta { delta, .. } => {
if let Some(text) = delta.text {
if !text.is_empty() {
let _ = events_tx.send(AgentEvent::Thinking {
content: text,
done: false,
mission_id: Some(mission_id),
});
}
}
}
StreamEvent::ContentBlockStart { content_block, .. } => {
if content_block.block_type == "tool_use" {
if let (Some(id), Some(name)) = (content_block.id, content_block.name) {
pending_tools.insert(id, name);
}
}
}
_ => {}
}
}
ClaudeEvent::Assistant(evt) => {
for block in evt.message.content {
match block {
ContentBlock::Text { text } => {
if !text.is_empty() {
final_result = text.clone();
let _ = events_tx.send(AgentEvent::Thinking {
content: text,
done: false,
mission_id: Some(mission_id),
});
}
}
ContentBlock::ToolUse { id, name, input } => {
pending_tools.insert(id.clone(), name.clone());
let _ = events_tx.send(AgentEvent::ToolCall {
tool_call_id: id.clone(),
name: name.clone(),
args: input,
mission_id: Some(mission_id),
});
}
ContentBlock::Thinking { thinking } => {
if !thinking.is_empty() {
let _ = events_tx.send(AgentEvent::Thinking {
content: thinking,
done: false,
mission_id: Some(mission_id),
});
}
}
_ => {}
}
}
}
ClaudeEvent::User(evt) => {
for block in evt.message.content {
if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block {
let name = pending_tools
.get(&tool_use_id)
.cloned()
.unwrap_or_else(|| "unknown".to_string());
let result_value = if let Some(ref extra) = evt.tool_use_result {
serde_json::json!({
"content": content,
"stdout": extra.stdout,
"stderr": extra.stderr,
"is_error": is_error,
})
} else {
serde_json::Value::String(content)
};
let _ = events_tx.send(AgentEvent::ToolResult {
tool_call_id: tool_use_id,
name,
result: result_value,
mission_id: Some(mission_id),
});
}
}
}
ClaudeEvent::Result(res) => {
if let Some(cost) = res.total_cost_usd {
total_cost_usd = cost;
}
if res.is_error || res.subtype == "error" {
had_error = true;
let err_msg = res.result.unwrap_or_else(|| "Unknown error".to_string());
let _ = events_tx.send(AgentEvent::Error {
message: err_msg.clone(),
mission_id: Some(mission_id),
resumable: true,
});
final_result = err_msg;
} else if let Some(result) = res.result {
final_result = result;
}
tracing::info!(
mission_id = %mission_id,
cost_usd = total_cost_usd,
"Claude Code execution completed"
);
break;
}
}
}
None => {
// Channel closed - process finished
break;
}
}
}
}
}
// Wait for process to finish
let _ = process_handle.await;
// Convert cost from USD to cents
let cost_cents = (total_cost_usd * 100.0) as u64;
if had_error {
AgentResult::failure(final_result, cost_cents)
.with_terminal_reason(TerminalReason::LlmError)
} else {
AgentResult::success(final_result, cost_cents)
}
}
/// Compact info about a running mission (for API responses). /// Compact info about a running mission (for API responses).
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct RunningMissionInfo { pub struct RunningMissionInfo {

View File

@@ -276,6 +276,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
Arc::clone(&mcp), Arc::clone(&mcp),
Arc::clone(&workspaces), Arc::clone(&workspaces),
Arc::clone(&library), Arc::clone(&library),
secrets.clone(),
); );
let state = Arc::new(AppState { let state = Arc::new(AppState {

View File

@@ -1,13 +1,429 @@
use anyhow::{anyhow, Result};
use serde::Deserialize;
use serde_json::Value;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use uuid::Uuid; use uuid::Uuid;
pub struct ClaudeCodeClient; /// Events emitted by the Claude CLI in stream-json mode.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum ClaudeEvent {
#[serde(rename = "system")]
System(SystemEvent),
#[serde(rename = "stream_event")]
StreamEvent(StreamEventWrapper),
#[serde(rename = "assistant")]
Assistant(AssistantEvent),
#[serde(rename = "user")]
User(UserEvent),
#[serde(rename = "result")]
Result(ResultEvent),
}
#[derive(Debug, Clone, Deserialize)]
pub struct SystemEvent {
pub subtype: String,
pub session_id: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StreamEventWrapper {
pub event: StreamEvent,
pub session_id: String,
#[serde(default)]
pub parent_tool_use_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum StreamEvent {
#[serde(rename = "message_start")]
MessageStart { message: Value },
#[serde(rename = "content_block_start")]
ContentBlockStart {
index: u32,
content_block: ContentBlockInfo,
},
#[serde(rename = "content_block_delta")]
ContentBlockDelta { index: u32, delta: Delta },
#[serde(rename = "content_block_stop")]
ContentBlockStop { index: u32 },
#[serde(rename = "message_delta")]
MessageDelta { delta: Value, usage: Option<Value> },
#[serde(rename = "message_stop")]
MessageStop,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContentBlockInfo {
#[serde(rename = "type")]
pub block_type: String,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Delta {
#[serde(rename = "type")]
pub delta_type: String,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub partial_json: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AssistantEvent {
pub message: AssistantMessage,
pub session_id: String,
#[serde(default)]
pub parent_tool_use_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AssistantMessage {
#[serde(default)]
pub content: Vec<ContentBlock>,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
#[serde(default)]
is_error: bool,
},
#[serde(rename = "thinking")]
Thinking {
thinking: String,
},
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserEvent {
pub message: UserMessage,
pub session_id: String,
#[serde(default)]
pub tool_use_result: Option<ToolUseResultExtra>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserMessage {
#[serde(default)]
pub content: Vec<ContentBlock>,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ToolUseResultExtra {
#[serde(default)]
pub stdout: Option<String>,
#[serde(default)]
pub stderr: Option<String>,
#[serde(default)]
pub interrupted: bool,
#[serde(default, rename = "isImage")]
pub is_image: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ResultEvent {
pub subtype: String,
#[serde(default)]
pub result: Option<String>,
pub session_id: String,
#[serde(default)]
pub is_error: bool,
#[serde(default)]
pub total_cost_usd: Option<f64>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub num_turns: Option<u32>,
}
/// Configuration for the Claude Code client.
#[derive(Debug, Clone)]
pub struct ClaudeCodeConfig {
pub cli_path: String,
pub api_key: Option<String>,
pub default_model: Option<String>,
}
impl Default for ClaudeCodeConfig {
fn default() -> Self {
Self {
cli_path: std::env::var("CLAUDE_CLI_PATH")
.unwrap_or_else(|_| "claude".to_string()),
api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
default_model: None,
}
}
}
/// Client for communicating with the Claude CLI.
pub struct ClaudeCodeClient {
config: ClaudeCodeConfig,
}
impl ClaudeCodeClient { impl ClaudeCodeClient {
pub fn new() -> Self { pub fn new() -> Self {
Self Self {
config: ClaudeCodeConfig::default(),
}
}
pub fn with_config(config: ClaudeCodeConfig) -> Self {
Self { config }
} }
pub fn create_session_id(&self) -> String { pub fn create_session_id(&self) -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
/// Execute a message and return a stream of events.
pub async fn execute_message(
&self,
directory: &str,
message: &str,
model: Option<&str>,
session_id: Option<&str>,
agent: Option<&str>,
) -> Result<(mpsc::Receiver<ClaudeEvent>, tokio::task::JoinHandle<()>)> {
let (tx, rx) = mpsc::channel(256);
let mut cmd = Command::new(&self.config.cli_path);
cmd.current_dir(directory)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg("--print")
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--include-partial-messages")
.arg("--dangerously-skip-permissions");
// Set API key if configured
if let Some(ref api_key) = self.config.api_key {
cmd.env("ANTHROPIC_API_KEY", api_key);
}
// Model selection
let effective_model = model.or(self.config.default_model.as_deref());
if let Some(m) = effective_model {
cmd.arg("--model").arg(m);
}
// Session ID for continuity
if let Some(sid) = session_id {
cmd.arg("--session-id").arg(sid);
}
// Agent selection
if let Some(a) = agent {
cmd.arg("--agent").arg(a);
}
info!(
"Spawning Claude CLI: directory={}, model={:?}, session_id={:?}, agent={:?}",
directory, effective_model, session_id, agent
);
let mut child = cmd.spawn().map_err(|e| {
error!("Failed to spawn Claude CLI: {}", e);
anyhow!("Failed to spawn Claude CLI: {}. Is it installed at '{}'?", e, self.config.cli_path)
})?;
// Write message to stdin
if let Some(mut stdin) = child.stdin.take() {
let msg = message.to_string();
tokio::spawn(async move {
if let Err(e) = stdin.write_all(msg.as_bytes()).await {
error!("Failed to write to Claude stdin: {}", e);
}
// Close stdin to signal end of input
drop(stdin);
});
}
// Spawn task to read stdout and parse events
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("Failed to capture Claude stdout"))?;
let handle = tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.is_empty() {
continue;
}
match serde_json::from_str::<ClaudeEvent>(&line) {
Ok(event) => {
debug!("Claude event: {:?}", event);
if tx.send(event).await.is_err() {
debug!("Receiver dropped, stopping Claude event stream");
break;
}
}
Err(e) => {
// Log but don't fail - some lines might be non-JSON
warn!(
"Failed to parse Claude event: {} - line: {}",
e,
if line.len() > 200 {
format!("{}...", &line[..200])
} else {
line.clone()
}
);
}
}
}
// Wait for process to finish
match child.wait().await {
Ok(status) => {
if !status.success() {
warn!("Claude CLI exited with status: {}", status);
} else {
debug!("Claude CLI exited successfully");
}
}
Err(e) => {
error!("Failed to wait for Claude CLI: {}", e);
}
}
});
Ok((rx, handle))
}
/// Get available agents from the Claude CLI.
pub async fn list_agents(&self) -> Result<Vec<String>> {
// Claude Code has built-in agents that are always available
// These are discovered from the init event, but we can provide defaults
Ok(vec![
"general-purpose".to_string(),
"Bash".to_string(),
"Explore".to_string(),
"Plan".to_string(),
])
}
}
impl Default for ClaudeCodeClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_system_event() {
let json = r#"{"type":"system","subtype":"init","cwd":"/tmp","session_id":"abc123","tools":["Bash","Read"],"model":"claude-sonnet-4-20250514","agents":["general-purpose","Bash"]}"#;
let event: ClaudeEvent = serde_json::from_str(json).unwrap();
match event {
ClaudeEvent::System(sys) => {
assert_eq!(sys.subtype, "init");
assert_eq!(sys.session_id, "abc123");
assert_eq!(sys.agents.len(), 2);
}
_ => panic!("Expected System event"),
}
}
#[test]
fn test_parse_stream_event_delta() {
let json = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}},"session_id":"abc123"}"#;
let event: ClaudeEvent = serde_json::from_str(json).unwrap();
match event {
ClaudeEvent::StreamEvent(wrapper) => {
assert_eq!(wrapper.session_id, "abc123");
match wrapper.event {
StreamEvent::ContentBlockDelta { delta, .. } => {
assert_eq!(delta.text, Some("Hello".to_string()));
}
_ => panic!("Expected ContentBlockDelta"),
}
}
_ => panic!("Expected StreamEvent"),
}
}
#[test]
fn test_parse_assistant_with_tool_use() {
let json = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_123","name":"Bash","input":{"command":"ls"}}],"stop_reason":"tool_use"},"session_id":"abc123"}"#;
let event: ClaudeEvent = serde_json::from_str(json).unwrap();
match event {
ClaudeEvent::Assistant(evt) => {
assert_eq!(evt.message.stop_reason, Some("tool_use".to_string()));
assert_eq!(evt.message.content.len(), 1);
match &evt.message.content[0] {
ContentBlock::ToolUse { id, name, .. } => {
assert_eq!(id, "toolu_123");
assert_eq!(name, "Bash");
}
_ => panic!("Expected ToolUse content"),
}
}
_ => panic!("Expected Assistant event"),
}
}
#[test]
fn test_parse_result_event() {
let json = r#"{"type":"result","subtype":"success","result":"Done","session_id":"abc123","is_error":false,"total_cost_usd":0.05}"#;
let event: ClaudeEvent = serde_json::from_str(json).unwrap();
match event {
ClaudeEvent::Result(res) => {
assert_eq!(res.subtype, "success");
assert_eq!(res.result, Some("Done".to_string()));
assert!(!res.is_error);
assert_eq!(res.total_cost_usd, Some(0.05));
}
_ => panic!("Expected Result event"),
}
}
} }

View File

@@ -1,20 +1,24 @@
mod client; pub mod client;
use anyhow::Error; use anyhow::Error;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::{mpsc, RwLock};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::debug;
use crate::backend::events::ExecutionEvent; use crate::backend::events::ExecutionEvent;
use crate::backend::{AgentInfo, Backend, Session, SessionConfig}; use crate::backend::{AgentInfo, Backend, Session, SessionConfig};
use client::ClaudeCodeClient; use client::{ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent};
/// Claude Code backend that spawns the Claude CLI for mission execution.
pub struct ClaudeCodeBackend { pub struct ClaudeCodeBackend {
id: String, id: String,
name: String, name: String,
client: ClaudeCodeClient, config: Arc<RwLock<ClaudeCodeConfig>>,
} }
impl ClaudeCodeBackend { impl ClaudeCodeBackend {
@@ -22,9 +26,34 @@ impl ClaudeCodeBackend {
Self { Self {
id: "claudecode".to_string(), id: "claudecode".to_string(),
name: "Claude Code".to_string(), name: "Claude Code".to_string(),
client: ClaudeCodeClient::new(), config: Arc::new(RwLock::new(ClaudeCodeConfig::default())),
} }
} }
pub fn with_config(config: ClaudeCodeConfig) -> Self {
Self {
id: "claudecode".to_string(),
name: "Claude Code".to_string(),
config: Arc::new(RwLock::new(config)),
}
}
/// Update the backend configuration.
pub async fn update_config(&self, config: ClaudeCodeConfig) {
let mut cfg = self.config.write().await;
*cfg = config;
}
/// Get the current configuration.
pub async fn get_config(&self) -> ClaudeCodeConfig {
self.config.read().await.clone()
}
}
impl Default for ClaudeCodeBackend {
fn default() -> Self {
Self::new()
}
} }
#[async_trait] #[async_trait]
@@ -38,12 +67,31 @@ impl Backend for ClaudeCodeBackend {
} }
async fn list_agents(&self) -> Result<Vec<AgentInfo>, Error> { async fn list_agents(&self) -> Result<Vec<AgentInfo>, Error> {
Ok(vec![]) // Claude Code has built-in agents
Ok(vec![
AgentInfo {
id: "general-purpose".to_string(),
name: "General Purpose".to_string(),
},
AgentInfo {
id: "Bash".to_string(),
name: "Bash Specialist".to_string(),
},
AgentInfo {
id: "Explore".to_string(),
name: "Codebase Explorer".to_string(),
},
AgentInfo {
id: "Plan".to_string(),
name: "Planner".to_string(),
},
])
} }
async fn create_session(&self, config: SessionConfig) -> Result<Session, Error> { async fn create_session(&self, config: SessionConfig) -> Result<Session, Error> {
let client = ClaudeCodeClient::new();
Ok(Session { Ok(Session {
id: self.client.create_session_id(), id: client.create_session_id(),
directory: config.directory, directory: config.directory,
model: config.model, model: config.model,
agent: config.agent, agent: config.agent,
@@ -53,24 +101,216 @@ impl Backend for ClaudeCodeBackend {
async fn send_message_streaming( async fn send_message_streaming(
&self, &self,
session: &Session, session: &Session,
_message: &str, message: &str,
) -> Result<(mpsc::Receiver<ExecutionEvent>, JoinHandle<()>), Error> { ) -> Result<(mpsc::Receiver<ExecutionEvent>, JoinHandle<()>), Error> {
let (tx, rx) = mpsc::channel(4); let config = self.config.read().await.clone();
let client = ClaudeCodeClient::with_config(config);
let (mut claude_rx, claude_handle) = client
.execute_message(
&session.directory,
message,
session.model.as_deref(),
Some(&session.id),
session.agent.as_deref(),
)
.await?;
let (tx, rx) = mpsc::channel(256);
let session_id = session.id.clone(); let session_id = session.id.clone();
// Spawn event conversion task
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
// Track pending tool calls for name lookup
let mut pending_tools: HashMap<String, String> = HashMap::new();
while let Some(event) = claude_rx.recv().await {
let exec_events = convert_claude_event(event, &mut pending_tools);
for exec_event in exec_events {
if tx.send(exec_event).await.is_err() {
debug!("ExecutionEvent receiver dropped");
break;
}
}
}
// Ensure MessageComplete is sent
let _ = tx let _ = tx
.send(ExecutionEvent::Error { .send(ExecutionEvent::MessageComplete {
message: "Claude Code backend is not configured".to_string(), session_id: session_id.clone(),
}) })
.await; .await;
let _ = tx
.send(ExecutionEvent::MessageComplete { session_id }) // Wait for Claude process to finish
.await; let _ = claude_handle.await;
}); });
Ok((rx, handle)) Ok((rx, handle))
} }
} }
/// Convert a Claude CLI event to one or more ExecutionEvents.
fn convert_claude_event(
event: ClaudeEvent,
pending_tools: &mut HashMap<String, String>,
) -> Vec<ExecutionEvent> {
let mut results = vec![];
match event {
ClaudeEvent::System(sys) => {
debug!(
"Claude session initialized: session_id={}, model={:?}, agents={:?}",
sys.session_id, sys.model, sys.agents
);
// System init doesn't map to an ExecutionEvent
}
ClaudeEvent::StreamEvent(wrapper) => {
match wrapper.event {
StreamEvent::ContentBlockDelta { delta, .. } => {
// Text streaming
if let Some(text) = delta.text {
if !text.is_empty() {
results.push(ExecutionEvent::TextDelta { content: text });
}
}
// Tool input streaming (partial JSON)
if let Some(partial) = delta.partial_json {
debug!("Tool input delta: {}", partial);
}
}
StreamEvent::ContentBlockStart { content_block, .. } => {
// Track tool use starts
if content_block.block_type == "tool_use" {
if let (Some(id), Some(name)) = (content_block.id, content_block.name) {
pending_tools.insert(id, name);
}
}
}
_ => {
// Other stream events (message_start, message_stop, etc.)
}
}
}
ClaudeEvent::Assistant(evt) => {
for block in evt.message.content {
match block {
ContentBlock::Text { text } => {
// Complete text block - emit as thinking
if !text.is_empty() {
results.push(ExecutionEvent::Thinking { content: text });
}
}
ContentBlock::ToolUse { id, name, input } => {
// Track tool for result mapping
pending_tools.insert(id.clone(), name.clone());
results.push(ExecutionEvent::ToolCall {
id,
name,
args: input,
});
}
ContentBlock::Thinking { thinking } => {
if !thinking.is_empty() {
results.push(ExecutionEvent::Thinking { content: thinking });
}
}
ContentBlock::ToolResult { .. } => {
// Tool results in assistant messages are unusual
}
}
}
}
ClaudeEvent::User(evt) => {
// User events contain tool results
for block in evt.message.content {
if let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} = block
{
// Look up tool name
let name = pending_tools
.get(&tool_use_id)
.cloned()
.unwrap_or_else(|| "unknown".to_string());
// Include extra result info if available
let result_value = if let Some(ref extra) = evt.tool_use_result {
serde_json::json!({
"content": content,
"stdout": extra.stdout,
"stderr": extra.stderr,
"is_error": is_error,
"interrupted": extra.interrupted,
})
} else {
Value::String(content)
};
results.push(ExecutionEvent::ToolResult {
id: tool_use_id,
name,
result: result_value,
});
}
}
}
ClaudeEvent::Result(res) => {
if res.is_error || res.subtype == "error" {
results.push(ExecutionEvent::Error {
message: res
.result
.unwrap_or_else(|| "Unknown error".to_string()),
});
} else {
debug!(
"Claude result: subtype={}, cost={:?}, duration={:?}ms",
res.subtype, res.total_cost_usd, res.duration_ms
);
}
// MessageComplete is sent after the loop
}
}
results
}
/// Create a registry entry for the Claude Code backend.
pub fn registry_entry() -> Arc<dyn Backend> { pub fn registry_entry() -> Arc<dyn Backend> {
Arc::new(ClaudeCodeBackend::new()) Arc::new(ClaudeCodeBackend::new())
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_list_agents() {
let backend = ClaudeCodeBackend::new();
let agents = backend.list_agents().await.unwrap();
assert!(agents.len() >= 4);
assert!(agents.iter().any(|a| a.id == "general-purpose"));
}
#[tokio::test]
async fn test_create_session() {
let backend = ClaudeCodeBackend::new();
let session = backend
.create_session(SessionConfig {
directory: "/tmp".to_string(),
title: Some("Test".to_string()),
model: Some("claude-sonnet-4-20250514".to_string()),
agent: None,
})
.await
.unwrap();
assert!(!session.id.is_empty());
assert_eq!(session.directory, "/tmp");
}
}