From 045d1b60d7a62e7dc011b63f4878a14db649f1cb Mon Sep 17 00:00:00 2001 From: Sam N <78718829+TetraTsunami@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:23:17 -0600 Subject: [PATCH 01/16] feat(acp): config supports ACP --- src/api/agents.rs | 1 + src/config.rs | 136 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/api/agents.rs b/src/api/agents.rs index 154fe6d5d..6bef20e26 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -531,6 +531,7 @@ pub(super) async fn create_agent( brave_search_key: None, cron_timezone: None, sandbox: None, + acp: None, cron: Vec::new(), }; let agent_config = raw_config.resolve(&instance_dir, defaults); diff --git a/src/config.rs b/src/config.rs index 098c78c23..808281923 100644 --- a/src/config.rs +++ b/src/config.rs @@ -348,6 +348,8 @@ pub struct DefaultsConfig { pub history_backfill_count: usize, pub cron: Vec, pub opencode: OpenCodeConfig, + /// ACP agent definitions shared across all agents. + pub acp: HashMap, /// Worker log mode: "errors_only", "all_separate", or "all_combined". pub worker_log_mode: crate::settings::WorkerLogMode, } @@ -376,6 +378,7 @@ impl std::fmt::Debug for DefaultsConfig { .field("history_backfill_count", &self.history_backfill_count) .field("cron", &self.cron) .field("opencode", &self.opencode) + .field("acp", &self.acp) .field("worker_log_mode", &self.worker_log_mode) .finish() } @@ -567,6 +570,40 @@ impl Default for OpenCodeConfig { } } +/// ACP (Agent Client Protocol) agent configuration. +/// +/// Configured under `[defaults.acp.]` in config.toml. Each entry +/// represents a separate ACP-compatible coding agent that Spacebot can spawn +/// and communicate with over stdio. +#[derive(Debug, Clone)] +pub struct AcpAgentConfig { + /// Unique identifier for this ACP agent (the TOML table key). + pub id: String, + /// Whether this ACP agent is available for use. + pub enabled: bool, + /// Path to the agent binary (supports "env:VAR_NAME" references). + pub command: String, + /// Arguments passed to the agent binary. + pub args: Vec, + /// Environment variables set when spawning the agent process. + pub env: HashMap, + /// Session timeout in seconds. `None` uses a default of 300s. + pub timeout: u64, +} + +impl Default for AcpAgentConfig { + fn default() -> Self { + Self { + id: String::new(), + enabled: true, + command: String::new(), + args: Vec::new(), + env: HashMap::new(), + timeout: 300, + } + } +} + /// Cortex configuration. #[derive(Debug, Clone, Copy)] pub struct CortexConfig { @@ -766,6 +803,8 @@ pub struct AgentConfig { pub cron_timezone: Option, /// Sandbox configuration for process containment. pub sandbox: Option, + /// Per-agent ACP overrides. None inherits from defaults. + pub acp: Option>, /// Cron job definitions for this agent. pub cron: Vec, } @@ -816,6 +855,7 @@ pub struct ResolvedAgentConfig { pub sandbox: crate::sandbox::SandboxConfig, /// Number of messages to fetch from the platform when a new channel is created. pub history_backfill_count: usize, + pub acp: HashMap, pub cron: Vec, } @@ -841,6 +881,7 @@ impl Default for DefaultsConfig { history_backfill_count: 50, cron: Vec::new(), opencode: OpenCodeConfig::default(), + acp: HashMap::new(), worker_log_mode: crate::settings::WorkerLogMode::default(), } } @@ -898,6 +939,7 @@ impl AgentConfig { ), sandbox: self.sandbox.clone().unwrap_or_default(), history_backfill_count: defaults.history_backfill_count, + acp: resolve_acp_configs(&defaults.acp, self.acp.as_ref()), cron: self.cron.clone(), } } @@ -1720,6 +1762,8 @@ struct TomlDefaultsConfig { brave_search_key: Option, cron_timezone: Option, opencode: Option, + #[serde(default)] + acp: HashMap, worker_log_mode: Option, } @@ -1820,6 +1864,18 @@ struct TomlOpenCodePermissions { webfetch: Option, } +#[derive(Deserialize, Clone, Default)] +struct TomlAcpAgentConfig { + #[serde(default = "default_enabled")] + enabled: bool, + command: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + timeout: Option, +} + #[derive(Deserialize, Clone)] struct TomlMcpServerConfig { name: String, @@ -1866,6 +1922,8 @@ struct TomlAgentConfig { cron_timezone: Option, sandbox: Option, #[serde(default)] + acp: Option>, + #[serde(default)] cron: Vec, } @@ -2173,6 +2231,20 @@ fn resolve_mcp_configs( merged } +/// Merge default ACP configs with optional per-agent overrides. +fn resolve_acp_configs( + default_configs: &HashMap, + agent_configs: Option<&HashMap>, +) -> HashMap { + let mut merged = default_configs.clone(); + if let Some(overrides) = agent_configs { + for (id, cfg) in overrides { + merged.insert(id.clone(), cfg.clone()); + } + } + merged +} + impl Config { /// Resolve the instance directory from env or default (~/.spacebot). pub fn default_instance_dir() -> PathBuf { @@ -2546,6 +2618,7 @@ impl Config { brave_search_key: None, cron_timezone: None, sandbox: None, + acp: None, cron: Vec::new(), }]; @@ -3157,6 +3230,41 @@ impl Config { } }) .unwrap_or_else(|| base_defaults.opencode.clone()), + acp: { + let mut merged = base_defaults.acp.clone(); + for (id, toml_acp) in &toml.defaults.acp { + let base_entry = merged.get(id); + let resolved_command = toml_acp + .command + .as_deref() + .and_then(resolve_env_value) + .or_else(|| toml_acp.command.clone()) + .or_else(|| base_entry.map(|b| b.command.clone())) + .unwrap_or_default(); + merged.insert( + id.clone(), + AcpAgentConfig { + id: id.clone(), + enabled: toml_acp.enabled, + command: resolved_command, + args: if toml_acp.args.is_empty() { + base_entry.map(|b| b.args.clone()).unwrap_or_default() + } else { + toml_acp.args.clone() + }, + env: if toml_acp.env.is_empty() { + base_entry.map(|b| b.env.clone()).unwrap_or_default() + } else { + toml_acp.env.clone() + }, + timeout: toml_acp + .timeout + .unwrap_or_else(|| base_entry.map(|b| b.timeout).unwrap_or(300)), + }, + ); + } + merged + }, worker_log_mode: toml .defaults .worker_log_mode @@ -3308,6 +3416,30 @@ impl Config { brave_search_key: a.brave_search_key.as_deref().and_then(resolve_env_value), cron_timezone: a.cron_timezone.as_deref().and_then(resolve_env_value), sandbox: a.sandbox, + acp: a.acp.map(|acp_map| { + acp_map + .into_iter() + .map(|(id, toml_acp)| { + let resolved_command = toml_acp + .command + .as_deref() + .and_then(resolve_env_value) + .or_else(|| toml_acp.command.clone()) + .unwrap_or_default(); + ( + id.clone(), + AcpAgentConfig { + id, + enabled: toml_acp.enabled, + command: resolved_command, + args: toml_acp.args, + env: toml_acp.env, + timeout: toml_acp.timeout.unwrap_or(300), + }, + ) + }) + .collect() + }), cron, }) }) @@ -3337,6 +3469,7 @@ impl Config { brave_search_key: None, cron_timezone: None, sandbox: None, + acp: None, cron: Vec::new(), }); } @@ -3625,6 +3758,8 @@ pub struct RuntimeConfig { pub opencode: ArcSwap, /// Shared pool of OpenCode server processes. Lazily initialized on first use. pub opencode_server_pool: Arc, + /// ACP agent definitions for this runtime. + pub acp: ArcSwap>, /// Cron store, set after agent initialization. pub cron_store: ArcSwap>>, /// Cron scheduler, set after agent initialization. @@ -3680,6 +3815,7 @@ impl RuntimeConfig { skills: ArcSwap::from_pointee(skills), opencode: ArcSwap::from_pointee(defaults.opencode.clone()), opencode_server_pool: Arc::new(server_pool), + acp: ArcSwap::from_pointee(agent_config.acp.clone()), cron_store: ArcSwap::from_pointee(None), cron_scheduler: ArcSwap::from_pointee(None), settings: ArcSwap::from_pointee(None), From 44d6fe394a4afbd3d55fc76ac805bbe6abbe785c Mon Sep 17 00:00:00 2001 From: Tsuni <78718829+TetraTsunami@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:29:34 -0600 Subject: [PATCH 02/16] feat(acp): add ACP worker runtime backend --- Cargo.lock | 113 +++++++- Cargo.toml | 2 + src/acp.rs | 9 + src/acp/worker.rs | 695 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 5 files changed, 813 insertions(+), 7 deletions(-) create mode 100644 src/acp.rs create mode 100644 src/acp/worker.rs diff --git a/Cargo.lock b/Cargo.lock index 945796092..cc6ea01d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,37 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more 2.1.1", + "futures", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" +dependencies = [ + "anyhow", + "derive_more 2.1.1", + "schemars 1.2.1", + "serde", + "serde_json", + "strum 0.27.2", +] + [[package]] name = "ahash" version = "0.8.12" @@ -501,6 +532,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -943,7 +986,7 @@ version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1455,6 +1498,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2516,7 +2568,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" dependencies = [ - "derive_more-impl", + "derive_more-impl 1.0.0", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", ] [[package]] @@ -2531,6 +2592,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -4633,7 +4708,7 @@ dependencies = [ "prost-types", "rand 0.9.2", "snafu", - "strum", + "strum 0.26.3", "tokio", "tracing", "xxhash-rust", @@ -7550,7 +7625,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8239,6 +8314,7 @@ name = "spacebot" version = "0.1.15" dependencies = [ "aes-gcm", + "agent-client-protocol", "anyhow", "arc-swap", "arrow-array", @@ -8301,6 +8377,7 @@ dependencies = [ "tokio-stream", "tokio-test", "tokio-tungstenite 0.28.0", + "tokio-util", "toml 0.8.23", "toml_edit 0.22.27", "tower-http", @@ -8653,7 +8730,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -8669,6 +8755,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8954,7 +9052,7 @@ checksum = "84992abeed3ae42e8401b25d266d12bcba1def0abe59d22f6b9781167545f71e" dependencies = [ "aquamarine", "bytes", - "derive_more", + "derive_more 1.0.0", "dptree", "either", "futures", @@ -8980,7 +9078,7 @@ dependencies = [ "bitflags 2.10.0", "bytes", "chrono", - "derive_more", + "derive_more 1.0.0", "either", "futures", "log", @@ -9318,6 +9416,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", diff --git a/Cargo.toml b/Cargo.toml index fd73aa72b..5df846b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ regex = "1.11" # Async utilities futures = "0.3" +tokio-util = { version = "0.7", features = ["compat"] } pin-project = "1" # Schema validation @@ -145,6 +146,7 @@ prometheus = { version = "0.13", optional = true } pdf-extract = "0.10.0" open = "5.3.3" urlencoding = "2.1.3" +agent-client-protocol = "0.9.4" [features] metrics = ["dep:prometheus"] diff --git a/src/acp.rs b/src/acp.rs new file mode 100644 index 000000000..8fa38763d --- /dev/null +++ b/src/acp.rs @@ -0,0 +1,9 @@ +//! Agent Client Protocol (ACP) integration for worker backends. +//! +//! ACP workers run external coding agents over stdio using the +//! `agent-client-protocol` crate. Spacebot acts as the ACP client side, +//! implementing filesystem + terminal capabilities required by coding agents. + +pub mod worker; + +pub use worker::{AcpWorker, AcpWorkerResult}; diff --git a/src/acp/worker.rs b/src/acp/worker.rs new file mode 100644 index 000000000..8361aba82 --- /dev/null +++ b/src/acp/worker.rs @@ -0,0 +1,695 @@ +//! ACP worker backend. +//! +//! Spawns an ACP-compatible agent subprocess and communicates over stdio. +//! Spacebot implements ACP `Client` methods (permissions, fs, terminal) and +//! drives prompt turns through the agent connection. + +use crate::config::AcpAgentConfig; +use crate::{AgentId, ChannelId, ProcessEvent, WorkerId}; + +use agent_client_protocol::{Agent as _, ClientSideConnection}; +use agent_client_protocol::{ + ClientCapabilities, ContentBlock, ContentChunk, + CreateTerminalRequest, CreateTerminalResponse, Error as AcpError, FileSystemCapability, + InitializeRequest, PermissionOptionKind, PromptRequest, PromptResponse, ProtocolVersion, + ReadTextFileRequest, ReadTextFileResponse, RequestPermissionRequest, RequestPermissionOutcome, + RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate, + TerminalExitStatus, TerminalId, TerminalOutputRequest, TerminalOutputResponse, ToolCallStatus, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, WriteTextFileRequest, WriteTextFileResponse, +}; +use anyhow::Context as _; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::io::AsyncReadExt as _; +use tokio::process::{Child, Command}; +use tokio::sync::{Mutex, broadcast, mpsc}; +use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; +use uuid::Uuid; + +/// ACP-backed worker. +pub struct AcpWorker { + pub id: WorkerId, + pub channel_id: Option, + pub agent_id: AgentId, + pub task: String, + pub directory: PathBuf, + pub acp: AcpAgentConfig, + pub event_tx: broadcast::Sender, + pub input_rx: Option>, +} + +/// Result of an ACP worker run. +pub struct AcpWorkerResult { + pub session_id: String, + pub result_text: String, +} + +impl AcpWorker { + pub fn new( + channel_id: Option, + agent_id: AgentId, + task: impl Into, + directory: PathBuf, + acp: AcpAgentConfig, + event_tx: broadcast::Sender, + ) -> Self { + Self { + id: Uuid::new_v4(), + channel_id, + agent_id, + task: task.into(), + directory, + acp, + event_tx, + input_rx: None, + } + } + + pub fn new_interactive( + channel_id: Option, + agent_id: AgentId, + task: impl Into, + directory: PathBuf, + acp: AcpAgentConfig, + event_tx: broadcast::Sender, + ) -> (Self, mpsc::Sender) { + let (input_tx, input_rx) = mpsc::channel(32); + let mut worker = Self::new(channel_id, agent_id, task, directory, acp, event_tx); + worker.input_rx = Some(input_rx); + (worker, input_tx) + } + + pub async fn run(mut self) -> anyhow::Result { + if self.acp.command.trim().is_empty() { + anyhow::bail!("ACP command is empty for worker config '{}'", self.acp.id); + } + + self.send_status(&format!("starting ACP agent '{}'", self.acp.id)); + + let mut command = Command::new(&self.acp.command); + command + .args(&self.acp.args) + .current_dir(&self.directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + for (name, value) in &self.acp.env { + command.env(name, value); + } + + let mut child = command.spawn().with_context(|| { + format!( + "failed to spawn ACP agent '{}' with command '{}'", + self.acp.id, self.acp.command + ) + })?; + + let child_stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("failed to capture ACP child stdin"))?; + let child_stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("failed to capture ACP child stdout"))?; + + if let Some(stderr) = child.stderr.take() { + let worker_id = self.id; + tokio::spawn(async move { + let mut reader = tokio::io::BufReader::new(stderr); + let mut buffer = Vec::new(); + if let Err(error) = tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buffer).await { + tracing::debug!(worker_id = %worker_id, %error, "failed to read ACP stderr"); + return; + } + if !buffer.is_empty() { + let output = String::from_utf8_lossy(&buffer); + tracing::debug!(worker_id = %worker_id, stderr = %output, "ACP stderr"); + } + }); + } + + let workspace_root = self + .directory + .canonicalize() + .unwrap_or_else(|_| self.directory.clone()); + + let acp_client = Arc::new(SpacebotAcpClient::new( + self.agent_id.clone(), + self.id, + self.channel_id.clone(), + self.event_tx.clone(), + workspace_root, + )); + + let timeout = self.acp.timeout.max(1); + let (result, session_id) = tokio::task::LocalSet::new() + .run_until(async { + let (connection, io_task) = ClientSideConnection::new( + acp_client.clone(), + child_stdin.compat_write(), + child_stdout.compat(), + |future| { + tokio::task::spawn_local(future); + }, + ); + + tokio::task::spawn_local(async move { + if let Err(error) = io_task.await { + tracing::debug!(%error, "ACP IO task ended with error"); + } + }); + + let initialize = InitializeRequest::new(ProtocolVersion::LATEST) + .client_capabilities( + ClientCapabilities::new() + .fs( + FileSystemCapability::new() + .read_text_file(true) + .write_text_file(true), + ) + .terminal(true), + ); + + let initialize_response = connection + .initialize(initialize) + .await + .context("ACP initialize failed")?; + + tracing::debug!( + worker_id = %self.id, + negotiated_protocol = ?initialize_response.protocol_version, + "ACP initialized" + ); + + let session = connection + .new_session(agent_client_protocol::NewSessionRequest::new( + self.directory.clone(), + )) + .await + .context("ACP session/new failed")?; + + let session_id = session.session_id.0.to_string(); + + self.send_status("running ACP task"); + + acp_client.reset_text().await; + let prompt_response = prompt_once( + &connection, + &session.session_id, + &self.task, + timeout, + ) + .await?; + + let mut result_text = acp_client.take_text().await; + if result_text.trim().is_empty() { + result_text = format!("ACP worker completed with stop reason: {:?}", prompt_response.stop_reason); + } + + if let Some(mut input_rx) = self.input_rx.take() { + self.send_status("waiting for follow-up"); + while let Some(message) = input_rx.recv().await { + self.send_status("processing follow-up"); + acp_client.reset_text().await; + let follow_up_response = + prompt_once(&connection, &session.session_id, &message, timeout).await?; + let follow_up_text = acp_client.take_text().await; + if !follow_up_text.trim().is_empty() { + result_text = follow_up_text; + } else { + result_text = format!( + "ACP follow-up completed with stop reason: {:?}", + follow_up_response.stop_reason + ); + } + self.send_status("waiting for follow-up"); + } + } + + Ok::<(String, String), anyhow::Error>((result_text, session_id)) + }) + .await?; + + let _ = child.kill().await; + + self.send_status("completed"); + + Ok(AcpWorkerResult { + session_id, + result_text: result, + }) + } + + fn send_status(&self, status: &str) { + let _ = self.event_tx.send(ProcessEvent::WorkerStatus { + agent_id: self.agent_id.clone(), + worker_id: self.id, + channel_id: self.channel_id.clone(), + status: status.to_string(), + }); + } +} + +async fn prompt_once( + connection: &ClientSideConnection, + session_id: &agent_client_protocol::SessionId, + message: &str, + timeout_seconds: u64, +) -> anyhow::Result { + let request = PromptRequest::new(session_id.clone(), vec![ContentBlock::from(message)]); + tokio::time::timeout(std::time::Duration::from_secs(timeout_seconds), connection.prompt(request)) + .await + .context("ACP prompt timed out")? + .context("ACP prompt failed") +} + +struct TerminalEntry { + child: Arc>, + output: Arc>>, + output_limit: Option, + truncated: AtomicBool, + exit_status: Arc>>, +} + +impl TerminalEntry { + fn new(child: Child, output_limit: Option) -> Arc { + Arc::new(Self { + child: Arc::new(Mutex::new(child)), + output: Arc::new(Mutex::new(Vec::new())), + output_limit, + truncated: AtomicBool::new(false), + exit_status: Arc::new(Mutex::new(None)), + }) + } +} + +struct SpacebotAcpClient { + agent_id: AgentId, + worker_id: WorkerId, + channel_id: Option, + event_tx: broadcast::Sender, + workspace_root: PathBuf, + terminals: Arc>>>, + collected_text: Arc>, +} + +impl SpacebotAcpClient { + fn new( + agent_id: AgentId, + worker_id: WorkerId, + channel_id: Option, + event_tx: broadcast::Sender, + workspace_root: PathBuf, + ) -> Self { + Self { + agent_id, + worker_id, + channel_id, + event_tx, + workspace_root, + terminals: Arc::new(Mutex::new(HashMap::new())), + collected_text: Arc::new(Mutex::new(String::new())), + } + } + + async fn reset_text(&self) { + *self.collected_text.lock().await = String::new(); + } + + async fn take_text(&self) -> String { + self.collected_text.lock().await.clone() + } + + fn send_status(&self, status: impl Into) { + let _ = self.event_tx.send(ProcessEvent::WorkerStatus { + agent_id: self.agent_id.clone(), + worker_id: self.worker_id, + channel_id: self.channel_id.clone(), + status: status.into(), + }); + } + + fn resolve_path(&self, path: &Path) -> agent_client_protocol::Result { + if !path.is_absolute() { + return Err(AcpError::invalid_params().data("path must be absolute")); + } + + let canonical_workspace = self + .workspace_root + .canonicalize() + .unwrap_or_else(|_| self.workspace_root.clone()); + + let candidate = if path.exists() { + path.canonicalize() + .map_err(|error| AcpError::resource_not_found(Some(path.display().to_string())).data(error.to_string()))? + } else { + let parent = path + .parent() + .ok_or_else(|| AcpError::invalid_params().data("path has no parent"))?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| AcpError::resource_not_found(Some(parent.display().to_string())).data(error.to_string()))?; + canonical_parent.join(path.file_name().ok_or_else(|| { + AcpError::invalid_params().data("path is missing file name") + })?) + }; + + if !candidate.starts_with(&canonical_workspace) { + return Err(AcpError::invalid_params().data(format!( + "path '{}' is outside workspace root '{}'", + candidate.display(), + canonical_workspace.display() + ))); + } + + Ok(candidate) + } + + async fn terminal_entry(&self, terminal_id: &TerminalId) -> agent_client_protocol::Result> { + self.terminals + .lock() + .await + .get(terminal_id.0.as_ref()) + .cloned() + .ok_or_else(|| AcpError::resource_not_found(Some(terminal_id.0.to_string()))) + } +} + +#[async_trait::async_trait(?Send)] +impl agent_client_protocol::Client for SpacebotAcpClient { + async fn request_permission( + &self, + args: RequestPermissionRequest, + ) -> agent_client_protocol::Result { + let title = args + .tool_call + .fields + .title + .clone() + .unwrap_or_else(|| "permission requested".to_string()); + + let _ = self.event_tx.send(ProcessEvent::WorkerPermission { + agent_id: self.agent_id.clone(), + worker_id: self.worker_id, + channel_id: self.channel_id.clone(), + permission_id: args.tool_call.tool_call_id.0.to_string(), + description: title, + patterns: Vec::new(), + }); + + let selected = args + .options + .iter() + .find(|option| { + matches!( + option.kind, + PermissionOptionKind::AllowAlways | PermissionOptionKind::AllowOnce + ) + }) + .or_else(|| args.options.first()) + .ok_or_else(|| AcpError::invalid_params().data("permission request has no options"))?; + + Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + selected.option_id.clone(), + )), + )) + } + + async fn session_notification( + &self, + args: SessionNotification, + ) -> agent_client_protocol::Result<()> { + match args.update { + SessionUpdate::AgentMessageChunk(ContentChunk { content, .. }) => { + if let ContentBlock::Text(text_content) = content { + let mut text = self.collected_text.lock().await; + text.push_str(&text_content.text); + } + } + SessionUpdate::ToolCall(tool_call) => { + self.send_status(format!( + "tool {}: {:?}", + tool_call.title, tool_call.status + )); + } + SessionUpdate::ToolCallUpdate(update) => { + if let Some(status) = update.fields.status { + let status_text = match status { + ToolCallStatus::Pending => "tool pending", + ToolCallStatus::InProgress => "tool running", + ToolCallStatus::Completed => "tool completed", + ToolCallStatus::Failed => "tool failed", + _ => "tool status updated", + }; + self.send_status(status_text); + } + } + SessionUpdate::Plan(_) => { + self.send_status("planning"); + } + _ => {} + } + + Ok(()) + } + + async fn write_text_file( + &self, + args: WriteTextFileRequest, + ) -> agent_client_protocol::Result { + let path = self.resolve_path(&args.path)?; + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(AcpError::into_internal_error)?; + } + + tokio::fs::write(&path, args.content) + .await + .map_err(AcpError::into_internal_error)?; + + Ok(WriteTextFileResponse::new()) + } + + async fn read_text_file( + &self, + args: ReadTextFileRequest, + ) -> agent_client_protocol::Result { + let path = self.resolve_path(&args.path)?; + let content = tokio::fs::read_to_string(&path) + .await + .map_err(AcpError::into_internal_error)?; + + let limited_content = match (args.line, args.limit) { + (Some(line), Some(limit)) => { + let start_index = line.saturating_sub(1) as usize; + content + .lines() + .skip(start_index) + .take(limit as usize) + .collect::>() + .join("\n") + } + (Some(line), None) => { + let start_index = line.saturating_sub(1) as usize; + content + .lines() + .skip(start_index) + .collect::>() + .join("\n") + } + (None, Some(limit)) => content.lines().take(limit as usize).collect::>().join("\n"), + (None, None) => content, + }; + + Ok(ReadTextFileResponse::new(limited_content)) + } + + async fn create_terminal( + &self, + args: CreateTerminalRequest, + ) -> agent_client_protocol::Result { + let mut command = Command::new(&args.command); + command + .args(&args.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let cwd = match args.cwd { + Some(cwd) => self.resolve_path(&cwd)?, + None => self.workspace_root.clone(), + }; + command.current_dir(cwd); + + for env_var in args.env { + command.env(env_var.name, env_var.value); + } + + let mut child = command.spawn().map_err(AcpError::into_internal_error)?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let output_limit = args.output_byte_limit.and_then(|v| usize::try_from(v).ok()); + let entry = TerminalEntry::new(child, output_limit); + + if let Some(stdout_reader) = stdout { + spawn_output_reader(entry.clone(), stdout_reader); + } + if let Some(stderr_reader) = stderr { + spawn_output_reader(entry.clone(), stderr_reader); + } + + let terminal_id = TerminalId::new(format!("term_{}", Uuid::new_v4())); + self.terminals + .lock() + .await + .insert(terminal_id.0.to_string(), entry); + + Ok(CreateTerminalResponse::new(terminal_id)) + } + + async fn terminal_output( + &self, + args: TerminalOutputRequest, + ) -> agent_client_protocol::Result { + let entry = self.terminal_entry(&args.terminal_id).await?; + + let exit_status = { + let mut stored = entry.exit_status.lock().await; + if stored.is_none() { + let mut child = entry.child.lock().await; + if let Some(status) = child.try_wait().map_err(AcpError::into_internal_error)? { + *stored = Some(status); + } + } + *stored + }; + + let output_bytes = entry.output.lock().await.clone(); + let output = String::from_utf8_lossy(&output_bytes).to_string(); + + Ok(TerminalOutputResponse::new( + output, + entry.truncated.load(Ordering::Relaxed), + ) + .exit_status(exit_status.map(to_terminal_exit_status))) + } + + async fn release_terminal( + &self, + args: agent_client_protocol::ReleaseTerminalRequest, + ) -> agent_client_protocol::Result { + if let Some(entry) = self + .terminals + .lock() + .await + .remove(args.terminal_id.0.as_ref()) + { + let mut child = entry.child.lock().await; + if child + .try_wait() + .map_err(AcpError::into_internal_error)? + .is_none() + { + let _ = child.kill().await; + } + } + + Ok(agent_client_protocol::ReleaseTerminalResponse::new()) + } + + async fn wait_for_terminal_exit( + &self, + args: WaitForTerminalExitRequest, + ) -> agent_client_protocol::Result { + let entry = self.terminal_entry(&args.terminal_id).await?; + + let status = { + let mut stored = entry.exit_status.lock().await; + if let Some(status) = *stored { + status + } else { + let mut child = entry.child.lock().await; + let status = child.wait().await.map_err(AcpError::into_internal_error)?; + *stored = Some(status); + status + } + }; + + Ok(WaitForTerminalExitResponse::new(to_terminal_exit_status( + status, + ))) + } + + async fn kill_terminal_command( + &self, + args: agent_client_protocol::KillTerminalCommandRequest, + ) -> agent_client_protocol::Result { + let entry = self.terminal_entry(&args.terminal_id).await?; + let mut child = entry.child.lock().await; + if child + .try_wait() + .map_err(AcpError::into_internal_error)? + .is_none() + { + child.kill().await.map_err(AcpError::into_internal_error)?; + } + + Ok(agent_client_protocol::KillTerminalCommandResponse::new()) + } +} + +fn spawn_output_reader( + entry: Arc, + mut reader: impl tokio::io::AsyncRead + Unpin + Send + 'static, +) { + tokio::spawn(async move { + let mut chunk = [0u8; 4096]; + loop { + let read = match reader.read(&mut chunk).await { + Ok(0) => break, + Ok(size) => size, + Err(error) => { + tracing::debug!(%error, "failed reading ACP terminal output"); + break; + } + }; + + let mut output = entry.output.lock().await; + output.extend_from_slice(&chunk[..read]); + if let Some(limit) = entry.output_limit + && output.len() > limit + { + let overflow = output.len() - limit; + output.drain(0..overflow); + entry.truncated.store(true, Ordering::Relaxed); + } + } + }); +} + +fn to_terminal_exit_status(status: std::process::ExitStatus) -> TerminalExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt as _; + TerminalExitStatus::new() + .exit_code(status.code().and_then(|c| u32::try_from(c).ok())) + .signal(status.signal().map(|signal| signal.to_string())) + } + + #[cfg(not(unix))] + { + TerminalExitStatus::new().exit_code(status.code().and_then(|c| u32::try_from(c).ok())) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0e28ff0a3..32b302f24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! Spacebot: A Rust agentic system where every LLM process has a dedicated role. pub mod agent; +pub mod acp; pub mod api; pub mod auth; pub mod config; From 5be654f5e8bf6b54e96d73ef881ba65dfb313216 Mon Sep 17 00:00:00 2001 From: Tsuni <78718829+TetraTsunami@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:29:44 -0600 Subject: [PATCH 03/16] feat(workers): wire ACP worker spawning through tools --- .../en/tools/spawn_worker_description.md.j2 | 2 +- src/agent/channel.rs | 142 ++++++++++++++++++ src/tools/spawn_worker.rs | 107 +++++++++++-- 3 files changed, 241 insertions(+), 10 deletions(-) diff --git a/prompts/en/tools/spawn_worker_description.md.j2 b/prompts/en/tools/spawn_worker_description.md.j2 index 8300b7f54..8d11f3eed 100644 --- a/prompts/en/tools/spawn_worker_description.md.j2 +++ b/prompts/en/tools/spawn_worker_description.md.j2 @@ -1 +1 @@ -Spawn an independent worker process. By default uses a built-in agent with {tools} tools. The worker only sees the task description you provide — no conversation history.{opencode_note} \ No newline at end of file +Spawn an independent worker process. By default uses a built-in agent with {tools} tools. The worker only sees the task description you provide — no conversation history.{opencode_note}{acp_note} diff --git a/src/agent/channel.rs b/src/agent/channel.rs index c30430585..be416eb2c 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -2193,6 +2193,148 @@ pub async fn spawn_opencode_worker_from_state( Ok(worker_id) } +/// Spawn an ACP-backed worker for coding tasks. +pub async fn spawn_acp_worker_from_state( + state: &ChannelState, + task: impl Into, + directory: &str, + acp_id: Option<&str>, + interactive: bool, +) -> std::result::Result { + check_worker_limit(state).await?; + ensure_dispatch_readiness(state, "acp_worker"); + let task = task.into(); + let directory = std::path::PathBuf::from(directory); + + let acp_configs = state.deps.runtime_config.acp.load(); + let selected = if let Some(id) = acp_id { + acp_configs + .get(id) + .cloned() + .ok_or_else(|| AgentError::Other(anyhow::anyhow!("unknown ACP worker id '{}': configure defaults.acp.{}", id, id)))? + } else { + let enabled = acp_configs + .values() + .filter(|cfg| cfg.enabled && !cfg.command.trim().is_empty()) + .cloned() + .collect::>(); + + match enabled.len() { + 0 => { + return Err(AgentError::Other(anyhow::anyhow!( + "no enabled ACP workers configured; add [defaults.acp.] in config.toml" + ))); + } + 1 => enabled[0].clone(), + _ => { + return Err(AgentError::Other(anyhow::anyhow!( + "multiple ACP workers configured; provide acp_id when worker_type is 'acp'" + ))); + } + } + }; + + if !selected.enabled { + return Err(AgentError::Other(anyhow::anyhow!( + "ACP worker '{}' is disabled", + selected.id + ))); + } + + if selected.command.trim().is_empty() { + return Err(AgentError::Other(anyhow::anyhow!( + "ACP worker '{}' command is empty", + selected.id + ))); + } + + let acp_label = selected.id.clone(); + let worker = if interactive { + let (worker, input_tx) = crate::acp::AcpWorker::new_interactive( + Some(state.channel_id.clone()), + state.deps.agent_id.clone(), + &task, + directory, + selected, + state.deps.event_tx.clone(), + ); + let worker_id = worker.id; + state + .worker_inputs + .write() + .await + .insert(worker_id, input_tx); + worker + } else { + crate::acp::AcpWorker::new( + Some(state.channel_id.clone()), + state.deps.agent_id.clone(), + &task, + directory, + selected, + state.deps.event_tx.clone(), + ) + }; + + let worker_id = worker.id; + + let worker_span = tracing::info_span!( + "worker.run", + worker_id = %worker_id, + channel_id = %state.channel_id, + task = %task, + worker_type = "acp", + acp_id = %acp_label, + ); + let handle = spawn_worker_task( + worker_id, + state.deps.event_tx.clone(), + state.deps.agent_id.clone(), + Some(state.channel_id.clone()), + async move { + let result = tokio::task::spawn_blocking(move || -> anyhow::Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| anyhow::anyhow!("failed to build ACP runtime: {error}"))?; + + runtime.block_on(async move { + let worker_result = worker.run().await?; + Ok::(worker_result.result_text) + }) + }) + .await + .map_err(|error| anyhow::anyhow!("ACP worker thread join error: {error}"))??; + + Ok::(result) + } + .instrument(worker_span), + ); + + state.worker_handles.write().await.insert(worker_id, handle); + + let acp_task = format!("[acp:{}] {}", acp_label, task); + { + let mut status = state.status_block.write().await; + status.add_worker(worker_id, &acp_task, false); + } + + state + .deps + .event_tx + .send(crate::ProcessEvent::WorkerStarted { + agent_id: state.deps.agent_id.clone(), + worker_id, + channel_id: Some(state.channel_id.clone()), + task: acp_task, + }) + .ok(); + + tracing::info!(worker_id = %worker_id, task = %task, acp_id = %acp_label, "ACP worker spawned"); + + Ok(worker_id) +} + /// Spawn a future as a tokio task that sends a `WorkerComplete` event on completion. /// /// Handles both success and error cases, logging failures and sending the diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index 44d73a50c..a37339cd9 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -2,7 +2,8 @@ use crate::WorkerId; use crate::agent::channel::{ - ChannelState, spawn_opencode_worker_from_state, spawn_worker_from_state, + ChannelState, spawn_acp_worker_from_state, spawn_opencode_worker_from_state, + spawn_worker_from_state, }; use rig::completion::ToolDefinition; use rig::tool::Tool; @@ -42,14 +43,16 @@ pub struct SpawnWorkerArgs { pub suggested_skills: Vec, /// Worker type: "builtin" (default) runs a Rig agent loop with shell/file/exec /// tools. "opencode" spawns an OpenCode subprocess with full coding agent - /// capabilities. Use "opencode" for complex coding tasks that benefit from - /// codebase exploration and context management. + /// capabilities. "acp" spawns an Agent Client Protocol worker. #[serde(default)] pub worker_type: Option, /// Working directory for the worker. Required for "opencode" workers. /// The OpenCode agent will operate in this directory. #[serde(default)] pub directory: Option, + /// ACP worker id from [defaults.acp.] when worker_type is "acp". + #[serde(default)] + pub acp_id: Option, } /// Output from spawn worker tool. @@ -77,6 +80,7 @@ impl Tool for SpawnWorkerTool { let browser_enabled = rc.browser_config.load().enabled; let web_search_enabled = rc.brave_search_key.load().is_some(); let opencode_enabled = rc.opencode.load().enabled; + let acp_enabled = !rc.acp.load().is_empty(); let mut tools_list = vec!["shell", "file", "exec"]; if browser_enabled { @@ -91,11 +95,17 @@ impl Tool for SpawnWorkerTool { } else { "" }; + let acp_note = if acp_enabled { + " Set worker_type to \"acp\" with a directory path for ACP-based coding tasks. Optionally provide acp_id to select a specific ACP worker from defaults.acp." + } else { + "" + }; let base_description = crate::prompts::text::get("tools/spawn_worker"); let description = base_description .replace("{tools}", &tools_list.join(", ")) - .replace("{opencode_note}", opencode_note); + .replace("{opencode_note}", opencode_note) + .replace("{acp_note}", acp_note); let mut properties = serde_json::json!({ "task": { @@ -114,23 +124,40 @@ impl Tool for SpawnWorkerTool { } }); - if opencode_enabled && let Some(obj) = properties.as_object_mut() { + if (opencode_enabled || acp_enabled) && let Some(obj) = properties.as_object_mut() { + let worker_type_enum = if opencode_enabled && acp_enabled { + serde_json::json!(["builtin", "opencode", "acp"]) + } else if opencode_enabled { + serde_json::json!(["builtin", "opencode"]) + } else { + serde_json::json!(["builtin", "acp"]) + }; obj.insert( "worker_type".to_string(), serde_json::json!({ "type": "string", - "enum": ["builtin", "opencode"], + "enum": worker_type_enum, "default": "builtin", - "description": "\"builtin\" (default) runs a Rig agent loop. \"opencode\" spawns a full OpenCode coding agent — use for complex multi-file coding tasks." + "description": "\"builtin\" (default) runs a Rig agent loop. \"opencode\" spawns a full OpenCode coding agent. \"acp\" spawns an ACP-backed coding agent." }), ); obj.insert( "directory".to_string(), serde_json::json!({ "type": "string", - "description": "Working directory for the worker. Required when worker_type is \"opencode\". The OpenCode agent operates in this directory." + "description": "Working directory for the worker. Required when worker_type is \"opencode\" or \"acp\"." }), ); + + if acp_enabled { + obj.insert( + "acp_id".to_string(), + serde_json::json!({ + "type": "string", + "description": "Optional ACP worker id from defaults.acp.. Recommended when multiple ACP workers are configured." + }), + ); + } } ToolDefinition { @@ -147,6 +174,7 @@ impl Tool for SpawnWorkerTool { async fn call(&self, args: Self::Args) -> Result { let readiness = self.state.deps.runtime_config.work_readiness(); let is_opencode = args.worker_type.as_deref() == Some("opencode"); + let is_acp = args.worker_type.as_deref() == Some("acp"); let worker_id = if is_opencode { let directory = args.directory.as_deref().ok_or_else(|| { @@ -156,6 +184,21 @@ impl Tool for SpawnWorkerTool { spawn_opencode_worker_from_state(&self.state, &args.task, directory, args.interactive) .await .map_err(|e| SpawnWorkerError(format!("{e}")))? + } else if is_acp { + let directory = args + .directory + .as_deref() + .ok_or_else(|| SpawnWorkerError("directory is required for acp workers".into()))?; + + spawn_acp_worker_from_state( + &self.state, + &args.task, + directory, + args.acp_id.as_deref(), + args.interactive, + ) + .await + .map_err(|e| SpawnWorkerError(format!("{e}")))? } else { spawn_worker_from_state( &self.state, @@ -171,7 +214,13 @@ impl Tool for SpawnWorkerTool { .map_err(|e| SpawnWorkerError(format!("{e}")))? }; - let worker_type_label = if is_opencode { "OpenCode" } else { "builtin" }; + let worker_type_label = if is_opencode { + "OpenCode" + } else if is_acp { + "ACP" + } else { + "builtin" + }; let message = if args.interactive { format!( "Interactive {worker_type_label} worker {worker_id} spawned for: {}. Route follow-ups with route_to_worker.", @@ -204,3 +253,43 @@ impl Tool for SpawnWorkerTool { }) } } + +#[cfg(test)] +mod tests { + use super::SpawnWorkerArgs; + + #[test] + fn deserialize_acp_worker_args() { + let value = serde_json::json!({ + "task": "implement feature", + "worker_type": "acp", + "directory": "/tmp/project", + "acp_id": "claude", + "interactive": true, + "suggested_skills": ["rust", "testing"] + }); + + let args: SpawnWorkerArgs = serde_json::from_value(value).expect("valid args"); + assert_eq!(args.task, "implement feature"); + assert_eq!(args.worker_type.as_deref(), Some("acp")); + assert_eq!(args.directory.as_deref(), Some("/tmp/project")); + assert_eq!(args.acp_id.as_deref(), Some("claude")); + assert!(args.interactive); + assert_eq!(args.suggested_skills, vec!["rust", "testing"]); + } + + #[test] + fn deserialize_defaults_for_worker_args() { + let value = serde_json::json!({ + "task": "quick check" + }); + + let args: SpawnWorkerArgs = serde_json::from_value(value).expect("valid args"); + assert_eq!(args.task, "quick check"); + assert!(!args.interactive); + assert!(args.suggested_skills.is_empty()); + assert!(args.worker_type.is_none()); + assert!(args.directory.is_none()); + assert!(args.acp_id.is_none()); + } +} From bc85239160412337898b0ac9321311531b5c2a66 Mon Sep 17 00:00:00 2001 From: Tsuni <78718829+TetraTsunami@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:29:56 -0600 Subject: [PATCH 04/16] feat(settings): add ACP worker configuration in API and UI --- interface/src/api/client.ts | 16 +++ interface/src/routes/Settings.tsx | 159 +++++++++++++++++++++++++++++- src/api/settings.rs | 102 ++++++++++++++++++- 3 files changed, 273 insertions(+), 4 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 3a031c0fd..bc13ef6ce 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -977,6 +977,20 @@ export interface OpenCodeSettingsUpdate { permissions?: Partial; } +export interface AcpSettings { + enabled: boolean; + command: string; + args: string[]; + timeout: number; +} + +export interface AcpSettingsUpdate { + enabled?: boolean; + command?: string; + args?: string[]; + timeout?: number; +} + export interface GlobalSettingsResponse { brave_search_key: string | null; api_enabled: boolean; @@ -984,6 +998,7 @@ export interface GlobalSettingsResponse { api_bind: string; worker_log_mode: string; opencode: OpenCodeSettings; + acp: Record; } export interface GlobalSettingsUpdate { @@ -993,6 +1008,7 @@ export interface GlobalSettingsUpdate { api_bind?: string; worker_log_mode?: string; opencode?: OpenCodeSettingsUpdate; + acp?: Record; } export interface GlobalSettingsUpdateResponse { diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 4e06a29e3..791cdbdbe 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1225,6 +1225,13 @@ function OpenCodeSection({ settings, isLoading }: GlobalSettingsSectionProps) { const [editPerm, setEditPerm] = useState(settings?.opencode?.permissions?.edit ?? "allow"); const [bashPerm, setBashPerm] = useState(settings?.opencode?.permissions?.bash ?? "allow"); const [webfetchPerm, setWebfetchPerm] = useState(settings?.opencode?.permissions?.webfetch ?? "allow"); + const [acpWorkers, setAcpWorkers] = useState>([]); const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); useEffect(() => { @@ -1238,7 +1245,18 @@ function OpenCodeSection({ settings, isLoading }: GlobalSettingsSectionProps) { setBashPerm(settings.opencode.permissions.bash); setWebfetchPerm(settings.opencode.permissions.webfetch); } - }, [settings?.opencode]); + + const rows = Object.entries(settings?.acp ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, config]) => ({ + id, + enabled: config.enabled, + command: config.command, + args: config.args.join(", "), + timeout: config.timeout.toString(), + })); + setAcpWorkers(rows); + }, [settings?.opencode, settings?.acp]); const updateMutation = useMutation({ mutationFn: api.updateGlobalSettings, @@ -1272,6 +1290,37 @@ function OpenCodeSection({ settings, isLoading }: GlobalSettingsSectionProps) { return; } + const acpPayload: Record = {}; + const seenIds = new Set(); + for (const worker of acpWorkers) { + const id = worker.id.trim(); + if (!id) { + setMessage({ text: "ACP worker ID cannot be empty", type: "error" }); + return; + } + if (seenIds.has(id)) { + setMessage({ text: `Duplicate ACP worker ID: ${id}`, type: "error" }); + return; + } + seenIds.add(id); + + const timeoutValue = parseInt(worker.timeout, 10); + if (isNaN(timeoutValue) || timeoutValue < 1) { + setMessage({ text: `ACP timeout must be at least 1 for ${id}`, type: "error" }); + return; + } + + acpPayload[id] = { + enabled: worker.enabled, + command: worker.command.trim(), + args: worker.args + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + timeout: timeoutValue, + }; + } + updateMutation.mutate({ opencode: { enabled, @@ -1285,15 +1334,46 @@ function OpenCodeSection({ settings, isLoading }: GlobalSettingsSectionProps) { webfetch: webfetchPerm, }, }, + acp: acpPayload, }); }; + const addAcpWorker = () => { + let counter = 1; + let candidateId = `worker-${counter}`; + while (acpWorkers.some((worker) => worker.id === candidateId)) { + counter += 1; + candidateId = `worker-${counter}`; + } + + setAcpWorkers([ + ...acpWorkers, + { id: candidateId, enabled: true, command: "", args: "", timeout: "300" }, + ]); + }; + + const updateAcpWorker = ( + index: number, + field: "id" | "enabled" | "command" | "args" | "timeout", + value: string | boolean, + ) => { + setAcpWorkers((current) => + current.map((worker, workerIndex) => + workerIndex === index ? { ...worker, [field]: value } : worker, + ), + ); + }; + + const removeAcpWorker = (index: number) => { + setAcpWorkers((current) => current.filter((_, workerIndex) => workerIndex !== index)); + }; + return (
-

OpenCode Workers

+

Coding Workers

- Spawn OpenCode coding agents as worker subprocesses. Requires the opencode binary on PATH or a custom path below. + Configure coding worker backends available to spawn_worker: OpenCode and ACP profiles.

@@ -1304,6 +1384,13 @@ function OpenCodeSection({ settings, isLoading }: GlobalSettingsSectionProps) {
) : (
+
+

OpenCode

+

+ Spawn OpenCode coding agents as worker subprocesses. +

+
+ {/* Enable toggle */}