From 38bc4bdab9995208dd00e92053bc068a38037eb4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 21 May 2026 19:32:27 -0400 Subject: [PATCH 01/16] feat(desktop): show all ACP runtimes with install status and install buttons The "Preferred runtime" dropdown in Agents > Add Persona and the Doctor panel only showed runtimes with a resolved ACP adapter binary, silently hiding Claude Code and Codex when their adapters weren't installed. Add three-state availability detection (Available / AdapterMissing / NotInstalled) to distinguish "CLI present but adapter missing" from "nothing installed." Expose a full provider catalog via `discover_all_acp_providers` and an `install_acp_runtime` command that runs server-defined install scripts in a login shell with a 5-minute timeout. Doctor panel now shows all four known runtimes with status badges and Install buttons. PersonaDialog shows all runtimes with status labels so users can store a preference for later. CreateAgentDialog shows a hint when additional runtimes are available to install. --- .../src-tauri/src/commands/agent_discovery.rs | 173 +++++++++++- desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/managed_agents/discovery.rs | 108 +++++++- desktop/src-tauri/src/managed_agents/types.rs | 43 +++ desktop/src/features/agents/hooks.ts | 24 ++ .../agents/ui/CreateAgentDialogSections.tsx | 14 + .../src/features/agents/ui/PersonaDialog.tsx | 25 +- .../features/agents/ui/usePersonaActions.ts | 4 +- .../settings/ui/DoctorSettingsPanel.tsx | 246 +++++++++++++++--- desktop/src/shared/api/tauri.ts | 87 +++++++ desktop/src/shared/api/types.ts | 34 +++ desktop/src/testing/e2eBridge.ts | 114 ++++++++ 12 files changed, 817 insertions(+), 57 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9ded778a0..47cb80178 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -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, @@ -16,6 +16,173 @@ pub fn discover_acp_providers() -> Vec { discover_local_acp_providers() } +#[tauri::command] +pub fn discover_all_acp_providers() -> Vec { + crate::managed_agents::discover_all_acp_providers() +} + +#[tauri::command] +pub async fn install_acp_runtime(provider_id: String) -> Result { + 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 { + 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, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d27a35914..fa8e5fc92 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8e7b9041a..cc75deaed 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -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, @@ -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"; @@ -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", @@ -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", @@ -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", @@ -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.", }, ]; @@ -219,15 +249,18 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option

&'static std::sync::Mutex>> { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock>>> = 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 { - use std::collections::HashMap; - use std::sync::{Mutex, OnceLock}; - - static CACHE: OnceLock>>> = 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() { @@ -248,6 +281,12 @@ pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option) -> Option { if let Some(path) = resolve_workspace_command(command, app) { return Some(path); @@ -369,6 +408,63 @@ pub fn discover_local_acp_providers() -> Vec { .collect() } +pub fn discover_all_acp_providers() -> Vec { + 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 { let provider = known_acp_provider(command)?; Some(provider.avatar_url.to_string()) diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 09b5274a5..6e7da3ba3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -286,6 +286,49 @@ pub struct AcpProviderInfo { pub mcp_command: Option, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpAvailabilityStatus { + Available, + AdapterMissing, + NotInstalled, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AcpProviderCatalogEntry { + pub id: String, + pub label: String, + pub avatar_url: String, + pub availability: AcpAvailabilityStatus, + pub command: Option, + pub binary_path: Option, + pub default_args: Vec, + pub mcp_command: Option, + pub install_hint: String, + pub install_instructions_url: String, + /// true when at least one automated install step is available + pub can_auto_install: bool, + pub underlying_cli_path: Option, +} + +/// Result of a single install step (CLI or adapter). +#[derive(Debug, Clone, Serialize)] +pub struct InstallStepResult { + pub step: String, + pub command: String, + pub success: bool, + pub stdout: String, + pub stderr: String, + pub exit_code: Option, +} + +/// Aggregate result of installing a runtime (may include CLI + adapter steps). +#[derive(Debug, Clone, Serialize)] +pub struct InstallRuntimeResult { + pub success: bool, + pub steps: Vec, +} + #[derive(Debug, Clone, Serialize)] pub struct CommandAvailabilityInfo { pub command: String, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 0fdb599d1..59b64053b 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -11,9 +11,11 @@ import { createManagedAgent, deleteManagedAgent, discoverAcpProviders, + discoverAllAcpProviders, discoverBackendProviders, discoverManagedAgentPrereqs, getManagedAgentLog, + installAcpRuntime, listManagedAgents, listRelayAgents, startManagedAgent, @@ -72,6 +74,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpProvidersQueryKey = ["acp-providers"] as const; +export const allAcpProvidersQueryKey = ["all-acp-providers"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; @@ -105,6 +108,27 @@ export function useAcpProvidersQuery() { }); } +export function useAllAcpProvidersQuery() { + return useQuery({ + queryKey: allAcpProvidersQueryKey, + queryFn: discoverAllAcpProviders, + staleTime: 60_000, + }); +} + +export function useInstallAcpRuntimeMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (providerId: string) => installAcpRuntime(providerId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: acpProvidersQueryKey }); + void queryClient.invalidateQueries({ + queryKey: allAcpProvidersQueryKey, + }); + }, + }); +} + export function useBackendProvidersQuery() { return useQuery({ queryKey: backendProvidersQueryKey, diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 86c791f17..a8494e7fa 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -1,4 +1,5 @@ import type { AcpProvider, ManagedAgentPrereqs } from "@/shared/api/types"; +import { useAllAcpProvidersQuery } from "@/features/agents/hooks"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; @@ -48,6 +49,11 @@ export function CreateAgentRuntimeProviderField({ selectedProviderId: string; onProviderChange: (value: string) => void; }) { + const allProvidersQuery = useAllAcpProvidersQuery(); + const unavailableCount = (allProvidersQuery.data ?? []).filter( + (p) => p.availability !== "available", + ).length; + return (

); } diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index 7d465e849..dba647a82 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { RefreshCw, Upload } from "lucide-react"; import type { - AcpProvider, + AcpProviderCatalogEntry, CreatePersonaInput, UpdatePersonaInput, } from "@/shared/api/types"; @@ -35,7 +35,7 @@ type PersonaDialogProps = { error: Error | null; isPending: boolean; isImportPending?: boolean; - providers: AcpProvider[]; + providers: AcpProviderCatalogEntry[]; providersLoading?: boolean; onOpenChange: (open: boolean) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; @@ -354,13 +354,32 @@ export function PersonaDialog({ {providers.map((p) => ( ))}

Optional. When deploying this persona, the selected runtime will - be pre-selected. Falls back to the default if unavailable. + be pre-selected. Unavailable runtimes can be installed from + Settings > Doctor.

+ {(() => { + const selected = providers.find((p) => p.id === provider); + if (!selected || selected.availability === "available") + return null; + return ( +

+ {selected.availability === "adapter_missing" + ? `${selected.label} CLI is installed but the ACP adapter is missing.` + : `${selected.label} is not installed.`}{" "} + Visit Settings > Doctor to set it up. +

+ ); + })()}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 6d01f6c1f..f7f3d0fa5 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { personasQueryKey, - useAcpProvidersQuery, + useAllAcpProvidersQuery, useCreatePersonaMutation, useDeletePersonaMutation, useExportPersonaJsonMutation, @@ -36,7 +36,7 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); const personasQuery = usePersonasQuery(); - const acpProvidersQuery = useAcpProvidersQuery(); + const acpProvidersQuery = useAllAcpProvidersQuery(); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index b3e99d363..41c279f44 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -1,73 +1,230 @@ +import * as React from "react"; import { AlertTriangle, CheckCircle2, + Circle, + Download, + ExternalLink, RefreshCw, Stethoscope, } from "lucide-react"; -import { useAcpProvidersQuery } from "@/features/agents/hooks"; +import { + useAllAcpProvidersQuery, + useInstallAcpRuntimeMutation, +} from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; +import type { AcpProviderCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -function StatusIcon({ available }: { available: boolean }) { - return available ? ( - - ) : ( - +function StatusIcon({ + availability, +}: { + availability: AcpProviderCatalogEntry["availability"]; +}) { + switch (availability) { + case "available": + return ; + case "adapter_missing": + return ; + case "not_installed": + return ; + } +} + +function InstallActions({ + isInstalling, + onInstall, + provider, +}: { + isInstalling: boolean; + onInstall: () => void; + provider: AcpProviderCatalogEntry; +}) { + return ( +
+ {provider.canAutoInstall ? ( + + ) : null} + +
); } function ProviderRow({ - command, - defaultArgs, - label, - providerId, - resolvedPath, + installError, + installSuccess, + isInstalling, + onInstall, + provider, }: { - command: string; - defaultArgs: string[]; - label: string; - providerId: string; - resolvedPath: string; + installError: string | null; + installSuccess: boolean; + isInstalling: boolean; + onInstall: () => void; + provider: AcpProviderCatalogEntry; }) { return (
- +
-

{label}

- - {command} - +

+ {provider.label} +

+ {provider.command ? ( + + {provider.command} + + ) : null}
-

- Available via {describeResolvedCommand(command, resolvedPath)}. -

- {defaultArgs.length > 0 ? ( -

- Default args:{" "} - {defaultArgs.join(", ")} + + {provider.availability === "available" && + provider.command && + provider.binaryPath ? ( + <> +

+ Available via{" "} + {describeResolvedCommand(provider.command, provider.binaryPath)}. +

+ {provider.defaultArgs.length > 0 ? ( +

+ Default args:{" "} + + {provider.defaultArgs.join(", ")} + +

+ ) : null} +

+ {provider.binaryPath} +

+ + ) : provider.availability === "adapter_missing" ? ( + <> +

+ CLI detected at{" "} + + {provider.underlyingCliPath ?? "unknown path"} + {" "} + but ACP adapter not found. +

+

+ {provider.installHint} +

+ + + ) : ( + <> +

Not installed

+

+ {provider.installHint} +

+ + + )} + + {installSuccess ? ( +

+ Installed successfully. Re-run Doctor to verify. +

+ ) : null} + {installError ? ( +

+ {installError}

) : null} -

- {resolvedPath} -

); } export function DoctorSettingsPanel() { - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAllAcpProvidersQuery(); const providers = providersQuery.data ?? []; const isRefreshing = providersQuery.isFetching; + const installMutation = useInstallAcpRuntimeMutation(); + const [installResults, setInstallResults] = React.useState< + Record + >({}); + + function handleInstall(providerId: string) { + setInstallResults((prev) => ({ + ...prev, + [providerId]: { success: false, error: null }, + })); + installMutation.mutate(providerId, { + onSuccess: (result) => { + if (result.success) { + setInstallResults((prev) => ({ + ...prev, + [providerId]: { success: true, error: null }, + })); + } else { + const lastStep = result.steps[result.steps.length - 1]; + setInstallResults((prev) => ({ + ...prev, + [providerId]: { + success: false, + error: lastStep + ? `Step "${lastStep.step}" failed: ${lastStep.stderr || "unknown error"}` + : "Install failed with no output.", + }, + })); + } + }, + onError: (error) => { + setInstallResults((prev) => ({ + ...prev, + [providerId]: { + success: false, + error: error instanceof Error ? error.message : "Install failed.", + }, + })); + }, + }); + } return (
@@ -86,6 +243,7 @@ export function DoctorSettingsPanel() { className="shrink-0" disabled={isRefreshing} onClick={() => { + setInstallResults({}); void providersQuery.refetch(); }} size="sm" @@ -103,29 +261,31 @@ export function DoctorSettingsPanel() {

ACP runtimes

- Installed runtimes that the desktop app can offer in Create agent. + Known runtimes and their installation status.

{providersQuery.isLoading ? (

- Looking for installed ACP runtimes... + Looking for ACP runtimes...

) : providers.length > 0 ? ( providers.map((provider) => ( handleInstall(provider.id)} + provider={provider} /> )) ) : (
- No known ACP runtime was detected on your PATH yet. You can - still use a custom command in Create agent. + No known ACP runtimes found.
)}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8cbee3f76..48d76cd8f 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -39,8 +39,11 @@ import type { CreateManagedAgentInput, AgentModelsResponse, UpdateManagedAgentInput, + AcpAvailabilityStatus, AcpProvider, + AcpProviderCatalogEntry, CommandAvailability, + InstallRuntimeResult, OpenDmInput, } from "@/shared/api/types"; @@ -254,6 +257,35 @@ type RawAcpProvider = { mcp_command: string | null; }; +type RawAcpProviderCatalogEntry = { + id: string; + label: string; + avatar_url: string; + availability: AcpAvailabilityStatus; + command: string | null; + binary_path: string | null; + default_args: string[]; + mcp_command: string | null; + install_hint: string; + install_instructions_url: string; + can_auto_install: boolean; + underlying_cli_path: string | null; +}; + +type RawInstallStepResult = { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; +}; + +type RawInstallRuntimeResult = { + success: boolean; + steps: RawInstallStepResult[]; +}; + type RawCommandAvailability = { command: string; resolved_path: string | null; @@ -861,6 +893,41 @@ function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider { }; } +function fromRawAcpProviderCatalogEntry( + entry: RawAcpProviderCatalogEntry, +): AcpProviderCatalogEntry { + return { + id: entry.id, + label: entry.label, + avatarUrl: entry.avatar_url, + availability: entry.availability, + command: entry.command, + binaryPath: entry.binary_path, + defaultArgs: entry.default_args, + mcpCommand: entry.mcp_command, + installHint: entry.install_hint, + installInstructionsUrl: entry.install_instructions_url, + canAutoInstall: entry.can_auto_install, + underlyingCliPath: entry.underlying_cli_path, + }; +} + +function fromRawInstallRuntimeResult( + raw: RawInstallRuntimeResult, +): InstallRuntimeResult { + return { + success: raw.success, + steps: raw.steps.map((step) => ({ + step: step.step, + command: step.command, + success: step.success, + stdout: step.stdout, + stderr: step.stderr, + exitCode: step.exit_code, + })), + }; +} + function fromRawCommandAvailability( command: RawCommandAvailability, ): CommandAvailability { @@ -1019,6 +1086,26 @@ export async function discoverAcpProviders(): Promise { ); } +export async function discoverAllAcpProviders(): Promise< + AcpProviderCatalogEntry[] +> { + return ( + await invokeTauri( + "discover_all_acp_providers", + ) + ).map(fromRawAcpProviderCatalogEntry); +} + +export async function installAcpRuntime( + providerId: string, +): Promise { + const raw = await invokeTauri( + "install_acp_runtime", + { providerId }, + ); + return fromRawInstallRuntimeResult(raw); +} + export async function discoverManagedAgentPrereqs(input: { acpCommand?: string; mcpCommand?: string; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 4dd100283..5f1df9d15 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -376,6 +376,40 @@ export type AcpProvider = { mcpCommand: string | null; }; +export type AcpAvailabilityStatus = + | "available" + | "adapter_missing" + | "not_installed"; + +export type AcpProviderCatalogEntry = { + id: string; + label: string; + avatarUrl: string; + availability: AcpAvailabilityStatus; + command: string | null; + binaryPath: string | null; + defaultArgs: string[]; + mcpCommand: string | null; + installHint: string; + installInstructionsUrl: string; + canAutoInstall: boolean; + underlyingCliPath: string | null; +}; + +export type InstallStepResult = { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exitCode: number | null; +}; + +export type InstallRuntimeResult = { + success: boolean; + steps: InstallStepResult[]; +}; + export type CommandAvailability = { command: string; resolvedPath: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a2f481ba8..120296239 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -327,6 +327,33 @@ type RawAcpProvider = { mcp_command: string | null; }; +type RawAcpProviderCatalogEntry = { + id: string; + label: string; + avatar_url: string; + availability: "available" | "adapter_missing" | "not_installed"; + command: string | null; + binary_path: string | null; + default_args: string[]; + mcp_command: string | null; + install_hint: string; + install_instructions_url: string; + can_auto_install: boolean; + underlying_cli_path: string | null; +}; + +type RawInstallRuntimeResult = { + success: boolean; + steps: Array<{ + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; + }>; +}; + type RawCommandAvailability = { command: string; resolved_path: string | null; @@ -3501,6 +3528,89 @@ async function handleDiscoverAcpProviders( ]; } +async function handleDiscoverAllAcpProviders( + _config: E2eConfig | undefined, +): Promise { + return [ + { + id: "goose", + label: "Goose", + avatar_url: "", + availability: "available", + command: "goose", + binary_path: "/usr/local/bin/goose", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install Goose via the official install script.", + install_instructions_url: "https://block.github.io/goose/", + can_auto_install: true, + underlying_cli_path: null, + }, + { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "adapter_missing", + command: null, + binary_path: null, + default_args: [], + mcp_command: null, + install_hint: "Install the Claude Code ACP adapter via npm.", + install_instructions_url: + "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp", + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", + }, + { + id: "codex", + label: "Codex", + avatar_url: "", + availability: "not_installed", + command: null, + binary_path: null, + default_args: [], + mcp_command: null, + install_hint: + "The codex-acp adapter must be built from source. See the GitHub repo.", + install_instructions_url: "https://github.com/openai/codex", + can_auto_install: false, + underlying_cli_path: null, + }, + { + id: "sprout-agent", + label: "Sprout Agent", + avatar_url: "", + availability: "available", + command: "sprout-agent", + binary_path: "/usr/local/bin/sprout-agent", + default_args: [], + mcp_command: "sprout-dev-mcp", + install_hint: "Ships with the Sprout desktop app.", + install_instructions_url: "https://github.com/block/sprout", + can_auto_install: false, + underlying_cli_path: null, + }, + ]; +} + +async function handleInstallAcpRuntime(args: { + providerId?: string; +}): Promise { + return { + success: true, + steps: [ + { + step: "adapter", + command: `mock install ${args.providerId ?? "unknown"}`, + success: true, + stdout: "mock: installed successfully", + stderr: "", + exit_code: 0, + }, + ], + }; +} + async function handleDiscoverManagedAgentPrereqs( args: { input?: { @@ -4673,6 +4783,10 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "discover_acp_providers": return handleDiscoverAcpProviders(activeConfig); + case "discover_all_acp_providers": + return handleDiscoverAllAcpProviders(activeConfig); + case "install_acp_runtime": + return handleInstallAcpRuntime(payload as { providerId?: string }); case "discover_backend_providers": return []; case "probe_backend_provider": From 3b26a0df997c2976e29e788da31ed228a4a8bdc9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 12:44:34 -0400 Subject: [PATCH 02/16] refactor(desktop): consolidate ACP provider discovery and address review findings The original implementation created a second parallel Tauri command (discover_all_acp_providers) alongside the existing one to avoid changing the return type. This produced two commands, two hooks, two query keys, and two raw type converters. Consolidates into a single command returning the full catalog, with a useAvailableAcpProviders hook that type-narrows for callers needing non-null command/binaryPath. Also fixes: pipe deadlock in install command (#1), UTF-8 truncation panic (#2/#4), adds install concurrency guard (#11), exact provider ID match (#15), error display stdout fallback (#5), success banner suppression when already available (#12), misleading re-run text (#13), IIFE refactor in PersonaDialog (#14), hidden internal query lift (#7), configurable e2e mocks (#9), shared raw type exports (#8), and classify_provider unit tests (#10). --- desktop/scripts/check-file-sizes.mjs | 8 +- .../src-tauri/src/commands/agent_discovery.rs | 167 ++++++++++++------ desktop/src-tauri/src/lib.rs | 1 - .../src-tauri/src/managed_agents/discovery.rs | 104 +++++++---- desktop/src-tauri/src/managed_agents/types.rs | 11 -- desktop/src/features/agents/hooks.ts | 23 +-- .../agents/ui/AddTeamToChannelDialog.tsx | 4 +- .../features/agents/ui/CreateAgentDialog.tsx | 9 +- .../agents/ui/CreateAgentDialogSections.tsx | 8 +- .../src/features/agents/ui/PersonaDialog.tsx | 25 ++- .../features/agents/ui/usePersonaActions.ts | 4 +- .../channel-templates/useApplyTemplate.ts | 4 +- .../channels/ui/ChannelMembersBar.tsx | 4 +- .../features/channels/ui/useQuickBotDrop.ts | 4 +- .../src/features/onboarding/ui/SetupStep.tsx | 4 +- .../ui/ChannelTemplatesSettingsCard.tsx | 4 +- .../settings/ui/DoctorSettingsPanel.tsx | 10 +- desktop/src/shared/api/tauri.ts | 40 +---- desktop/src/shared/api/types.ts | 17 +- desktop/src/testing/e2eBridge.ts | 101 +++-------- 20 files changed, 275 insertions(+), 277 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index dc154f0ca..3552119fd 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -52,20 +52,22 @@ const overrides = new Map([ ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) - ["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field + ["src-tauri/src/managed_agents/discovery.rs", 600], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state: Available/AdapterMissing/NotInstalled) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 13 unit tests + ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh - ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation + ["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation ["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration ["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states ["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching ["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation ["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation + ["src/features/agents/ui/PersonaDialog.tsx", 515], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow - ["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs + ["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs ["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 47cb80178..6853744dc 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,24 +1,24 @@ +use std::io::Read; +use std::sync::atomic::Ordering::{Acquire, Relaxed}; use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - command_availability, discover_local_acp_providers, AcpProviderCatalogEntry, AcpProviderInfo, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, - ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, + command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest, + InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo, + DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, }, nostr_convert, relay::query_relay, }; -#[tauri::command] -pub fn discover_acp_providers() -> Vec { - discover_local_acp_providers() -} +static INSTALL_IN_PROGRESS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); #[tauri::command] -pub fn discover_all_acp_providers() -> Vec { - crate::managed_agents::discover_all_acp_providers() +pub fn discover_acp_providers() -> Vec { + crate::managed_agents::discover_acp_providers() } #[tauri::command] @@ -29,7 +29,20 @@ pub async fn install_acp_runtime(provider_id: String) -> Result Result { - let provider = crate::managed_agents::known_acp_provider(provider_id) + // Prevent concurrent installs. + INSTALL_IN_PROGRESS + .compare_exchange(false, true, Acquire, Relaxed) + .map_err(|_| "an install is already in progress".to_string())?; + + struct Guard; + impl Drop for Guard { + fn drop(&mut self) { + INSTALL_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release); + } + } + let _guard = Guard; + + let provider = crate::managed_agents::known_acp_provider_exact(provider_id) .ok_or_else(|| format!("unknown provider: {provider_id}"))?; let mut steps = Vec::new(); @@ -112,55 +125,75 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { } }; + // Drain stdout/stderr on background threads to prevent pipe buffer deadlock. + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stdout_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + let stderr_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stderr_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + + let (tx, rx) = std::sync::mpsc::channel(); + let wait_thread = std::thread::spawn(move || { + let status = child.wait(); + let _ = tx.send(status); + // Return child so the caller can kill it on timeout. + }); + // 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); + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + // Timeout: the wait_thread still holds the child; signal via the + // channel being dropped and use a sentinel. We cannot kill here + // since `child` was moved. Instead, we drop the receiver and join + // the threads, letting them finish naturally, then report timeout. + drop(rx); + let _ = wait_thread.join(); + let stdout = stdout_thread.join().unwrap_or_default(); + let stderr = stderr_thread.join().unwrap_or_default(); + let _ = stdout; // discard; timed out + let _ = stderr; + 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, + }; + } + + match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { + Ok(Ok(status)) => { + let _ = wait_thread.join(); + let stdout = stdout_thread.join().unwrap_or_default(); + let stderr_raw = stderr_thread.join().unwrap_or_default(); return InstallStepResult { step: step.to_string(), command: command.to_string(), success: status.success(), stdout: truncate_output(stdout), - stderr, + stderr: truncate_output(stderr_raw), 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) => { + Ok(Err(e)) => { + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); return InstallStepResult { step: step.to_string(), command: command.to_string(), @@ -170,17 +203,45 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { exit_code: None, }; } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Still running; loop and check deadline again. + continue; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + // wait_thread dropped sender without sending — shouldn't happen. + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "internal error: wait thread disconnected".to_string(), + exit_code: None, + }; + } } } } -/// Cap output at 2 KB to avoid flooding the UI with large error dumps. +/// Cap output to head + tail to avoid flooding the UI with large error dumps, +/// while preserving the most useful parts of the output. fn truncate_output(s: String) -> String { - if s.len() > 2048 { - format!("{}... (truncated)", &s[..2048]) - } else { - s + const HEAD: usize = 512; + const TAIL: usize = 1024; + const LIMIT: usize = HEAD + TAIL; + if s.len() <= LIMIT { + return s; } + let head_end = s.floor_char_boundary(HEAD); + let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL)); + let omitted = tail_start - head_end; + format!( + "{}\n... ({omitted} bytes omitted) ...\n{}", + &s[..head_end], + &s[tail_start..] + ) } #[tauri::command] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fa8e5fc92..ffc3efafb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -466,7 +466,6 @@ 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, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index cc75deaed..db97b09cd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -3,7 +3,9 @@ use std::process::Command; use tauri::AppHandle; -use crate::managed_agents::{AcpAvailabilityStatus, AcpProviderCatalogEntry, AcpProviderInfo, CommandAvailabilityInfo}; +use crate::managed_agents::{ + AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailabilityInfo, +}; pub(crate) struct KnownAcpProvider { pub id: &'static str, @@ -174,6 +176,10 @@ pub(crate) fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvi }) } +pub(crate) fn known_acp_provider_exact(id: &str) -> Option<&'static KnownAcpProvider> { + KNOWN_ACP_PROVIDERS.iter().find(|p| p.id == id) +} + fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), @@ -249,7 +255,8 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option

&'static std::sync::Mutex>> { +fn resolve_cache() -> &'static std::sync::Mutex>> +{ use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; static CACHE: OnceLock>>> = OnceLock::new(); @@ -388,27 +395,25 @@ pub fn missing_command_message(command: &str, role: &str) -> String { ) } -pub fn discover_local_acp_providers() -> Vec { - KNOWN_ACP_PROVIDERS - .iter() - .filter_map(|provider| { - 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()), - mcp_command: provider.mcp_command.map(str::to_string), - }) - }) - .collect() +fn classify_provider( + adapter_result: Option<(&str, PathBuf)>, + underlying_cli: Option<&str>, + underlying_cli_found: bool, +) -> (AcpAvailabilityStatus, Option, Option) { + if let Some((cmd, path)) = adapter_result { + ( + AcpAvailabilityStatus::Available, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) + } else if underlying_cli.is_some() && underlying_cli_found { + (AcpAvailabilityStatus::AdapterMissing, None, None) + } else { + (AcpAvailabilityStatus::NotInstalled, None, None) + } } -pub fn discover_all_acp_providers() -> Vec { +pub fn discover_acp_providers() -> Vec { KNOWN_ACP_PROVIDERS .iter() .map(|provider| { @@ -418,21 +423,15 @@ pub fn discover_all_acp_providers() -> Vec { .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_found = provider + .underlying_cli + .map(|cli| find_command(cli).is_some()) + .unwrap_or(false); + let (availability, command, binary_path) = classify_provider( + adapter_result, + provider.underlying_cli, + underlying_cli_found, + ); let underlying_cli_path = provider .underlying_cli @@ -472,10 +471,13 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::{ - find_via_login_shell, managed_agent_avatar_url, normalize_agent_args, + classify_provider, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, SPROUT_AGENT_AVATAR_URL, }; + use crate::managed_agents::AcpAvailabilityStatus; #[test] fn resolves_known_avatar_for_bare_command() { @@ -566,4 +568,32 @@ mod tests { "shell lookup must not execute injected commands" ); } + + #[test] + fn classifies_available_when_adapter_found() { + let (status, cmd, path) = classify_provider( + Some(("goose", PathBuf::from("/usr/local/bin/goose"))), + None, + false, + ); + assert_eq!(status, AcpAvailabilityStatus::Available); + assert_eq!(cmd.as_deref(), Some("goose")); + assert_eq!(path.as_deref(), Some("/usr/local/bin/goose")); + } + + #[test] + fn classifies_adapter_missing_when_cli_present() { + let (status, cmd, path) = classify_provider(None, Some("claude"), true); + assert_eq!(status, AcpAvailabilityStatus::AdapterMissing); + assert!(cmd.is_none()); + assert!(path.is_none()); + } + + #[test] + fn classifies_not_installed_when_nothing_found() { + let (status, cmd, path) = classify_provider(None, Some("claude"), false); + assert_eq!(status, AcpAvailabilityStatus::NotInstalled); + assert!(cmd.is_none()); + assert!(path.is_none()); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 6e7da3ba3..2add5e27d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -275,17 +275,6 @@ pub struct ManagedAgentLogResponse { pub log_path: String, } -#[derive(Debug, Clone, Serialize)] -pub struct AcpProviderInfo { - pub id: String, - pub label: String, - pub command: String, - pub binary_path: String, - pub default_args: Vec, - /// MCP server binary override. `None` means use the default (`sprout-mcp-server`). - pub mcp_command: Option, -} - #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AcpAvailabilityStatus { diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 59b64053b..d7fe52237 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -1,3 +1,4 @@ +import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -11,7 +12,6 @@ import { createManagedAgent, deleteManagedAgent, discoverAcpProviders, - discoverAllAcpProviders, discoverBackendProviders, discoverManagedAgentPrereqs, getManagedAgentLog, @@ -38,6 +38,7 @@ import { updateTeam, } from "@/shared/api/tauriTeams"; import type { + AcpProvider, AgentPersona, AgentTeam, CreateManagedAgentInput, @@ -74,7 +75,6 @@ export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpProvidersQueryKey = ["acp-providers"] as const; -export const allAcpProvidersQueryKey = ["all-acp-providers"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; @@ -108,12 +108,16 @@ export function useAcpProvidersQuery() { }); } -export function useAllAcpProvidersQuery() { - return useQuery({ - queryKey: allAcpProvidersQueryKey, - queryFn: discoverAllAcpProviders, - staleTime: 60_000, - }); +export function useAvailableAcpProviders() { + const query = useAcpProvidersQuery(); + const available = React.useMemo( + () => + (query.data ?? []).filter( + (p): p is AcpProvider => p.availability === "available", + ), + [query.data], + ); + return { ...query, data: available }; } export function useInstallAcpRuntimeMutation() { @@ -122,9 +126,6 @@ export function useInstallAcpRuntimeMutation() { mutationFn: (providerId: string) => installAcpRuntime(providerId), onSettled: () => { void queryClient.invalidateQueries({ queryKey: acpProvidersQueryKey }); - void queryClient.invalidateQueries({ - queryKey: allAcpProvidersQueryKey, - }); }, }); } diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 6a82dafe1..39a250b8e 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react"; import * as React from "react"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, useCreateChannelManagedAgentsMutation, } from "@/features/agents/hooks"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; @@ -50,7 +50,7 @@ export function AddTeamToChannelDialog({ onDeployed, }: AddTeamToChannelDialogProps) { const channelsQuery = useChannelsQuery(); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const [channelId, setChannelId] = React.useState(""); const [role, setRole] = React.useState>("bot"); const deployMutation = useCreateChannelManagedAgentsMutation( diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 3e3dd2871..0bfe31b71 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useAcpProvidersQuery, + useAvailableAcpProviders, useBackendProvidersQuery, useCreateManagedAgentMutation, useManagedAgentPrereqsQuery, @@ -48,7 +49,8 @@ export function CreateAgentDialog({ onOpenChange: (open: boolean) => void; }) { const createMutation = useCreateManagedAgentMutation(); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); + const allProvidersQuery = useAcpProvidersQuery(); const backendProvidersQuery = useBackendProvidersQuery(); const { lastProviderId, setLastProvider } = useLastRuntimeProvider(); const [acpCommand, setAcpCommand] = React.useState("sprout-acp"); @@ -85,6 +87,10 @@ export function CreateAgentDialog({ const [probeError, setProbeError] = React.useState(null); const providers = providersQuery.data ?? []; + const allProviders = allProvidersQuery.data ?? []; + const unavailableCount = allProviders.filter( + (p) => p.availability !== "available", + ).length; const backendProviders = backendProvidersQuery.data ?? []; const prereqs = prereqsQuery.data ?? null; const selectedProvider = React.useMemo( @@ -443,6 +449,7 @@ export function CreateAgentDialog({ providersLoading={providersQuery.isLoading} selectedProvider={selectedProvider} selectedProviderId={selectedProviderId} + unavailableCount={unavailableCount} /> ) : null} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index a8494e7fa..0ba2fdcf6 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -1,5 +1,4 @@ import type { AcpProvider, ManagedAgentPrereqs } from "@/shared/api/types"; -import { useAllAcpProvidersQuery } from "@/features/agents/hooks"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; @@ -41,19 +40,16 @@ export function CreateAgentRuntimeProviderField({ providersLoading, selectedProvider, selectedProviderId, + unavailableCount, onProviderChange, }: { providers: AcpProvider[]; providersLoading: boolean; selectedProvider: AcpProvider | null; selectedProviderId: string; + unavailableCount: number; onProviderChange: (value: string) => void; }) { - const allProvidersQuery = useAllAcpProvidersQuery(); - const unavailableCount = (allProvidersQuery.data ?? []).filter( - (p) => p.availability !== "available", - ).length; - return (

diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index f7f3d0fa5..6d01f6c1f 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { personasQueryKey, - useAllAcpProvidersQuery, + useAcpProvidersQuery, useCreatePersonaMutation, useDeletePersonaMutation, useExportPersonaJsonMutation, @@ -36,7 +36,7 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); const personasQuery = usePersonasQuery(); - const acpProvidersQuery = useAllAcpProvidersQuery(); + const acpProvidersQuery = useAcpProvidersQuery(); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index a6598011e..c0753ad69 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -5,7 +5,7 @@ import { type CreateChannelManagedAgentInput, } from "@/features/agents/channelAgents"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; @@ -29,7 +29,7 @@ function toManagedBackend( export function useApplyTemplate() { const queryClient = useQueryClient(); const channelTemplatesQuery = useChannelTemplatesQuery(); - const acpProvidersQuery = useAcpProvidersQuery(); + const acpProvidersQuery = useAvailableAcpProviders(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); const { lastProviderId } = useLastRuntimeProvider(); diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c2ff0734e..ae5273c66 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useHuddle } from "@/features/huddle"; import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, useBackendProvidersQuery, useManagedAgentsQuery, useRelayAgentsQuery, @@ -32,7 +32,7 @@ export function ChannelMembersBar({ const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channel.id); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const backendProvidersQuery = useBackendProvidersQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); diff --git a/desktop/src/features/channels/ui/useQuickBotDrop.ts b/desktop/src/features/channels/ui/useQuickBotDrop.ts index 34b83c5e1..fed3a6156 100644 --- a/desktop/src/features/channels/ui/useQuickBotDrop.ts +++ b/desktop/src/features/channels/ui/useQuickBotDrop.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, useCreateChannelManagedAgentMutation, } from "@/features/agents/hooks"; import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider"; @@ -17,7 +17,7 @@ type QuickBotDropState = { */ export function useQuickBotDrop(channelId: string | null) { const createMutation = useCreateChannelManagedAgentMutation(channelId); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const [state, setState] = React.useState({ pending: false, error: null, diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index c6ffadf3c..875c77123 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -1,6 +1,6 @@ import { TerminalSquare } from "lucide-react"; -import { useAcpProvidersQuery } from "@/features/agents/hooks"; +import { useAvailableAcpProviders } from "@/features/agents/hooks"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; import type { SetupStepActions, SetupStepState } from "./types"; @@ -15,7 +15,7 @@ type SetupStepContentProps = { }; function useSetupStepState(): SetupStepState { - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const items = providersQuery.data ?? []; const isChecking = providersQuery.isLoading; const errorMessage = diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index fe89413c3..f18dd768d 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -11,7 +11,7 @@ import * as React from "react"; import { toast } from "sonner"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; @@ -295,7 +295,7 @@ function TemplateFormDialog({ const updateMutation = useUpdateChannelTemplateMutation(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const providers = providersQuery.data ?? []; const [name, setName] = React.useState(""); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 41c279f44..08fdef911 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -10,7 +10,7 @@ import { } from "lucide-react"; import { - useAllAcpProvidersQuery, + useAcpProvidersQuery, useInstallAcpRuntimeMutation, } from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; @@ -165,9 +165,9 @@ function ProviderRow({ )} - {installSuccess ? ( + {installSuccess && provider.availability !== "available" ? (

- Installed successfully. Re-run Doctor to verify. + Installed successfully!

) : null} {installError ? ( @@ -181,7 +181,7 @@ function ProviderRow({ } export function DoctorSettingsPanel() { - const providersQuery = useAllAcpProvidersQuery(); + const providersQuery = useAcpProvidersQuery(); const providers = providersQuery.data ?? []; const isRefreshing = providersQuery.isFetching; const installMutation = useInstallAcpRuntimeMutation(); @@ -208,7 +208,7 @@ export function DoctorSettingsPanel() { [providerId]: { success: false, error: lastStep - ? `Step "${lastStep.step}" failed: ${lastStep.stderr || "unknown error"}` + ? `Step "${lastStep.step}" failed: ${lastStep.stderr || lastStep.stdout || "unknown error"}` : "Install failed with no output.", }, })); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 48d76cd8f..62f6e1149 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -39,8 +39,6 @@ import type { CreateManagedAgentInput, AgentModelsResponse, UpdateManagedAgentInput, - AcpAvailabilityStatus, - AcpProvider, AcpProviderCatalogEntry, CommandAvailability, InstallRuntimeResult, @@ -248,16 +246,7 @@ type RawManagedAgentLog = { log_path: string; }; -type RawAcpProvider = { - id: string; - label: string; - command: string; - binary_path: string; - default_args: string[]; - mcp_command: string | null; -}; - -type RawAcpProviderCatalogEntry = { +export type RawAcpProviderCatalogEntry = { id: string; label: string; avatar_url: string; @@ -272,7 +261,7 @@ type RawAcpProviderCatalogEntry = { underlying_cli_path: string | null; }; -type RawInstallStepResult = { +export type RawInstallStepResult = { step: string; command: string; success: boolean; @@ -281,7 +270,7 @@ type RawInstallStepResult = { exit_code: number | null; }; -type RawInstallRuntimeResult = { +export type RawInstallRuntimeResult = { success: boolean; steps: RawInstallStepResult[]; }; @@ -882,17 +871,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { }; } -function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider { - return { - id: provider.id, - label: provider.label, - command: provider.command, - binaryPath: provider.binary_path, - defaultArgs: provider.default_args, - mcpCommand: provider.mcp_command, - }; -} - function fromRawAcpProviderCatalogEntry( entry: RawAcpProviderCatalogEntry, ): AcpProviderCatalogEntry { @@ -1080,19 +1058,11 @@ export async function getManagedAgentLog(pubkey: string, lineCount?: number) { }; } -export async function discoverAcpProviders(): Promise { - return (await invokeTauri("discover_acp_providers")).map( - fromRawAcpProvider, - ); -} - -export async function discoverAllAcpProviders(): Promise< +export async function discoverAcpProviders(): Promise< AcpProviderCatalogEntry[] > { return ( - await invokeTauri( - "discover_all_acp_providers", - ) + await invokeTauri("discover_acp_providers") ).map(fromRawAcpProviderCatalogEntry); } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 5f1df9d15..579b6c924 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -366,16 +366,6 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; -export type AcpProvider = { - id: string; - label: string; - command: string; - binaryPath: string; - defaultArgs: string[]; - /** MCP server binary override, or `null` for the default (`sprout-mcp-server`). */ - mcpCommand: string | null; -}; - export type AcpAvailabilityStatus = | "available" | "adapter_missing" @@ -396,6 +386,13 @@ export type AcpProviderCatalogEntry = { underlyingCliPath: string | null; }; +/** An AcpProviderCatalogEntry that is confirmed available — command and binaryPath are non-null. */ +export type AcpProvider = AcpProviderCatalogEntry & { + availability: "available"; + command: string; + binaryPath: string; +}; + export type InstallStepResult = { step: string; command: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 120296239..f4d39f6fb 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4,6 +4,10 @@ import { finalizeEvent } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import type { RelayEvent } from "@/shared/api/types"; +import type { + RawAcpProviderCatalogEntry, + RawInstallRuntimeResult, +} from "@/shared/api/tauri"; type TestIdentity = { privateKey: string; @@ -30,6 +34,8 @@ type E2eConfig = { mode?: "mock" | "relay"; mock?: { acpProviders?: MockAcpProvider[]; + acpProvidersCatalog?: RawAcpProviderCatalogEntry[]; + installAcpRuntimeResult?: RawInstallRuntimeResult; managedAgentPrereqs?: { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; @@ -318,42 +324,6 @@ type RawManagedAgentLog = { log_path: string; }; -type RawAcpProvider = { - id: string; - label: string; - command: string; - binary_path: string; - default_args: string[]; - mcp_command: string | null; -}; - -type RawAcpProviderCatalogEntry = { - id: string; - label: string; - avatar_url: string; - availability: "available" | "adapter_missing" | "not_installed"; - command: string | null; - binary_path: string | null; - default_args: string[]; - mcp_command: string | null; - install_hint: string; - install_instructions_url: string; - can_auto_install: boolean; - underlying_cli_path: string | null; -}; - -type RawInstallRuntimeResult = { - success: boolean; - steps: Array<{ - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - }>; -}; - type RawCommandAvailability = { command: string; resolved_path: string | null; @@ -3495,42 +3465,11 @@ async function handleListRelayAgents(): Promise { async function handleDiscoverAcpProviders( config: E2eConfig | undefined, -): Promise { - const configuredProviders = config?.mock?.acpProviders; - if (configuredProviders) { - return configuredProviders.map((provider) => ({ - id: provider.id, - label: provider.label, - command: provider.command, - binary_path: provider.binaryPath, - default_args: [...provider.defaultArgs], - mcp_command: provider.mcpCommand ?? null, - })); - } - - return [ - { - id: "goose", - label: "Goose", - command: "goose", - binary_path: "/usr/local/bin/goose", - default_args: ["acp"], - mcp_command: null, - }, - { - id: "codex", - label: "Codex", - command: "codex-acp", - binary_path: "/usr/local/bin/codex-acp", - default_args: [], - mcp_command: null, - }, - ]; -} - -async function handleDiscoverAllAcpProviders( - _config: E2eConfig | undefined, ): Promise { + const configured = config?.mock?.acpProvidersCatalog; + if (configured) { + return configured; + } return [ { id: "goose", @@ -3593,9 +3532,16 @@ async function handleDiscoverAllAcpProviders( ]; } -async function handleInstallAcpRuntime(args: { - providerId?: string; -}): Promise { +async function handleInstallAcpRuntime( + args: { + providerId?: string; + }, + config: E2eConfig | undefined, +): Promise { + const configured = config?.mock?.installAcpRuntimeResult; + if (configured) { + return configured; + } return { success: true, steps: [ @@ -4783,10 +4729,11 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "discover_acp_providers": return handleDiscoverAcpProviders(activeConfig); - case "discover_all_acp_providers": - return handleDiscoverAllAcpProviders(activeConfig); case "install_acp_runtime": - return handleInstallAcpRuntime(payload as { providerId?: string }); + return handleInstallAcpRuntime( + payload as { providerId?: string }, + activeConfig, + ); case "discover_backend_providers": return []; case "probe_backend_provider": From e10336d19238f197c6a8b7ec7a2e199ce9617d10 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 16:33:40 -0400 Subject: [PATCH 03/16] fix(desktop): address crossfire review findings for ACP provider consolidation Fix CI-breaking missing `AcpAvailabilityStatus` import in tauri.ts, eliminate zombie process leak on install timeout by saving PID before thread move and sending SIGTERM, remove duplicate Map keys in check-file-sizes.mjs that silently shadowed earlier entries, clean up dead `MockAcpProvider` type from e2eBridge, and add missing `classify_provider` test for the no-underlying-CLI code path. --- desktop/scripts/check-file-sizes.mjs | 6 ++--- .../src-tauri/src/commands/agent_discovery.rs | 23 +++++++++++-------- .../src-tauri/src/managed_agents/discovery.rs | 8 +++++++ desktop/src/shared/api/tauri.ts | 1 + desktop/src/testing/e2eBridge.ts | 10 -------- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3552119fd..8033b6866 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -45,14 +45,12 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts - ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions - ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) - ["src-tauri/src/managed_agents/discovery.rs", 600], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state: Available/AdapterMissing/NotInstalled) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 13 unit tests + ["src-tauri/src/managed_agents/discovery.rs", 610], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state: Available/AdapterMissing/NotInstalled) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 14 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh @@ -79,7 +77,7 @@ const overrides = new Map([ ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations - ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 6853744dc..58ef9fcef 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -28,6 +28,8 @@ pub async fn install_acp_runtime(provider_id: String) -> Result Result { // Prevent concurrent installs. INSTALL_IN_PROGRESS @@ -144,11 +146,14 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { buf }); + // Save the PID before moving `child` into the wait thread so we can + // kill the process on timeout. + let child_pid = child.id(); + let (tx, rx) = std::sync::mpsc::channel(); let wait_thread = std::thread::spawn(move || { let status = child.wait(); let _ = tx.send(status); - // Return child so the caller can kill it on timeout. }); // 5-minute timeout for install commands. @@ -156,16 +161,16 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { loop { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { - // Timeout: the wait_thread still holds the child; signal via the - // channel being dropped and use a sentinel. We cannot kill here - // since `child` was moved. Instead, we drop the receiver and join - // the threads, letting them finish naturally, then report timeout. + // Timeout: kill the child process via its PID, then join all + // threads so nothing leaks. + #[cfg(unix)] + unsafe { + libc::kill(child_pid as i32, libc::SIGTERM); + } drop(rx); let _ = wait_thread.join(); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr = stderr_thread.join().unwrap_or_default(); - let _ = stdout; // discard; timed out - let _ = stderr; + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); return InstallStepResult { step: step.to_string(), command: command.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index db97b09cd..863bed22f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -596,4 +596,12 @@ mod tests { assert!(cmd.is_none()); assert!(path.is_none()); } + + #[test] + fn classifies_not_installed_when_no_underlying_cli() { + let (status, cmd, path) = classify_provider(None, None, false); + assert_eq!(status, AcpAvailabilityStatus::NotInstalled); + assert!(cmd.is_none()); + assert!(path.is_none()); + } } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 62f6e1149..3da85ff2d 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -39,6 +39,7 @@ import type { CreateManagedAgentInput, AgentModelsResponse, UpdateManagedAgentInput, + AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailability, InstallRuntimeResult, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f4d39f6fb..56fceccca 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -15,15 +15,6 @@ type TestIdentity = { username: string; }; -type MockAcpProvider = { - id: string; - label: string; - command: string; - binaryPath: string; - defaultArgs: string[]; - mcpCommand?: string | null; -}; - type MockCommandAvailability = { available?: boolean; command?: string; @@ -33,7 +24,6 @@ type MockCommandAvailability = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { - acpProviders?: MockAcpProvider[]; acpProvidersCatalog?: RawAcpProviderCatalogEntry[]; installAcpRuntimeResult?: RawInstallRuntimeResult; managedAgentPrereqs?: { From 39a7aa90159241e71ce5cea4c23166a74fab2748 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 16:54:14 -0400 Subject: [PATCH 04/16] fix(desktop): update E2E test helper to use consolidated acpProvidersCatalog field The onboarding E2E test passed `{ acpProviders: [] }` to the mock bridge, but the bridge handler was renamed to read `acpProvidersCatalog` during consolidation. The stale field name was silently ignored, causing the default catalog (with available providers) to be returned instead of an empty list. --- desktop/tests/e2e/onboarding.spec.ts | 2 +- desktop/tests/helpers/bridge.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0d2b61f3f..71d5adf4e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -197,7 +197,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as await installMockBridge( page, { - acpProviders: [], + acpProvidersCatalog: [], }, { skipOnboardingSeed: true }, ); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 5bb4d8487..e318b9dc8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -35,14 +35,6 @@ export const TEST_IDENTITIES = { type BridgeMode = "mock" | "relay"; -type MockAcpProvider = { - id: string; - label: string; - command: string; - binaryPath: string; - defaultArgs: string[]; -}; - type MockCommandAvailability = { available?: boolean; command?: string; @@ -50,7 +42,7 @@ type MockCommandAvailability = { }; type MockBridgeOptions = { - acpProviders?: MockAcpProvider[]; + acpProvidersCatalog?: Record[]; managedAgentPrereqs?: { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; From f1821bce8e04f7b1236e39d72c0eb7ab2dd7bf35 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 17:24:57 -0400 Subject: [PATCH 05/16] fix(desktop): fix broken install links, wrong adapter package, and hermit env leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace window.open() with for "View instructions" links — Tauri blocks window.open but handles anchor tags correctly. Fix Claude Code adapter install command: the package is @agentclientprotocol/claude-agent-acp (not @anthropic-ai/claude-agent-acp which doesn't exist on npm). Strip NPM_CONFIG_PREFIX, NPM_CONFIG_CACHE, and COREPACK_HOME from the install subprocess environment so npm uses the user's normal registry rather than inheriting hermit's project-local Artifactory proxy config. --- desktop/src-tauri/src/commands/agent_discovery.rs | 6 ++++++ desktop/src-tauri/src/managed_agents/discovery.rs | 4 ++-- desktop/src/features/settings/ui/DoctorSettingsPanel.tsx | 9 +++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 58ef9fcef..c4762a5b4 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -105,6 +105,12 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { let mut cmd = std::process::Command::new(shell); cmd.args(["-l", "-c", command]); + // Strip hermit env vars so npm/node use the user's normal registry and + // global prefix rather than the project-local hermit-managed paths. + cmd.env_remove("NPM_CONFIG_PREFIX"); + cmd.env_remove("NPM_CONFIG_CACHE"); + cmd.env_remove("COREPACK_HOME"); + if let Some(ref path) = shell_path { cmd.env("PATH", path); } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 863bed22f..6f50a2a84 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -82,8 +82,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ 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", + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", install_hint: "Install the Claude Code ACP adapter via npm.", }, KnownAcpProvider { diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 08fdef911..49c70bc88 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -60,14 +60,15 @@ function InstallActions({ {isInstalling ? "Installing..." : "Install"} ) : null} - +
); } From 338f0aff1dbf07c3a03642c27234e0a4d4761560 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 17:35:03 -0400 Subject: [PATCH 06/16] fix(desktop): use tauri-plugin-opener for external URLs in Doctor panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri does not open external URLs via — the click is silently swallowed. Use openUrl() from @tauri-apps/plugin-opener which correctly dispatches to the system default browser. --- .../src/features/settings/ui/DoctorSettingsPanel.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 49c70bc88..199a34e9c 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -8,6 +8,7 @@ import { RefreshCw, Stethoscope, } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { useAcpProvidersQuery, @@ -60,15 +61,14 @@ function InstallActions({ {isInstalling ? "Installing..." : "Install"} ) : null} - void openUrl(provider.installInstructionsUrl)} + type="button" > View instructions - +
); } From 4696cc4cd620f7c46dce85da34bd625527d9f493 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 18:44:06 -0400 Subject: [PATCH 07/16] fix(desktop): correct ACP provider install semantics for Goose and Codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goose has native ACP support (no separate adapter), so the install script belongs in cli_install_commands, not adapter_install_commands. Also sets underlying_cli to "goose" since the CLI binary exists. Codex ACP adapter is available on npm as @zed-industries/codex-acp — enable auto-install and point docs link to the actual adapter repo instead of the main Codex repo. --- desktop/src-tauri/src/managed_agents/discovery.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 6f50a2a84..bd3c2ba6c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -66,9 +66,9 @@ 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"], + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + adapter_install_commands: &[], install_instructions_url: "https://block.github.io/goose/", install_hint: "Install Goose via the official install script.", }, @@ -96,9 +96,9 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ 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.", + adapter_install_commands: &["npm install -g @zed-industries/codex-acp"], + install_instructions_url: "https://github.com/zed-industries/codex-acp", + install_hint: "Install the Codex ACP adapter via npm.", }, KnownAcpProvider { id: "sprout-agent", From d4b9ac16d84bef529c6e3fd5aaecc5460975406a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:26:15 -0400 Subject: [PATCH 08/16] fix(desktop): clear resolve cache on re-run, per-provider concurrency, dynamic install hints Re-run button now clears the binary resolve cache so it actually re-probes the filesystem instead of returning stale cached results. Replace global AtomicBool install guard with per-provider Mutex> so concurrent installs for different providers (e.g. claude + codex) can proceed in parallel. Add codex CLI install command (npm install -g @openai/codex) so clicking Install when both CLI and adapter are missing installs both. Compute install_hint dynamically based on availability state so the UI accurately describes what the Install button will do. --- desktop/scripts/check-file-sizes.mjs | 2 +- .../src-tauri/src/commands/agent_discovery.rs | 31 ++++++++++----- .../src-tauri/src/managed_agents/discovery.rs | 38 ++++++++++++++++++- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8033b6866..45336587a 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,7 +50,7 @@ const overrides = new Map([ ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) - ["src-tauri/src/managed_agents/discovery.rs", 610], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state: Available/AdapterMissing/NotInstalled) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 14 unit tests + ["src-tauri/src/managed_agents/discovery.rs", 645], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state with dynamic install_hint) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 14 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index c4762a5b4..f8319c039 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,5 +1,4 @@ use std::io::Read; -use std::sync::atomic::Ordering::{Acquire, Relaxed}; use tauri::{AppHandle, State}; use crate::{ @@ -13,11 +12,16 @@ use crate::{ relay::query_relay, }; -static INSTALL_IN_PROGRESS: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); +fn active_installs() -> &'static std::sync::Mutex> { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + static ACTIVE: OnceLock>> = OnceLock::new(); + ACTIVE.get_or_init(|| Mutex::new(HashSet::new())) +} #[tauri::command] pub fn discover_acp_providers() -> Vec { + crate::managed_agents::clear_resolve_cache(); crate::managed_agents::discover_acp_providers() } @@ -31,18 +35,25 @@ pub async fn install_acp_runtime(provider_id: String) -> Result Result { - // Prevent concurrent installs. - INSTALL_IN_PROGRESS - .compare_exchange(false, true, Acquire, Relaxed) - .map_err(|_| "an install is already in progress".to_string())?; + // Prevent concurrent installs for the same provider. + { + let mut set = active_installs() + .lock() + .map_err(|_| "install lock poisoned".to_string())?; + if !set.insert(provider_id.to_string()) { + return Err(format!("an install is already in progress for {provider_id}")); + } + } - struct Guard; + struct Guard(String); impl Drop for Guard { fn drop(&mut self) { - INSTALL_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release); + if let Ok(mut set) = active_installs().lock() { + set.remove(&self.0); + } } } - let _guard = Guard; + let _guard = Guard(provider_id.to_string()); let provider = crate::managed_agents::known_acp_provider_exact(provider_id) .ok_or_else(|| format!("unknown provider: {provider_id}"))?; diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index bd3c2ba6c..29d1fd7cf 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -95,7 +95,7 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ mcp_command: None, mcp_hooks: false, underlying_cli: Some("codex"), - cli_install_commands: &[], + cli_install_commands: &["npm install -g @openai/codex"], adapter_install_commands: &["npm install -g @zed-industries/codex-acp"], install_instructions_url: "https://github.com/zed-industries/codex-acp", install_hint: "Install the Codex ACP adapter via npm.", @@ -446,6 +446,40 @@ pub fn discover_acp_providers() -> Vec { let can_auto_install = !provider.cli_install_commands.is_empty() || !provider.adapter_install_commands.is_empty(); + let install_hint = if availability == AcpAvailabilityStatus::Available { + provider.install_hint.to_string() + } else if provider.underlying_cli.is_some() { + let has_cli_cmds = !provider.cli_install_commands.is_empty(); + let has_adapter_cmds = !provider.adapter_install_commands.is_empty(); + match availability { + AcpAvailabilityStatus::AdapterMissing => { + if has_adapter_cmds { + format!("Install the {} ACP adapter via the Install button, or follow the manual instructions.", provider.label) + } else { + provider.install_hint.to_string() + } + } + AcpAvailabilityStatus::NotInstalled => match (has_cli_cmds, has_adapter_cmds) { + (true, true) => format!( + "Install will set up the {} CLI and its ACP adapter.", + provider.label + ), + (true, false) => format!( + "Install will set up the {} CLI. Install the ACP adapter manually.", + provider.label + ), + (false, true) => format!( + "Install will set up the ACP adapter. Install the {} CLI manually.", + provider.label + ), + (false, false) => provider.install_hint.to_string(), + }, + AcpAvailabilityStatus::Available => unreachable!(), + } + } else { + provider.install_hint.to_string() + }; + AcpProviderCatalogEntry { id: provider.id.to_string(), label: provider.label.to_string(), @@ -455,7 +489,7 @@ pub fn discover_acp_providers() -> Vec { binary_path, default_args, mcp_command: provider.mcp_command.map(str::to_string), - install_hint: provider.install_hint.to_string(), + install_hint, install_instructions_url: provider.install_instructions_url.to_string(), can_auto_install, underlying_cli_path, From 6560d96ba5564d29be94ca6e0fa8c4ae279036d5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:35:44 -0400 Subject: [PATCH 09/16] feat(desktop): add cli_missing availability state for ACP providers When the ACP adapter binary is found but the underlying CLI is missing (e.g. codex-acp installed without codex), classify_provider now returns CliMissing instead of Available. The adapter can't function without the CLI, so Doctor shows an amber warning with an Install button that will set up the missing CLI. --- desktop/scripts/check-file-sizes.mjs | 2 +- .../src-tauri/src/managed_agents/discovery.rs | 79 +++++++++++++------ desktop/src-tauri/src/managed_agents/types.rs | 1 + .../src/features/agents/ui/PersonaDialog.tsx | 12 ++- .../settings/ui/DoctorSettingsPanel.tsx | 23 +++++- desktop/src/shared/api/types.ts | 1 + 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 45336587a..d93c83bf6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,7 +50,7 @@ const overrides = new Map([ ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) - ["src-tauri/src/managed_agents/discovery.rs", 645], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider + discover_acp_providers (three-state with dynamic install_hint) + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 14 unit tests + ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 29d1fd7cf..e95621327 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -401,11 +401,19 @@ fn classify_provider( underlying_cli_found: bool, ) -> (AcpAvailabilityStatus, Option, Option) { if let Some((cmd, path)) = adapter_result { - ( - AcpAvailabilityStatus::Available, - Some(cmd.to_string()), - Some(path.display().to_string()), - ) + if underlying_cli.is_some() && !underlying_cli_found { + ( + AcpAvailabilityStatus::CliMissing, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) + } else { + ( + AcpAvailabilityStatus::Available, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) + } } else if underlying_cli.is_some() && underlying_cli_found { (AcpAvailabilityStatus::AdapterMissing, None, None) } else { @@ -446,20 +454,38 @@ pub fn discover_acp_providers() -> Vec { let can_auto_install = !provider.cli_install_commands.is_empty() || !provider.adapter_install_commands.is_empty(); - let install_hint = if availability == AcpAvailabilityStatus::Available { - provider.install_hint.to_string() - } else if provider.underlying_cli.is_some() { - let has_cli_cmds = !provider.cli_install_commands.is_empty(); - let has_adapter_cmds = !provider.adapter_install_commands.is_empty(); - match availability { - AcpAvailabilityStatus::AdapterMissing => { - if has_adapter_cmds { - format!("Install the {} ACP adapter via the Install button, or follow the manual instructions.", provider.label) - } else { - provider.install_hint.to_string() - } + let install_hint = match availability { + AcpAvailabilityStatus::Available => provider.install_hint.to_string(), + _ if provider.underlying_cli.is_none() => provider.install_hint.to_string(), + AcpAvailabilityStatus::CliMissing => { + let has_cli_cmds = !provider.cli_install_commands.is_empty(); + if has_cli_cmds { + format!( + "ACP adapter found, but the {} CLI is missing. Install will set up the CLI.", + provider.label + ) + } else { + format!( + "ACP adapter found, but the {} CLI is missing. Install it manually.", + provider.label + ) } - AcpAvailabilityStatus::NotInstalled => match (has_cli_cmds, has_adapter_cmds) { + } + AcpAvailabilityStatus::AdapterMissing => { + let has_adapter_cmds = !provider.adapter_install_commands.is_empty(); + if has_adapter_cmds { + format!( + "Install the {} ACP adapter via the Install button, or follow the manual instructions.", + provider.label + ) + } else { + provider.install_hint.to_string() + } + } + AcpAvailabilityStatus::NotInstalled => { + let has_cli_cmds = !provider.cli_install_commands.is_empty(); + let has_adapter_cmds = !provider.adapter_install_commands.is_empty(); + match (has_cli_cmds, has_adapter_cmds) { (true, true) => format!( "Install will set up the {} CLI and its ACP adapter.", provider.label @@ -473,11 +499,8 @@ pub fn discover_acp_providers() -> Vec { provider.label ), (false, false) => provider.install_hint.to_string(), - }, - AcpAvailabilityStatus::Available => unreachable!(), + } } - } else { - provider.install_hint.to_string() }; AcpProviderCatalogEntry { @@ -638,4 +661,16 @@ mod tests { assert!(cmd.is_none()); assert!(path.is_none()); } + + #[test] + fn classifies_cli_missing_when_adapter_found_but_cli_absent() { + let (status, cmd, path) = classify_provider( + Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), + Some("codex"), + false, + ); + assert_eq!(status, AcpAvailabilityStatus::CliMissing); + assert_eq!(cmd.as_deref(), Some("codex-acp")); + assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2add5e27d..8bb49c614 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -280,6 +280,7 @@ pub struct ManagedAgentLogResponse { pub enum AcpAvailabilityStatus { Available, AdapterMissing, + CliMissing, NotInstalled, } diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index b15824266..ab8f98b05 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -272,7 +272,9 @@ export function PersonaDialog({

{selectedProvider.availability === "adapter_missing" ? `${selectedProvider.label} CLI is installed but the ACP adapter is missing.` - : `${selectedProvider.label} is not installed.`}{" "} + : selectedProvider.availability === "cli_missing" + ? `${selectedProvider.label} ACP adapter is installed but the CLI is missing.` + : `${selectedProvider.label} is not installed.`}{" "} Visit Settings > Doctor to set it up.

) : null; @@ -367,9 +369,11 @@ export function PersonaDialog({ {p.label} {p.availability === "adapter_missing" ? " (adapter missing)" - : p.availability === "not_installed" - ? " (not installed)" - : ""} + : p.availability === "cli_missing" + ? " (CLI missing)" + : p.availability === "not_installed" + ? " (not installed)" + : ""} ))} diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 199a34e9c..aee5dc03d 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -29,6 +29,8 @@ function StatusIcon({ return ; case "adapter_missing": return ; + case "cli_missing": + return ; case "not_installed": return ; } @@ -92,7 +94,8 @@ function ProviderRow({ "flex items-start gap-3 rounded-xl border px-4 py-3", provider.availability === "available" ? "border-border/70 bg-background/80" - : provider.availability === "adapter_missing" + : provider.availability === "adapter_missing" || + provider.availability === "cli_missing" ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-muted/30", )} @@ -152,6 +155,24 @@ function ProviderRow({ provider={provider} /> + ) : provider.availability === "cli_missing" ? ( + <> +

+ ACP adapter found at{" "} + + {provider.binaryPath ?? "unknown path"} + {" "} + but the {provider.label} CLI is not installed. +

+

+ {provider.installHint} +

+ + ) : ( <>

Not installed

diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 579b6c924..cb7b8d842 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -369,6 +369,7 @@ export type CancelManagedAgentTurnResult = { export type AcpAvailabilityStatus = | "available" | "adapter_missing" + | "cli_missing" | "not_installed"; export type AcpProviderCatalogEntry = { From 54b618e65674ac38acf5a33b9561d902184c769d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:38:58 -0400 Subject: [PATCH 10/16] style(desktop): fix cargo fmt long line in install concurrency guard --- desktop/src-tauri/src/commands/agent_discovery.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index f8319c039..75f2e8a6b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -41,7 +41,9 @@ fn install_acp_runtime_blocking(provider_id: &str) -> Result Date: Fri, 22 May 2026 19:40:41 -0400 Subject: [PATCH 11/16] fix(desktop): use official install script for Codex CLI instead of npm @openai/codex on npm uses platform-specific sub-packages and the latest alpha publishes only a win32-x64 variant, causing EBADPLATFORM on macOS. Switch to the official install script which handles platform detection. --- desktop/src-tauri/src/managed_agents/discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index e95621327..7da679dde 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -95,7 +95,7 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ mcp_command: None, mcp_hooks: false, underlying_cli: Some("codex"), - cli_install_commands: &["npm install -g @openai/codex"], + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], adapter_install_commands: &["npm install -g @zed-industries/codex-acp"], install_instructions_url: "https://github.com/zed-industries/codex-acp", install_hint: "Install the Codex ACP adapter via npm.", From a2ba77f3051ab891dc75697a85d81633478bb958 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:41:48 -0400 Subject: [PATCH 12/16] fix(desktop): use official install script for Claude Code CLI Claude Code is now a Rust binary with platform-specific npm packaging. Their docs recommend the native install script over npm. Switch to curl -fsSL https://claude.ai/install.sh | bash. --- desktop/src-tauri/src/managed_agents/discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 7da679dde..381e5546a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -81,7 +81,7 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ mcp_command: None, mcp_hooks: false, underlying_cli: Some("claude"), - cli_install_commands: &["npm install -g @anthropic-ai/claude-code"], + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", install_hint: "Install the Claude Code ACP adapter via npm.", From a8a25998aefeb344a532fd431b007565a5b37461 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:49:47 -0400 Subject: [PATCH 13/16] refactor(desktop): split install_hint into cli_install_hint and adapter_install_hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single install_hint field was semantically ambiguous — it referred to the CLI for some providers and the adapter for others. Split into two fields so each provider clearly describes both install steps. The dynamic hint composition in discover_acp_providers selects the right hint based on availability state: CliMissing shows the CLI hint, AdapterMissing shows the adapter hint, NotInstalled shows both. --- .../src-tauri/src/managed_agents/discovery.rs | 72 ++++++------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 381e5546a..4a47f061c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -25,8 +25,10 @@ pub(crate) struct KnownAcpProvider { 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, + /// Human-readable hint about installing the CLI binary. + pub cli_install_hint: &'static str, + /// Human-readable hint about installing the ACP adapter. + pub adapter_install_hint: &'static str, } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -70,7 +72,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], adapter_install_commands: &[], install_instructions_url: "https://block.github.io/goose/", - install_hint: "Install Goose via the official install script.", + cli_install_hint: "Install Goose via the official install script.", + adapter_install_hint: "", }, KnownAcpProvider { id: "claude", @@ -84,7 +87,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - install_hint: "Install the Claude Code ACP adapter via npm.", + cli_install_hint: "Install the Claude Code CLI via the official install script.", + adapter_install_hint: "Install the Claude Code ACP adapter via npm.", }, KnownAcpProvider { id: "codex", @@ -98,7 +102,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], adapter_install_commands: &["npm install -g @zed-industries/codex-acp"], install_instructions_url: "https://github.com/zed-industries/codex-acp", - install_hint: "Install the Codex ACP adapter via npm.", + cli_install_hint: "Install the Codex CLI via the official install script.", + adapter_install_hint: "Install the Codex ACP adapter via npm.", }, KnownAcpProvider { id: "sprout-agent", @@ -112,7 +117,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ cli_install_commands: &[], adapter_install_commands: &[], install_instructions_url: "https://github.com/block/sprout", - install_hint: "Ships with the Sprout desktop app.", + cli_install_hint: "Ships with the Sprout desktop app.", + adapter_install_hint: "", }, ]; @@ -454,51 +460,19 @@ pub fn discover_acp_providers() -> Vec { let can_auto_install = !provider.cli_install_commands.is_empty() || !provider.adapter_install_commands.is_empty(); + let cli_hint = provider.cli_install_hint; + let adapter_hint = provider.adapter_install_hint; let install_hint = match availability { - AcpAvailabilityStatus::Available => provider.install_hint.to_string(), - _ if provider.underlying_cli.is_none() => provider.install_hint.to_string(), - AcpAvailabilityStatus::CliMissing => { - let has_cli_cmds = !provider.cli_install_commands.is_empty(); - if has_cli_cmds { - format!( - "ACP adapter found, but the {} CLI is missing. Install will set up the CLI.", - provider.label - ) - } else { - format!( - "ACP adapter found, but the {} CLI is missing. Install it manually.", - provider.label - ) - } - } - AcpAvailabilityStatus::AdapterMissing => { - let has_adapter_cmds = !provider.adapter_install_commands.is_empty(); - if has_adapter_cmds { - format!( - "Install the {} ACP adapter via the Install button, or follow the manual instructions.", - provider.label - ) - } else { - provider.install_hint.to_string() - } - } + AcpAvailabilityStatus::Available => cli_hint.to_string(), + AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), + AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), AcpAvailabilityStatus::NotInstalled => { - let has_cli_cmds = !provider.cli_install_commands.is_empty(); - let has_adapter_cmds = !provider.adapter_install_commands.is_empty(); - match (has_cli_cmds, has_adapter_cmds) { - (true, true) => format!( - "Install will set up the {} CLI and its ACP adapter.", - provider.label - ), - (true, false) => format!( - "Install will set up the {} CLI. Install the ACP adapter manually.", - provider.label - ), - (false, true) => format!( - "Install will set up the ACP adapter. Install the {} CLI manually.", - provider.label - ), - (false, false) => provider.install_hint.to_string(), + if !cli_hint.is_empty() && !adapter_hint.is_empty() { + format!("{cli_hint} {adapter_hint}") + } else if !cli_hint.is_empty() { + cli_hint.to_string() + } else { + adapter_hint.to_string() } } }; From 77ef419fdb7aa9ee5b2ff916d1c8bce073fc2415 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:03:41 -0400 Subject: [PATCH 14/16] fix(desktop): improve Doctor panel clarity for CLI vs ACP adapter status Rename section header to "Agent CLIs and ACP runtimes" with a descriptive subheader. Show both CLI and ACP adapter paths when a provider has a separate adapter (Claude Code, Codex). Add "ACP support built-in" note for providers with native ACP (Goose, Sprout Agent). --- .../settings/ui/DoctorSettingsPanel.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index aee5dc03d..be7dda159 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -133,9 +133,28 @@ function ProviderRow({

) : null} -

- {provider.binaryPath} -

+ {provider.underlyingCliPath && + provider.underlyingCliPath !== provider.binaryPath ? ( +
+

+ CLI:{" "} + {provider.underlyingCliPath} +

+

+ ACP adapter:{" "} + {provider.binaryPath} +

+
+ ) : ( + <> +

+ {provider.binaryPath} +

+

+ ACP support built-in — no separate adapter needed. +

+ + )} ) : provider.availability === "adapter_missing" ? ( <> @@ -281,9 +300,11 @@ export function DoctorSettingsPanel() {
-

ACP runtimes

+

+ Agent CLIs and ACP runtimes +

- Known runtimes and their installation status. + Installation status of supported agent CLIs and their ACP runtimes.

From 539ca457d54ba6c34e6c2b33a718bb56aeebbf35 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:13:52 -0400 Subject: [PATCH 15/16] fix(desktop): close stdin for install subprocesses to prevent interactive hangs Install commands like the Codex CLI script prompt "Start Codex now? [y/N]" which blocks forever when the subprocess inherits stdin from the Tauri process. Redirect stdin from /dev/null so any interactive prompt gets an immediate EOF and defaults to its non-interactive behavior. --- desktop/src-tauri/src/commands/agent_discovery.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 75f2e8a6b..10d615d60 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -129,6 +129,7 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { } let mut child = match cmd + .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() From d2b548627d1836b9c297e8919c3b11114cf73e8f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:21:21 -0400 Subject: [PATCH 16/16] fix(desktop): detach install subprocesses from controlling terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install scripts like Codex's install.sh explicitly open /dev/tty to prompt interactively ("Start Codex now? [y/N]"), bypassing our stdin(Stdio::null()). Call setsid() in pre_exec to create a new session without a controlling terminal, forcing the script's fallback to read from stdin (/dev/null → EOF → default answer). --- desktop/src-tauri/src/commands/agent_discovery.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 10d615d60..7c27d73bc 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -128,6 +128,20 @@ fn run_install_command(step: &str, command: &str) -> InstallStepResult { cmd.env("PATH", path); } + // Detach from the controlling terminal so install scripts that read from + // /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin + // (which is /dev/null) instead of blocking forever. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + } + let mut child = match cmd .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped())