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';
|
'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 && (
|
||||||
|
|||||||
@@ -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
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::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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user