From 19d8b520bc8eb12bc54bd415d93f777242bba5a6 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 26 Mar 2026 12:50:44 -0700 Subject: [PATCH] Support old and new Claude ACP runtimes (#193) --- crates/sprout-acp/README.md | 14 +-- crates/sprout-acp/src/config.rs | 13 ++- .../src-tauri/src/commands/agent_models.rs | 12 +-- desktop/src-tauri/src/commands/agents.rs | 58 +++++------ .../src-tauri/src/managed_agents/discovery.rs | 96 ++++++++++++++----- .../src-tauri/src/managed_agents/runtime.rs | 12 +-- desktop/src-tauri/src/managed_agents/types.rs | 1 - desktop/src/features/agents/channelAgents.ts | 12 ++- lefthook.yml | 4 + 9 files changed, 142 insertions(+), 80 deletions(-) diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md index 5a1941a23..a7dccd32c 100644 --- a/crates/sprout-acp/README.md +++ b/crates/sprout-acp/README.md @@ -9,7 +9,7 @@ Sprout Relay ──WS──→ sprout-acp ──stdio──→ Your Agent (send_message, etc.) ``` -Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/zed-industries/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp)). +Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/zed-industries/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). ## Prerequisites @@ -76,20 +76,22 @@ sprout-acp ## Running with Claude Code -[claude-agent-acp](https://github.com/zed-industries/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface. +[claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface. ```bash -# Build the adapter -cd /path/to/claude-agent-acp && npm install && npm run build +# Install the current adapter package +npm install -g @agentclientprotocol/claude-agent-acp # Run export ANTHROPIC_API_KEY="sk-ant-..." -export SPROUT_ACP_AGENT_COMMAND="node" # full path if using hermit: /path/to/sprout2/bin/node -export SPROUT_ACP_AGENT_ARGS="/path/to/claude-agent-acp/dist/index.js" +export SPROUT_ACP_AGENT_COMMAND="claude-agent-acp" sprout-acp ``` +Older installs that still expose `claude-code-acp` are also supported. `sprout-acp` +treats both Claude ACP command names as the same zero-arg runtime. + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index ca02a710b..0e567d6c3 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -348,9 +348,8 @@ fn normalize_agent_command_identity(command: &str) -> String { fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), - "codex" | "codex-acp" | "claude-agent-acp" | "claude-code" | "claudecode" => { - Some(Vec::new()) - } + "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" + | "claudecode" => Some(Vec::new()), _ => None, } } @@ -912,6 +911,14 @@ mod tests { normalize_agent_args("claude-code", vec!["acp".into()]), Vec::::new() ); + assert_eq!( + normalize_agent_args("claude-code-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("claude-agent-acp", vec!["acp".into()]), + Vec::::new() + ); } #[test] diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4027650de..7aed05678 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -6,9 +6,9 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, - load_managed_agents, missing_command_message, resolve_command, save_managed_agents, - sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, ManagedAgentSummary, - UpdateManagedAgentRequest, DEFAULT_AGENT_ARG, + load_managed_agents, missing_command_message, normalize_agent_args, resolve_command, + save_managed_agents, sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, + ManagedAgentSummary, UpdateManagedAgentRequest, }, util::now_iso, }; @@ -45,11 +45,7 @@ pub async fn get_agent_models( let resolved = resolve_command(&record.acp_command, Some(&app)) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - let args = if record.agent_args.is_empty() { - vec![DEFAULT_AGENT_ARG.to_string()] - } else { - record.agent_args.clone() - }; + let args = normalize_agent_args(&record.agent_command, record.agent_args.clone()); ( resolved, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 4dab53045..19944b9c9 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,13 +6,13 @@ use crate::{ managed_agents::{ build_managed_agent_summary, default_token_scopes, discover_provider_candidates, find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, - managed_agent_avatar_url, managed_agent_log_path, mint_token_via_api, provider_deploy, - read_log_tail, resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config, - BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, - ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, - MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, + managed_agent_avatar_url, managed_agent_log_path, mint_token_via_api, normalize_agent_args, + provider_deploy, read_log_tail, resolve_provider_binary, save_managed_agents, + start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes, + validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord, + ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, }, relay::{relay_ws_url, sync_managed_agent_profile}, @@ -314,8 +314,7 @@ pub async fn create_managed_agent( if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); } - // Provider config was already validated in Pre-Phase 2. - // Cache the discovered binary path for deploy_to_provider. + // Provider config was already validated in Pre-Phase 2; cache the discovered binary path for deploy_to_provider. let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend { // Use resolve_provider_binary (discovered candidates only). resolve_provider_binary(id) @@ -325,7 +324,24 @@ pub async fn create_managed_agent( None }; - let mut record = crate::managed_agents::ManagedAgentRecord { + let agent_command = input + .agent_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_AGENT_COMMAND) + .to_string(); + let agent_args = normalize_agent_args( + &agent_command, + input + .agent_args + .iter() + .map(|arg| arg.trim().to_string()) + .filter(|arg| !arg.is_empty()) + .collect::>(), + ); + + let record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), persona_id: requested_persona_id.clone(), @@ -339,19 +355,8 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .unwrap_or(DEFAULT_ACP_COMMAND) .to_string(), - agent_command: input - .agent_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_AGENT_COMMAND) - .to_string(), - agent_args: input - .agent_args - .into_iter() - .map(|arg| arg.trim().to_string()) - .filter(|arg| !arg.is_empty()) - .collect::>(), + agent_command, + agent_args, mcp_command: input .mcp_command .as_deref() @@ -363,8 +368,7 @@ pub async fn create_managed_agent( .turn_timeout_seconds .filter(|seconds| *seconds > 0) .unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS), - // 0 or None → harness uses its own default (300s idle, 3600s max). - // The harness CLI also clamps 0 → minimum, so both paths are safe. + // 0 or None → harness uses its own default (300s idle, 3600s max), and the CLI also clamps 0 → minimum. idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), parallelism: input @@ -403,10 +407,6 @@ pub async fn create_managed_agent( last_error: None, }; - if record.agent_args.is_empty() { - record.agent_args.push(DEFAULT_AGENT_ARG.to_string()); - } - records.push(record); let mut spawn_error = None; diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 467d531d5..c23fd3f34 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,7 +9,7 @@ use tauri::AppHandle; use crate::{ app_state::AppState, - managed_agents::{AcpProviderInfo, CommandAvailabilityInfo, DEFAULT_AGENT_ARG}, + managed_agents::{AcpProviderInfo, CommandAvailabilityInfo}, models::MintTokenBody, relay::{relay_http_base_url, send_json_request}, }; @@ -17,9 +17,8 @@ use crate::{ struct KnownAcpProvider { id: &'static str, label: &'static str, - command: &'static str, + commands: &'static [&'static str], aliases: &'static [&'static str], - default_args: &'static [&'static str], avatar_url: &'static str, } @@ -38,25 +37,22 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ KnownAcpProvider { id: "goose", label: "Goose", - command: "goose", + commands: &["goose"], aliases: &[], - default_args: &[DEFAULT_AGENT_ARG], avatar_url: GOOSE_AVATAR_URL, }, KnownAcpProvider { id: "claude", label: "Claude Code", - command: "claude-agent-acp", + commands: &["claude-agent-acp", "claude-code-acp"], aliases: &["claude-code", "claudecode"], - default_args: &[], avatar_url: CLAUDE_CODE_AVATAR_URL, }, KnownAcpProvider { id: "codex", label: "Codex", - command: "codex-acp", + commands: &["codex-acp"], aliases: &[], - default_args: &[], avatar_url: CODEX_AVATAR_URL, }, ]; @@ -113,11 +109,46 @@ fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> { KNOWN_ACP_PROVIDERS.iter().find(|provider| { normalized == provider.id - || normalized == normalize_command_identity(provider.command) + || provider + .commands + .iter() + .any(|command| normalized == normalize_command_identity(command)) || provider.aliases.iter().any(|alias| normalized == *alias) }) } +fn default_agent_args(command: &str) -> Option> { + match normalize_command_identity(command).as_str() { + "goose" => Some(vec!["acp".to_string()]), + "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" + | "claudecode" => Some(Vec::new()), + _ => None, + } +} + +pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { + let normalized = agent_args + .into_iter() + .map(|arg| arg.trim().to_string()) + .filter(|arg| !arg.is_empty()) + .collect::>(); + + let Some(default_args) = default_agent_args(command) else { + return normalized; + }; + + if normalized.is_empty() { + return default_args; + } + + if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty() + { + return default_args; + } + + normalized +} + fn command_search_dirs(app: Option<&AppHandle>) -> Vec { let mut dirs = vec![ workspace_root_dir().join("target/release"), @@ -253,17 +284,17 @@ pub fn discover_local_acp_providers() -> Vec { KNOWN_ACP_PROVIDERS .iter() .filter_map(|provider| { - find_command(provider.command).map(|binary_path| AcpProviderInfo { - id: provider.id.to_string(), - label: provider.label.to_string(), - command: provider.command.to_string(), - binary_path: binary_path.display().to_string(), - default_args: provider - .default_args - .iter() - .map(|arg| (*arg).to_string()) - .collect(), - }) + provider + .commands + .iter() + .find_map(|command| find_command(command).map(|path| (*command, path))) + .map(|(command, binary_path)| AcpProviderInfo { + id: provider.id.to_string(), + label: provider.label.to_string(), + command: command.to_string(), + binary_path: binary_path.display().to_string(), + default_args: normalize_agent_args(command, Vec::new()), + }) }) .collect() } @@ -345,7 +376,8 @@ pub async fn mint_token_via_api( #[cfg(test)] mod tests { use super::{ - managed_agent_avatar_url, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + managed_agent_avatar_url, normalize_agent_args, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, + GOOSE_AVATAR_URL, }; #[test] @@ -369,10 +401,30 @@ mod tests { managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), Some(CLAUDE_CODE_AVATAR_URL.to_string()) ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); } #[test] fn returns_none_for_unknown_commands() { assert!(managed_agent_avatar_url("custom-agent").is_none()); } + + #[test] + fn normalizes_claude_and_codex_args_to_empty() { + assert_eq!( + normalize_agent_args("claude-agent-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("claude-code-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("codex-acp", vec!["acp".into()]), + Vec::::new() + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 84bc2461d..be721ce92 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -4,9 +4,9 @@ use tauri::AppHandle; use crate::{ managed_agents::{ - append_log_marker, managed_agent_log_path, missing_command_message, open_log_file, - resolve_command, ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary, - DEFAULT_AGENT_ARG, + append_log_marker, managed_agent_log_path, missing_command_message, normalize_agent_args, + open_log_file, resolve_command, ManagedAgentProcess, ManagedAgentRecord, + ManagedAgentSummary, }, util::now_iso, }; @@ -274,11 +274,7 @@ pub fn start_managed_agent_process( let stderr = stdout .try_clone() .map_err(|error| format!("failed to clone log handle: {error}"))?; - let agent_args = if record.agent_args.is_empty() { - vec![DEFAULT_AGENT_ARG.to_string()] - } else { - record.agent_args.clone() - }; + let agent_args = normalize_agent_args(&record.agent_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command, Some(app)) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; let resolved_mcp_command = resolve_command(&record.mcp_command, Some(app)) diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 6a4c9b51d..6ea75e548 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -319,7 +319,6 @@ pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp"; pub const DEFAULT_ADMIN_COMMAND: &str = "sprout-admin"; pub const DEFAULT_AGENT_COMMAND: &str = "goose"; pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server"; -pub const DEFAULT_AGENT_ARG: &str = "acp"; /// 5 min — matches the CLI harness default (SPROUT_ACP_IDLE_TIMEOUT). pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 300; /// 1 hour — absolute wall-clock safety cap per turn. diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 18c16fc4d..c8815c3dc 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -83,10 +83,16 @@ function commandBasename(command: string) { return parts[parts.length - 1] ?? normalized; } +function normalizeCommandIdentity(command: string) { + const lower = commandBasename(command).toLowerCase(); + if (lower === "claude-code-acp" || lower === "claude-agent-acp") { + return "claude-acp"; + } + return lower; +} + function commandsMatch(left: string, right: string) { - return ( - commandBasename(left).toLowerCase() === commandBasename(right).toLowerCase() - ); + return normalizeCommandIdentity(left) === normalizeCommandIdentity(right); } function parseTimestamp(value: string | null | undefined) { diff --git a/lefthook.yml b/lefthook.yml index 3a024b70a..aecd067e5 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,6 +2,8 @@ pre-commit: commands: rust-fmt: run: just fmt-check + desktop-tauri-fmt: + run: just desktop-tauri-fmt-check desktop-check: run: just desktop-check @@ -16,6 +18,8 @@ pre-push: run: just test-unit desktop-check: run: just desktop-check + desktop-tauri-fmt: + run: just desktop-tauri-fmt-check desktop-build: run: just desktop-build desktop-tauri-check: