From 9e875171897b29f722fa624b5698cbad643ebbb8 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Fri, 29 May 2026 11:29:02 -0400
Subject: [PATCH 1/3] fix(desktop): reap orphaned agent processes on shutdown
 and restart

Agent workers (goose, sprout-agent, etc.) are spawned in their own
process groups by sprout-acp for crash isolation, making them
unreachable by the desktop's group-kill during shutdown. When the
desktop process is killed by SIGINT/SIGTERM/SIGHUP before the Tauri
RunEvent handler fires, no cleanup runs at all.

Three fixes:

1. Register SIGINT/SIGTERM/SIGHUP handlers that call
   shutdown_managed_agents() before exit, using the existing
   shutdown_done guard to prevent double-execution with RunEvent.

2. Add a system-wide process sweep on launch and shutdown that
   enumerates all user processes via libproc (macOS) or /proc (Linux),
   identifies known agent binaries, and kills orphans not tracked by
   the current session.

3. Attempt group-kill on dead PID-file entries before removing them,
   catching cases where the group leader exited but members survived.
---
 desktop/scripts/check-file-sizes.mjs          |   4 +-
 desktop/src-tauri/Cargo.lock                  |  26 ++-
 desktop/src-tauri/Cargo.toml                  |   1 +
 desktop/src-tauri/src/lib.rs                  |  66 +++++++-
 .../src-tauri/src/managed_agents/restore.rs   |   6 +
 .../src-tauri/src/managed_agents/runtime.rs   | 151 ++++++++++++++++++
 6 files changed, 249 insertions(+), 5 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 1f04aed4b4..8eeedcc195 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -52,7 +52,7 @@ const overrides = new Map([
   ["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", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
   ["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/runtime.rs", 1260], // ... + 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) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux)
   ["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)
@@ -79,7 +79,7 @@ const overrides = new Map([
   ["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
   ["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", 735], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch
+  ["src-tauri/src/lib.rs", 800], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
   ["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
 ]);
 
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index d9af766776..d8f1aa719a 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -271,7 +271,7 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
 dependencies = [
- "nix",
+ "nix 0.30.1",
  "rand 0.9.4",
 ]
 
@@ -1101,6 +1101,17 @@ version = "0.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
 
+[[package]]
+name = "ctrlc"
+version = "3.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
+dependencies = [
+ "dispatch2",
+ "nix 0.31.3",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "ctutils"
 version = "0.4.2"
@@ -2931,6 +2942,18 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.11.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
 [[package]]
 name = "nostr"
 version = "0.44.3"
@@ -4855,6 +4878,7 @@ dependencies = [
  "base64 0.22.1",
  "bzip2 0.6.1",
  "chrono",
+ "ctrlc",
  "dirs",
  "earshot",
  "futures-util",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index f838f3dd22..3733e41e4a 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
 
 [target.'cfg(unix)'.dependencies]
 libc = "0.2"
+ctrlc = "3"
 
 [target.'cfg(windows)'.dependencies]
 windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index dd443e3623..f0a39788fa 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -148,6 +148,12 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
     // All tracked PIDs have already been killed above, so pass an empty skip list.
     managed_agents::sweep_orphaned_agent_processes(app, &[]);
 
+    // System-wide sweep: agent workers (goose, sprout-agent, etc.) are spawned
+    // in their own process groups by sprout-acp, so group-kills above only
+    // reach the harness, not the workers. Scan all user processes and kill any
+    // known agent binaries that are still running.
+    managed_agents::sweep_system_agent_processes(&[]);
+
     if changed {
         save_managed_agents(app, &records)?;
     }
@@ -645,11 +651,67 @@ pub fn run() {
         .build(tauri::generate_context!())
         .expect("error while building tauri application");
 
-    let shutdown_done = AtomicBool::new(false);
+    let shutdown_done = Arc::new(AtomicBool::new(false));
+
+    // Register signal handlers so agent cleanup runs even when the process is
+    // killed by SIGINT (Ctrl+C), SIGTERM, or SIGHUP (terminal close) before
+    // Tauri's RunEvent::Exit fires. The `shutdown_done` guard prevents
+    // double-execution with the RunEvent handler.
+    #[cfg(unix)]
+    {
+        let signal_app = app.handle().clone();
+        let signal_shutdown_done = Arc::clone(&shutdown_done);
+        let signal_shutdown_started = Arc::clone(&shutdown_started);
+        // ctrlc handles SIGINT. For SIGTERM and SIGHUP we use raw libc
+        // handlers that set a flag, and a background thread that waits on it.
+        // This avoids pulling in extra crates and works with the ctrlc crate.
+        static SIGNAL_RECEIVED: AtomicBool = AtomicBool::new(false);
+
+        extern "C" fn signal_flag_handler(_sig: libc::c_int) {
+            SIGNAL_RECEIVED.store(true, Ordering::SeqCst);
+        }
+
+        unsafe {
+            libc::signal(
+                libc::SIGTERM,
+                signal_flag_handler as *const () as libc::sighandler_t,
+            );
+            libc::signal(
+                libc::SIGHUP,
+                signal_flag_handler as *const () as libc::sighandler_t,
+            );
+        }
+
+        // Background thread polls for SIGTERM/SIGHUP and runs cleanup.
+        let term_app = signal_app.clone();
+        let term_done = Arc::clone(&signal_shutdown_done);
+        let term_started = Arc::clone(&signal_shutdown_started);
+        std::thread::spawn(move || {
+            while !SIGNAL_RECEIVED.load(Ordering::SeqCst) {
+                std::thread::sleep(std::time::Duration::from_millis(100));
+            }
+            term_started.store(true, Ordering::SeqCst);
+            if !term_done.swap(true, Ordering::SeqCst) {
+                let _ = shutdown_managed_agents(&term_app);
+            }
+            std::process::exit(0);
+        });
+
+        // SIGINT (Ctrl+C) via ctrlc crate — runs handler on a dedicated thread.
+        let _ = ctrlc::set_handler(move || {
+            signal_shutdown_started.store(true, Ordering::SeqCst);
+            if !signal_shutdown_done.swap(true, Ordering::SeqCst) {
+                let _ = shutdown_managed_agents(&signal_app);
+            }
+            std::process::exit(0);
+        });
+    }
+
+    let run_shutdown_done = Arc::clone(&shutdown_done);
     app.run(move |app_handle, event| match event {
         RunEvent::ExitRequested { .. } | RunEvent::Exit => {
             shutdown_started.store(true, Ordering::SeqCst);
-            if !shutdown_done.swap(true, Ordering::SeqCst) {
+            if !run_shutdown_done.swap(true, Ordering::SeqCst) {
                 prevent_sleep::release(&app_handle.state::<AppState>().prevent_sleep);
                 if let Err(error) = shutdown_managed_agents(app_handle) {
                     eprintln!("sprout-desktop: failed to stop managed agents: {error}");
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index a8ad78924f..716034d53f 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -53,6 +53,12 @@ pub fn restore_managed_agents_on_launch(
             .collect();
         super::sweep_orphaned_agent_processes(app, &tracked_pids);
 
+        // System-wide sweep: enumerate all user processes and kill any known
+        // agent binaries not tracked by this session. Catches orphans whose
+        // PID files were already cleaned up (e.g. agent workers in their own
+        // process group whose parent harness exited).
+        super::sweep_system_agent_processes(&tracked_pids);
+
         let candidates: Vec<String> = records
             .iter()
             .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 6ad66eecf5..f79008d231 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -230,6 +230,17 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32])
         sigterm_then_sigkill(&orphans);
     }
 
+    // For PID-file entries whose leader is dead, still try killing the process
+    // group — child processes (e.g. MCP servers) may have survived the leader.
+    let dead_groups: Vec<i32> = entries
+        .iter()
+        .filter(|(_, pid)| !skip_pids.contains(pid) && !process_is_running(*pid))
+        .map(|(_, pid)| *pid as i32)
+        .collect();
+    if !dead_groups.is_empty() {
+        sigterm_then_sigkill(&dead_groups);
+    }
+
     // Clean up PID files for processes we just killed or that are already gone.
     for (pubkey, pid) in &entries {
         if skip_pids.contains(pid) {
@@ -246,6 +257,146 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
     let _ = app;
 }
 
+/// Enumerate all processes on the system owned by the current user and kill any
+/// that match `KNOWN_AGENT_BINARIES` but aren't in `skip_pids`. This catches
+/// orphans that escaped PID-file-based cleanup (e.g. agent workers spawned with
+/// their own process group whose parent harness already exited and had its PID
+/// file removed).
+#[cfg(target_os = "macos")]
+pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
+    extern "C" {
+        fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
+        fn proc_pidinfo(
+            pid: libc::c_int,
+            flavor: libc::c_int,
+            arg: u64,
+            buffer: *mut libc::c_void,
+            buffersize: libc::c_int,
+        ) -> libc::c_int;
+    }
+
+    #[repr(C)]
+    struct BSDInfo {
+        _pad: [u8; 24],
+        pbi_uid: u32,
+        _rest: [u8; 104],
+    }
+    const PROC_PIDTBSDINFO: libc::c_int = 3;
+
+    let my_uid = unsafe { libc::getuid() };
+
+    let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
+    if count <= 0 {
+        return;
+    }
+
+    let buf_len = (count as usize) * 2;
+    let mut pids: Vec<libc::c_int> = vec![0; buf_len];
+    let actual = unsafe {
+        proc_listallpids(
+            pids.as_mut_ptr(),
+            (buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
+        )
+    };
+    if actual <= 0 {
+        return;
+    }
+    pids.truncate(actual as usize);
+
+    let my_pid = std::process::id() as i32;
+    let mut orphans: Vec<i32> = Vec::new();
+
+    for &pid in &pids {
+        if pid <= 0 {
+            continue;
+        }
+        let upid = pid as u32;
+        if skip_pids.contains(&upid) || pid == my_pid {
+            continue;
+        }
+        // Check binary name first (cheap proc_name call) before UID lookup.
+        if !process_belongs_to_us(upid) {
+            continue;
+        }
+        // Verify UID to avoid killing another user's identically-named binary.
+        let mut info = std::mem::MaybeUninit::<BSDInfo>::zeroed();
+        let ret = unsafe {
+            proc_pidinfo(
+                pid,
+                PROC_PIDTBSDINFO,
+                0,
+                info.as_mut_ptr() as *mut libc::c_void,
+                std::mem::size_of::<BSDInfo>() as libc::c_int,
+            )
+        };
+        if ret <= 0 {
+            continue;
+        }
+        let info = unsafe { info.assume_init() };
+        if info.pbi_uid != my_uid {
+            continue;
+        }
+        orphans.push(pid);
+    }
+
+    if !orphans.is_empty() {
+        eprintln!(
+            "sprout-desktop: system sweep found {} orphaned agent process(es), cleaning up",
+            orphans.len()
+        );
+        sigterm_then_sigkill(&orphans);
+    }
+}
+
+#[cfg(all(unix, not(target_os = "macos")))]
+pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
+    let my_uid = unsafe { libc::getuid() };
+    let mut orphans: Vec<i32> = Vec::new();
+    let my_pid = std::process::id() as i32;
+
+    let Ok(entries) = std::fs::read_dir("/proc") else {
+        return;
+    };
+    for entry in entries.flatten() {
+        let name = entry.file_name();
+        let Some(name_str) = name.to_str() else {
+            continue;
+        };
+        let Ok(pid) = name_str.parse::<i32>() else {
+            continue;
+        };
+        if pid <= 0 || pid == my_pid {
+            continue;
+        }
+        let upid = pid as u32;
+        if skip_pids.contains(&upid) {
+            continue;
+        }
+        // Check ownership via /proc/<pid> metadata.
+        let Ok(meta) = entry.metadata() else {
+            continue;
+        };
+        use std::os::unix::fs::MetadataExt;
+        if meta.uid() != my_uid {
+            continue;
+        }
+        if process_belongs_to_us(upid) {
+            orphans.push(pid);
+        }
+    }
+
+    if !orphans.is_empty() {
+        eprintln!(
+            "sprout-desktop: system sweep found {} orphaned agent process(es), cleaning up",
+            orphans.len()
+        );
+        sigterm_then_sigkill(&orphans);
+    }
+}
+
+#[cfg(not(unix))]
+pub(crate) fn sweep_system_agent_processes(_skip_pids: &[u32]) {}
+
 /// Kill stale agent processes from a previous session whose PID is still alive
 /// but not tracked in the current `runtimes` map. Updates the record fields and
 /// returns `true` if any records were modified.

From d81bedbda0b6a8cc666e8a8212d9fbd92292a2c6 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Fri, 29 May 2026 11:52:34 -0400
Subject: [PATCH 2/3] fix(desktop): address crossfire review findings for
 orphan reap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

BSDInfo struct had pbi_uid at wrong byte offset (24 vs 20), silently
reading pbi_gid instead — correct to offset 20 with static size assert.
Collapse signal handlers to use ctrlc "termination" feature (covers
SIGINT/SIGTERM/SIGHUP in one call), eliminating raw libc::signal +
polling thread. Log ctrlc::set_handler errors instead of discarding.
Merge orphan + dead-group kill batches into single sigterm_then_sigkill
call to halve worst-case shutdown latency.
---
 desktop/scripts/check-file-sizes.mjs          |  4 +-
 desktop/src-tauri/Cargo.toml                  |  2 +-
 desktop/src-tauri/src/lib.rs                  | 52 ++++---------------
 .../src-tauri/src/managed_agents/runtime.rs   | 31 +++++------
 4 files changed, 27 insertions(+), 62 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 8eeedcc195..c019fdc6f4 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -52,7 +52,7 @@ const overrides = new Map([
   ["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", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
   ["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", 1260], // ... + 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) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux)
+  ["src-tauri/src/managed_agents/runtime.rs", 1200], // ... + 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) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux)
   ["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)
@@ -79,7 +79,7 @@ const overrides = new Map([
   ["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
   ["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", 800], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
+  ["src-tauri/src/lib.rs", 770], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
   ["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
 ]);
 
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 3733e41e4a..f802a5732f 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -21,7 +21,7 @@ tauri-build = { version = "2", features = [] }
 
 [target.'cfg(unix)'.dependencies]
 libc = "0.2"
-ctrlc = "3"
+ctrlc = { version = "3", features = ["termination"] }
 
 [target.'cfg(windows)'.dependencies]
 windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index f0a39788fa..570cb556f1 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -653,58 +653,26 @@ pub fn run() {
 
     let shutdown_done = Arc::new(AtomicBool::new(false));
 
-    // Register signal handlers so agent cleanup runs even when the process is
-    // killed by SIGINT (Ctrl+C), SIGTERM, or SIGHUP (terminal close) before
-    // Tauri's RunEvent::Exit fires. The `shutdown_done` guard prevents
-    // double-execution with the RunEvent handler.
+    // Agent cleanup on SIGINT (Ctrl+C), SIGTERM, and SIGHUP (terminal close).
+    // The ctrlc crate with the "termination" feature covers all three signals
+    // and runs the handler on a dedicated thread (safe for mutex operations).
+    // `shutdown_done` prevents double-execution with the RunEvent handler.
+    // `process::exit(0)` intentionally skips Drop impls to avoid re-entrant
+    // locking in destructors during signal teardown.
     #[cfg(unix)]
     {
         let signal_app = app.handle().clone();
         let signal_shutdown_done = Arc::clone(&shutdown_done);
         let signal_shutdown_started = Arc::clone(&shutdown_started);
-        // ctrlc handles SIGINT. For SIGTERM and SIGHUP we use raw libc
-        // handlers that set a flag, and a background thread that waits on it.
-        // This avoids pulling in extra crates and works with the ctrlc crate.
-        static SIGNAL_RECEIVED: AtomicBool = AtomicBool::new(false);
-
-        extern "C" fn signal_flag_handler(_sig: libc::c_int) {
-            SIGNAL_RECEIVED.store(true, Ordering::SeqCst);
-        }
-
-        unsafe {
-            libc::signal(
-                libc::SIGTERM,
-                signal_flag_handler as *const () as libc::sighandler_t,
-            );
-            libc::signal(
-                libc::SIGHUP,
-                signal_flag_handler as *const () as libc::sighandler_t,
-            );
-        }
-
-        // Background thread polls for SIGTERM/SIGHUP and runs cleanup.
-        let term_app = signal_app.clone();
-        let term_done = Arc::clone(&signal_shutdown_done);
-        let term_started = Arc::clone(&signal_shutdown_started);
-        std::thread::spawn(move || {
-            while !SIGNAL_RECEIVED.load(Ordering::SeqCst) {
-                std::thread::sleep(std::time::Duration::from_millis(100));
-            }
-            term_started.store(true, Ordering::SeqCst);
-            if !term_done.swap(true, Ordering::SeqCst) {
-                let _ = shutdown_managed_agents(&term_app);
-            }
-            std::process::exit(0);
-        });
-
-        // SIGINT (Ctrl+C) via ctrlc crate — runs handler on a dedicated thread.
-        let _ = ctrlc::set_handler(move || {
+        if let Err(e) = ctrlc::set_handler(move || {
             signal_shutdown_started.store(true, Ordering::SeqCst);
             if !signal_shutdown_done.swap(true, Ordering::SeqCst) {
                 let _ = shutdown_managed_agents(&signal_app);
             }
             std::process::exit(0);
-        });
+        }) {
+            eprintln!("sprout-desktop: failed to register signal handler: {e}");
+        }
     }
 
     let run_shutdown_done = Arc::clone(&shutdown_done);
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index f79008d231..3174137ba6 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -218,27 +218,23 @@ fn sigterm_then_sigkill(pids: &[i32]) {
 #[cfg(unix)]
 pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) {
     let entries = super::read_all_agent_pid_files(app);
-    let orphans: Vec<i32> = entries
+    // Collect live orphans AND dead-leader groups into a single kill batch.
+    // Dead leaders: PGID may have been recycled, but the window is narrow
+    // (PID files are from this session) and the cost of missing surviving
+    // group members outweighs the recycling risk.
+    let targets: Vec<i32> = entries
         .iter()
         .filter(|(_, pid)| {
-            !skip_pids.contains(pid) && process_is_running(*pid) && process_belongs_to_us(*pid)
+            if skip_pids.contains(pid) {
+                return false;
+            }
+            (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid)
         })
         .map(|(_, pid)| *pid as i32)
         .collect();
 
-    if !orphans.is_empty() {
-        sigterm_then_sigkill(&orphans);
-    }
-
-    // For PID-file entries whose leader is dead, still try killing the process
-    // group — child processes (e.g. MCP servers) may have survived the leader.
-    let dead_groups: Vec<i32> = entries
-        .iter()
-        .filter(|(_, pid)| !skip_pids.contains(pid) && !process_is_running(*pid))
-        .map(|(_, pid)| *pid as i32)
-        .collect();
-    if !dead_groups.is_empty() {
-        sigterm_then_sigkill(&dead_groups);
+    if !targets.is_empty() {
+        sigterm_then_sigkill(&targets);
     }
 
     // Clean up PID files for processes we just killed or that are already gone.
@@ -277,10 +273,11 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
 
     #[repr(C)]
     struct BSDInfo {
-        _pad: [u8; 24],
+        _pad: [u8; 20],
         pbi_uid: u32,
-        _rest: [u8; 104],
+        _rest: [u8; 112],
     }
+    const _: () = assert!(std::mem::size_of::<BSDInfo>() == 136);
     const PROC_PIDTBSDINFO: libc::c_int = 3;
 
     let my_uid = unsafe { libc::getuid() };

From 9fc8d926db3939ee957d979f3f99964c27aa0cdc Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Fri, 29 May 2026 14:08:32 -0400
Subject: [PATCH 3/3] fix(desktop): add env marker to prevent killing
 non-Sprout agent processes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The system-wide orphan sweep matched agent binary names + UID but had
no Sprout-specific marker, so independently-launched goose or codex-acp
sessions would be killed on Sprout shutdown. Set SPROUT_MANAGED_AGENT=1
on sprout-acp at spawn time — propagates automatically through the full
tree (sprout-acp → goose → MCP servers) — and verify the marker via
KERN_PROCARGS2 (macOS) or /proc/environ (Linux) before killing.

Also fix sigterm_then_sigkill to check group liveness (kill(-pid, 0))
instead of leader liveness (kill(pid, 0)) so SIGKILL escalation reaches
surviving children when the group leader is already dead.
---
 desktop/scripts/check-file-sizes.mjs          |   2 +-
 .../src-tauri/src/managed_agents/runtime.rs   | 106 +++++++++++++++++-
 2 files changed, 105 insertions(+), 3 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index c019fdc6f4..17a9bda12a 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -52,7 +52,7 @@ const overrides = new Map([
   ["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", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
   ["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", 1200], // ... + 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) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux)
+  ["src-tauri/src/managed_agents/runtime.rs", 1300], // ... + 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) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux)
   ["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)
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 3174137ba6..4d00c0027b 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -118,6 +118,97 @@ pub(crate) fn process_belongs_to_us(_pid: u32) -> bool {
     false
 }
 
+/// Check if a running process has `SPROUT_MANAGED_AGENT=1` in its environment,
+/// distinguishing Sprout-spawned agent trees from independently-launched ones.
+#[cfg(target_os = "macos")]
+fn process_has_sprout_marker(pid: u32) -> bool {
+    const MARKER: &[u8] = b"SPROUT_MANAGED_AGENT=1";
+
+    let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
+    let mut buf_size: libc::size_t = 0;
+
+    // First call: get required buffer size.
+    if unsafe {
+        libc::sysctl(
+            mib.as_mut_ptr(),
+            3,
+            std::ptr::null_mut(),
+            &mut buf_size,
+            std::ptr::null_mut(),
+            0,
+        )
+    } != 0
+    {
+        return false;
+    }
+
+    let mut buf: Vec<u8> = vec![0; buf_size];
+    if unsafe {
+        libc::sysctl(
+            mib.as_mut_ptr(),
+            3,
+            buf.as_mut_ptr() as *mut libc::c_void,
+            &mut buf_size,
+            std::ptr::null_mut(),
+            0,
+        )
+    } != 0
+    {
+        return false;
+    }
+    buf.truncate(buf_size);
+
+    // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...]
+    if buf.len() < std::mem::size_of::<libc::c_int>() {
+        return false;
+    }
+    let mut n_args: libc::c_int = 0;
+    unsafe {
+        std::ptr::copy_nonoverlapping(
+            buf.as_ptr(),
+            &mut n_args as *mut libc::c_int as *mut u8,
+            std::mem::size_of::<libc::c_int>(),
+        );
+    }
+    let mut pos = std::mem::size_of::<libc::c_int>();
+
+    // Skip exec path (scan to first null).
+    while pos < buf.len() && buf[pos] != 0 {
+        pos += 1;
+    }
+    // Skip null padding between exec path and argv[0].
+    while pos < buf.len() && buf[pos] == 0 {
+        pos += 1;
+    }
+    // Skip argc argument strings.
+    let mut args_remaining = n_args;
+    while args_remaining > 0 && pos < buf.len() {
+        while pos < buf.len() && buf[pos] != 0 {
+            pos += 1;
+        }
+        while pos < buf.len() && buf[pos] == 0 {
+            pos += 1;
+        }
+        args_remaining -= 1;
+    }
+    // Remaining bytes are null-delimited environment strings.
+    buf[pos..].split(|&b| b == 0).any(|entry| entry == MARKER)
+}
+
+#[cfg(all(unix, not(target_os = "macos")))]
+fn process_has_sprout_marker(pid: u32) -> bool {
+    let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else {
+        return false;
+    };
+    data.split(|&b| b == 0)
+        .any(|entry| entry == b"SPROUT_MANAGED_AGENT=1")
+}
+
+#[cfg(not(unix))]
+fn process_has_sprout_marker(_pid: u32) -> bool {
+    false
+}
+
 #[cfg(unix)]
 fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> {
     let pgid = -(pid as i32);
@@ -202,7 +293,9 @@ fn sigterm_then_sigkill(pids: &[i32]) {
     std::thread::sleep(std::time::Duration::from_millis(200));
 
     for &pid in pids {
-        if process_is_running(pid as u32) {
+        // Check if the group has any living members, not just the leader.
+        // kill(-pid, 0) returns 0 if ANY member of the group is signalable.
+        if unsafe { libc::kill(-pid, 0) } == 0 {
             unsafe {
                 libc::kill(-pid, libc::SIGKILL);
             }
@@ -333,6 +426,9 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
         if info.pbi_uid != my_uid {
             continue;
         }
+        if !process_has_sprout_marker(upid) {
+            continue;
+        }
         orphans.push(pid);
     }
 
@@ -377,7 +473,7 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
         if meta.uid() != my_uid {
             continue;
         }
-        if process_belongs_to_us(upid) {
+        if process_belongs_to_us(upid) && process_has_sprout_marker(upid) {
             orphans.push(pid);
         }
     }
@@ -886,6 +982,12 @@ pub fn spawn_agent_child(
         command.env(key, value);
     }
 
+    // Mark as Sprout-managed so the system-wide orphan sweep can
+    // distinguish our processes from independently-launched agent binaries.
+    // Propagates automatically through the full tree (sprout-acp → goose →
+    // MCP servers) because neither sprout-acp nor goose calls env_clear().
+    command.env("SPROUT_MANAGED_AGENT", "1");
+
     // Spawn the harness in its own process group so we can kill the entire
     // tree (harness + MCP servers + agent subprocesses) on shutdown.
     #[cfg(unix)]
