mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Support old and new Claude ACP runtimes (#193)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user