From 225859b61487348630cb81d7cf79fd8801554982 Mon Sep 17 00:00:00 2001
From: Travis Vasceannie
Date: Sat, 24 Jan 2026 02:41:24 +0000
Subject: [PATCH] Commit all current changes
---
dashboard/src/app/workspaces/page.tsx | 23 +++
src/api/ai_providers.rs | 57 +++---
src/api/backends.rs | 36 ++--
src/api/control.rs | 2 +-
src/api/fs.rs | 16 +-
src/api/mission_runner.rs | 14 +-
src/api/routes.rs | 4 +-
src/api/system.rs | 261 ++++++++++++++++++++++----
src/backend/claudecode/client.rs | 13 +-
src/backend/claudecode/mod.rs | 4 +-
src/backend_config.rs | 6 +-
src/tools/terminal.rs | 41 ++--
src/workspace.rs | 25 +--
13 files changed, 374 insertions(+), 128 deletions(-)
diff --git a/dashboard/src/app/workspaces/page.tsx b/dashboard/src/app/workspaces/page.tsx
index b7ad311..af70470 100644
--- a/dashboard/src/app/workspaces/page.tsx
+++ b/dashboard/src/app/workspaces/page.tsx
@@ -70,6 +70,7 @@ export default function WorkspacesPage() {
const [newWorkspaceName, setNewWorkspaceName] = useState('');
const [newWorkspaceType, setNewWorkspaceType] = useState<'host' | 'chroot'>('chroot');
const [newWorkspaceTemplate, setNewWorkspaceTemplate] = useState('');
+ const [newWorkspacePath, setNewWorkspacePath] = useState('');
const [skillsFilter, setSkillsFilter] = useState('');
const [selectedSkills, setSelectedSkills] = useState([]);
const [workspaceTab, setWorkspaceTab] = useState<'overview' | 'skills' | 'environment' | 'template' | 'build'>('overview');
@@ -264,10 +265,15 @@ export default function WorkspacesPage() {
try {
setCreating(true);
const workspaceType = newWorkspaceTemplate ? 'chroot' : newWorkspaceType;
+ const hostPath =
+ workspaceType === 'host'
+ ? (newWorkspacePath.trim() || `workspaces/${newWorkspaceName}`)
+ : undefined;
const created = await createWorkspace({
name: newWorkspaceName,
workspace_type: workspaceType,
template: newWorkspaceTemplate || undefined,
+ path: hostPath,
});
// Refresh workspace list immediately after creation so it appears in the UI
@@ -294,6 +300,7 @@ export default function WorkspacesPage() {
}
setShowNewWorkspaceDialog(false);
setNewWorkspaceName('');
+ setNewWorkspacePath('');
setNewWorkspaceTemplate('');
setSelectedWorkspace(workspaceToShow);
} catch (err) {
@@ -1112,6 +1119,22 @@ export default function WorkspacesPage() {
: 'Creates isolated Linux filesystem'}
+
+ {!newWorkspaceTemplate && newWorkspaceType === 'host' && (
+
+
+
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"
+ />
+
+ Leave blank to use workspaces/{newWorkspaceName || 'name'}
+
+
+ )}
diff --git a/src/api/ai_providers.rs b/src/api/ai_providers.rs
index c016f50..ad54c69 100644
--- a/src/api/ai_providers.rs
+++ b/src/api/ai_providers.rs
@@ -184,13 +184,11 @@ fn get_anthropic_key_from_opencode_auth() -> Option
{
// Check for API key first
let auth_type = anthropic_auth.get("type").and_then(|v| v.as_str());
match auth_type {
- Some("api_key") | Some("api") => {
- anthropic_auth
- .get("key")
- .or_else(|| anthropic_auth.get("api_key"))
- .and_then(|v| v.as_str())
- .map(|s| s.to_string())
- }
+ Some("api_key") | Some("api") => anthropic_auth
+ .get("key")
+ .or_else(|| anthropic_auth.get("api_key"))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string()),
Some("oauth") => {
// Return OAuth access token - Claude CLI can use this
anthropic_auth
@@ -1348,7 +1346,9 @@ async fn get_provider_for_backend(
Ok(Json(BackendProviderResponse {
configured: true,
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,
oauth,
has_credentials,
@@ -1894,25 +1894,28 @@ async fn oauth_callback_inner(
// 2. The old format: code#state
// 3. Just the code
let input = req.code.trim();
- let (code_string, state_string): (String, Option) = if let Ok(url) = url::Url::parse(input) {
- // Parse as URL
- let code = url.query_pairs()
- .find(|(k, _)| k == "code")
- .map(|(_, v)| v.to_string());
- let state = url.query_pairs()
- .find(|(k, _)| k == "state")
- .map(|(_, v)| v.to_string());
- (code.unwrap_or_default(), state)
- } else if input.contains('#') {
- // Old format: code#state
- let mut parts = input.splitn(2, '#');
- let code = parts.next().unwrap_or(input).to_string();
- let state = parts.next().map(|s| s.to_string());
- (code, state)
- } else {
- // Just the code
- (input.to_string(), None)
- };
+ let (code_string, state_string): (String, Option) =
+ if let Ok(url) = url::Url::parse(input) {
+ // Parse as URL
+ let code = url
+ .query_pairs()
+ .find(|(k, _)| k == "code")
+ .map(|(_, v)| v.to_string());
+ let state = url
+ .query_pairs()
+ .find(|(k, _)| k == "state")
+ .map(|(_, v)| v.to_string());
+ (code.unwrap_or_default(), state)
+ } else if input.contains('#') {
+ // Old format: code#state
+ let mut parts = input.splitn(2, '#');
+ let code = parts.next().unwrap_or(input).to_string();
+ let state = parts.next().map(|s| s.to_string());
+ (code, state)
+ } else {
+ // Just the code
+ (input.to_string(), None)
+ };
if code_string.is_empty() {
return Err((
diff --git a/src/api/backends.rs b/src/api/backends.rs
index 60bfbf0..b5b8487 100644
--- a/src/api/backends.rs
+++ b/src/api/backends.rs
@@ -113,11 +113,12 @@ pub async fn get_backend_config(
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?;
drop(registry);
- let config_entry = state
- .backend_configs
- .get(&id)
- .await
- .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not configured", id)))?;
+ let config_entry = state.backend_configs.get(&id).await.ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ format!("Backend {} not configured", id),
+ )
+ })?;
let mut settings = config_entry.settings.clone();
@@ -169,10 +170,12 @@ pub async fn update_backend_config(
let updated_settings = match id.as_str() {
"opencode" => {
- let settings = req
- .settings
- .as_object()
- .ok_or_else(|| (StatusCode::BAD_REQUEST, "Invalid settings payload".to_string()))?;
+ let settings = req.settings.as_object().ok_or_else(|| {
+ (
+ StatusCode::BAD_REQUEST,
+ "Invalid settings payload".to_string(),
+ )
+ })?;
let base_url = settings
.get("base_url")
.and_then(|v| v.as_str())
@@ -197,15 +200,12 @@ pub async fn update_backend_config(
"claudecode" => {
let mut settings = req.settings.clone();
if let Some(api_key) = settings.get("api_key").and_then(|v| v.as_str()) {
- let store = state
- .secrets
- .as_ref()
- .ok_or_else(|| {
- (
- StatusCode::BAD_REQUEST,
- "Secrets store not available".to_string(),
- )
- })?;
+ let store = state.secrets.as_ref().ok_or_else(|| {
+ (
+ StatusCode::BAD_REQUEST,
+ "Secrets store not available".to_string(),
+ )
+ })?;
store
.set_secret("claudecode", "api_key", api_key, None)
.await
diff --git a/src/api/control.rs b/src/api/control.rs
index 8cec7ca..49c6270 100644
--- a/src/api/control.rs
+++ b/src/api/control.rs
@@ -3641,7 +3641,7 @@ async fn run_single_control_turn(
tracing::warn!("Failed to prepare mission workspace: {}", e);
ws.path.clone()
}
- };
+ };
(dir, Some(ws))
} else {
(
diff --git a/src/api/fs.rs b/src/api/fs.rs
index 36ac19e..61f54c6 100644
--- a/src/api/fs.rs
+++ b/src/api/fs.rs
@@ -169,16 +169,12 @@ async fn resolve_path_for_workspace(
path: &str,
mission_id: Option,
) -> Result {
- let workspace = state
- .workspaces
- .get(workspace_id)
- .await
- .ok_or_else(|| {
- (
- StatusCode::NOT_FOUND,
- format!("Workspace {} not found", workspace_id),
- )
- })?;
+ let workspace = state.workspaces.get(workspace_id).await.ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ format!("Workspace {} not found", workspace_id),
+ )
+ })?;
let workspace_root = workspace.path.canonicalize().map_err(|e| {
(
diff --git a/src/api/mission_runner.rs b/src/api/mission_runner.rs
index d20b38b..7366230 100644
--- a/src/api/mission_runner.rs
+++ b/src/api/mission_runner.rs
@@ -19,7 +19,9 @@ use tokio_util::sync::CancellationToken;
use uuid::Uuid;
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::mcp::McpRegistry;
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(cli_path) = settings.get("cli_path").and_then(|v| v.as_str()) {
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());
}
}
@@ -596,8 +601,8 @@ pub async fn run_claudecode_turn(
secrets: Option>,
app_working_dir: &std::path::Path,
) -> AgentResult {
- use std::collections::HashMap;
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
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),
resumable: true,
});
- return AgentResult::failure(err_msg, 0)
- .with_terminal_reason(TerminalReason::LlmError);
+ return AgentResult::failure(err_msg, 0).with_terminal_reason(TerminalReason::LlmError);
}
};
diff --git a/src/api/routes.rs b/src/api/routes.rs
index 47c6885..3825153 100644
--- a/src/api/routes.rs
+++ b/src/api/routes.rs
@@ -156,9 +156,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
];
let backend_configs = Arc::new(
crate::backend_config::BackendConfigStore::new(
- config
- .working_dir
- .join(".openagent/backend_config.json"),
+ config.working_dir.join(".openagent/backend_config.json"),
backend_defaults,
)
.await,
diff --git a/src/api/system.rs b/src/api/system.rs
index e5e0bca..07fee2a 100644
--- a/src/api/system.rs
+++ b/src/api/system.rs
@@ -3,6 +3,7 @@
//! Provides endpoints to query and update system components like OpenCode
//! and oh-my-opencode.
+use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
@@ -25,6 +26,54 @@ use super::routes::AppState;
/// Default repo path for Open Agent source
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 {
+ 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 {
+ 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.
#[derive(Debug, Clone, Serialize)]
pub struct ComponentInfo {
@@ -89,7 +138,10 @@ pub fn routes() -> Router> {
.route("/components", get(get_components))
.route("/components/:name/update", post(update_component))
.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.
@@ -130,6 +182,9 @@ async fn get_components(State(state): State>) -> Json 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
let client = reqwest::Client::new();
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,
installed: true,
update_available,
- path: Some("/usr/local/bin/opencode".to_string()),
+ path: Some(opencode_path_display.clone()),
status,
};
}
@@ -164,7 +219,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
}
// 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() => {
let version_str = String::from_utf8_lossy(&output.stdout);
let version = version_str.lines().next().map(|l| {
@@ -185,7 +240,7 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
version,
installed: true,
update_available,
- path: Some("/usr/local/bin/opencode".to_string()),
+ path: Some(opencode_path_display.clone()),
status,
}
}
@@ -202,15 +257,28 @@ async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
/// Get Claude Code version and status.
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
- 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() => {
let version_str = String::from_utf8_lossy(&output.stdout);
- // Parse version from output like "claude 1.0.3"
let version = version_str
.lines()
.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 status = if update_available.is_some() {
@@ -224,7 +292,7 @@ async fn get_claude_code_info() -> ComponentInfo {
version,
installed: true,
update_available,
- path: which_claude_code().await,
+ path: Some(cli_path),
status,
}
}
@@ -241,13 +309,13 @@ async fn get_claude_code_info() -> ComponentInfo {
/// Find the path to the Claude Code binary.
async fn which_claude_code() -> Option {
+ 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()?;
if output.status.success() {
- Some(
- String::from_utf8_lossy(&output.stdout)
- .trim()
- .to_string(),
- )
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
@@ -809,18 +877,59 @@ fn stream_opencode_update() -> impl Stream- {
+ None => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
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),
}).unwrap()));
@@ -844,21 +953,102 @@ fn stream_opencode_update() -> impl Stream
- {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
- event_type: "error".to_string(),
- message: format!("Failed to restart service: {}", stderr),
- progress: None,
+ 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: "error".to_string(),
- message: format!("Failed to restart service: {}", e),
- progress: None,
+ 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()));
}
}
}
- 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);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
@@ -866,7 +1056,7 @@ fn stream_opencode_update() -> impl Stream
- {
+ Some(Err(e)) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to install binary: {}", e),
@@ -934,19 +1124,26 @@ fn stream_claude_code_update() -> impl Stream
- {
- let version = String::from_utf8_lossy(&output.stdout)
+ let version_line = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
- .map(|l| l.trim().to_string())
- .unwrap_or_else(|| "unknown".to_string());
+ .unwrap_or("")
+ .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 {
event_type: "complete".to_string(),
diff --git a/src/backend/claudecode/client.rs b/src/backend/claudecode/client.rs
index 30913ab..17bbb87 100644
--- a/src/backend/claudecode/client.rs
+++ b/src/backend/claudecode/client.rs
@@ -148,9 +148,7 @@ pub enum ContentBlock {
is_error: bool,
},
#[serde(rename = "thinking")]
- Thinking {
- thinking: String,
- },
+ Thinking { thinking: String },
}
#[derive(Debug, Clone, Deserialize)]
@@ -208,8 +206,7 @@ pub struct ClaudeCodeConfig {
impl Default for ClaudeCodeConfig {
fn default() -> Self {
Self {
- cli_path: std::env::var("CLAUDE_CLI_PATH")
- .unwrap_or_else(|_| "claude".to_string()),
+ cli_path: std::env::var("CLAUDE_CLI_PATH").unwrap_or_else(|_| "claude".to_string()),
api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
default_model: None,
}
@@ -299,7 +296,11 @@ impl ClaudeCodeClient {
let mut child = cmd.spawn().map_err(|e| {
error!("Failed to spawn Claude CLI: {}", e);
- anyhow!("Failed to spawn Claude CLI: {}. Is it installed at '{}'?", e, self.config.cli_path)
+ anyhow!(
+ "Failed to spawn Claude CLI: {}. Is it installed at '{}'?",
+ e,
+ self.config.cli_path
+ )
})?;
// Write message to stdin
diff --git a/src/backend/claudecode/mod.rs b/src/backend/claudecode/mod.rs
index 2206d7d..29bd861 100644
--- a/src/backend/claudecode/mod.rs
+++ b/src/backend/claudecode/mod.rs
@@ -265,9 +265,7 @@ fn convert_claude_event(
ClaudeEvent::Result(res) => {
if res.is_error || res.subtype == "error" {
results.push(ExecutionEvent::Error {
- message: res
- .result
- .unwrap_or_else(|| "Unknown error".to_string()),
+ message: res.result.unwrap_or_else(|| "Unknown error".to_string()),
});
} else {
debug!(
diff --git a/src/backend_config.rs b/src/backend_config.rs
index 1828f5b..4991cd1 100644
--- a/src/backend_config.rs
+++ b/src/backend_config.rs
@@ -21,7 +21,11 @@ fn default_enabled() -> bool {
}
impl BackendConfigEntry {
- pub fn new(id: impl Into, name: impl Into, settings: serde_json::Value) -> Self {
+ pub fn new(
+ id: impl Into,
+ name: impl Into,
+ settings: serde_json::Value,
+ ) -> Self {
Self {
id: id.into(),
name: name.into(),
diff --git a/src/tools/terminal.rs b/src/tools/terminal.rs
index ed48b6e..32f624f 100644
--- a/src/tools/terminal.rs
+++ b/src/tools/terminal.rs
@@ -259,19 +259,38 @@ fn default_timeout_from_env() -> Duration {
Duration::from_secs_f64(DEFAULT_COMMAND_TIMEOUT_SECS)
}
-fn parse_timeout(args: &Value) -> Duration {
- if let Some(ms) = args.get("timeout_ms").and_then(|v| v.as_u64()) {
- return Duration::from_millis(ms.max(1));
- }
- if let Some(secs) = args.get("timeout_secs").and_then(|v| v.as_u64()) {
- 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);
+fn max_timeout_from_env() -> Option {
+ if let Ok(raw) = env::var("OPEN_AGENT_COMMAND_TIMEOUT_MAX_SECS") {
+ if let Ok(value) = raw.parse::() {
+ if value > 0.0 {
+ return Some(Duration::from_secs_f64(value));
+ }
}
}
- 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 {
diff --git a/src/workspace.rs b/src/workspace.rs
index 16d5b9a..a34e860 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -579,6 +579,11 @@ fn opencode_entry_from_mcp(
merged_env
.entry("WORKING_DIR".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 let Some(name) = workspace_root.file_name().and_then(|n| n.to_str()) {
if !name.trim().is_empty() {
@@ -739,7 +744,10 @@ fn claude_entry_from_mcp(
entry.insert("command".to_string(), json!(command));
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()));
}
@@ -1544,14 +1552,8 @@ pub async fn prepare_mission_workspace_with_skills(
library: Option<&LibraryStore>,
mission_id: Uuid,
) -> anyhow::Result {
- prepare_mission_workspace_with_skills_backend(
- workspace,
- mcp,
- library,
- mission_id,
- "opencode",
- )
- .await
+ prepare_mission_workspace_with_skills_backend(workspace, mcp, library, mission_id, "opencode")
+ .await
}
/// 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 let Some(lib) = library {
let context = format!("mission-{}", mission_id);
- let skill_names =
- resolve_workspace_skill_names(workspace, lib).await.unwrap_or_default();
+ let skill_names = resolve_workspace_skill_names(workspace, lib)
+ .await
+ .unwrap_or_default();
let skills = collect_skill_contents(&skill_names, &context, lib).await;
skill_contents = Some(skills);
}