From 3ea5c79b9585ca5be790b904e883086cf4718eb2 Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Sun, 18 Jan 2026 10:34:26 +0000 Subject: [PATCH] feat: add claude support --- .../src/components/new-mission-dialog.tsx | 73 +++++-- dashboard/src/lib/api.ts | 69 +++++++ src/agents/opencode.rs | 28 +-- src/api/backends.rs | 180 ++++++++++++++++++ src/api/control.rs | 20 +- src/api/desktop.rs | 26 +-- src/api/library.rs | 5 +- src/api/mission_store/file.rs | 2 + src/api/mission_store/memory.rs | 2 + src/api/mission_store/mod.rs | 8 + src/api/mission_store/sqlite.rs | 24 ++- src/api/mod.rs | 1 + src/api/routes.rs | 48 ++++- src/api/system.rs | 28 ++- src/api/workspaces.rs | 6 +- src/backend/claudecode/client.rs | 13 ++ src/backend/claudecode/mod.rs | 76 ++++++++ src/backend/events.rs | 26 +++ src/backend/mod.rs | 46 +++++ src/backend/opencode/client.rs | 1 + src/backend/opencode/mod.rs | 144 ++++++++++++++ src/backend/registry.rs | 54 ++++++ src/bin/workspace_mcp.rs | 9 +- src/lib.rs | 1 + src/library/env_crypto.rs | 28 ++- src/library/mod.rs | 24 +-- src/library/rename.rs | 55 ++++-- src/mcp/registry.rs | 10 +- src/opencode/mod.rs | 44 ++--- src/workspace.rs | 179 ++++++++++++++++- 30 files changed, 1078 insertions(+), 152 deletions(-) create mode 100644 src/api/backends.rs create mode 100644 src/backend/claudecode/client.rs create mode 100644 src/backend/claudecode/mod.rs create mode 100644 src/backend/events.rs create mode 100644 src/backend/mod.rs create mode 100644 src/backend/opencode/client.rs create mode 100644 src/backend/opencode/mod.rs create mode 100644 src/backend/registry.rs diff --git a/dashboard/src/components/new-mission-dialog.tsx b/dashboard/src/components/new-mission-dialog.tsx index 7f58594..07b68c8 100644 --- a/dashboard/src/components/new-mission-dialog.tsx +++ b/dashboard/src/components/new-mission-dialog.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { Plus } from 'lucide-react'; import useSWR from 'swr'; -import { getVisibleAgents, getOpenAgentConfig } from '@/lib/api'; +import { getVisibleAgents, getOpenAgentConfig, listBackends, listBackendAgents, type Backend, type BackendAgent } from '@/lib/api'; import type { Provider, Workspace } from '@/lib/api'; interface NewMissionDialogProps { @@ -14,6 +14,7 @@ interface NewMissionDialogProps { workspaceId?: string; agent?: string; modelOverride?: string; + backend?: string; }) => Promise | void; } @@ -51,11 +52,26 @@ export function NewMissionDialog({ const [newMissionWorkspace, setNewMissionWorkspace] = useState(''); const [newMissionAgent, setNewMissionAgent] = useState(''); const [newMissionModelOverride, setNewMissionModelOverride] = useState(''); + const [newMissionBackend, setNewMissionBackend] = useState('opencode'); const [submitting, setSubmitting] = useState(false); const [defaultSet, setDefaultSet] = useState(false); const dialogRef = useRef(null); - // SWR: fetch once, cache globally, revalidate in background + // SWR: fetch backends + const { data: backends } = useSWR('backends', listBackends, { + revalidateOnFocus: false, + dedupingInterval: 30000, + fallbackData: [{ id: 'opencode', name: 'OpenCode' }, { id: 'claudecode', name: 'Claude Code' }], + }); + + // SWR: fetch agents for selected backend + const { data: backendAgents } = useSWR( + newMissionBackend ? `backend-${newMissionBackend}-agents` : null, + () => listBackendAgents(newMissionBackend), + { revalidateOnFocus: false, dedupingInterval: 30000 } + ); + + // SWR: fetch once, cache globally, revalidate in background (fallback for agent list) const { data: agentsPayload } = useSWR('opencode-agents', getVisibleAgents, { revalidateOnFocus: false, dedupingInterval: 30000, @@ -65,7 +81,8 @@ export function NewMissionDialog({ dedupingInterval: 30000, }); - const opencodeAgents = agentsPayload ? parseAgentNames(agentsPayload) : []; + // Parse agents from either backend API or fallback + const agents = backendAgents?.map(a => a.name) || parseAgentNames(agentsPayload); const formatWorkspaceType = (type: Workspace['workspace_type']) => type === 'host' ? 'host' : 'isolated'; @@ -87,22 +104,23 @@ export function NewMissionDialog({ // Set default agent when dialog opens (only once per open) // Wait for both agents AND config to load before setting defaults useEffect(() => { - if (!open || defaultSet || opencodeAgents.length === 0) return; + if (!open || defaultSet || agents.length === 0) return; // Wait for config to finish loading (undefined = still loading, null/object = loaded) if (config === undefined) return; - if (config?.default_agent && opencodeAgents.includes(config.default_agent)) { + if (config?.default_agent && agents.includes(config.default_agent)) { setNewMissionAgent(config.default_agent); - } else if (opencodeAgents.includes('Sisyphus')) { + } else if (agents.includes('Sisyphus')) { setNewMissionAgent('Sisyphus'); } setDefaultSet(true); - }, [open, defaultSet, opencodeAgents, config]); + }, [open, defaultSet, agents, config]); const resetForm = () => { setNewMissionWorkspace(''); setNewMissionAgent(''); setNewMissionModelOverride(''); + setNewMissionBackend('opencode'); setDefaultSet(false); }; @@ -119,6 +137,7 @@ export function NewMissionDialog({ workspaceId: newMissionWorkspace || undefined, agent: newMissionAgent || undefined, modelOverride: newMissionModelOverride || undefined, + backend: newMissionBackend || undefined, }); setOpen(false); resetForm(); @@ -183,6 +202,36 @@ export function NewMissionDialog({

Where the mission will run

+ {/* Backend selection */} +
+ + +

AI coding backend to power this mission

+
+ {/* Agent selection */}
@@ -204,14 +253,14 @@ export function NewMissionDialog({ - {opencodeAgents.includes("Sisyphus") && ( + {agents.includes("Sisyphus") && ( )} - {opencodeAgents.length > 0 && ( - - {opencodeAgents.map((agent) => ( + {agents.length > 0 && ( + b.id === newMissionBackend)?.name || 'Backend'} Agents`} className="bg-[#1a1a1a]"> + {agents.map((agent: string) => ( @@ -220,7 +269,7 @@ export function NewMissionDialog({ )}

- OpenCode agents are provided by plugins; defaults are recommended + Agents are provided by plugins; defaults are recommended

diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index abf9d65..47664bd 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -345,6 +345,8 @@ export interface Mission { workspace_id?: string; workspace_name?: string; agent?: string; + /** Backend used for this mission ("opencode" or "claudecode") */ + backend?: string; history: MissionHistoryEntry[]; desktop_sessions?: DesktopSessionInfo[]; created_at: string; @@ -412,6 +414,8 @@ export interface CreateMissionOptions { agent?: string; /** Override model for this mission (provider/model) */ modelOverride?: string; + /** Backend to use for this mission ("opencode" or "claudecode") */ + backend?: string; } export async function createMission( @@ -422,12 +426,14 @@ export async function createMission( workspace_id?: string; agent?: string; model_override?: string; + backend?: string; } = {}; if (options?.title) body.title = options.title; if (options?.workspaceId) body.workspace_id = options.workspaceId; if (options?.agent) body.agent = options.agent; if (options?.modelOverride) body.model_override = options.modelOverride; + if (options?.backend) body.backend = options.backend; const res = await apiFetch("/api/control/missions", { method: "POST", @@ -2815,3 +2821,66 @@ export async function updateLibraryRemote( } return res.json(); } + +// ============================================ +// Backends API +// ============================================ + +export interface Backend { + id: string; + name: string; +} + +export interface BackendAgent { + id: string; + name: string; +} + +export interface BackendConfig { + id: string; + name: string; + enabled: boolean; + settings: Record; +} + +// List all available backends +export async function listBackends(): Promise { + const res = await apiFetch('/api/backends'); + if (!res.ok) throw new Error('Failed to list backends'); + return res.json(); +} + +// Get a specific backend +export async function getBackend(id: string): Promise { + const res = await apiFetch(`/api/backends/${encodeURIComponent(id)}`); + if (!res.ok) throw new Error('Failed to get backend'); + return res.json(); +} + +// List agents for a specific backend +export async function listBackendAgents(backendId: string): Promise { + const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/agents`); + if (!res.ok) throw new Error('Failed to list backend agents'); + return res.json(); +} + +// Get backend configuration +export async function getBackendConfig(backendId: string): Promise { + const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/config`); + if (!res.ok) throw new Error('Failed to get backend config'); + return res.json(); +} + +// Update backend configuration +export async function updateBackendConfig( + backendId: string, + settings: Record +): Promise<{ ok: boolean; message?: string }> { + const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }), + }); + if (!res.ok) throw new Error('Failed to update backend config'); + return res.json(); +} diff --git a/src/agents/opencode.rs b/src/agents/opencode.rs index ccb6e29..b1773f7 100644 --- a/src/agents/opencode.rs +++ b/src/agents/opencode.rs @@ -91,29 +91,21 @@ impl OpenCodeAgent { mission_id: ctx.mission_id, } } - OpenCodeEvent::ToolCall { - tool_call_id, - name, - args, - } => { + OpenCodeEvent::ToolCall { id, name, args } => { tracing::info!( - tool_call_id = %tool_call_id, + tool_call_id = %id, name = %name, "Forwarding tool_call event to control broadcast" ); AgentEvent::ToolCall { - tool_call_id: tool_call_id.clone(), + tool_call_id: id.clone(), name: name.clone(), args: args.clone(), mission_id: ctx.mission_id, } } - OpenCodeEvent::ToolResult { - tool_call_id, - name, - result, - } => AgentEvent::ToolResult { - tool_call_id: tool_call_id.clone(), + OpenCodeEvent::ToolResult { id, name, result } => AgentEvent::ToolResult { + tool_call_id: id.clone(), name: name.clone(), result: result.clone(), mission_id: ctx.mission_id, @@ -430,10 +422,9 @@ impl Agent for OpenCodeAgent { } } - if let OpenCodeEvent::ToolCall { tool_call_id, name, .. } = &oc_event - { + if let OpenCodeEvent::ToolCall { id, name, .. } = &oc_event { self.handle_frontend_tool_call( - tool_call_id, + id, name, &session.id, &directory, @@ -493,10 +484,9 @@ impl Agent for OpenCodeAgent { sse_text_buffer = content.clone(); } } - if let OpenCodeEvent::ToolCall { tool_call_id, name, .. } = &oc_event - { + if let OpenCodeEvent::ToolCall { id, name, .. } = &oc_event { self.handle_frontend_tool_call( - tool_call_id, + id, name, &session.id, &directory, diff --git a/src/api/backends.rs b/src/api/backends.rs new file mode 100644 index 0000000..9a5b30c --- /dev/null +++ b/src/api/backends.rs @@ -0,0 +1,180 @@ +//! Backend management API endpoints. + +use std::sync::Arc; + +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::backend::registry::BackendInfo; + +use super::auth::AuthUser; +use super::routes::AppState; + +/// Backend information returned by API +#[derive(Debug, Clone, Serialize)] +pub struct BackendResponse { + pub id: String, + pub name: String, +} + +impl From for BackendResponse { + fn from(info: BackendInfo) -> Self { + Self { + id: info.id, + name: info.name, + } + } +} + +/// Agent information returned by API +#[derive(Debug, Clone, Serialize)] +pub struct AgentResponse { + pub id: String, + pub name: String, +} + +/// List all available backends +pub async fn list_backends( + State(state): State>, + Extension(_user): Extension, +) -> Json> { + let registry = state.backend_registry.read().await; + let backends: Vec = registry.list().into_iter().map(Into::into).collect(); + Json(backends) +} + +/// Get a specific backend by ID +pub async fn get_backend( + State(state): State>, + Extension(_user): Extension, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let registry = state.backend_registry.read().await; + match registry.get(&id) { + Some(backend) => Ok(Json(BackendResponse { + id: backend.id().to_string(), + name: backend.name().to_string(), + })), + None => Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id))), + } +} + +/// List agents for a specific backend +pub async fn list_backend_agents( + State(state): State>, + Extension(_user): Extension, + Path(id): Path, +) -> Result>, (StatusCode, String)> { + let registry = state.backend_registry.read().await; + let backend = registry + .get(&id) + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?; + + match backend.list_agents().await { + Ok(agents) => { + let agents: Vec = agents + .into_iter() + .map(|a| AgentResponse { + id: a.id, + name: a.name, + }) + .collect(); + Ok(Json(agents)) + } + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to list agents: {}", e), + )), + } +} + +/// Backend configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendConfig { + pub id: String, + pub name: String, + pub enabled: bool, + pub settings: serde_json::Value, +} + +/// Get backend configuration +pub async fn get_backend_config( + State(state): State>, + Extension(_user): Extension, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let registry = state.backend_registry.read().await; + let backend = registry + .get(&id) + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?; + + // Return backend-specific configuration + let settings = match id.as_str() { + "opencode" => { + let base_url = std::env::var("OPENCODE_BASE_URL") + .unwrap_or_else(|_| "http://127.0.0.1:4096".to_string()); + let default_agent = std::env::var("OPENCODE_DEFAULT_AGENT").ok(); + let permissive = std::env::var("OPENCODE_PERMISSIVE") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + serde_json::json!({ + "base_url": base_url, + "default_agent": default_agent, + "permissive": permissive, + }) + } + "claudecode" => { + // Check if Claude Code API key is configured + let api_key_configured = state + .secrets + .as_ref() + .map(|s| { + // Check async context + false // TODO: implement proper secret check + }) + .unwrap_or(false); + serde_json::json!({ + "api_key_configured": api_key_configured, + }) + } + _ => serde_json::json!({}), + }; + + Ok(Json(BackendConfig { + id: backend.id().to_string(), + name: backend.name().to_string(), + enabled: true, + settings, + })) +} + +/// Request to update backend configuration +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateBackendConfigRequest { + pub settings: serde_json::Value, +} + +/// Update backend configuration +pub async fn update_backend_config( + State(state): State>, + Extension(_user): Extension, + Path(id): Path, + Json(_req): Json, +) -> Result, (StatusCode, String)> { + let registry = state.backend_registry.read().await; + if registry.get(&id).is_none() { + return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id))); + } + + // Backend configuration is currently read from environment variables + // TODO: Implement persistent backend configuration storage + + Ok(Json(serde_json::json!({ + "ok": true, + "message": "Backend configuration is currently read-only" + }))) +} diff --git a/src/api/control.rs b/src/api/control.rs index b1a25d7..12be263 100644 --- a/src/api/control.rs +++ b/src/api/control.rs @@ -481,6 +481,8 @@ pub enum ControlCommand { agent: Option, /// Optional model override (provider/model) model_override: Option, + /// Backend to use for this mission ("opencode" or "claudecode") + backend: Option, respond: oneshot::Sender>, }, /// Update mission status @@ -1046,6 +1048,8 @@ pub struct CreateMissionRequest { pub agent: Option, /// Optional model override (provider/model) pub model_override: Option, + /// Backend to use for this mission ("opencode" or "claudecode") + pub backend: Option, } pub async fn create_mission( @@ -1055,16 +1059,17 @@ pub async fn create_mission( ) -> Result, (StatusCode, String)> { let (tx, rx) = oneshot::channel(); - let (title, workspace_id, agent, model_override) = body + let (title, workspace_id, agent, model_override, backend) = body .map(|b| { ( b.title.clone(), b.workspace_id, b.agent.clone(), b.model_override.clone(), + b.backend.clone(), ) }) - .unwrap_or((None, None, None, None)); + .unwrap_or((None, None, None, None, None)); // Validate agent exists before creating mission (fail fast with clear error) if let Some(ref agent_name) = agent { @@ -1081,6 +1086,7 @@ pub async fn create_mission( workspace_id, agent, model_override, + backend, respond: tx, }) .await @@ -2057,7 +2063,7 @@ async fn control_actor_loop( // Helper to create a new mission async fn create_new_mission(mission_store: &Arc) -> Result { - create_new_mission_with_title(mission_store, None, None, None, None).await + create_new_mission_with_title(mission_store, None, None, None, None, None).await } // Helper to create a new mission with title @@ -2067,9 +2073,10 @@ async fn control_actor_loop( workspace_id: Option, agent: Option<&str>, model_override: Option<&str>, + backend: Option<&str>, ) -> Result { mission_store - .create_mission(title, workspace_id, agent, model_override) + .create_mission(title, workspace_id, agent, model_override, backend) .await } @@ -2566,7 +2573,7 @@ async fn control_actor_loop( } } } - ControlCommand::CreateMission { title, workspace_id, agent, model_override, respond } => { + ControlCommand::CreateMission { title, workspace_id, agent, model_override, backend, respond } => { // First persist current mission history persist_mission_history( &mission_store, @@ -2575,13 +2582,14 @@ async fn control_actor_loop( ) .await; - // Create a new mission with optional title, workspace, and agent + // Create a new mission with optional title, workspace, agent, and backend match create_new_mission_with_title( &mission_store, title.as_deref(), workspace_id, agent.as_deref(), model_override.as_deref(), + backend.as_deref(), ) .await { Ok(mission) => { diff --git a/src/api/desktop.rs b/src/api/desktop.rs index c28d4cf..73320fd 100644 --- a/src/api/desktop.rs +++ b/src/api/desktop.rs @@ -346,18 +346,20 @@ async fn collect_desktop_sessions(state: &Arc) -> Vec = sessions_by_display.into_values().collect(); diff --git a/src/api/library.rs b/src/api/library.rs index 3192909..c905682 100644 --- a/src/api/library.rs +++ b/src/api/library.rs @@ -1325,7 +1325,10 @@ async fn rename_item( if !result.success { return Err(( StatusCode::BAD_REQUEST, - result.error.clone().unwrap_or_else(|| "Rename failed".to_string()), + result + .error + .clone() + .unwrap_or_else(|| "Rename failed".to_string()), )); } diff --git a/src/api/mission_store/file.rs b/src/api/mission_store/file.rs index ec79831..467dc60 100644 --- a/src/api/mission_store/file.rs +++ b/src/api/mission_store/file.rs @@ -102,6 +102,7 @@ impl MissionStore for FileMissionStore { workspace_id: Option, agent: Option<&str>, model_override: Option<&str>, + backend: Option<&str>, ) -> Result { let now = now_string(); let mission = Mission { @@ -112,6 +113,7 @@ impl MissionStore for FileMissionStore { workspace_name: None, agent: agent.map(|s| s.to_string()), model_override: model_override.map(|s| s.to_string()), + backend: backend.unwrap_or("opencode").to_string(), history: vec![], created_at: now.clone(), updated_at: now, diff --git a/src/api/mission_store/memory.rs b/src/api/mission_store/memory.rs index 2149ae4..f13c37a 100644 --- a/src/api/mission_store/memory.rs +++ b/src/api/mission_store/memory.rs @@ -53,6 +53,7 @@ impl MissionStore for InMemoryMissionStore { workspace_id: Option, agent: Option<&str>, model_override: Option<&str>, + backend: Option<&str>, ) -> Result { let now = now_string(); let mission = Mission { @@ -63,6 +64,7 @@ impl MissionStore for InMemoryMissionStore { workspace_name: None, agent: agent.map(|s| s.to_string()), model_override: model_override.map(|s| s.to_string()), + backend: backend.unwrap_or("opencode").to_string(), history: vec![], created_at: now.clone(), updated_at: now, diff --git a/src/api/mission_store/mod.rs b/src/api/mission_store/mod.rs index 890d784..512e7aa 100644 --- a/src/api/mission_store/mod.rs +++ b/src/api/mission_store/mod.rs @@ -38,6 +38,9 @@ pub struct Mission { /// Optional model override (provider/model) #[serde(skip_serializing_if = "Option::is_none")] pub model_override: Option, + /// Backend to use for this mission ("opencode" or "claudecode") + #[serde(default = "default_backend")] + pub backend: String, pub history: Vec, pub created_at: String, pub updated_at: String, @@ -52,6 +55,10 @@ pub struct Mission { pub desktop_sessions: Vec, } +fn default_backend() -> String { + "opencode".to_string() +} + fn default_workspace_id() -> Uuid { crate::workspace::DEFAULT_WORKSPACE_ID } @@ -119,6 +126,7 @@ pub trait MissionStore: Send + Sync { workspace_id: Option, agent: Option<&str>, model_override: Option<&str>, + backend: Option<&str>, ) -> Result; /// Update mission status. diff --git a/src/api/mission_store/sqlite.rs b/src/api/mission_store/sqlite.rs index 593bde0..fef63a2 100644 --- a/src/api/mission_store/sqlite.rs +++ b/src/api/mission_store/sqlite.rs @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS missions ( workspace_name TEXT, agent TEXT, model_override TEXT, + backend TEXT NOT NULL DEFAULT 'opencode', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, interrupted_at TEXT, @@ -192,7 +193,8 @@ impl MissionStore for SqliteMissionStore { let mut stmt = conn .prepare( "SELECT id, status, title, workspace_id, workspace_name, agent, model_override, - created_at, updated_at, interrupted_at, resumable, desktop_sessions + created_at, updated_at, interrupted_at, resumable, desktop_sessions, + COALESCE(backend, 'opencode') as backend FROM missions ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2", @@ -205,6 +207,7 @@ impl MissionStore for SqliteMissionStore { let status_str: String = row.get(1)?; let workspace_id_str: String = row.get(3)?; let desktop_sessions_json: Option = row.get(11)?; + let backend: String = row.get(12)?; Ok(Mission { id: Uuid::parse_str(&id_str).unwrap_or_default(), @@ -215,6 +218,7 @@ impl MissionStore for SqliteMissionStore { workspace_name: row.get(4)?, agent: row.get(5)?, model_override: row.get(6)?, + backend, history: vec![], // Loaded separately if needed created_at: row.get(7)?, updated_at: row.get(8)?, @@ -246,7 +250,8 @@ impl MissionStore for SqliteMissionStore { let mut stmt = conn .prepare( "SELECT id, status, title, workspace_id, workspace_name, agent, model_override, - created_at, updated_at, interrupted_at, resumable, desktop_sessions + created_at, updated_at, interrupted_at, resumable, desktop_sessions, + COALESCE(backend, 'opencode') as backend FROM missions WHERE id = ?1", ) .map_err(|e| e.to_string())?; @@ -257,6 +262,7 @@ impl MissionStore for SqliteMissionStore { let status_str: String = row.get(1)?; let workspace_id_str: String = row.get(3)?; let desktop_sessions_json: Option = row.get(11)?; + let backend: String = row.get(12)?; Ok(Mission { id: Uuid::parse_str(&id_str).unwrap_or_default(), @@ -267,6 +273,7 @@ impl MissionStore for SqliteMissionStore { workspace_name: row.get(4)?, agent: row.get(5)?, model_override: row.get(6)?, + backend, history: vec![], created_at: row.get(7)?, updated_at: row.get(8)?, @@ -327,11 +334,13 @@ impl MissionStore for SqliteMissionStore { workspace_id: Option, agent: Option<&str>, model_override: Option<&str>, + backend: Option<&str>, ) -> Result { let conn = self.conn.clone(); let now = now_string(); let id = Uuid::new_v4(); let workspace_id = workspace_id.unwrap_or(crate::workspace::DEFAULT_WORKSPACE_ID); + let backend = backend.unwrap_or("opencode").to_string(); let mission = Mission { id, @@ -341,6 +350,7 @@ impl MissionStore for SqliteMissionStore { workspace_name: None, agent: agent.map(|s| s.to_string()), model_override: model_override.map(|s| s.to_string()), + backend: backend.clone(), history: vec![], created_at: now.clone(), updated_at: now.clone(), @@ -353,8 +363,8 @@ impl MissionStore for SqliteMissionStore { tokio::task::spawn_blocking(move || { let conn = conn.blocking_lock(); conn.execute( - "INSERT INTO missions (id, status, title, workspace_id, agent, model_override, created_at, updated_at, resumable) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO missions (id, status, title, workspace_id, agent, model_override, backend, created_at, updated_at, resumable) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ m.id.to_string(), status_to_string(m.status), @@ -362,6 +372,7 @@ impl MissionStore for SqliteMissionStore { m.workspace_id.to_string(), m.agent, m.model_override, + m.backend, m.created_at, m.updated_at, 0, @@ -589,7 +600,8 @@ impl MissionStore for SqliteMissionStore { let mut stmt = conn .prepare( "SELECT id, status, title, workspace_id, workspace_name, agent, model_override, - created_at, updated_at, interrupted_at, resumable, desktop_sessions + created_at, updated_at, interrupted_at, resumable, desktop_sessions, + COALESCE(backend, 'opencode') as backend FROM missions WHERE status = 'active' AND updated_at < ?1", ) @@ -601,6 +613,7 @@ impl MissionStore for SqliteMissionStore { let status_str: String = row.get(1)?; let workspace_id_str: String = row.get(3)?; let desktop_sessions_json: Option = row.get(11)?; + let backend: String = row.get(12)?; Ok(Mission { id: Uuid::parse_str(&id_str).unwrap_or_default(), @@ -611,6 +624,7 @@ impl MissionStore for SqliteMissionStore { workspace_name: row.get(4)?, agent: row.get(5)?, model_override: row.get(6)?, + backend, history: vec![], created_at: row.get(7)?, updated_at: row.get(8)?, diff --git a/src/api/mod.rs b/src/api/mod.rs index c7c01a3..71850fd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -17,6 +17,7 @@ pub mod ai_providers; mod auth; +pub mod backends; mod console; pub mod control; pub mod desktop; diff --git a/src/api/routes.rs b/src/api/routes.rs index 99f4875..74e43e4 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -22,12 +22,14 @@ use tower_http::trace::TraceLayer; use uuid::Uuid; use crate::agents::{AgentContext, AgentRef, OpenCodeAgent}; +use crate::backend::registry::BackendRegistry; use crate::config::{AuthMode, Config}; use crate::mcp::McpRegistry; use crate::workspace; use super::ai_providers as ai_providers_api; use super::auth::{self, AuthUser}; +use super::backends as backends_api; use super::console; use super::control; use super::desktop; @@ -72,6 +74,8 @@ pub struct AppState { pub console_pool: Arc, /// Global settings store pub settings: Arc, + /// Backend registry for multi-backend support + pub backend_registry: Arc>, } /// Start the HTTP server. @@ -136,12 +140,29 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { // Initialize global settings store let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await); + // Initialize backend registry with OpenCode and Claude Code backends + let opencode_base_url = + std::env::var("OPENCODE_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:4096".to_string()); + let opencode_default_agent = std::env::var("OPENCODE_DEFAULT_AGENT").ok(); + let opencode_permissive = std::env::var("OPENCODE_PERMISSIVE") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + let mut backend_registry = BackendRegistry::new("opencode"); + backend_registry.register(crate::backend::opencode::registry_entry( + opencode_base_url.clone(), + opencode_default_agent, + opencode_permissive, + )); + backend_registry.register(crate::backend::claudecode::registry_entry()); + let backend_registry = Arc::new(RwLock::new(backend_registry)); + tracing::info!("Backend registry initialized with {} backends", 2); + // Start background OpenCode session cleanup task { - let opencode_base_url = std::env::var("OPENCODE_BASE_URL") - .unwrap_or_else(|_| "http://127.0.0.1:4096".to_string()); + let opencode_base_url_clone = opencode_base_url.clone(); tokio::spawn(async move { - opencode_session_cleanup_task(&opencode_base_url).await; + opencode_session_cleanup_task(&opencode_base_url_clone).await; }); } @@ -229,6 +250,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { secrets, console_pool, settings, + backend_registry, }); // Start background desktop session cleanup task @@ -403,6 +425,21 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { .nest("/api/desktop", desktop::routes()) // System component management endpoints .nest("/api/system", system_api::routes()) + // Backend management endpoints + .route("/api/backends", get(backends_api::list_backends)) + .route("/api/backends/:id", get(backends_api::get_backend)) + .route( + "/api/backends/:id/agents", + get(backends_api::list_backend_agents), + ) + .route( + "/api/backends/:id/config", + get(backends_api::get_backend_config), + ) + .route( + "/api/backends/:id/config", + axum::routing::put(backends_api::update_backend_config), + ) .layer(middleware::from_fn_with_state( Arc::clone(&state), auth::require_auth, @@ -965,10 +1002,7 @@ async fn opencode_session_cleanup_task(base_url: &str) { match client.cleanup_old_sessions(MAX_SESSION_AGE).await { Ok(deleted) => { if deleted > 0 { - tracing::info!( - deleted = deleted, - "OpenCode session cleanup completed" - ); + tracing::info!(deleted = deleted, "OpenCode session cleanup completed"); } else { tracing::debug!("OpenCode session cleanup: no old sessions to delete"); } diff --git a/src/api/system.rs b/src/api/system.rs index ef67f74..ab68d89 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -163,10 +163,11 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { match Command::new("opencode").arg("--version").output().await { Ok(output) if output.status.success() => { let version_str = String::from_utf8_lossy(&output.stdout); - let version = version_str - .lines() - .next() - .map(|l| l.trim().replace("opencode version ", "").replace("opencode ", "")); + let version = version_str.lines().next().map(|l| { + l.trim() + .replace("opencode version ", "") + .replace("opencode ", "") + }); let update_available = check_opencode_update(version.as_deref()).await; let status = if update_available.is_some() { @@ -279,7 +280,9 @@ async fn check_open_agent_update(current_version: Option<&str>) -> Option) -> Option b). fn version_is_newer(a: &str, b: &str) -> bool { - let parse = |v: &str| -> Vec { - v.split('.') - .filter_map(|s| s.parse().ok()) - .collect() - }; + let parse = |v: &str| -> Vec { v.split('.').filter_map(|s| s.parse().ok()).collect() }; let va = parse(a); let vb = parse(b); @@ -388,10 +387,7 @@ async fn get_oh_my_opencode_version() -> Option { if output.status.success() { let version_str = String::from_utf8_lossy(&output.stdout); - return version_str - .lines() - .next() - .map(|l| l.trim().to_string()); + return version_str.lines().next().map(|l| l.trim().to_string()); } None @@ -1018,7 +1014,9 @@ async fn update_plugin( } /// Stream the plugin update process. -fn stream_plugin_update(package: String) -> impl Stream> { +fn stream_plugin_update( + package: String, +) -> impl Stream> { async_stream::stream! { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { event_type: "log".to_string(), diff --git a/src/api/workspaces.rs b/src/api/workspaces.rs index 0b50ec3..925e92c 100644 --- a/src/api/workspaces.rs +++ b/src/api/workspaces.rs @@ -1226,7 +1226,11 @@ async fn get_init_log( let total_lines = lines.len() as u32; // Return last 500 lines max - let start = if lines.len() > 500 { lines.len() - 500 } else { 0 }; + let start = if lines.len() > 500 { + lines.len() - 500 + } else { + 0 + }; let truncated_content = lines[start..].join("\n"); Ok(Json(InitLogResponse { diff --git a/src/backend/claudecode/client.rs b/src/backend/claudecode/client.rs new file mode 100644 index 0000000..df308f1 --- /dev/null +++ b/src/backend/claudecode/client.rs @@ -0,0 +1,13 @@ +use uuid::Uuid; + +pub struct ClaudeCodeClient; + +impl ClaudeCodeClient { + pub fn new() -> Self { + Self + } + + pub fn create_session_id(&self) -> String { + Uuid::new_v4().to_string() + } +} diff --git a/src/backend/claudecode/mod.rs b/src/backend/claudecode/mod.rs new file mode 100644 index 0000000..b1c477e --- /dev/null +++ b/src/backend/claudecode/mod.rs @@ -0,0 +1,76 @@ +mod client; + +use anyhow::Error; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::backend::events::ExecutionEvent; +use crate::backend::{AgentInfo, Backend, Session, SessionConfig}; + +use client::ClaudeCodeClient; + +pub struct ClaudeCodeBackend { + id: String, + name: String, + client: ClaudeCodeClient, +} + +impl ClaudeCodeBackend { + pub fn new() -> Self { + Self { + id: "claudecode".to_string(), + name: "Claude Code".to_string(), + client: ClaudeCodeClient::new(), + } + } +} + +#[async_trait] +impl Backend for ClaudeCodeBackend { + fn id(&self) -> &str { + &self.id + } + + fn name(&self) -> &str { + &self.name + } + + async fn list_agents(&self) -> Result, Error> { + Ok(vec![]) + } + + async fn create_session(&self, config: SessionConfig) -> Result { + Ok(Session { + id: self.client.create_session_id(), + directory: config.directory, + model: config.model, + agent: config.agent, + }) + } + + async fn send_message_streaming( + &self, + session: &Session, + _message: &str, + ) -> Result<(mpsc::Receiver, JoinHandle<()>), Error> { + let (tx, rx) = mpsc::channel(4); + let session_id = session.id.clone(); + let handle = tokio::spawn(async move { + let _ = tx + .send(ExecutionEvent::Error { + message: "Claude Code backend is not configured".to_string(), + }) + .await; + let _ = tx + .send(ExecutionEvent::MessageComplete { session_id }) + .await; + }); + Ok((rx, handle)) + } +} + +pub fn registry_entry() -> Arc { + Arc::new(ClaudeCodeBackend::new()) +} diff --git a/src/backend/events.rs b/src/backend/events.rs new file mode 100644 index 0000000..e40698c --- /dev/null +++ b/src/backend/events.rs @@ -0,0 +1,26 @@ +use serde_json::Value; + +/// Backend-agnostic execution events. +#[derive(Debug, Clone)] +pub enum ExecutionEvent { + /// Agent is thinking/reasoning. + Thinking { content: String }, + /// Agent is calling a tool. + ToolCall { + id: String, + name: String, + args: Value, + }, + /// Tool execution completed. + ToolResult { + id: String, + name: String, + result: Value, + }, + /// Text content being streamed. + TextDelta { content: String }, + /// Message execution completed. + MessageComplete { session_id: String }, + /// Error occurred. + Error { message: String }, +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..bc61200 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,46 @@ +pub mod claudecode; +pub mod events; +pub mod opencode; +pub mod registry; + +use anyhow::Error; +use async_trait::async_trait; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use events::ExecutionEvent; + +#[derive(Debug, Clone)] +pub struct AgentInfo { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct SessionConfig { + pub directory: String, + pub title: Option, + pub model: Option, + pub agent: Option, +} + +#[derive(Debug, Clone)] +pub struct Session { + pub id: String, + pub directory: String, + pub model: Option, + pub agent: Option, +} + +#[async_trait] +pub trait Backend: Send + Sync { + fn id(&self) -> &str; + fn name(&self) -> &str; + async fn list_agents(&self) -> Result, Error>; + async fn create_session(&self, config: SessionConfig) -> Result; + async fn send_message_streaming( + &self, + session: &Session, + message: &str, + ) -> Result<(mpsc::Receiver, JoinHandle<()>), Error>; +} diff --git a/src/backend/opencode/client.rs b/src/backend/opencode/client.rs new file mode 100644 index 0000000..4f6139d --- /dev/null +++ b/src/backend/opencode/client.rs @@ -0,0 +1 @@ +pub use crate::opencode::*; diff --git a/src/backend/opencode/mod.rs b/src/backend/opencode/mod.rs new file mode 100644 index 0000000..c96613e --- /dev/null +++ b/src/backend/opencode/mod.rs @@ -0,0 +1,144 @@ +mod client; + +use anyhow::{anyhow, Context, Error}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::backend::events::ExecutionEvent; +use crate::backend::{AgentInfo, Backend, Session, SessionConfig}; +use client::OpenCodeClient; + +pub struct OpenCodeBackend { + id: String, + name: String, + client: OpenCodeClient, +} + +impl OpenCodeBackend { + pub fn new(base_url: String, default_agent: Option, permissive: bool) -> Self { + Self { + id: "opencode".to_string(), + name: "OpenCode".to_string(), + client: OpenCodeClient::new(base_url, default_agent, permissive), + } + } + + pub fn client(&self) -> &OpenCodeClient { + &self.client + } + + async fn fetch_agents(&self) -> Result { + let base_url = self.client.base_url().trim_end_matches('/'); + if base_url.is_empty() { + return Err(anyhow!("OpenCode base URL is not configured")); + } + let url = format!("{}/agent", base_url); + let resp = reqwest::Client::new() + .get(url) + .send() + .await + .context("Failed to call OpenCode /agent")?; + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("OpenCode /agent failed: {}", text)); + } + resp.json::() + .await + .context("Failed to parse OpenCode agent payload") + } + + fn parse_agents(payload: Value) -> Vec { + let raw = match payload { + Value::Array(arr) => arr, + Value::Object(mut obj) => obj + .remove("agents") + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(), + _ => Vec::new(), + }; + + raw.into_iter() + .filter_map(|entry| match entry { + Value::String(name) => Some(AgentInfo { + id: name.clone(), + name, + }), + Value::Object(mut obj) => { + let name = obj + .remove("name") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .or_else(|| { + obj.remove("id") + .and_then(|v| v.as_str().map(|s| s.to_string())) + }); + name.map(|name| AgentInfo { + id: name.clone(), + name, + }) + } + _ => None, + }) + .collect() + } +} + +#[async_trait] +impl Backend for OpenCodeBackend { + fn id(&self) -> &str { + &self.id + } + + fn name(&self) -> &str { + &self.name + } + + async fn list_agents(&self) -> Result, Error> { + let payload = self.fetch_agents().await?; + Ok(Self::parse_agents(payload)) + } + + async fn create_session(&self, config: SessionConfig) -> Result { + let session = self + .client + .create_session(&config.directory, config.title.as_deref()) + .await?; + Ok(Session { + id: session.id, + directory: config.directory, + model: config.model, + agent: config.agent, + }) + } + + async fn send_message_streaming( + &self, + session: &Session, + message: &str, + ) -> Result<(mpsc::Receiver, JoinHandle<()>), Error> { + let (rx, handle) = self + .client + .send_message_streaming( + &session.id, + &session.directory, + message, + session.model.as_deref(), + session.agent.as_deref(), + ) + .await?; + let join_handle = tokio::spawn(async move { + let _ = handle.await; + }); + Ok((rx, join_handle)) + } +} + +pub fn registry_entry( + base_url: String, + default_agent: Option, + permissive: bool, +) -> Arc { + Arc::new(OpenCodeBackend::new(base_url, default_agent, permissive)) +} diff --git a/src/backend/registry.rs b/src/backend/registry.rs new file mode 100644 index 0000000..7ada24e --- /dev/null +++ b/src/backend/registry.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use super::Backend; + +#[derive(Debug, Clone)] +pub struct BackendInfo { + pub id: String, + pub name: String, +} + +pub struct BackendRegistry { + backends: HashMap>, + default_backend: String, +} + +impl BackendRegistry { + pub fn new(default_backend: impl Into) -> Self { + Self { + backends: HashMap::new(), + default_backend: default_backend.into(), + } + } + + pub fn register(&mut self, backend: Arc) { + self.backends.insert(backend.id().to_string(), backend); + } + + pub fn list(&self) -> Vec { + let mut list: Vec<_> = self + .backends + .values() + .map(|backend| BackendInfo { + id: backend.id().to_string(), + name: backend.name().to_string(), + }) + .collect(); + list.sort_by(|a, b| a.name.cmp(&b.name)); + list + } + + pub fn get(&self, id: &str) -> Option> { + self.backends.get(id).cloned() + } + + pub fn default_backend(&self) -> Option> { + self.get(&self.default_backend) + .or_else(|| self.backends.values().next().cloned()) + } + + pub fn default_id(&self) -> &str { + &self.default_backend + } +} diff --git a/src/bin/workspace_mcp.rs b/src/bin/workspace_mcp.rs index 638416f..daa9b86 100644 --- a/src/bin/workspace_mcp.rs +++ b/src/bin/workspace_mcp.rs @@ -455,7 +455,9 @@ impl Tool for UpdateSkillTool { // Validate skill name (prevent path traversal) if skill_name.contains("..") || skill_name.contains('/') || skill_name.contains('\\') { - return Err(anyhow::anyhow!("Invalid skill name: contains path separators or '..'")); + return Err(anyhow::anyhow!( + "Invalid skill name: contains path separators or '..'" + )); } // Get backend API URL (defaults to localhost in dev) @@ -475,7 +477,10 @@ impl Tool for UpdateSkillTool { if ref_path.contains("..") { return Err(anyhow::anyhow!("Invalid file_path: contains '..'")); } - format!("{}/api/library/skill/{}/files/{}", api_base, skill_name, ref_path) + format!( + "{}/api/library/skill/{}/files/{}", + api_base, skill_name, ref_path + ) } else { format!("{}/api/library/skill/{}", api_base, skill_name) }; diff --git a/src/lib.rs b/src/lib.rs index 82cf107..457660d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod agents; pub mod ai_providers; pub mod api; +pub mod backend; pub mod config; pub mod library; pub mod mcp; diff --git a/src/library/env_crypto.rs b/src/library/env_crypto.rs index 6a0e657..80a5fec 100644 --- a/src/library/env_crypto.rs +++ b/src/library/env_crypto.rs @@ -223,14 +223,16 @@ const VERSIONED_TAG_REGEX: &str = r#"([^<]*)"#; /// Check if a value is an unversioned encrypted tag (user input format). pub fn is_unversioned_encrypted(value: &str) -> bool { let trimmed = value.trim(); - trimmed.starts_with("") && trimmed.ends_with("") && !trimmed.contains(" v=\"") + trimmed.starts_with("") + && trimmed.ends_with("") + && !trimmed.contains(" v=\"") } /// Encrypt all unversioned value tags in content. /// Transforms plaintext to ciphertext. pub fn encrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result { - let re = regex::Regex::new(UNVERSIONED_TAG_REGEX) - .map_err(|e| anyhow!("Invalid regex: {}", e))?; + let re = + regex::Regex::new(UNVERSIONED_TAG_REGEX).map_err(|e| anyhow!("Invalid regex: {}", e))?; let mut result = content.to_string(); let mut offset: i64 = 0; @@ -264,8 +266,7 @@ pub fn encrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Resultciphertext tags in content. /// Transforms ciphertext to plaintext. pub fn decrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result { - let re = regex::Regex::new(VERSIONED_TAG_REGEX) - .map_err(|e| anyhow!("Invalid regex: {}", e))?; + let re = regex::Regex::new(VERSIONED_TAG_REGEX).map_err(|e| anyhow!("Invalid regex: {}", e))?; let mut result = content.to_string(); let mut offset: i64 = 0; @@ -309,7 +310,10 @@ pub async fn load_or_create_private_key(env_file_path: &Path) -> Result<[u8; KEY let key_hex = hex::encode(key); // Append to .env file - let env_line = format!("\n# Auto-generated encryption key for template env vars\n{}={}\n", PRIVATE_KEY_ENV, key_hex); + let env_line = format!( + "\n# Auto-generated encryption key for template env vars\n{}={}\n", + PRIVATE_KEY_ENV, key_hex + ); // Create or append to .env file let mut file = fs::OpenOptions::new() @@ -475,7 +479,9 @@ mod tests { // Too short assert!(parse_key("abc").is_err()); // Invalid hex - assert!(parse_key("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_err()); + assert!( + parse_key("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_err() + ); } #[test] @@ -502,8 +508,12 @@ mod tests { #[test] fn test_is_unversioned_encrypted() { assert!(is_unversioned_encrypted("secret")); - assert!(is_unversioned_encrypted(" secret ")); - assert!(!is_unversioned_encrypted("secret")); + assert!(is_unversioned_encrypted( + " secret " + )); + assert!(!is_unversioned_encrypted( + "secret" + )); assert!(!is_unversioned_encrypted("plaintext")); } diff --git a/src/library/mod.rs b/src/library/mod.rs index 635c6e1..cc5b7e9 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -335,14 +335,15 @@ impl LibraryStore { if file_name.ends_with(".md") { // Skip SKILL.md from the files list (it's in the content field) if file_name != "SKILL.md" { - let raw_content = - fs::read_to_string(&entry_path).await.unwrap_or_default(); + let raw_content = fs::read_to_string(&entry_path).await.unwrap_or_default(); // Decrypt any encrypted tags for display - let file_content = if let Ok(Some(key)) = env_crypto::load_private_key_from_env() { - env_crypto::decrypt_content_tags(&key, &raw_content).unwrap_or(raw_content) - } else { - raw_content - }; + let file_content = + if let Ok(Some(key)) = env_crypto::load_private_key_from_env() { + env_crypto::decrypt_content_tags(&key, &raw_content) + .unwrap_or(raw_content) + } else { + raw_content + }; md_files.push(SkillFile { name: file_name, path: relative_path, @@ -1244,7 +1245,10 @@ impl LibraryStore { .context("Failed to decrypt template env vars")?, None => { // No key configured - check if any values are encrypted - let has_encrypted = config.env_vars.values().any(|v| env_crypto::is_encrypted(v)); + let has_encrypted = config + .env_vars + .values() + .any(|v| env_crypto::is_encrypted(v)); if has_encrypted { tracing::warn!( "Template '{}' has encrypted env vars but PRIVATE_KEY is not configured", @@ -1397,9 +1401,7 @@ impl LibraryStore { } else if let Err(e) = fs::write(&path, &content).await { tracing::warn!("Failed to copy oh-my-opencode.json to Library: {}", e); } else { - tracing::info!( - "Copied oh-my-opencode.json from system to Library for versioning" - ); + tracing::info!("Copied oh-my-opencode.json from system to Library for versioning"); } return Ok(settings); diff --git a/src/library/rename.rs b/src/library/rename.rs index bb38782..f40d17c 100644 --- a/src/library/rename.rs +++ b/src/library/rename.rs @@ -45,10 +45,7 @@ impl ItemType { #[serde(tag = "type", rename_all = "snake_case")] pub enum RenameChange { /// Rename a file or directory. - RenameFile { - from: String, - to: String, - }, + RenameFile { from: String, to: String }, /// Update a reference in a file. UpdateReference { file: String, @@ -84,7 +81,11 @@ pub struct RenameResult { impl LibraryStore { /// Find all references to an item in the library. - pub async fn find_references(&self, item_type: ItemType, name: &str) -> Result> { + pub async fn find_references( + &self, + item_type: ItemType, + name: &str, + ) -> Result> { let mut refs = Vec::new(); match item_type { @@ -260,7 +261,11 @@ impl LibraryStore { success: false, changes: vec![], warnings: vec![], - error: Some(format!("{} '{}' already exists", item_type.as_str(), new_name)), + error: Some(format!( + "{} '{}' already exists", + item_type.as_str(), + new_name + )), }); } @@ -305,7 +310,10 @@ impl LibraryStore { } // Execute the rename - if let Err(e) = self.execute_rename(item_type, old_name, new_name, &old_path, &new_path).await { + if let Err(e) = self + .execute_rename(item_type, old_name, new_name, &old_path, &new_path) + .await + { return Ok(RenameResult { success: false, changes: vec![], @@ -316,8 +324,17 @@ impl LibraryStore { // Execute reference updates for change in &changes { - if let RenameChange::UpdateReference { file, field, old_value, new_value } = change { - if let Err(e) = self.update_reference(file, field, old_value, new_value).await { + if let RenameChange::UpdateReference { + file, + field, + old_value, + new_value, + } = change + { + if let Err(e) = self + .update_reference(file, field, old_value, new_value) + .await + { warnings.push(format!("Failed to update {}: {}", file, e)); } } @@ -332,7 +349,12 @@ impl LibraryStore { } /// Get the old and new paths for an item type. - fn get_item_paths(&self, item_type: ItemType, old_name: &str, new_name: &str) -> (std::path::PathBuf, std::path::PathBuf) { + fn get_item_paths( + &self, + item_type: ItemType, + old_name: &str, + new_name: &str, + ) -> (std::path::PathBuf, std::path::PathBuf) { match item_type { ItemType::Skill => ( self.path.join("skill").join(old_name), @@ -355,8 +377,12 @@ impl LibraryStore { self.path.join("tool").join(format!("{}.ts", new_name)), ), ItemType::WorkspaceTemplate => ( - self.path.join("workspace-template").join(format!("{}.json", old_name)), - self.path.join("workspace-template").join(format!("{}.json", new_name)), + self.path + .join("workspace-template") + .join(format!("{}.json", old_name)), + self.path + .join("workspace-template") + .join(format!("{}.json", new_name)), ), } } @@ -375,7 +401,10 @@ impl LibraryStore { let content = fs::read_to_string(old_path).await?; if let Ok(mut template) = serde_json::from_str::(&content) { if let Some(obj) = template.as_object_mut() { - obj.insert("name".to_string(), serde_json::Value::String(new_name.to_string())); + obj.insert( + "name".to_string(), + serde_json::Value::String(new_name.to_string()), + ); let updated = serde_json::to_string_pretty(&template)?; fs::write(old_path, updated).await?; } diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index a297e29..3b6f8ec 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -159,8 +159,14 @@ impl McpRegistry { desktop.scope = McpScope::Workspace; let workspace_command = { - let release = working_dir.join("target").join("release").join("workspace-mcp"); - let debug = working_dir.join("target").join("debug").join("workspace-mcp"); + let release = working_dir + .join("target") + .join("release") + .join("workspace-mcp"); + let debug = working_dir + .join("target") + .join("debug") + .join("workspace-mcp"); if release.exists() { release.to_string_lossy().to_string() } else if debug.exists() { diff --git a/src/opencode/mod.rs b/src/opencode/mod.rs index 11e2653..2d293e5 100644 --- a/src/opencode/mod.rs +++ b/src/opencode/mod.rs @@ -759,7 +759,9 @@ impl OpenCodeClient { if let Some(updated_at) = &session.updated_at { if let Ok(updated) = chrono::DateTime::parse_from_rfc3339(updated_at) { let age = now.signed_duration_since(updated.with_timezone(&chrono::Utc)); - if age > chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::hours(1)) { + if age + > chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::hours(1)) + { if let Err(e) = self.delete_session(&session.id).await { tracing::warn!( session_id = %session.id, @@ -802,29 +804,10 @@ pub struct ToolStatusInfo { } /// Events emitted by OpenCode during execution. -#[derive(Debug, Clone)] -pub enum OpenCodeEvent { - /// Agent is thinking/reasoning - Thinking { content: String }, - /// Agent is calling a tool - ToolCall { - tool_call_id: String, - name: String, - args: serde_json::Value, - }, - /// Tool execution completed - ToolResult { - tool_call_id: String, - name: String, - result: serde_json::Value, - }, - /// Text content being streamed - TextDelta { content: String }, - /// Message execution completed - MessageComplete { session_id: String }, - /// Error occurred - Error { message: String }, -} +/// +/// The concrete event type lives in the backend module so it can be shared +/// across backends. We keep this alias for backwards compatibility. +pub type OpenCodeEvent = crate::backend::events::ExecutionEvent; #[derive(Debug, Default)] struct SseState { @@ -999,7 +982,7 @@ fn handle_tool_part_update( ); Some(OpenCodeEvent::ToolCall { - tool_call_id, + id: tool_call_id, name: tool_name, args, }) @@ -1022,7 +1005,7 @@ fn handle_tool_part_update( ); Some(OpenCodeEvent::ToolResult { - tool_call_id, + id: tool_call_id, name: tool_name, result, }) @@ -1047,7 +1030,7 @@ fn handle_tool_part_update( ); Some(OpenCodeEvent::ToolResult { - tool_call_id, + id: tool_call_id, name: tool_name, result, }) @@ -1094,9 +1077,7 @@ fn parse_sse_event( let event_type = match json.get("type").and_then(|v| v.as_str()).or(event_name) { Some(event_type) => event_type, - None => { - return None - } + None => return None, }; let props = json .get("properties") @@ -1238,7 +1219,7 @@ fn parse_sse_event( }; state.emitted_tool_calls.insert(call_id.clone(), ()); Some(OpenCodeEvent::ToolCall { - tool_call_id: call_id, + id: call_id, name, args, }) @@ -1360,7 +1341,6 @@ impl Default for OpenCodeAssistantInfo { } } - pub fn extract_text(parts: &[serde_json::Value]) -> String { let mut out = Vec::new(); for part in parts { diff --git a/src/workspace.rs b/src/workspace.rs index 4bee1d8..3b0b6ed 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -649,10 +649,7 @@ fn opencode_entry_from_mcp( "OPEN_AGENT_CONTEXT_ROOT".to_string(), "/root/context".to_string(), ); - nspawn_env.insert( - "OPEN_AGENT_CONTEXT_DIR_NAME".to_string(), - context_dir_name, - ); + nspawn_env.insert("OPEN_AGENT_CONTEXT_DIR_NAME".to_string(), context_dir_name); } // Network configuration based on shared_network setting: @@ -770,7 +767,7 @@ async fn write_opencode_config( // which runs inside the container with container networking tools.insert("Bash".to_string(), json!(false)); // Claude Code built-in tools.insert("bash".to_string(), json!(false)); // lowercase variant - // Enable MCP-provided tools (workspace MCP runs inside container via nspawn) + // Enable MCP-provided tools (workspace MCP runs inside container via nspawn) tools.insert("workspace_*".to_string(), json!(true)); tools.insert("desktop_*".to_string(), json!(true)); tools.insert("playwright_*".to_string(), json!(true)); @@ -804,6 +801,178 @@ async fn write_opencode_config( Ok(()) } +/// Write Claude Code configuration to the workspace. +/// Generates `.claude/settings.local.json` and `CLAUDE.md` files. +async fn write_claudecode_config( + workspace_dir: &Path, + mcp_configs: Vec, + workspace_root: &Path, + workspace_type: WorkspaceType, + workspace_env: &HashMap, + skill_contents: Option<&[SkillContent]>, +) -> anyhow::Result<()> { + // Create .claude directory + let claude_dir = workspace_dir.join(".claude"); + tokio::fs::create_dir_all(&claude_dir).await?; + + // Build MCP servers config in Claude Code format + let mut mcp_servers = serde_json::Map::new(); + + let filtered_configs = mcp_configs.into_iter().filter(|c| c.enabled); + + for config in filtered_configs { + let server_config = match &config.transport { + McpTransport::Stdio(stdio) => { + // For container workspaces, wrap commands with nspawn + let (command, args) = match workspace_type { + WorkspaceType::Chroot => { + let container_name = workspace_root + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "default".to_string()); + let mut nspawn_args = vec![ + "-q".to_string(), + "-D".to_string(), + workspace_root.to_string_lossy().to_string(), + "-M".to_string(), + container_name, + "--".to_string(), + ]; + nspawn_args.extend(stdio.command.iter().cloned()); + nspawn_args.extend(stdio.args.iter().cloned()); + ("systemd-nspawn".to_string(), nspawn_args) + } + WorkspaceType::Host => { + let command = stdio.command.first().cloned().unwrap_or_default(); + let args: Vec = stdio + .command + .iter() + .skip(1) + .chain(stdio.args.iter()) + .cloned() + .collect(); + (command, args) + } + }; + + // Merge environment variables + let mut env = workspace_env.clone(); + env.extend(stdio.env.clone()); + + json!({ + "command": command, + "args": args, + "env": env, + }) + } + McpTransport::Http(http) => { + json!({ + "url": http.endpoint, + "headers": http.headers, + }) + } + }; + + let key = sanitize_key(&config.name); + mcp_servers.insert(key, server_config); + } + + // Write settings.local.json + let settings = json!({ + "mcpServers": mcp_servers, + }); + let settings_path = claude_dir.join("settings.local.json"); + let settings_content = serde_json::to_string_pretty(&settings)?; + tokio::fs::write(&settings_path, settings_content).await?; + + // Generate CLAUDE.md from skills + if let Some(skills) = skill_contents { + if !skills.is_empty() { + let mut claude_md = String::new(); + claude_md.push_str("# Project Context\n\n"); + claude_md.push_str("This file was generated by Open Agent. It contains context from configured skills.\n\n"); + + for skill in skills { + claude_md.push_str(&format!("## {}\n\n", skill.name)); + // Strip frontmatter if present + let content = if skill.content.starts_with("---") { + if let Some(end) = skill.content[3..].find("---") { + skill.content[3 + end + 3..].trim_start().to_string() + } else { + skill.content.clone() + } + } else { + skill.content.clone() + }; + claude_md.push_str(&content); + claude_md.push_str("\n\n"); + } + + let claude_md_path = workspace_dir.join("CLAUDE.md"); + tokio::fs::write(&claude_md_path, claude_md).await?; + } + } + + Ok(()) +} + +/// Write backend-specific configuration to the workspace. +/// This is the main entry point for config generation. +pub async fn write_backend_config( + workspace_dir: &Path, + backend_id: &str, + mcp_configs: Vec, + workspace_root: &Path, + workspace_type: WorkspaceType, + workspace_env: &HashMap, + skill_allowlist: Option<&[String]>, + skill_contents: Option<&[SkillContent]>, + shared_network: Option, +) -> anyhow::Result<()> { + match backend_id { + "opencode" => { + write_opencode_config( + workspace_dir, + mcp_configs, + workspace_root, + workspace_type, + workspace_env, + skill_allowlist, + shared_network, + ) + .await + } + "claudecode" => { + write_claudecode_config( + workspace_dir, + mcp_configs, + workspace_root, + workspace_type, + workspace_env, + skill_contents, + ) + .await + } + _ => { + // Unknown backend - write OpenCode config as fallback + tracing::warn!( + backend = backend_id, + "Unknown backend, falling back to OpenCode config" + ); + write_opencode_config( + workspace_dir, + mcp_configs, + workspace_root, + workspace_type, + workspace_env, + skill_allowlist, + shared_network, + ) + .await + } + } +} + /// Skill content to be written to the workspace. pub struct SkillContent { /// Skill name (folder name)