Skip to content
Closed
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
173 changes: 170 additions & 3 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ use tauri::{AppHandle, State};
use crate::{
app_state::AppState,
managed_agents::{
command_availability, discover_local_acp_providers, AcpProviderInfo,
DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo,
DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
command_availability, discover_local_acp_providers, AcpProviderCatalogEntry, AcpProviderInfo,
DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult,
ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
},
nostr_convert,
relay::query_relay,
Expand All @@ -16,6 +16,173 @@ pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
discover_local_acp_providers()
}

#[tauri::command]
pub fn discover_all_acp_providers() -> Vec<AcpProviderCatalogEntry> {
crate::managed_agents::discover_all_acp_providers()
}

#[tauri::command]
pub async fn install_acp_runtime(provider_id: String) -> Result<InstallRuntimeResult, String> {
tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&provider_id))
.await
.map_err(|e| format!("install task panicked: {e}"))?
}

fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResult, String> {
let provider = crate::managed_agents::known_acp_provider(provider_id)
.ok_or_else(|| format!("unknown provider: {provider_id}"))?;

let mut steps = Vec::new();

// Phase 1: Install CLI if missing and commands are available.
if let Some(cli) = provider.underlying_cli {
if crate::managed_agents::resolve_command(cli, None).is_none() {
for cmd in provider.cli_install_commands {
let result = run_install_command("cli", cmd);
let success = result.success;
steps.push(result);
if !success {
return Ok(InstallRuntimeResult {
success: false,
steps,
});
}
}
}
}

// Phase 2: Install adapter if missing and commands are available.
let adapter_found = provider
.commands
.iter()
.any(|cmd| crate::managed_agents::resolve_command(cmd, None).is_some());
if !adapter_found {
for cmd in provider.adapter_install_commands {
let result = run_install_command("adapter", cmd);
let success = result.success;
steps.push(result);
if !success {
return Ok(InstallRuntimeResult {
success: false,
steps,
});
}
}
}

// Clear the resolve cache so the next discovery picks up new binaries.
crate::managed_agents::clear_resolve_cache();

Ok(InstallRuntimeResult {
success: true,
steps,
})
}

fn run_install_command(step: &str, command: &str) -> InstallStepResult {
let shell_path = crate::managed_agents::login_shell_path();
let shell = if std::path::Path::new("/bin/zsh").exists() {
"/bin/zsh"
} else {
"/bin/bash"
};

let mut cmd = std::process::Command::new(shell);
cmd.args(["-l", "-c", command]);

if let Some(ref path) = shell_path {
cmd.env("PATH", path);
}

let mut child = match cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: format!("failed to spawn shell: {e}"),
exit_code: None,
};
}
};

// 5-minute timeout for install commands.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut s, &mut buf);
buf
})
.unwrap_or_default();
let stderr_raw = child
.stderr
.take()
.map(|mut s| {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut s, &mut buf);
buf
})
.unwrap_or_default();
let stderr = truncate_output(stderr_raw);
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: status.success(),
stdout: truncate_output(stdout),
stderr,
exit_code: status.code(),
};
}
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: "install command timed out after 5 minutes".to_string(),
exit_code: None,
};
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
Err(e) => {
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: format!("failed to check process status: {e}"),
exit_code: None,
};
}
}
}
}

/// Cap output at 2 KB to avoid flooding the UI with large error dumps.
fn truncate_output(s: String) -> String {
if s.len() > 2048 {
format!("{}... (truncated)", &s[..2048])
} else {
s
}
}

#[tauri::command]
pub fn discover_managed_agent_prereqs(
input: DiscoverManagedAgentPrereqsRequest,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,8 @@ pub fn run() {
get_relay_http_url,
get_media_proxy_port,
discover_acp_providers,
discover_all_acp_providers,
install_acp_runtime,
discover_managed_agent_prereqs,
sign_event,
decrypt_observer_event,
Expand Down
108 changes: 102 additions & 6 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::process::Command;

use tauri::AppHandle;

use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo};
use crate::managed_agents::{AcpAvailabilityStatus, AcpProviderCatalogEntry, AcpProviderInfo, CommandAvailabilityInfo};

pub(crate) struct KnownAcpProvider {
pub id: &'static str,
Expand All @@ -15,6 +15,16 @@ pub(crate) struct KnownAcpProvider {
pub mcp_command: Option<&'static str>,
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
pub mcp_hooks: bool,
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
pub underlying_cli: Option<&'static str>,
/// Shell commands to install the runtime CLI itself (run sequentially).
pub cli_install_commands: &'static [&'static str],
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
pub adapter_install_commands: &'static [&'static str],
/// Link to docs/repo for manual instructions.
pub install_instructions_url: &'static str,
/// Human-readable guidance for the UI.
pub install_hint: &'static str,
}

const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
Expand Down Expand Up @@ -54,6 +64,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: GOOSE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
cli_install_commands: &[],
adapter_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
install_instructions_url: "https://block.github.io/goose/",
install_hint: "Install Goose via the official install script.",
},
KnownAcpProvider {
id: "claude",
Expand All @@ -63,6 +78,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: CLAUDE_CODE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("claude"),
cli_install_commands: &["npm install -g @anthropic-ai/claude-code"],
adapter_install_commands: &["npm install -g @anthropic-ai/claude-agent-acp"],
install_instructions_url: "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
install_hint: "Install the Claude Code ACP adapter via npm.",
},
KnownAcpProvider {
id: "codex",
Expand All @@ -72,6 +92,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: CODEX_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("codex"),
cli_install_commands: &[],
adapter_install_commands: &[],
install_instructions_url: "https://github.com/openai/codex",
install_hint: "The codex-acp adapter must be built from source. See the GitHub repo.",
},
KnownAcpProvider {
id: "sprout-agent",
Expand All @@ -81,6 +106,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: SPROUT_AGENT_AVATAR_URL,
mcp_command: Some("sprout-dev-mcp"),
mcp_hooks: true,
underlying_cli: None,
cli_install_commands: &[],
adapter_install_commands: &[],
install_instructions_url: "https://github.com/block/sprout",
install_hint: "Ships with the Sprout desktop app.",
},
];

Expand Down Expand Up @@ -219,15 +249,18 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option<P
.find(|candidate| candidate.exists())
}

fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<PathBuf>>> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Resolve a command to an absolute path, caching results for the app lifetime.
/// The cache eliminates redundant login-shell spawns when multiple agents share
/// the same binaries (e.g. `npx`, `uvx`).
pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};

static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let cache = resolve_cache();

// Fast path: return cached result without allocating a key.
if let Ok(guard) = cache.lock() {
Expand All @@ -248,6 +281,12 @@ pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf
result
}

/// Clear the resolve_command cache so that newly-installed binaries are detected.
pub fn clear_resolve_cache() {
let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner());
guard.clear();
}

fn resolve_command_uncached(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
if let Some(path) = resolve_workspace_command(command, app) {
return Some(path);
Expand Down Expand Up @@ -369,6 +408,63 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
.collect()
}

pub fn discover_all_acp_providers() -> Vec<AcpProviderCatalogEntry> {
KNOWN_ACP_PROVIDERS
.iter()
.map(|provider| {
// Try to find the ACP adapter binary.
let adapter_result = provider
.commands
.iter()
.find_map(|command| find_command(command).map(|path| (*command, path)));

let (availability, command, binary_path) = if let Some((cmd, path)) = adapter_result {
(
AcpAvailabilityStatus::Available,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
} else if let Some(cli) = provider.underlying_cli {
if find_command(cli).is_some() {
(AcpAvailabilityStatus::AdapterMissing, None, None)
} else {
(AcpAvailabilityStatus::NotInstalled, None, None)
}
} else {
(AcpAvailabilityStatus::NotInstalled, None, None)
};

let underlying_cli_path = provider
.underlying_cli
.and_then(|cli| find_command(cli))
.map(|p| p.display().to_string());

let default_args = command
.as_deref()
.map(|cmd| normalize_agent_args(cmd, Vec::new()))
.unwrap_or_default();

let can_auto_install = !provider.cli_install_commands.is_empty()
|| !provider.adapter_install_commands.is_empty();

AcpProviderCatalogEntry {
id: provider.id.to_string(),
label: provider.label.to_string(),
avatar_url: provider.avatar_url.to_string(),
availability,
command,
binary_path,
default_args,
mcp_command: provider.mcp_command.map(str::to_string),
install_hint: provider.install_hint.to_string(),
install_instructions_url: provider.install_instructions_url.to_string(),
can_auto_install,
underlying_cli_path,
}
})
.collect()
}

pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
let provider = known_acp_provider(command)?;
Some(provider.avatar_url.to_string())
Expand Down
Loading
Loading