Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
38bc4bd
feat(desktop): show all ACP runtimes with install status and install …
wpfleger96 May 21, 2026
3b26a0d
refactor(desktop): consolidate ACP provider discovery and address rev…
wpfleger96 May 22, 2026
e10336d
fix(desktop): address crossfire review findings for ACP provider cons…
wpfleger96 May 22, 2026
39a7aa9
fix(desktop): update E2E test helper to use consolidated acpProviders…
wpfleger96 May 22, 2026
f1821bc
fix(desktop): fix broken install links, wrong adapter package, and he…
wpfleger96 May 22, 2026
338f0af
fix(desktop): use tauri-plugin-opener for external URLs in Doctor panel
wpfleger96 May 22, 2026
4696cc4
fix(desktop): correct ACP provider install semantics for Goose and Codex
wpfleger96 May 22, 2026
d4b9ac1
fix(desktop): clear resolve cache on re-run, per-provider concurrency…
wpfleger96 May 22, 2026
6560d96
feat(desktop): add cli_missing availability state for ACP providers
wpfleger96 May 22, 2026
54b618e
style(desktop): fix cargo fmt long line in install concurrency guard
wpfleger96 May 22, 2026
aed67c1
fix(desktop): use official install script for Codex CLI instead of npm
wpfleger96 May 22, 2026
a2ba77f
fix(desktop): use official install script for Claude Code CLI
wpfleger96 May 22, 2026
a8a2599
refactor(desktop): split install_hint into cli_install_hint and adapt…
wpfleger96 May 22, 2026
77ef419
fix(desktop): improve Doctor panel clarity for CLI vs ACP adapter status
wpfleger96 May 23, 2026
539ca45
fix(desktop): close stdin for install subprocesses to prevent interac…
wpfleger96 May 23, 2026
d2b5486
fix(desktop): detach install subprocesses from controlling terminal
wpfleger96 May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,27 @@ 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/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", 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
["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
Expand All @@ -77,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) {
Expand Down
275 changes: 271 additions & 4 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,286 @@
use std::io::Read;
use tauri::{AppHandle, State};

use crate::{
app_state::AppState,
managed_agents::{
command_availability, discover_local_acp_providers, AcpProviderInfo,
DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo,
command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest,
InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo,
DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
},
nostr_convert,
relay::query_relay,
};

fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static ACTIVE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
ACTIVE.get_or_init(|| Mutex::new(HashSet::new()))
}

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

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

/// Err(_) = infrastructure failure (panic, concurrency guard).
/// Ok({success: false}) = an install step failed (stderr captured in steps).
fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResult, 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(String);
impl Drop for Guard {
fn drop(&mut self) {
if let Ok(mut set) = active_installs().lock() {
set.remove(&self.0);
}
}
}
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}"))?;

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]);

// 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);
}

// 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())
.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,
};
}
};

// 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
});

// 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);
});

// 5-minute timeout for install commands.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
// 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_thread.join();
let _ = stderr_thread.join();
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: truncate_output(stderr_raw),
exit_code: status.code(),
};
}
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(),
success: false,
stdout: String::new(),
stderr: format!("failed to check process status: {e}"),
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 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 {
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]
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ pub fn run() {
get_relay_http_url,
get_media_proxy_port,
discover_acp_providers,
install_acp_runtime,
discover_managed_agent_prereqs,
sign_event,
decrypt_observer_event,
Expand Down
Loading
Loading