diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md index a869bbe3..55d143e9 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/agentclientprotocol/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)), **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)), and **amp** (via [acp-amp](https://github.com/SuperagenticAI/acp-amp)). ## Prerequisites @@ -92,6 +92,21 @@ 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. +## Running with Amp + +[acp-amp](https://github.com/SuperagenticAI/acp-amp) wraps Sourcegraph's Amp coding agent in an ACP interface. + +```bash +# Install the adapter +npm install -g @superagenticai/acp-amp + +# Run +export AMP_API_KEY="..." # your Amp API key +export SPROUT_ACP_AGENT_COMMAND="acp-amp" + +sprout-acp +``` + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs index 198228c7..1e753102 100644 --- a/crates/sprout-acp/src/acp.rs +++ b/crates/sprout-acp/src/acp.rs @@ -287,6 +287,19 @@ impl AcpClient { self.send_request("session/set_config_option", params).await } + /// Send `session/set_mode` — newer ACP path used by adapters like amp-acp. + pub async fn session_set_mode( + &mut self, + session_id: &str, + mode_id: &str, + ) -> Result { + let params = serde_json::json!({ + "sessionId": session_id, + "modeId": mode_id, + }); + self.send_request("session/set_mode", params).await + } + /// Send `session/set_model` (unstable ACP path). pub async fn session_set_model( &mut self, diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index 613edca9..7caa39eb 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -151,7 +151,7 @@ impl std::fmt::Display for PermissionMode { about = "Query available models from the configured agent" )] pub struct ModelsArgs { - /// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp"). + /// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp", "acp-amp"). #[arg(long, env = "SPROUT_ACP_AGENT_COMMAND", default_value = "goose")] pub agent_command: String, @@ -439,7 +439,7 @@ 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-acp" | "claude-code" - | "claudecode" => Some(Vec::new()), + | "claudecode" | "acp-amp" | "amp-acp" => Some(Vec::new()), _ => None, } } diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index da8e2511..ecd709b6 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -416,13 +416,13 @@ async fn create_session_and_apply_model( } // Apply permission mode if not the agent's built-in default AND the agent - // advertises the requested mode in session/new. Agents that don't support + // advertises a compatible mode in session/new. Agents that don't support // the mode (e.g., goose crashes on unrecognized set_config_option values) // are safely skipped — the harness auto-approves via handle_permission_request. - if !ctx.permission_mode.is_default() - && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) - { - apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?; + if !ctx.permission_mode.is_default() { + if let Some(mode_id) = resolve_mode_id(&resp.raw, &ctx.permission_mode) { + apply_permission_mode(&mut agent.acp, &resp.session_id, &mode_id).await?; + } } Ok(resp.session_id) @@ -507,71 +507,118 @@ async fn apply_model_switch( /// /// Non-fatal for most errors: logs and proceeds. The agent falls back /// to its default permission mode (`"default"`), which still works via -/// Check if the agent's `session/new` response advertises a given mode ID -/// in `result.modes.availableModes[].id`. Returns `false` if the modes -/// field is absent or the mode isn't listed. -fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool { - session_new_result - .get("modes") - .and_then(|m| m.get("availableModes")) - .and_then(|a| a.as_array()) - .map(|modes| { - modes - .iter() - .any(|m| m.get("id").and_then(|v| v.as_str()) == Some(mode_wire)) - }) - .unwrap_or(false) +/// Find the agent's advertised mode ID that matches the requested permission +/// mode. Tries the canonical ACP wire string first (e.g. `"bypassPermissions"`), +/// then falls back to known aliases used by other ACP adapters (e.g. amp-acp +/// advertises `"bypass"` instead of `"bypassPermissions"`). +/// +/// Returns `None` if the agent doesn't advertise a compatible mode. +fn resolve_mode_id( + session_new_result: &serde_json::Value, + mode: &PermissionMode, +) -> Option { + let available = session_new_result + .get("modes")? + .get("availableModes")? + .as_array()?; + + let ids: Vec<&str> = available + .iter() + .filter_map(|m| m.get("id")?.as_str()) + .collect(); + + // Exact match on the canonical wire string. + let wire = mode.as_wire_str(); + if ids.contains(&wire) { + return Some(wire.to_string()); + } + + // Fallback aliases for known variations across ACP adapters. + let aliases: &[&str] = match mode { + PermissionMode::BypassPermissions => &["bypass"], + PermissionMode::AcceptEdits => &["accept-edits", "accept_edits"], + PermissionMode::DontAsk => &["dont-ask", "dont_ask"], + _ => &[], + }; + + aliases + .iter() + .find(|a| ids.contains(a)) + .map(|a| a.to_string()) } /// per-tool auto-approval in `handle_permission_request`. /// +/// Tries `session/set_config_option` first (Claude-style), and if the agent +/// doesn't support it, falls back to `session/set_mode` (amp-acp style). +/// /// **Fatal exception:** if the agent process exits (e.g., goose crashes on /// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn. async fn apply_permission_mode( acp: &mut AcpClient, session_id: &str, - mode: &PermissionMode, + mode_id: &str, ) -> Result<(), AcpError> { - let wire = mode.as_wire_str(); - let result = tokio::time::timeout(PERMISSION_MODE_TIMEOUT, async { - acp.session_set_config_option(session_id, "mode", wire) + // Try session/set_config_option first (Claude-style). + let config_result = tokio::time::timeout(PERMISSION_MODE_TIMEOUT, async { + acp.session_set_config_option(session_id, "mode", mode_id) .await }) .await; - match result { + match config_result { Ok(Ok(_)) => { tracing::info!( target: "pool::permission", - "applied permission mode {wire:?} on session {session_id}" + "applied permission mode {mode_id:?} via set_config_option on session {session_id}" ); + return Ok(()); } - // Transport-class errors may have corrupted the stdio stream — propagate - // so the caller can respawn the agent. + // Transport-class errors — propagate so the caller can respawn. Ok(Err(e @ AcpError::Io(_))) | Ok(Err(e @ AcpError::WriteTimeout(_))) | Ok(Err(e @ AcpError::Timeout(_))) - | Ok(Err(e @ AcpError::Protocol(_))) | Ok(Err(e @ AcpError::AgentExited)) => { - tracing::error!( + return Err(e); + } + Err(_) => { + return Err(AcpError::Timeout(PERMISSION_MODE_TIMEOUT)); + } + // Application-level error (e.g. "method not found") — try set_mode. + Ok(Err(e)) => { + tracing::debug!( target: "pool::permission", - "fatal error setting permission mode {wire:?}: {e}" + "set_config_option not supported ({e}), trying session/set_mode" ); + } + } + + // Fallback: session/set_mode (amp-acp style). + let mode_result = tokio::time::timeout(PERMISSION_MODE_TIMEOUT, async { + acp.session_set_mode(session_id, mode_id).await + }) + .await; + + match mode_result { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::permission", + "applied permission mode {mode_id:?} via set_mode on session {session_id}" + ); + } + Ok(Err(e @ AcpError::Io(_))) + | Ok(Err(e @ AcpError::WriteTimeout(_))) + | Ok(Err(e @ AcpError::Timeout(_))) + | Ok(Err(e @ AcpError::AgentExited)) => { return Err(e); } - // Application-level errors — agent is fine, just uses default permission mode. Ok(Err(e)) => { tracing::warn!( target: "pool::permission", - "failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval" + "failed to set permission mode {mode_id:?}: {e} — falling back to per-tool auto-approval" ); } Err(_) => { - // Outer timeout fired — stream may be in unknown state. - tracing::error!( - target: "pool::permission", - "permission mode set timed out ({PERMISSION_MODE_TIMEOUT:?}) — treating as fatal" - ); return Err(AcpError::Timeout(PERMISSION_MODE_TIMEOUT)); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index c23fd3f3..b23f96e1 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -25,6 +25,7 @@ struct KnownAcpProvider { const GOOSE_AVATAR_URL: &str = "https://block.github.io/goose/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +const AMP_AVATAR_URL: &str = "https://ampcode.com/img/amp-logo.png"; const COMMON_BINARY_PATHS: &[&str] = &[ "/opt/homebrew/bin", @@ -55,6 +56,13 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ aliases: &[], avatar_url: CODEX_AVATAR_URL, }, + KnownAcpProvider { + id: "amp", + label: "Amp", + commands: &["acp-amp"], + aliases: &["amp-acp"], + avatar_url: AMP_AVATAR_URL, + }, ]; fn workspace_root_dir() -> PathBuf { @@ -121,7 +129,7 @@ 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()), + | "claudecode" | "acp-amp" | "amp-acp" => Some(Vec::new()), _ => None, } }