Commit all current changes

This commit is contained in:
2026-01-24 02:41:24 +00:00
parent 05bf62c88d
commit 225859b614
13 changed files with 374 additions and 128 deletions

View File

@@ -70,6 +70,7 @@ export default function WorkspacesPage() {
const [newWorkspaceName, setNewWorkspaceName] = useState(''); const [newWorkspaceName, setNewWorkspaceName] = useState('');
const [newWorkspaceType, setNewWorkspaceType] = useState<'host' | 'chroot'>('chroot'); const [newWorkspaceType, setNewWorkspaceType] = useState<'host' | 'chroot'>('chroot');
const [newWorkspaceTemplate, setNewWorkspaceTemplate] = useState(''); const [newWorkspaceTemplate, setNewWorkspaceTemplate] = useState('');
const [newWorkspacePath, setNewWorkspacePath] = useState('');
const [skillsFilter, setSkillsFilter] = useState(''); const [skillsFilter, setSkillsFilter] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]); const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [workspaceTab, setWorkspaceTab] = useState<'overview' | 'skills' | 'environment' | 'template' | 'build'>('overview'); const [workspaceTab, setWorkspaceTab] = useState<'overview' | 'skills' | 'environment' | 'template' | 'build'>('overview');
@@ -264,10 +265,15 @@ export default function WorkspacesPage() {
try { try {
setCreating(true); setCreating(true);
const workspaceType = newWorkspaceTemplate ? 'chroot' : newWorkspaceType; const workspaceType = newWorkspaceTemplate ? 'chroot' : newWorkspaceType;
const hostPath =
workspaceType === 'host'
? (newWorkspacePath.trim() || `workspaces/${newWorkspaceName}`)
: undefined;
const created = await createWorkspace({ const created = await createWorkspace({
name: newWorkspaceName, name: newWorkspaceName,
workspace_type: workspaceType, workspace_type: workspaceType,
template: newWorkspaceTemplate || undefined, template: newWorkspaceTemplate || undefined,
path: hostPath,
}); });
// Refresh workspace list immediately after creation so it appears in the UI // Refresh workspace list immediately after creation so it appears in the UI
@@ -294,6 +300,7 @@ export default function WorkspacesPage() {
} }
setShowNewWorkspaceDialog(false); setShowNewWorkspaceDialog(false);
setNewWorkspaceName(''); setNewWorkspaceName('');
setNewWorkspacePath('');
setNewWorkspaceTemplate(''); setNewWorkspaceTemplate('');
setSelectedWorkspace(workspaceToShow); setSelectedWorkspace(workspaceToShow);
} catch (err) { } catch (err) {
@@ -1112,6 +1119,22 @@ export default function WorkspacesPage() {
: 'Creates isolated Linux filesystem'} : 'Creates isolated Linux filesystem'}
</p> </p>
</div> </div>
{!newWorkspaceTemplate && newWorkspaceType === 'host' && (
<div>
<label className="text-xs text-white/40 mb-2 block">Path (relative to /home/trav)</label>
<input
type="text"
placeholder={`workspaces/${newWorkspaceName || 'my-workspace'}`}
value={newWorkspacePath}
onChange={(e) => setNewWorkspacePath(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg bg-black/20 border border-white/[0.06] text-sm text-white placeholder:text-white/25 focus:outline-none focus:border-indigo-500/50"
/>
<p className="text-xs text-white/35 mt-2">
Leave blank to use <span className="text-white/70">workspaces/{newWorkspaceName || 'name'}</span>
</p>
</div>
)}
</div> </div>
<div className="px-6 py-4 border-t border-white/[0.06] flex items-center justify-end gap-2"> <div className="px-6 py-4 border-t border-white/[0.06] flex items-center justify-end gap-2">

View File

@@ -184,13 +184,11 @@ fn get_anthropic_key_from_opencode_auth() -> Option<String> {
// Check for API key first // Check for API key first
let auth_type = anthropic_auth.get("type").and_then(|v| v.as_str()); let auth_type = anthropic_auth.get("type").and_then(|v| v.as_str());
match auth_type { match auth_type {
Some("api_key") | Some("api") => { Some("api_key") | Some("api") => anthropic_auth
anthropic_auth .get("key")
.get("key") .or_else(|| anthropic_auth.get("api_key"))
.or_else(|| anthropic_auth.get("api_key")) .and_then(|v| v.as_str())
.and_then(|v| v.as_str()) .map(|s| s.to_string()),
.map(|s| s.to_string())
}
Some("oauth") => { Some("oauth") => {
// Return OAuth access token - Claude CLI can use this // Return OAuth access token - Claude CLI can use this
anthropic_auth anthropic_auth
@@ -1348,7 +1346,9 @@ async fn get_provider_for_backend(
Ok(Json(BackendProviderResponse { Ok(Json(BackendProviderResponse {
configured: true, configured: true,
provider_type: Some("anthropic".to_string()), provider_type: Some("anthropic".to_string()),
provider_name: anthropic_config.and_then(|c| c.name).or_else(|| Some("Anthropic".to_string())), provider_name: anthropic_config
.and_then(|c| c.name)
.or_else(|| Some("Anthropic".to_string())),
api_key, api_key,
oauth, oauth,
has_credentials, has_credentials,
@@ -1894,25 +1894,28 @@ async fn oauth_callback_inner(
// 2. The old format: code#state // 2. The old format: code#state
// 3. Just the code // 3. Just the code
let input = req.code.trim(); let input = req.code.trim();
let (code_string, state_string): (String, Option<String>) = if let Ok(url) = url::Url::parse(input) { let (code_string, state_string): (String, Option<String>) =
// Parse as URL if let Ok(url) = url::Url::parse(input) {
let code = url.query_pairs() // Parse as URL
.find(|(k, _)| k == "code") let code = url
.map(|(_, v)| v.to_string()); .query_pairs()
let state = url.query_pairs() .find(|(k, _)| k == "code")
.find(|(k, _)| k == "state") .map(|(_, v)| v.to_string());
.map(|(_, v)| v.to_string()); let state = url
(code.unwrap_or_default(), state) .query_pairs()
} else if input.contains('#') { .find(|(k, _)| k == "state")
// Old format: code#state .map(|(_, v)| v.to_string());
let mut parts = input.splitn(2, '#'); (code.unwrap_or_default(), state)
let code = parts.next().unwrap_or(input).to_string(); } else if input.contains('#') {
let state = parts.next().map(|s| s.to_string()); // Old format: code#state
(code, state) let mut parts = input.splitn(2, '#');
} else { let code = parts.next().unwrap_or(input).to_string();
// Just the code let state = parts.next().map(|s| s.to_string());
(input.to_string(), None) (code, state)
}; } else {
// Just the code
(input.to_string(), None)
};
if code_string.is_empty() { if code_string.is_empty() {
return Err(( return Err((

View File

@@ -113,11 +113,12 @@ pub async fn get_backend_config(
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?; .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?;
drop(registry); drop(registry);
let config_entry = state let config_entry = state.backend_configs.get(&id).await.ok_or_else(|| {
.backend_configs (
.get(&id) StatusCode::NOT_FOUND,
.await format!("Backend {} not configured", id),
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not configured", id)))?; )
})?;
let mut settings = config_entry.settings.clone(); let mut settings = config_entry.settings.clone();
@@ -169,10 +170,12 @@ pub async fn update_backend_config(
let updated_settings = match id.as_str() { let updated_settings = match id.as_str() {
"opencode" => { "opencode" => {
let settings = req let settings = req.settings.as_object().ok_or_else(|| {
.settings (
.as_object() StatusCode::BAD_REQUEST,
.ok_or_else(|| (StatusCode::BAD_REQUEST, "Invalid settings payload".to_string()))?; "Invalid settings payload".to_string(),
)
})?;
let base_url = settings let base_url = settings
.get("base_url") .get("base_url")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -197,15 +200,12 @@ pub async fn update_backend_config(
"claudecode" => { "claudecode" => {
let mut settings = req.settings.clone(); let mut settings = req.settings.clone();
if let Some(api_key) = settings.get("api_key").and_then(|v| v.as_str()) { if let Some(api_key) = settings.get("api_key").and_then(|v| v.as_str()) {
let store = state let store = state.secrets.as_ref().ok_or_else(|| {
.secrets (
.as_ref() StatusCode::BAD_REQUEST,
.ok_or_else(|| { "Secrets store not available".to_string(),
( )
StatusCode::BAD_REQUEST, })?;
"Secrets store not available".to_string(),
)
})?;
store store
.set_secret("claudecode", "api_key", api_key, None) .set_secret("claudecode", "api_key", api_key, None)
.await .await

View File

@@ -3641,7 +3641,7 @@ async fn run_single_control_turn(
tracing::warn!("Failed to prepare mission workspace: {}", e); tracing::warn!("Failed to prepare mission workspace: {}", e);
ws.path.clone() ws.path.clone()
} }
}; };
(dir, Some(ws)) (dir, Some(ws))
} else { } else {
( (

View File

@@ -169,16 +169,12 @@ async fn resolve_path_for_workspace(
path: &str, path: &str,
mission_id: Option<uuid::Uuid>, mission_id: Option<uuid::Uuid>,
) -> Result<PathBuf, (StatusCode, String)> { ) -> Result<PathBuf, (StatusCode, String)> {
let workspace = state let workspace = state.workspaces.get(workspace_id).await.ok_or_else(|| {
.workspaces (
.get(workspace_id) StatusCode::NOT_FOUND,
.await format!("Workspace {} not found", workspace_id),
.ok_or_else(|| { )
( })?;
StatusCode::NOT_FOUND,
format!("Workspace {} not found", workspace_id),
)
})?;
let workspace_root = workspace.path.canonicalize().map_err(|e| { let workspace_root = workspace.path.canonicalize().map_err(|e| {
( (

View File

@@ -19,7 +19,9 @@ use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason}; use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason};
use crate::backend::claudecode::client::{ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent}; use crate::backend::claudecode::client::{
ClaudeCodeClient, ClaudeCodeConfig, ClaudeEvent, ContentBlock, StreamEvent,
};
use crate::config::Config; use crate::config::Config;
use crate::mcp::McpRegistry; use crate::mcp::McpRegistry;
use crate::secrets::SecretsStore; use crate::secrets::SecretsStore;
@@ -574,7 +576,10 @@ fn get_claudecode_cli_path_from_config(_app_working_dir: &std::path::Path) -> Op
if let Some(settings) = config.get("settings") { if let Some(settings) = config.get("settings") {
if let Some(cli_path) = settings.get("cli_path").and_then(|v| v.as_str()) { if let Some(cli_path) = settings.get("cli_path").and_then(|v| v.as_str()) {
if !cli_path.is_empty() { if !cli_path.is_empty() {
tracing::info!("Using Claude Code CLI path from backend config: {}", cli_path); tracing::info!(
"Using Claude Code CLI path from backend config: {}",
cli_path
);
return Some(cli_path.to_string()); return Some(cli_path.to_string());
} }
} }
@@ -596,8 +601,8 @@ pub async fn run_claudecode_turn(
secrets: Option<Arc<SecretsStore>>, secrets: Option<Arc<SecretsStore>>,
app_working_dir: &std::path::Path, app_working_dir: &std::path::Path,
) -> AgentResult { ) -> AgentResult {
use std::collections::HashMap;
use super::ai_providers::get_anthropic_api_key_for_claudecode; use super::ai_providers::get_anthropic_api_key_for_claudecode;
use std::collections::HashMap;
// Try to get API key from Anthropic provider configured for Claude Code backend // Try to get API key from Anthropic provider configured for Claude Code backend
let api_key = if let Some(key) = get_anthropic_api_key_for_claudecode(app_working_dir) { let api_key = if let Some(key) = get_anthropic_api_key_for_claudecode(app_working_dir) {
@@ -665,8 +670,7 @@ pub async fn run_claudecode_turn(
mission_id: Some(mission_id), mission_id: Some(mission_id),
resumable: true, resumable: true,
}); });
return AgentResult::failure(err_msg, 0) return AgentResult::failure(err_msg, 0).with_terminal_reason(TerminalReason::LlmError);
.with_terminal_reason(TerminalReason::LlmError);
} }
}; };

View File

@@ -156,9 +156,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
]; ];
let backend_configs = Arc::new( let backend_configs = Arc::new(
crate::backend_config::BackendConfigStore::new( crate::backend_config::BackendConfigStore::new(
config config.working_dir.join(".openagent/backend_config.json"),
.working_dir
.join(".openagent/backend_config.json"),
backend_defaults, backend_defaults,
) )
.await, .await,

View File

@@ -3,6 +3,7 @@
//! Provides endpoints to query and update system components like OpenCode //! Provides endpoints to query and update system components like OpenCode
//! and oh-my-opencode. //! and oh-my-opencode.
use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@@ -25,6 +26,54 @@ use super::routes::AppState;
/// Default repo path for Open Agent source /// Default repo path for Open Agent source
const OPEN_AGENT_REPO_PATH: &str = "/opt/open_agent/vaduz-v1"; const OPEN_AGENT_REPO_PATH: &str = "/opt/open_agent/vaduz-v1";
fn resolve_opencode_binary_path() -> PathBuf {
if let Ok(path) = std::env::var("OPENCODE_BIN_PATH") {
if !path.trim().is_empty() {
return PathBuf::from(path);
}
}
if let Ok(path) = std::env::var("OPENCODE_BIN") {
if !path.trim().is_empty() {
return PathBuf::from(path);
}
}
if let Ok(home) = std::env::var("HOME") {
let candidate = PathBuf::from(home).join(".opencode/bin/opencode");
if candidate.exists() {
return candidate;
}
}
PathBuf::from("/usr/local/bin/opencode")
}
fn resolve_claude_code_binary_path() -> Option<PathBuf> {
for key in ["CLAUDE_CLI_PATH", "CLAUDE_CODE_BIN_PATH", "CLAUDE_BIN_PATH"] {
if let Ok(path) = std::env::var(key) {
let trimmed = path.trim();
if !trimmed.is_empty() {
let candidate = PathBuf::from(trimmed);
if candidate.exists() {
return Some(candidate);
}
}
}
}
None
}
fn parse_claude_code_version(line: &str) -> Option<String> {
for token in line.split_whitespace() {
let cleaned = token.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
if cleaned.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(cleaned.to_string());
}
}
None
}
/// Information about a system component. /// Information about a system component.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ComponentInfo { pub struct ComponentInfo {
@@ -89,7 +138,10 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/components", get(get_components)) .route("/components", get(get_components))
.route("/components/:name/update", post(update_component)) .route("/components/:name/update", post(update_component))
.route("/plugins/installed", get(get_installed_plugins)) .route("/plugins/installed", get(get_installed_plugins))
.route("/plugins/:package/update", post(update_plugin)) .route(
"/plugins/:package/update",
get(update_plugin).post(update_plugin),
)
} }
/// Get information about all system components. /// Get information about all system components.
@@ -130,6 +182,9 @@ async fn get_components(State(state): State<Arc<AppState>>) -> Json<SystemCompon
/// Get OpenCode version and status. /// Get OpenCode version and status.
async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo { async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
let opencode_path = resolve_opencode_binary_path();
let opencode_path_display = opencode_path.display().to_string();
// Try to get version from the health endpoint // Try to get version from the health endpoint
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let health_url = format!("{}/global/health", config.opencode_base_url); let health_url = format!("{}/global/health", config.opencode_base_url);
@@ -155,7 +210,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
version, version,
installed: true, installed: true,
update_available, update_available,
path: Some("/usr/local/bin/opencode".to_string()), path: Some(opencode_path_display.clone()),
status, status,
}; };
} }
@@ -164,7 +219,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
} }
// Fallback: try to run opencode --version // Fallback: try to run opencode --version
match Command::new("opencode").arg("--version").output().await { match Command::new(&opencode_path).arg("--version").output().await {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let version_str = String::from_utf8_lossy(&output.stdout); let version_str = String::from_utf8_lossy(&output.stdout);
let version = version_str.lines().next().map(|l| { let version = version_str.lines().next().map(|l| {
@@ -185,7 +240,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
version, version,
installed: true, installed: true,
update_available, update_available,
path: Some("/usr/local/bin/opencode".to_string()), path: Some(opencode_path_display.clone()),
status, status,
} }
} }
@@ -202,15 +257,28 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
/// Get Claude Code version and status. /// Get Claude Code version and status.
async fn get_claude_code_info() -> ComponentInfo { async fn get_claude_code_info() -> ComponentInfo {
let cli_path = which_claude_code().await;
// Try to run claude --version to check if it's installed // Try to run claude --version to check if it's installed
match Command::new("claude").arg("--version").output().await { let Some(cli_path) = cli_path else {
return ComponentInfo {
name: "claude_code".to_string(),
version: None,
installed: false,
update_available: None,
path: None,
status: ComponentStatus::NotInstalled,
};
};
match Command::new(&cli_path).arg("--version").output().await {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let version_str = String::from_utf8_lossy(&output.stdout); let version_str = String::from_utf8_lossy(&output.stdout);
// Parse version from output like "claude 1.0.3"
let version = version_str let version = version_str
.lines() .lines()
.next() .next()
.map(|l| l.trim().replace("claude ", "").replace("Claude ", "")); .and_then(parse_claude_code_version)
.or_else(|| version_str.lines().next().map(|l| l.trim().to_string()));
let update_available = check_claude_code_update(version.as_deref()).await; let update_available = check_claude_code_update(version.as_deref()).await;
let status = if update_available.is_some() { let status = if update_available.is_some() {
@@ -224,7 +292,7 @@ async fn get_claude_code_info() -> ComponentInfo {
version, version,
installed: true, installed: true,
update_available, update_available,
path: which_claude_code().await, path: Some(cli_path),
status, status,
} }
} }
@@ -241,13 +309,13 @@ async fn get_claude_code_info() -> ComponentInfo {
/// Find the path to the Claude Code binary. /// Find the path to the Claude Code binary.
async fn which_claude_code() -> Option<String> { async fn which_claude_code() -> Option<String> {
if let Some(path) = resolve_claude_code_binary_path() {
return Some(path.display().to_string());
}
let output = Command::new("which").arg("claude").output().await.ok()?; let output = Command::new("which").arg("claude").output().await.ok()?;
if output.status.success() { if output.status.success() {
Some( Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
String::from_utf8_lossy(&output.stdout)
.trim()
.to_string(),
)
} else { } else {
None None
} }
@@ -809,18 +877,59 @@ fn stream_opencode_update() -> impl Stream<Item = Result<Event, std::convert::In
progress: Some(50), progress: Some(50),
}).unwrap())); }).unwrap()));
// Copy to /usr/local/bin let opencode_path = resolve_opencode_binary_path();
let opencode_path_display = opencode_path.display().to_string();
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let install_result = Command::new("install") let source_path = PathBuf::from(format!("{}/.opencode/bin/opencode", home));
.args(["-m", "0755", &format!("{}/.opencode/bin/opencode", home), "/usr/local/bin/opencode"])
.output() if let Some(parent) = opencode_path.parent() {
.await; if let Err(e) = tokio::fs::create_dir_all(parent).await {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!(
"Failed to create install directory {}: {}",
parent.display(),
e
),
progress: None,
}).unwrap()));
return;
}
}
if !source_path.exists() {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!(
"OpenCode binary not found at {} after install script.",
source_path.display()
),
progress: None,
}).unwrap()));
return;
}
let install_result = if opencode_path == source_path {
None
} else {
Some(
Command::new("install")
.args([
"-m",
"0755",
source_path.to_str().unwrap_or(""),
&opencode_path_display,
])
.output()
.await,
)
};
match install_result { match install_result {
Ok(output) if output.status.success() => { None => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(), event_type: "log".to_string(),
message: "Binary installed, restarting service...".to_string(), message: format!("Binary already installed at {}, restarting service...", opencode_path_display),
progress: Some(80), progress: Some(80),
}).unwrap())); }).unwrap()));
@@ -844,21 +953,102 @@ fn stream_opencode_update() -> impl Stream<Item = Result<Event, std::convert::In
Ok(output) => { Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(), event_type: "log".to_string(),
message: format!("Failed to restart service: {}", stderr), message: format!(
progress: None, "OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service",
opencode_path_display,
stderr.trim()
),
progress: Some(95),
}).unwrap()));
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "OpenCode updated; manual restart required.".to_string(),
progress: Some(100),
}).unwrap())); }).unwrap()));
} }
Err(e) => { Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(), event_type: "log".to_string(),
message: format!("Failed to restart service: {}", e), message: format!(
progress: None, "OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service",
opencode_path_display,
e
),
progress: Some(95),
}).unwrap()));
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "OpenCode updated; manual restart required.".to_string(),
progress: Some(100),
}).unwrap())); }).unwrap()));
} }
} }
} }
Ok(output) => { Some(Ok(output)) if output.status.success() => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!("Binary installed at {}, restarting service...", opencode_path_display),
progress: Some(80),
}).unwrap()));
// Restart the opencode service
let restart_result = Command::new("systemctl")
.args(["restart", "opencode.service"])
.output()
.await;
match restart_result {
Ok(output) if output.status.success() => {
// Wait a moment for the service to start
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "OpenCode updated successfully!".to_string(),
progress: Some(100),
}).unwrap()));
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!(
"OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service",
opencode_path_display,
stderr.trim()
),
progress: Some(95),
}).unwrap()));
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "OpenCode updated; manual restart required.".to_string(),
progress: Some(100),
}).unwrap()));
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!(
"OpenCode installed at {}, but failed to restart opencode.service: {}. Please run: sudo systemctl restart opencode.service",
opencode_path_display,
e
),
progress: Some(95),
}).unwrap()));
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "OpenCode updated; manual restart required.".to_string(),
progress: Some(100),
}).unwrap()));
}
}
}
Some(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(), event_type: "error".to_string(),
@@ -866,7 +1056,7 @@ fn stream_opencode_update() -> impl Stream<Item = Result<Event, std::convert::In
progress: None, progress: None,
}).unwrap())); }).unwrap()));
} }
Err(e) => { Some(Err(e)) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(), event_type: "error".to_string(),
message: format!("Failed to install binary: {}", e), message: format!("Failed to install binary: {}", e),
@@ -934,19 +1124,26 @@ fn stream_claude_code_update() -> impl Stream<Item = Result<Event, std::convert:
progress: Some(80), progress: Some(80),
}).unwrap())); }).unwrap()));
let verify_path = which_claude_code()
.await
.unwrap_or_else(|| "claude".to_string());
// Verify installation // Verify installation
let verify_result = Command::new("claude") let verify_result = Command::new(&verify_path)
.arg("--version") .arg("--version")
.output() .output()
.await; .await;
match verify_result { match verify_result {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout) let version_line = String::from_utf8_lossy(&output.stdout)
.lines() .lines()
.next() .next()
.map(|l| l.trim().to_string()) .unwrap_or("")
.unwrap_or_else(|| "unknown".to_string()); .trim()
.to_string();
let version = parse_claude_code_version(&version_line)
.unwrap_or_else(|| version_line);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent { yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(), event_type: "complete".to_string(),

View File

@@ -148,9 +148,7 @@ pub enum ContentBlock {
is_error: bool, is_error: bool,
}, },
#[serde(rename = "thinking")] #[serde(rename = "thinking")]
Thinking { Thinking { thinking: String },
thinking: String,
},
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -208,8 +206,7 @@ pub struct ClaudeCodeConfig {
impl Default for ClaudeCodeConfig { impl Default for ClaudeCodeConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
cli_path: std::env::var("CLAUDE_CLI_PATH") cli_path: std::env::var("CLAUDE_CLI_PATH").unwrap_or_else(|_| "claude".to_string()),
.unwrap_or_else(|_| "claude".to_string()),
api_key: std::env::var("ANTHROPIC_API_KEY").ok(), api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
default_model: None, default_model: None,
} }
@@ -299,7 +296,11 @@ impl ClaudeCodeClient {
let mut child = cmd.spawn().map_err(|e| { let mut child = cmd.spawn().map_err(|e| {
error!("Failed to spawn Claude CLI: {}", e); error!("Failed to spawn Claude CLI: {}", e);
anyhow!("Failed to spawn Claude CLI: {}. Is it installed at '{}'?", e, self.config.cli_path) anyhow!(
"Failed to spawn Claude CLI: {}. Is it installed at '{}'?",
e,
self.config.cli_path
)
})?; })?;
// Write message to stdin // Write message to stdin

View File

@@ -265,9 +265,7 @@ fn convert_claude_event(
ClaudeEvent::Result(res) => { ClaudeEvent::Result(res) => {
if res.is_error || res.subtype == "error" { if res.is_error || res.subtype == "error" {
results.push(ExecutionEvent::Error { results.push(ExecutionEvent::Error {
message: res message: res.result.unwrap_or_else(|| "Unknown error".to_string()),
.result
.unwrap_or_else(|| "Unknown error".to_string()),
}); });
} else { } else {
debug!( debug!(

View File

@@ -21,7 +21,11 @@ fn default_enabled() -> bool {
} }
impl BackendConfigEntry { impl BackendConfigEntry {
pub fn new(id: impl Into<String>, name: impl Into<String>, settings: serde_json::Value) -> Self { pub fn new(
id: impl Into<String>,
name: impl Into<String>,
settings: serde_json::Value,
) -> Self {
Self { Self {
id: id.into(), id: id.into(),
name: name.into(), name: name.into(),

View File

@@ -259,19 +259,38 @@ fn default_timeout_from_env() -> Duration {
Duration::from_secs_f64(DEFAULT_COMMAND_TIMEOUT_SECS) Duration::from_secs_f64(DEFAULT_COMMAND_TIMEOUT_SECS)
} }
fn parse_timeout(args: &Value) -> Duration { fn max_timeout_from_env() -> Option<Duration> {
if let Some(ms) = args.get("timeout_ms").and_then(|v| v.as_u64()) { if let Ok(raw) = env::var("OPEN_AGENT_COMMAND_TIMEOUT_MAX_SECS") {
return Duration::from_millis(ms.max(1)); if let Ok(value) = raw.parse::<f64>() {
} if value > 0.0 {
if let Some(secs) = args.get("timeout_secs").and_then(|v| v.as_u64()) { return Some(Duration::from_secs_f64(value));
return Duration::from_secs(secs.max(1)); }
}
if let Some(secs) = args.get("timeout").and_then(|v| v.as_f64()) {
if secs > 0.0 {
return Duration::from_secs_f64(secs);
} }
} }
default_timeout_from_env() None
}
fn parse_timeout(args: &Value) -> Duration {
let mut timeout = if let Some(ms) = args.get("timeout_ms").and_then(|v| v.as_u64()) {
Duration::from_millis(ms.max(1))
} else if let Some(secs) = args.get("timeout_secs").and_then(|v| v.as_u64()) {
Duration::from_secs(secs.max(1))
} else if let Some(secs) = args.get("timeout").and_then(|v| v.as_f64()) {
if secs > 0.0 {
Duration::from_secs_f64(secs)
} else {
default_timeout_from_env()
}
} else {
default_timeout_from_env()
};
if let Some(max) = max_timeout_from_env() {
if timeout > max {
timeout = max;
}
}
timeout
} }
fn parse_env(args: &Value) -> HashMap<String, String> { fn parse_env(args: &Value) -> HashMap<String, String> {

View File

@@ -579,6 +579,11 @@ fn opencode_entry_from_mcp(
merged_env merged_env
.entry("WORKING_DIR".to_string()) .entry("WORKING_DIR".to_string())
.or_insert_with(|| workspace_dir.to_string_lossy().to_string()); .or_insert_with(|| workspace_dir.to_string_lossy().to_string());
if config.name == "workspace" {
merged_env
.entry("OPEN_AGENT_COMMAND_TIMEOUT_MAX_SECS".to_string())
.or_insert_with(|| "55".to_string());
}
if workspace_type == WorkspaceType::Chroot { if workspace_type == WorkspaceType::Chroot {
if let Some(name) = workspace_root.file_name().and_then(|n| n.to_str()) { if let Some(name) = workspace_root.file_name().and_then(|n| n.to_str()) {
if !name.trim().is_empty() { if !name.trim().is_empty() {
@@ -739,7 +744,10 @@ fn claude_entry_from_mcp(
entry.insert("command".to_string(), json!(command)); entry.insert("command".to_string(), json!(command));
entry.insert("args".to_string(), json!(args)); entry.insert("args".to_string(), json!(args));
if let Some(env) = opencode_entry.get("environment").and_then(|v| v.as_object()) { if let Some(env) = opencode_entry
.get("environment")
.and_then(|v| v.as_object())
{
entry.insert("env".to_string(), serde_json::Value::Object(env.clone())); entry.insert("env".to_string(), serde_json::Value::Object(env.clone()));
} }
@@ -1544,14 +1552,8 @@ pub async fn prepare_mission_workspace_with_skills(
library: Option<&LibraryStore>, library: Option<&LibraryStore>,
mission_id: Uuid, mission_id: Uuid,
) -> anyhow::Result<PathBuf> { ) -> anyhow::Result<PathBuf> {
prepare_mission_workspace_with_skills_backend( prepare_mission_workspace_with_skills_backend(workspace, mcp, library, mission_id, "opencode")
workspace, .await
mcp,
library,
mission_id,
"opencode",
)
.await
} }
/// Prepare a workspace directory for a mission with skill and tool syncing for a specific backend. /// Prepare a workspace directory for a mission with skill and tool syncing for a specific backend.
@@ -1575,8 +1577,9 @@ pub async fn prepare_mission_workspace_with_skills_backend(
if backend_id == "claudecode" { if backend_id == "claudecode" {
if let Some(lib) = library { if let Some(lib) = library {
let context = format!("mission-{}", mission_id); let context = format!("mission-{}", mission_id);
let skill_names = let skill_names = resolve_workspace_skill_names(workspace, lib)
resolve_workspace_skill_names(workspace, lib).await.unwrap_or_default(); .await
.unwrap_or_default();
let skills = collect_skill_contents(&skill_names, &context, lib).await; let skills = collect_skill_contents(&skill_names, &context, lib).await;
skill_contents = Some(skills); skill_contents = Some(skills);
} }