feat: add claude support
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
180
src/api/backends.rs
Normal 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"
|
||||
})))
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
pub mod ai_providers;
|
||||
mod auth;
|
||||
pub mod backends;
|
||||
mod console;
|
||||
pub mod control;
|
||||
pub mod desktop;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
13
src/backend/claudecode/client.rs
Normal file
13
src/backend/claudecode/client.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
76
src/backend/claudecode/mod.rs
Normal file
76
src/backend/claudecode/mod.rs
Normal 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
26
src/backend/events.rs
Normal 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
46
src/backend/mod.rs
Normal 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>;
|
||||
}
|
||||
1
src/backend/opencode/client.rs
Normal file
1
src/backend/opencode/client.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use crate::opencode::*;
|
||||
144
src/backend/opencode/mod.rs
Normal file
144
src/backend/opencode/mod.rs
Normal 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
54
src/backend/registry.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
179
src/workspace.rs
179
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<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)
|
||||
|
||||
Reference in New Issue
Block a user