Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion crates/sprout-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down
13 changes: 13 additions & 0 deletions crates/sprout-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value, AcpError> {
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,
Expand Down
4 changes: 2 additions & 2 deletions crates/sprout-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -439,7 +439,7 @@ 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-acp" | "claude-code"
| "claudecode" => Some(Vec::new()),
| "claudecode" | "acp-amp" | "amp-acp" => Some(Vec::new()),
_ => None,
}
}
Expand Down
121 changes: 84 additions & 37 deletions crates/sprout-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<String> {
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));
}
}
Expand Down
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -121,7 +129,7 @@ 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()),
| "claudecode" | "acp-amp" | "amp-acp" => Some(Vec::new()),
_ => None,
}
}
Expand Down
Loading