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:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
getLibraryOpenCodeSettings,
|
||||
saveLibraryOpenCodeSettings,
|
||||
@@ -10,8 +11,10 @@ import {
|
||||
saveOpenAgentConfig,
|
||||
listOpenCodeAgents,
|
||||
OpenAgentConfig,
|
||||
listBackends,
|
||||
getBackendConfig,
|
||||
} 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 { ConfigCodeEditor } from '@/components/config-code-editor';
|
||||
import { useLibrary } from '@/contexts/library-context';
|
||||
@@ -39,6 +42,31 @@ export default function SettingsPage() {
|
||||
refreshStatus,
|
||||
} = 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
|
||||
const [settings, setSettings] = useState<string>('');
|
||||
const [originalSettings, setOriginalSettings] = useState<string>('');
|
||||
@@ -407,7 +435,27 @@ export default function SettingsPage() {
|
||||
</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="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -626,6 +674,125 @@ export default function SettingsPage() {
|
||||
</select>
|
||||
</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 */}
|
||||
{showCommitDialog && (
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function SettingsPage() {
|
||||
const [claudeForm, setClaudeForm] = useState({
|
||||
api_key: '',
|
||||
default_model: '',
|
||||
cli_path: '',
|
||||
api_key_configured: false,
|
||||
enabled: true,
|
||||
});
|
||||
@@ -231,6 +232,7 @@ export default function SettingsPage() {
|
||||
setClaudeForm((prev) => ({
|
||||
...prev,
|
||||
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),
|
||||
enabled: claudecodeBackendConfig.enabled,
|
||||
}));
|
||||
@@ -350,6 +352,7 @@ export default function SettingsPage() {
|
||||
try {
|
||||
const settings: Record<string, unknown> = {
|
||||
default_model: claudeForm.default_model || null,
|
||||
cli_path: claudeForm.cli_path || null,
|
||||
};
|
||||
if (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"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
onClick={handleSaveClaudeBackend}
|
||||
|
||||
226
docs/HARNESS_SYSTEM.md
Normal file
226
docs/HARNESS_SYSTEM.md
Normal 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`
|
||||
@@ -26,6 +26,7 @@ use uuid::Uuid;
|
||||
use crate::agents::{AgentContext, AgentRef, TerminalReason};
|
||||
use crate::config::Config;
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::workspace;
|
||||
|
||||
use super::auth::AuthUser;
|
||||
@@ -658,6 +659,7 @@ pub struct ControlHub {
|
||||
mcp: Arc<McpRegistry>,
|
||||
workspaces: workspace::SharedWorkspaceStore,
|
||||
library: SharedLibrary,
|
||||
secrets: Option<Arc<SecretsStore>>,
|
||||
}
|
||||
|
||||
impl ControlHub {
|
||||
@@ -667,6 +669,7 @@ impl ControlHub {
|
||||
mcp: Arc<McpRegistry>,
|
||||
workspaces: workspace::SharedWorkspaceStore,
|
||||
library: SharedLibrary,
|
||||
secrets: Option<Arc<SecretsStore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -675,6 +678,7 @@ impl ControlHub {
|
||||
mcp,
|
||||
workspaces,
|
||||
library,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,6 +717,7 @@ impl ControlHub {
|
||||
Arc::clone(&self.workspaces),
|
||||
Arc::clone(&self.library),
|
||||
mission_store,
|
||||
self.secrets.clone(),
|
||||
);
|
||||
sessions.insert(user.id.clone(), state.clone());
|
||||
state
|
||||
@@ -1809,6 +1814,7 @@ fn spawn_control_session(
|
||||
workspaces: workspace::SharedWorkspaceStore,
|
||||
library: SharedLibrary,
|
||||
mission_store: Arc<dyn MissionStore>,
|
||||
secrets: Option<Arc<SecretsStore>>,
|
||||
) -> ControlState {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<ControlCommand>(256);
|
||||
let (events_tx, events_rx) = broadcast::channel::<AgentEvent>(1024);
|
||||
@@ -1860,6 +1866,7 @@ fn spawn_control_session(
|
||||
current_tree,
|
||||
progress,
|
||||
mission_store,
|
||||
secrets,
|
||||
));
|
||||
|
||||
// Spawn background stale mission cleanup task (if enabled)
|
||||
@@ -1968,6 +1975,7 @@ async fn control_actor_loop(
|
||||
current_tree: Arc<RwLock<Option<AgentTreeNode>>>,
|
||||
progress: Arc<RwLock<ExecutionProgress>>,
|
||||
mission_store: Arc<dyn MissionStore>,
|
||||
secrets: Option<Arc<SecretsStore>>,
|
||||
) {
|
||||
// Queue stores (id, content, agent) for the current/primary mission
|
||||
let mut queue: VecDeque<(Uuid, String, Option<String>)> = VecDeque::new();
|
||||
@@ -2304,6 +2312,7 @@ async fn control_actor_loop(
|
||||
Arc::clone(&status),
|
||||
mission_cmd_tx.clone(),
|
||||
Arc::new(RwLock::new(Some(tid))),
|
||||
secrets.clone(),
|
||||
);
|
||||
}
|
||||
let _ = respond.send(was_running);
|
||||
@@ -2361,6 +2370,7 @@ async fn control_actor_loop(
|
||||
Arc::clone(&status),
|
||||
mission_cmd_tx.clone(),
|
||||
Arc::new(RwLock::new(Some(tid))),
|
||||
secrets.clone(),
|
||||
);
|
||||
tracing::info!("Auto-started mission {} in parallel", tid);
|
||||
parallel_runners.insert(tid, runner);
|
||||
@@ -2722,6 +2732,7 @@ async fn control_actor_loop(
|
||||
Arc::clone(&status),
|
||||
mission_cmd_tx.clone(),
|
||||
Arc::new(RwLock::new(Some(mission_id))), // Each runner tracks its own mission
|
||||
secrets.clone(),
|
||||
);
|
||||
|
||||
if started {
|
||||
|
||||
@@ -19,8 +19,10 @@ use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason};
|
||||
use crate::backend::claudecode::client::{ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent};
|
||||
use crate::config::Config;
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::task::{extract_deliverables, DeliverableSet};
|
||||
use crate::workspace;
|
||||
|
||||
@@ -225,6 +227,7 @@ impl MissionRunner {
|
||||
status: Arc<RwLock<ControlStatus>>,
|
||||
mission_cmd_tx: mpsc::Sender<crate::tools::mission::MissionControlCommand>,
|
||||
current_mission: Arc<RwLock<Option<Uuid>>>,
|
||||
secrets: Option<Arc<SecretsStore>>,
|
||||
) -> bool {
|
||||
// Don't start if already running
|
||||
if self.is_running() {
|
||||
@@ -294,6 +297,7 @@ impl MissionRunner {
|
||||
Some(workspace_id),
|
||||
backend_id,
|
||||
agent_override,
|
||||
secrets,
|
||||
)
|
||||
.await;
|
||||
(msg_id, user_message, result)
|
||||
@@ -397,10 +401,12 @@ async fn run_mission_turn(
|
||||
workspace_id: Option<Uuid>,
|
||||
backend_id: String,
|
||||
agent_override: Option<String>,
|
||||
secrets: Option<Arc<SecretsStore>>,
|
||||
) -> AgentResult {
|
||||
let mut config = config;
|
||||
if let Some(agent) = agent_override {
|
||||
config.opencode_agent = Some(agent);
|
||||
let effective_agent = agent_override.clone();
|
||||
if let Some(ref agent) = effective_agent {
|
||||
config.opencode_agent = Some(agent.clone());
|
||||
}
|
||||
tracing::info!(
|
||||
mission_id = %mission_id,
|
||||
@@ -500,31 +506,45 @@ async fn run_mission_turn(
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
// Execute based on backend
|
||||
let result = match backend_id.as_str() {
|
||||
"claudecode" => {
|
||||
run_claudecode_turn(
|
||||
&mission_work_dir,
|
||||
&user_message,
|
||||
config.default_model.as_deref(),
|
||||
effective_agent.as_deref(),
|
||||
mission_id,
|
||||
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!(
|
||||
mission_id = %mission_id,
|
||||
success = result.success,
|
||||
@@ -536,6 +556,239 @@ async fn run_mission_turn(
|
||||
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).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RunningMissionInfo {
|
||||
|
||||
@@ -276,6 +276,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
Arc::clone(&mcp),
|
||||
Arc::clone(&workspaces),
|
||||
Arc::clone(&library),
|
||||
secrets.clone(),
|
||||
);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
mod client;
|
||||
pub mod client;
|
||||
|
||||
use anyhow::Error;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::backend::events::ExecutionEvent;
|
||||
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 {
|
||||
id: String,
|
||||
name: String,
|
||||
client: ClaudeCodeClient,
|
||||
config: Arc<RwLock<ClaudeCodeConfig>>,
|
||||
}
|
||||
|
||||
impl ClaudeCodeBackend {
|
||||
@@ -22,9 +26,34 @@ impl ClaudeCodeBackend {
|
||||
Self {
|
||||
id: "claudecode".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]
|
||||
@@ -38,12 +67,31 @@ impl Backend for ClaudeCodeBackend {
|
||||
}
|
||||
|
||||
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> {
|
||||
let client = ClaudeCodeClient::new();
|
||||
Ok(Session {
|
||||
id: self.client.create_session_id(),
|
||||
id: client.create_session_id(),
|
||||
directory: config.directory,
|
||||
model: config.model,
|
||||
agent: config.agent,
|
||||
@@ -53,24 +101,216 @@ impl Backend for ClaudeCodeBackend {
|
||||
async fn send_message_streaming(
|
||||
&self,
|
||||
session: &Session,
|
||||
_message: &str,
|
||||
message: &str,
|
||||
) -> 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();
|
||||
|
||||
// Spawn event conversion task
|
||||
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
|
||||
.send(ExecutionEvent::Error {
|
||||
message: "Claude Code backend is not configured".to_string(),
|
||||
.send(ExecutionEvent::MessageComplete {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await;
|
||||
let _ = tx
|
||||
.send(ExecutionEvent::MessageComplete { session_id })
|
||||
.await;
|
||||
|
||||
// Wait for Claude process to finish
|
||||
let _ = claude_handle.await;
|
||||
});
|
||||
|
||||
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> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user