Support old and new Claude ACP runtimes (#193)

This commit is contained in:
Wes
2026-03-26 12:50:44 -07:00
committed by GitHub
parent 70a77e308c
commit 19d8b520bc
9 changed files with 142 additions and 80 deletions
+8 -6
View File
@@ -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).
+10 -3
View File
@@ -348,9 +348,8 @@ fn normalize_agent_command_identity(command: &str) -> String {
fn default_agent_args(command: &str) -> Option<Vec<String>> {
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::<String>::new()
);
assert_eq!(
normalize_agent_args("claude-code-acp", vec!["acp".into()]),
Vec::<String>::new()
);
assert_eq!(
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
Vec::<String>::new()
);
}
#[test]
@@ -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,
+29 -29
View File
@@ -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::<Vec<_>>(),
);
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::<Vec<_>>(),
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;
@@ -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<Vec<String>> {
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<String>) -> Vec<String> {
let normalized = agent_args
.into_iter()
.map(|arg| arg.trim().to_string())
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>();
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<PathBuf> {
let mut dirs = vec![
workspace_root_dir().join("target/release"),
@@ -253,17 +284,17 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
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::<String>::new()
);
assert_eq!(
normalize_agent_args("claude-code-acp", vec!["acp".into()]),
Vec::<String>::new()
);
assert_eq!(
normalize_agent_args("codex-acp", vec!["acp".into()]),
Vec::<String>::new()
);
}
}
@@ -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))
@@ -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.
+9 -3
View File
@@ -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) {
+4
View File
@@ -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: