feat: add claude support

This commit is contained in:
Thomas Marchand
2026-01-18 10:34:26 +00:00
parent 6389dccfc3
commit 3ea5c79b95
30 changed files with 1078 additions and 152 deletions

View File

@@ -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> | 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<HTMLDivElement>(null);
// SWR: fetch once, cache globally, revalidate in background
// SWR: fetch backends
const { data: backends } = useSWR<Backend[]>('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<BackendAgent[]>(
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({
<p className="text-xs text-white/30 mt-1.5">Where the mission will run</p>
</div>
{/* Backend selection */}
<div>
<label className="block text-xs text-white/50 mb-1.5">Backend</label>
<select
value={newMissionBackend}
onChange={(e) => {
setNewMissionBackend(e.target.value);
// Reset agent selection when backend changes
setNewMissionAgent('');
setDefaultSet(false);
}}
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-sm text-white focus:border-indigo-500/50 focus:outline-none appearance-none cursor-pointer"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
backgroundPosition: 'right 0.5rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
paddingRight: '2.5rem',
}}
>
{backends?.map((backend) => (
<option key={backend.id} value={backend.id} className="bg-[#1a1a1a]">
{backend.name}{backend.id === 'opencode' ? ' (Recommended)' : ''}
</option>
))}
</select>
<p className="text-xs text-white/30 mt-1.5">AI coding backend to power this mission</p>
</div>
{/* Agent selection */}
<div>
<label className="block text-xs text-white/50 mb-1.5">Agent Configuration</label>
@@ -204,14 +253,14 @@ export function NewMissionDialog({
<option value="" className="bg-[#1a1a1a]">
{defaultAgentLabel}
</option>
{opencodeAgents.includes("Sisyphus") && (
{agents.includes("Sisyphus") && (
<option value="Sisyphus" className="bg-[#1a1a1a]">
Sisyphus (recommended)
</option>
)}
{opencodeAgents.length > 0 && (
<optgroup label="OpenCode Agents" className="bg-[#1a1a1a]">
{opencodeAgents.map((agent) => (
{agents.length > 0 && (
<optgroup label={`${backends?.find(b => b.id === newMissionBackend)?.name || 'Backend'} Agents`} className="bg-[#1a1a1a]">
{agents.map((agent: string) => (
<option key={agent} value={agent} className="bg-[#1a1a1a]">
{agent}
</option>
@@ -220,7 +269,7 @@ export function NewMissionDialog({
)}
</select>
<p className="text-xs text-white/30 mt-1.5">
OpenCode agents are provided by plugins; defaults are recommended
Agents are provided by plugins; defaults are recommended
</p>
</div>

View File

@@ -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<string, unknown>;
}
// List all available backends
export async function listBackends(): Promise<Backend[]> {
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<Backend> {
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<BackendAgent[]> {
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<BackendConfig> {
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<string, unknown>
): 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();
}

View File

@@ -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,

180
src/api/backends.rs Normal file
View File

@@ -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<BackendInfo> 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<Arc<AppState>>,
Extension(_user): Extension<AuthUser>,
) -> Json<Vec<BackendResponse>> {
let registry = state.backend_registry.read().await;
let backends: Vec<BackendResponse> = registry.list().into_iter().map(Into::into).collect();
Json(backends)
}
/// Get a specific backend by ID
pub async fn get_backend(
State(state): State<Arc<AppState>>,
Extension(_user): Extension<AuthUser>,
Path(id): Path<String>,
) -> Result<Json<BackendResponse>, (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<Arc<AppState>>,
Extension(_user): Extension<AuthUser>,
Path(id): Path<String>,
) -> Result<Json<Vec<AgentResponse>>, (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<AgentResponse> = 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<Arc<AppState>>,
Extension(_user): Extension<AuthUser>,
Path(id): Path<String>,
) -> Result<Json<BackendConfig>, (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<Arc<AppState>>,
Extension(_user): Extension<AuthUser>,
Path(id): Path<String>,
Json(_req): Json<UpdateBackendConfigRequest>,
) -> Result<Json<serde_json::Value>, (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"
})))
}

View File

@@ -481,6 +481,8 @@ pub enum ControlCommand {
agent: Option<String>,
/// Optional model override (provider/model)
model_override: Option<String>,
/// Backend to use for this mission ("opencode" or "claudecode")
backend: Option<String>,
respond: oneshot::Sender<Result<Mission, String>>,
},
/// Update mission status
@@ -1046,6 +1048,8 @@ pub struct CreateMissionRequest {
pub agent: Option<String>,
/// Optional model override (provider/model)
pub model_override: Option<String>,
/// Backend to use for this mission ("opencode" or "claudecode")
pub backend: Option<String>,
}
pub async fn create_mission(
@@ -1055,16 +1059,17 @@ pub async fn create_mission(
) -> Result<Json<Mission>, (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<dyn MissionStore>) -> Result<Mission, String> {
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<Uuid>,
agent: Option<&str>,
model_override: Option<&str>,
backend: Option<&str>,
) -> Result<Mission, String> {
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) => {

View File

@@ -346,18 +346,20 @@ async fn collect_desktop_sessions(state: &Arc<AppState>) -> Vec<DesktopSessionDe
let running_displays = get_running_xvfb_displays().await;
for display in running_displays {
// Check if this display is already in our list
sessions_by_display.entry(display.clone()).or_insert_with(|| DesktopSessionDetail {
display: display.clone(),
status: DesktopSessionStatus::Unknown,
mission_id: None,
mission_title: None,
mission_status: None,
started_at: "unknown".to_string(),
stopped_at: None,
keep_alive_until: None,
auto_close_in_secs: None,
process_running: true,
});
sessions_by_display
.entry(display.clone())
.or_insert_with(|| DesktopSessionDetail {
display: display.clone(),
status: DesktopSessionStatus::Unknown,
mission_id: None,
mission_title: None,
mission_status: None,
started_at: "unknown".to_string(),
stopped_at: None,
keep_alive_until: None,
auto_close_in_secs: None,
process_running: true,
});
}
let mut sessions: Vec<DesktopSessionDetail> = sessions_by_display.into_values().collect();

View File

@@ -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()),
));
}

View File

@@ -102,6 +102,7 @@ impl MissionStore for FileMissionStore {
workspace_id: Option<Uuid>,
agent: Option<&str>,
model_override: Option<&str>,
backend: Option<&str>,
) -> Result<Mission, String> {
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,

View File

@@ -53,6 +53,7 @@ impl MissionStore for InMemoryMissionStore {
workspace_id: Option<Uuid>,
agent: Option<&str>,
model_override: Option<&str>,
backend: Option<&str>,
) -> Result<Mission, String> {
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,

View File

@@ -38,6 +38,9 @@ pub struct Mission {
/// Optional model override (provider/model)
#[serde(skip_serializing_if = "Option::is_none")]
pub model_override: Option<String>,
/// Backend to use for this mission ("opencode" or "claudecode")
#[serde(default = "default_backend")]
pub backend: String,
pub history: Vec<MissionHistoryEntry>,
pub created_at: String,
pub updated_at: String,
@@ -52,6 +55,10 @@ pub struct Mission {
pub desktop_sessions: Vec<DesktopSessionInfo>,
}
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<Uuid>,
agent: Option<&str>,
model_override: Option<&str>,
backend: Option<&str>,
) -> Result<Mission, String>;
/// Update mission status.

View File

@@ -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<String> = 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<String> = 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<Uuid>,
agent: Option<&str>,
model_override: Option<&str>,
backend: Option<&str>,
) -> Result<Mission, String> {
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<String> = 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)?,

View File

@@ -17,6 +17,7 @@
pub mod ai_providers;
mod auth;
pub mod backends;
mod console;
pub mod control;
pub mod desktop;

View File

@@ -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<console::SessionPool>,
/// Global settings store
pub settings: Arc<crate::settings::SettingsStore>,
/// Backend registry for multi-backend support
pub backend_registry: Arc<RwLock<BackendRegistry>>,
}
/// 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");
}

View File

@@ -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<String
return None;
}
let latest_tag = String::from_utf8_lossy(&tag_result.stdout).trim().to_string();
let latest_tag = String::from_utf8_lossy(&tag_result.stdout)
.trim()
.to_string();
let latest_version = latest_tag.trim_start_matches('v');
if latest_version != current && version_is_newer(latest_version, current) {
@@ -291,11 +294,7 @@ async fn check_open_agent_update(current_version: Option<&str>) -> Option<String
/// Simple semver comparison (newer returns true if a > b).
fn version_is_newer(a: &str, b: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
v.split('.')
.filter_map(|s| s.parse().ok())
.collect()
};
let parse = |v: &str| -> Vec<u32> { 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<String> {
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<Item = Result<Event, std::convert::Infallible>> {
fn stream_plugin_update(
package: String,
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
async_stream::stream! {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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<Vec<AgentInfo>, Error> {
Ok(vec![])
}
async fn create_session(&self, config: SessionConfig) -> Result<Session, Error> {
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<ExecutionEvent>, 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<dyn Backend> {
Arc::new(ClaudeCodeBackend::new())
}

26
src/backend/events.rs Normal file
View File

@@ -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 },
}

46
src/backend/mod.rs Normal file
View File

@@ -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<String>,
pub model: Option<String>,
pub agent: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Session {
pub id: String,
pub directory: String,
pub model: Option<String>,
pub agent: Option<String>,
}
#[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>;
}

View File

@@ -0,0 +1 @@
pub use crate::opencode::*;

144
src/backend/opencode/mod.rs Normal file
View File

@@ -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<String>, 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<Value, Error> {
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::<Value>()
.await
.context("Failed to parse OpenCode agent payload")
}
fn parse_agents(payload: Value) -> Vec<AgentInfo> {
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<Vec<AgentInfo>, Error> {
let payload = self.fetch_agents().await?;
Ok(Self::parse_agents(payload))
}
async fn create_session(&self, config: SessionConfig) -> Result<Session, Error> {
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<ExecutionEvent>, 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<String>,
permissive: bool,
) -> Arc<dyn Backend> {
Arc::new(OpenCodeBackend::new(base_url, default_agent, permissive))
}

54
src/backend/registry.rs Normal file
View File

@@ -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<String, Arc<dyn Backend>>,
default_backend: String,
}
impl BackendRegistry {
pub fn new(default_backend: impl Into<String>) -> Self {
Self {
backends: HashMap::new(),
default_backend: default_backend.into(),
}
}
pub fn register(&mut self, backend: Arc<dyn Backend>) {
self.backends.insert(backend.id().to_string(), backend);
}
pub fn list(&self) -> Vec<BackendInfo> {
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<Arc<dyn Backend>> {
self.backends.get(id).cloned()
}
pub fn default_backend(&self) -> Option<Arc<dyn Backend>> {
self.get(&self.default_backend)
.or_else(|| self.backends.values().next().cloned())
}
pub fn default_id(&self) -> &str {
&self.default_backend
}
}

View File

@@ -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)
};

View File

@@ -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;

View File

@@ -223,14 +223,16 @@ const VERSIONED_TAG_REGEX: &str = r#"<encrypted v="(\d+)">([^<]*)</encrypted>"#;
/// 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("<encrypted>") && trimmed.ends_with("</encrypted>") && !trimmed.contains(" v=\"")
trimmed.starts_with("<encrypted>")
&& trimmed.ends_with("</encrypted>")
&& !trimmed.contains(" v=\"")
}
/// Encrypt all unversioned <encrypted>value</encrypted> tags in content.
/// Transforms <encrypted>plaintext</encrypted> to <encrypted v="1">ciphertext</encrypted>.
pub fn encrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result<String> {
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) -> Result<Str
/// Decrypt all versioned <encrypted v="N">ciphertext</encrypted> tags in content.
/// Transforms <encrypted v="1">ciphertext</encrypted> to <encrypted>plaintext</encrypted>.
pub fn decrypt_content_tags(key: &[u8; KEY_LENGTH], content: &str) -> Result<String> {
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("<encrypted>secret</encrypted>"));
assert!(is_unversioned_encrypted(" <encrypted>secret</encrypted> "));
assert!(!is_unversioned_encrypted("<encrypted v=\"1\">secret</encrypted>"));
assert!(is_unversioned_encrypted(
" <encrypted>secret</encrypted> "
));
assert!(!is_unversioned_encrypted(
"<encrypted v=\"1\">secret</encrypted>"
));
assert!(!is_unversioned_encrypted("plaintext"));
}

View File

@@ -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);

View File

@@ -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<Vec<RenameChange>> {
pub async fn find_references(
&self,
item_type: ItemType,
name: &str,
) -> Result<Vec<RenameChange>> {
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::<serde_json::Value>(&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?;
}

View File

@@ -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() {

View File

@@ -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 {

View File

@@ -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<McpServerConfig>,
workspace_root: &Path,
workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>,
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<String> = 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<McpServerConfig>,
workspace_root: &Path,
workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>,
skill_allowlist: Option<&[String]>,
skill_contents: Option<&[SkillContent]>,
shared_network: Option<bool>,
) -> 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)