agentmux_launcher/
main.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4// AgentMux Launcher — Sets DLL search path then spawns srv + the CEF host.
5//
6// Phase B.1: launcher now spawns srv directly (sibling of host) so srv
7// survives host crashes. Both children are assigned to the launcher's
8// Job Object J0 with KILL_ON_JOB_CLOSE; killing the launcher reaps
9// the entire tree atomically via the OS.
10//
11// This was previously a tiny sync wrapper that just SetDllDirectoryW'd
12// runtime/ then spawned the CEF host. Phase B grew it into the
13// privileged owner per
14// specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md.
15//
16// Process tree after B.1:
17//   launcher (J0)
18//     ├── srv     (assigned to J0; survives host crash)
19//     └── host    (assigned to J0; CEF render workers inherit J0)
20
21#![cfg_attr(
22    all(not(debug_assertions), target_os = "windows"),
23    windows_subsystem = "windows"
24)]
25
26mod config;
27mod data_dir;
28mod diag;
29mod event_log;
30mod hash;
31mod host_pipe;
32mod ipc;
33mod mem_supervisor;
34mod reducer;
35mod saga;
36#[cfg(target_os = "windows")]
37mod splash;
38#[cfg(target_os = "macos")]
39mod splash_mac;
40#[cfg(target_os = "linux")]
41mod splash_linux;
42// Splash footer support. The baked font + software text blitter are only used by
43// the software-buffer backends (Linux, Windows); macOS renders native text.
44#[cfg(any(target_os = "linux", target_os = "windows"))]
45mod splash_font;
46#[cfg(any(target_os = "linux", target_os = "windows"))]
47mod splash_text;
48mod splash_config;
49mod splash_info;
50mod srv_spawner;
51mod state;
52mod wrr;
53
54/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
55/// fault terminates the process immediately instead of wedging it behind a
56/// modal. No-op off Windows. Spec:
57/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
58#[cfg(target_os = "windows")]
59fn suppress_os_crash_dialogs() {
60    use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
61    use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
62    unsafe {
63        // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
64        // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
65        // collection, the postmortem diagnostics this stability work needs.
66        // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
67        // reports" path.
68        let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
69        // SEM_FAILCRITICALERRORS suppresses the critical-error handler
70        // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
71        SetErrorMode(SEM_FAILCRITICALERRORS);
72    }
73}
74
75#[cfg(not(target_os = "windows"))]
76fn suppress_os_crash_dialogs() {}
77
78/// Process entry point. `suppress_os_crash_dialogs()` runs FIRST — before the
79/// Tokio runtime is built. The runtime is built explicitly here (rather than
80/// via `#[tokio::main]`, whose generated wrapper would construct it before any
81/// of our code runs) so a fault during runtime construction can't surface the
82/// Windows crash modal either. Spec:
83/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
84fn main() {
85    suppress_os_crash_dialogs();
86
87    // Dev/demo affordance: `--splash-selftest` shows the splash in isolation
88    // (no srv/host), holds it briefly, then exits — for eyeballing the footer +
89    // layout. See SPEC_SPLASH_USERINFO_AND_DISABLE_2026_06_21.md.
90    if std::env::args().any(|a| a == "--splash-selftest") {
91        splash_selftest();
92        return;
93    }
94
95    // macOS: paint the splash FIRST, on the main thread, before any heavy work
96    // — this is the whole reason the splash lives in the small fast launcher
97    // rather than the slow CEF host. AppKit must own the main thread, so the
98    // srv+host supervisor (`launcher_main`) runs on a worker thread with its
99    // own Tokio runtime; the splash pumps a CoreFoundation runloop on main
100    // until the host signals first paint. See `splash_mac`.
101    #[cfg(target_os = "macos")]
102    {
103        // Splash disabled → no AppKit splash; run the supervisor directly on the
104        // main thread (there's no runloop to pump without a splash window).
105        if splash_config::splash_disabled() {
106            tokio::runtime::Runtime::new()
107                .expect("failed to build Tokio runtime")
108                .block_on(launcher_main());
109            return;
110        }
111        let splash = splash_mac::Splash::show();
112        std::thread::Builder::new()
113            .name("launcher-supervisor".into())
114            .spawn(|| {
115                // Catch panics so a supervisor crash always exits the process
116                // rather than leaving the main-thread AppKit runloop spinning
117                // as an invisible orphan.
118                let result = std::panic::catch_unwind(|| {
119                    tokio::runtime::Runtime::new()
120                        .expect("failed to build Tokio runtime")
121                        .block_on(launcher_main());
122                });
123                if result.is_err() {
124                    eprintln!("AgentMux launcher supervisor panicked — exiting");
125                    std::process::exit(1);
126                }
127                // Supervisor finished cleanly (host exited / fatal).
128                std::process::exit(0);
129            })
130            .expect("failed to spawn launcher supervisor thread");
131        splash.run_until_dismissed(); // pumps the runloop, then parks forever
132        return;
133    }
134
135    #[cfg(not(target_os = "macos"))]
136    {
137        // Linux: paint the splash before any heavy work (mirrors the macOS path,
138        // but on its own thread since neither X11 nor Wayland needs the main
139        // thread). spawn() sets AGENTMUX_SPLASH_READY_FILE so the host — spawned
140        // later inside launcher_main — inherits it and signals first paint.
141        // Windows keeps spawning its splash inside launcher_main (event-name
142        // model). See splash_linux/.
143        #[cfg(target_os = "linux")]
144        if !splash_config::splash_disabled() {
145            splash_linux::spawn();
146        }
147
148        tokio::runtime::Runtime::new()
149            .expect("failed to build Tokio runtime")
150            .block_on(launcher_main());
151    }
152}
153
154/// `--splash-selftest`: show the splash with no srv/host behind it, hold it for a
155/// few seconds (or `AGENTMUX_SPLASH_HOLD_MS`), then exit. A demo/dev affordance
156/// for eyeballing the footer + centering without launching the whole app.
157fn splash_selftest() {
158    let hold = std::env::var("AGENTMUX_SPLASH_HOLD_MS")
159        .ok()
160        .and_then(|s| s.parse::<u64>().ok())
161        .map(|ms| std::time::Duration::from_millis(ms.max(3000)))
162        .unwrap_or_else(|| std::time::Duration::from_secs(6));
163
164    #[cfg(target_os = "linux")]
165    {
166        splash_linux::spawn();
167        std::thread::sleep(hold);
168    }
169    #[cfg(target_os = "macos")]
170    {
171        let splash = splash_mac::Splash::show();
172        if let Ok(p) = std::env::var("AGENTMUX_SPLASH_DUMP_PNG") {
173            splash.dump_png(&p);
174        }
175        let _ = splash; // run_until_dismissed parks; selftest just holds then exits
176        std::thread::sleep(hold);
177    }
178    #[cfg(target_os = "windows")]
179    {
180        let _ = splash::spawn_splash("selftest");
181        std::thread::sleep(hold);
182    }
183}
184
185async fn launcher_main() {
186    let exe_path = std::env::current_exe().expect("cannot resolve exe path");
187    let exe_dir = exe_path.parent().expect("exe has no parent directory");
188    // Production + Windows dev use a `runtime/` subdir (launcher at root,
189    // host + libs + srv under runtime/). The macOS/Linux `task dev` flat
190    // layout (Phase 1, SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30)
191    // drops the launcher next to the host in dist/cef-dev/ so the host's
192    // `../Frameworks` resolution and asset anchoring are byte-identical to
193    // the legacy direct-invoke path — no `runtime/` to descend into. Fall
194    // back to exe_dir when there's no runtime/ subdir. Windows always has
195    // one, so its behavior is unchanged.
196    let runtime_dir = {
197        let rt = exe_dir.join("runtime");
198        if rt.is_dir() {
199            rt
200        } else {
201            exe_dir.to_path_buf()
202        }
203    };
204
205    log(&format!(
206        "starting — exe={} runtime={}",
207        exe_path.display(),
208        runtime_dir.display()
209    ));
210
211    // Set DLL search path so libcef.dll (in runtime/) is found by the
212    // CEF host's load-time linker. SetDllDirectoryW is process-local
213    // and inherited by child processes — both srv (which doesn't
214    // need libcef but harmless) and host (which absolutely does).
215    #[cfg(target_os = "windows")]
216    {
217        use std::os::windows::ffi::OsStrExt;
218        let wide: Vec<u16> = runtime_dir
219            .as_os_str()
220            .encode_wide()
221            .chain(Some(0))
222            .collect();
223        unsafe {
224            windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
225        }
226    }
227    log("SetDllDirectoryW done");
228
229    let args: Vec<String> = std::env::args().skip(1).collect();
230
231    // LSD-3 — `agentmux.exe --diag sagas` is OFFLINE: it reads the
232    // launcher saga SQLite log directly, with no IPC and no running
233    // launcher. So it MUST run BEFORE the CEF runtime existence
234    // check below — the offline-diagnostic value is most needed
235    // exactly when the launcher won't start (e.g. corrupt runtime
236    // folder). (codex P1 + reagent P1 PR #647 round 3.)
237    if matches!(
238        (args.first().map(String::as_str), args.get(1).map(String::as_str)),
239        (Some("--diag"), Some("sagas"))
240    ) {
241        match diag::run_sagas_diag(exe_dir).await {
242            Ok(()) => std::process::exit(0),
243            Err(msg) => {
244                eprintln!("--diag sagas failed: {}", msg);
245                std::process::exit(1);
246            }
247        }
248    }
249
250    let real_exe = find_cef_binary(&runtime_dir);
251    log(&format!("resolved CEF binary: {}", real_exe.display()));
252    // Self-spawn guard: if host resolution ever points back at the
253    // launcher's own binary (the flat dev layout's failure mode —
254    // launcher + host co-located), spawning it would recurse into an
255    // unbounded launcher fork bomb. find_cef_binary excludes
256    // `agentmux-launcher` by name; this is the loud backstop in case a
257    // future binary slips past that filter. Compare canonicalized paths
258    // so symlink/`./` differences don't defeat the check.
259    if let (Ok(a), Ok(b)) = (
260        std::fs::canonicalize(&real_exe),
261        std::fs::canonicalize(&exe_path),
262    ) {
263        if a == b {
264            log(&format!(
265                "FATAL: host resolved to the launcher's own binary ({}) — refusing to self-spawn",
266                a.display()
267            ));
268            eprintln!("AgentMux runtime is misconfigured (host == launcher). Aborting.");
269            std::process::exit(1);
270        }
271    }
272    if !real_exe.exists() {
273        log(&format!(
274            "FATAL: CEF binary not found at {}",
275            real_exe.display()
276        ));
277        eprintln!(
278            "AgentMux runtime not found in: {}\nMake sure the runtime/ folder is intact.",
279            runtime_dir.display()
280        );
281        std::process::exit(1);
282    }
283
284    log(&format!("forwarding {} CLI args to host", args.len()));
285
286    // Phase B.8 — `agentmux.exe --diag wrr` and `--diag srv` Tool
287    // clients. Connect to the running launcher (or srv) over IPC,
288    // capture events for a short window, print summary, exit.
289    // (Note: --diag sagas is handled above, before the CEF runtime
290    // check, since it doesn't need IPC.)
291    if matches!(args.first().map(String::as_str), Some("--diag")) {
292        let topic = args.get(1).map(String::as_str).unwrap_or("");
293        match topic {
294            "wrr" => match diag::run_wrr_diag(exe_dir).await {
295                Ok(()) => std::process::exit(0),
296                Err(msg) => {
297                    eprintln!("--diag wrr failed: {}", msg);
298                    std::process::exit(1);
299                }
300            },
301            // Phase E.7 — operator visibility into the srv reducer's
302            // canonical state (workspaces / tabs / blocks / sagas) +
303            // recent activity. Same `Tool` IPC pattern as `--diag wrr`,
304            // talks to the srv pipe instead of the launcher pipe.
305            "srv" => match diag::run_srv_diag(exe_dir).await {
306                Ok(()) => std::process::exit(0),
307                Err(msg) => {
308                    eprintln!("--diag srv failed: {}", msg);
309                    std::process::exit(1);
310                }
311            },
312            // sagas is handled above, before the runtime check.
313            "sagas" => {
314                // Should never reach here — `sagas` is matched + handled
315                // above the CEF runtime check. Kept for completeness.
316                unreachable!("--diag sagas is handled before runtime check");
317            }
318            "" => {
319                eprintln!("usage: agentmux.exe --diag <topic>\nknown topics: wrr, srv, sagas");
320                std::process::exit(2);
321            }
322            other => {
323                eprintln!("unknown --diag topic: {} (known: wrr, srv, sagas)", other);
324                std::process::exit(2);
325            }
326        }
327    }
328
329    #[cfg(target_os = "windows")]
330    {
331        run_windows(exe_dir, &real_exe, &args).await;
332    }
333
334    #[cfg(not(target_os = "windows"))]
335    {
336        // Phase 1 (SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30):
337        // the launcher now owns srv + host on macOS/Linux too — it
338        // spawns the backend, hands the host its endpoints via env,
339        // and supervises both with the same crash budget Windows uses.
340        // The legacy exec-into-host escape hatch lives in
341        // `task dev:standalone` (host invoked directly, no launcher).
342        run_unix(exe_dir, &real_exe, &args).await;
343    }
344}
345
346/// Phase 1 host supervision (spec
347/// `docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md`): on an
348/// abnormal host exit the launcher relaunches the host, but at most
349/// `HOST_RESTART_BUDGET` times within `HOST_RESTART_WINDOW` — a crash budget
350/// so a deterministic crash cannot spin forever (spec §10-A). Shared by
351/// the Windows (`run_windows`) and Unix (`run_unix`) supervisors.
352const HOST_RESTART_BUDGET: usize = 3;
353const HOST_RESTART_WINDOW: std::time::Duration = std::time::Duration::from_secs(60);
354
355/// Spawn the CEF host suspended, assign it to the launcher's Job Object, and
356/// resume it. Returns the running child, or `None` if any step failed — the
357/// caller decides (fatal on first launch, give-up on a restart). `splash_event`
358/// is passed on every launch — including restarts — so a relaunched host can
359/// still dismiss a splash left pending by a host that crashed pre-first-frame.
360/// `disable_gpu` is the retry ladder's rung-2 degraded mode (spec §7): when set
361/// the host is launched with `--disable-gpu` (software rendering).
362#[cfg(target_os = "windows")]
363fn spawn_host_supervised(
364    real_exe: &std::path::Path,
365    args: &[String],
366    srv: &srv_spawner::SrvSpawnResult,
367    host_env: &[(&'static str, std::ffi::OsString)],
368    pipe_path: &str,
369    job_present: bool,
370    job_handle: windows_sys::Win32::Foundation::HANDLE,
371    splash_event: Option<&str>,
372    disable_gpu: bool,
373) -> Option<tokio::process::Child> {
374    use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
375
376    // The launcher's resolved `real_exe` lives in `runtime/`; pass its
377    // parent dir as AGENTMUX_HOME so the host can anchor asset lookups
378    // (frontend/index.html, etc.) on something stable rather than
379    // `std::env::current_exe()`. Windows's GetModuleFileName keeps
380    // returning the original load-time path even after the parent dir
381    // is renamed or unlinked out from under it — the 2026-05-28
382    // incident pattern. AGENTMUX_HOME is whatever path *we* (the
383    // launcher) successfully resolved real_exe through, so it always
384    // points at the runtime dir that actually contains the binaries.
385    // See docs/retro/retro-portable-rm-running-install-2026-05-28.md.
386    let host_runtime_dir = real_exe.parent().map(|p| p.to_path_buf());
387
388    let mut host_cmd = tokio::process::Command::new(real_exe);
389    host_cmd
390        .args(args)
391        .env("AGENTMUX_BACKEND_WS", &srv.ws_endpoint)
392        .env("AGENTMUX_BACKEND_WEB", &srv.web_endpoint)
393        .env("AGENTMUX_BACKEND_PID", srv.pid.to_string())
394        .env("AGENTMUX_AUTH_KEY", &srv.auth_key)
395        .env("AGENTMUX_INSTANCE_ID", &srv.instance_id)
396        .envs(host_env.iter().cloned())
397        .env("AGENTMUX_LAUNCHER_PIPE", pipe_path)
398        .creation_flags(CREATE_SUSPENDED)
399        .kill_on_drop(false); // J0 handles cleanup.
400    if let Some(dir) = host_runtime_dir {
401        host_cmd.env("AGENTMUX_HOME", dir);
402    }
403    if let Some(name) = splash_event {
404        host_cmd.env("AGENTMUX_SPLASH_EVENT", name);
405    }
406    // Retry-ladder rung 2 (spec §7): software rendering — no GPU process to
407    // crash. A Chromium switch the host forwards to CEF.
408    if disable_gpu {
409        host_cmd.arg("--disable-gpu");
410    }
411
412    let mut host_child = match host_cmd.spawn() {
413        Ok(c) => c,
414        Err(e) => {
415            log(&format!("failed to spawn CEF host: {}", e));
416            return None;
417        }
418    };
419    let host_pid = host_child.id().unwrap_or(0);
420    log(&format!("spawned CEF host pid={} (suspended)", host_pid));
421
422    // Assign to J0 BEFORE resuming so CEF render children inherit the job.
423    if job_present && host_pid != 0 {
424        match srv_spawner::assign_pid_to_job(host_pid, job_handle) {
425            Ok(()) => log(&format!(
426                "Job Object assigned to host pid={}, KILL_ON_JOB_CLOSE active",
427                host_pid
428            )),
429            Err(e) => log(&format!(
430                "WARN: AssignProcessToJobObject(host) failed: {} — host children may escape job",
431                e
432            )),
433        }
434    }
435
436    // Resume the suspended main thread.
437    if let Err(e) = resume_main_thread(host_pid) {
438        log(&format!("failed to resume host pid={}: {}", host_pid, e));
439        let _ = host_child.start_kill();
440        return None;
441    }
442    Some(host_child)
443}
444
445/// Spawn the CEF host on Unix with the srv endpoints + canonical
446/// data-dir env handed off, mirroring `spawn_host_supervised` minus the
447/// Windows-only machinery (Job Object assignment, CREATE_SUSPENDED /
448/// resume, named-pipe handle, splash event). `disable_gpu` is the retry
449/// ladder's degraded rung (software rendering). Returns the running
450/// child or `None` if spawn failed.
451///
452/// AGENTMUX_HOME is intentionally NOT set: on the flat dev layout the
453/// host's `current_exe().parent()` fallback resolves to the same dir the
454/// launcher would export, so omitting it keeps asset + framework lookup
455/// byte-identical to the legacy direct-invoke (`task dev:standalone`)
456/// path. Phase 2 will set it once the production runtime/ layout lands
457/// on macOS.
458#[cfg(not(target_os = "windows"))]
459fn spawn_host_unix(
460    real_exe: &std::path::Path,
461    args: &[String],
462    srv: &srv_spawner::SrvSpawnResult,
463    host_env: &[(&'static str, std::ffi::OsString)],
464    disable_gpu: bool,
465) -> Option<tokio::process::Child> {
466    let mut host_cmd = tokio::process::Command::new(real_exe);
467    host_cmd
468        .args(args)
469        .env("AGENTMUX_BACKEND_WS", &srv.ws_endpoint)
470        .env("AGENTMUX_BACKEND_WEB", &srv.web_endpoint)
471        .env("AGENTMUX_BACKEND_PID", srv.pid.to_string())
472        .env("AGENTMUX_AUTH_KEY", &srv.auth_key)
473        .env("AGENTMUX_INSTANCE_ID", &srv.instance_id)
474        // Parent-identity stamp: our pid == the host's getppid (we spawn it
475        // directly). A dev-build host normally ignores AGENTMUX_BACKEND_WS
476        // (it could be a stale value inherited from a parent agentmux pane);
477        // this lets the host verify the hand-off is genuinely ours THIS run
478        // and adopt our launcher-owned srv instead of double-spawning. See
479        // agentmux-cef/src/main.rs::launcher_is_genuine_parent.
480        .env("AGENTMUX_LAUNCHER_PID", std::process::id().to_string())
481        .envs(host_env.iter().cloned())
482        // We reap children ourselves on shutdown (SIGTERM, then SIGKILL
483        // backstop) — kill_on_drop would SIGKILL the host the moment the
484        // Child is dropped, robbing CEF of the chance to reap its render
485        // subprocesses cleanly.
486        .kill_on_drop(false);
487    if disable_gpu {
488        host_cmd.arg("--disable-gpu");
489    }
490    // Linux process-tree reap: PR_SET_PDEATHSIG asks the kernel to SIGKILL
491    // this child the moment its parent (the launcher) dies, even abnormally
492    // (e.g. launcher panic, OOM, SIGKILL from outside). This is the Linux
493    // analogue of Windows' Job Object KILL_ON_JOB_CLOSE that A0 wires up
494    // (SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05 §3 step 3 and
495    // §A1.4). Without it, a launcher crash orphans the CEF host onto PID 1
496    // and the user is left with a zombie tree until manual cleanup.
497    //
498    // The closure runs between fork() and execve() in the child; per
499    // POSIX it MUST NOT allocate, take locks, or call async-signal-unsafe
500    // functions. `prctl(2)` is async-signal-safe. macOS lacks prctl, so
501    // this is gated to Linux; macOS uses NSTask's auto-reap behavior.
502    #[cfg(target_os = "linux")]
503    {
504        use std::os::unix::process::CommandExt as _;
505        // Safety: prctl is async-signal-safe. No allocation, no syscalls
506        // beyond prctl itself. Errors from prctl are non-fatal — the host
507        // still spawns; we just lose the auto-reap guarantee.
508        unsafe {
509            host_cmd.pre_exec(|| {
510                libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0);
511                Ok(())
512            });
513        }
514    }
515    match host_cmd.spawn() {
516        Ok(c) => {
517            let pid = c.id().unwrap_or(0);
518            log(&format!("spawned CEF host pid={} (unix)", pid));
519            Some(c)
520        }
521        Err(e) => {
522            log(&format!("failed to spawn CEF host: {}", e));
523            None
524        }
525    }
526}
527
528/// Send SIGTERM to a child so it can shut down gracefully — for the CEF
529/// host that means reaping its render subprocesses (a tokio `start_kill`
530/// SIGKILL would orphan them); for srv it's a clean shutdown. No-op if the
531/// child has already exited (its pid may have been reaped). Best-effort:
532/// the SIGKILL grace-window backstop in `run_unix` catches anything that
533/// ignores SIGTERM.
534#[cfg(not(target_os = "windows"))]
535fn terminate_child_gracefully(child: &tokio::process::Child) {
536    if let Some(pid) = child.id() {
537        // SAFETY: kill(2) with a process-scoped pid + a constant signal —
538        // no memory is touched. A stale pid just returns ESRCH (ignored).
539        unsafe {
540            libc::kill(pid as libc::pid_t, libc::SIGTERM);
541        }
542    }
543}
544
545/// Await the next delivery of a Unix signal, or never resolve if the
546/// signal stream couldn't be installed. Lets `run_unix`'s `select!`
547/// treat an absent handler as "this branch is dormant" rather than
548/// special-casing `Option` at every poll.
549#[cfg(not(target_os = "windows"))]
550async fn next_signal(s: &mut Option<tokio::signal::unix::Signal>) {
551    match s {
552        Some(sig) => {
553            sig.recv().await;
554        }
555        None => std::future::pending::<()>().await,
556    }
557}
558
559/// Bind the launcher's IPC socket with single-instance enforcement +
560/// crash-safe stale-socket recovery, serialized across concurrent
561/// launchers via `flock(2)`.
562///
563/// Returns a bound `UnixListener` on success. Calls `std::process::exit`
564/// on:
565///   * second-instance detection (exit code 0)
566///   * a hard bind failure that isn't `EADDRINUSE` (exit code 2)
567///   * unable to acquire the recovery lock (exit code 2)
568///
569/// Why the lockfile (codex P1 + reagent P1 on PR #1288): see the call-
570/// site comment. Two-launcher concurrent stale-cleanup would otherwise
571/// produce two live launchers for one data dir.
572#[cfg(not(target_os = "windows"))]
573fn bind_socket_with_recovery(
574    socket_path: &str,
575    data_dir: &std::path::Path,
576    dir_hash: &str,
577) -> tokio::net::UnixListener {
578    use std::os::unix::io::AsRawFd as _;
579
580    // Fast path: bind without contention.
581    match ipc::server::bind_first_unix_socket(socket_path) {
582        Ok(l) => return l,
583        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { /* slow path below */ }
584        Err(e) => {
585            log(&format!("FATAL: bind {} failed: {}", socket_path, e));
586            eprintln!(
587                "AgentMux failed to start: could not bind IPC socket.\n\nSocket: {}\nError: {}",
588                socket_path, e
589            );
590            std::process::exit(2);
591        }
592    }
593
594    // Slow path: contention. Acquire the recovery lock so only one
595    // launcher at a time does the connect-probe + unlink + rebind.
596    let lock_path = format!("{}.lock", socket_path);
597    let lock_file = match std::fs::OpenOptions::new()
598        .create(true)
599        .read(true)
600        .write(true)
601        .truncate(false)
602        .open(&lock_path)
603    {
604        Ok(f) => f,
605        Err(e) => {
606            log(&format!(
607                "FATAL: could not open recovery lockfile {}: {}",
608                lock_path, e
609            ));
610            eprintln!(
611                "AgentMux failed to start: could not open IPC recovery lockfile.\n\nLockfile: {}\nError: {}",
612                lock_path, e
613            );
614            std::process::exit(2);
615        }
616    };
617    // Block until we have the lock — another launcher's recovery
618    // window is bounded by a single bind + a single connect-probe;
619    // we won't wait long. flock(2) is auto-released on close (when
620    // the OS reaps the launcher process), so a SIGKILL'd holder
621    // doesn't leak the lock.
622    if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 {
623        let errno = std::io::Error::last_os_error();
624        log(&format!(
625            "FATAL: flock({}, LOCK_EX) failed: {}",
626            lock_path, errno
627        ));
628        eprintln!(
629            "AgentMux failed to start: could not acquire IPC recovery lock.\n\nLockfile: {}\nError: {}",
630            lock_path, errno
631        );
632        std::process::exit(2);
633    }
634
635    // Retry the bind under the lock — another launcher may have
636    // already cleaned up the stale file while we were waiting on
637    // flock, leaving us free to bind directly.
638    match ipc::server::bind_first_unix_socket(socket_path) {
639        Ok(l) => return l,
640        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { /* probe below */ }
641        Err(e) => {
642            log(&format!(
643                "FATAL: post-lock bind {} failed: {}",
644                socket_path, e
645            ));
646            eprintln!(
647                "AgentMux failed to start: post-lock IPC bind failed.\n\nSocket: {}\nError: {}",
648                socket_path, e
649            );
650            std::process::exit(2);
651        }
652    }
653
654    // Disambiguate: is the existing socket a real running launcher,
655    // or a stale file?
656    match std::os::unix::net::UnixStream::connect(socket_path) {
657        Ok(_) => {
658            // Real second-instance. Forward an `open_new_window` request to the
659            // already-running launcher's host (Windows-parity — main.rs:1292),
660            // then exit cleanly. SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
661            eprintln!(
662                "AgentMux is already running for this data directory.\n\nSocket: {}",
663                socket_path
664            );
665            forward_open_new_window_or_log(data_dir, dir_hash);
666            log(&format!(
667                "[ipc] second-instance detected — existing launcher owns {}",
668                socket_path
669            ));
670            std::process::exit(0);
671        }
672        Err(connect_err)
673            if connect_err.kind() == std::io::ErrorKind::ConnectionRefused
674                || connect_err.raw_os_error() == Some(libc::ENOENT) =>
675        {
676            // Stale socket file from a crashed launcher. Unlink and
677            // rebind. The lock serializes us against other launchers
678            // ALSO doing recovery, but it does NOT block a fresh
679            // launcher taking the fast-path bind() above — that
680            // launcher is lock-free and can win the socket in the
681            // microsecond window between our `remove_file` and our
682            // `bind`. If that happens, AddrInUse means a real
683            // launcher just claimed the socket and WE are now the
684            // losing second instance, not a failed start.
685            // (Reagent P2 on PR #1288.)
686            log(&format!(
687                "[ipc] stale socket file at {} — unlinking and rebinding (under recovery lock)",
688                socket_path
689            ));
690            let _ = std::fs::remove_file(socket_path);
691            match ipc::server::bind_first_unix_socket(socket_path) {
692                Ok(l) => l,
693                Err(retry_e) if retry_e.kind() == std::io::ErrorKind::AddrInUse => {
694                    eprintln!(
695                        "AgentMux is already running for this data directory.\n\nSocket: {}",
696                        socket_path
697                    );
698                    forward_open_new_window_or_log(data_dir, dir_hash);
699                    log(&format!(
700                        "[ipc] post-recovery bind lost the race to a fresh launcher on {} — exiting as second instance",
701                        socket_path
702                    ));
703                    std::process::exit(0);
704                }
705                Err(retry_e) => {
706                    log(&format!(
707                        "FATAL: bind retry after stale-socket unlink failed: {}",
708                        retry_e
709                    ));
710                    eprintln!(
711                        "AgentMux failed to start: IPC rebind after stale cleanup failed.\n\nSocket: {}\nError: {}",
712                        socket_path, retry_e
713                    );
714                    std::process::exit(2);
715                }
716            }
717        }
718        Err(other) => {
719            log(&format!(
720                "[ipc] AddrInUse but connect probe failed in an unexpected way: {} — \
721                 treating as second instance and exiting cleanly",
722                other
723            ));
724            std::process::exit(0);
725        }
726    }
727    // `lock_file` drops here; flock auto-released on close.
728}
729
730/// Unix (macOS/Linux) main flow: resolve paths → bind launcher IPC
731/// socket (single-instance signal) → set up reducer / event log / saga
732/// coordinator / IPC server → spawn srv → spawn host with srv endpoints
733/// AND the launcher socket path in env → supervised wait → cleanup.
734///
735/// As of A1 (SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.md §4)
736/// the launcher's window/pool/instance reducer + durable saga
737/// coordinator are now live on Linux. The 17 host-side `report_*` IPC
738/// calls reach the reducer; the saga log persists at
739/// `<data-dir>/db/launcher-sagas.db`; second-instance launches are
740/// detected via socket-bind contention.
741///
742/// Differs from `run_windows` only where the OS forces it:
743///   * No Job Object — A0 (`PR_SET_PDEATHSIG` in `spawn_host_unix` and
744///     `srv_spawner`) gives the equivalent kernel-side reap when the
745///     launcher dies abnormally. Terminal Ctrl+C reaches the process
746///     group; explicit SIGINT/SIGTERM handlers cover the
747///     `kill <launcher-pid>` case.
748///   * Unix-domain-socket IPC (A1) instead of named pipes; protocol on
749///     the wire is identical (newline-delimited JSON Command/Event).
750///     The launcher-side server uses `tokio::net::UnixListener`; the
751///     host-side client uses `tokio::net::UnixStream`. See
752///     `ipc::server::run_ipc_server` (Unix arm) and
753///     `agentmux-cef/src/launcher_ipc.rs::connect_to_launcher` (Unix arm).
754///   * srv-side IPC is still skipped on Linux (srv is launched with an
755///     empty `srv_pipe_path`); follow-up PR will bring srv's Unix
756///     socket online too.
757///   * Cleanup is SIGTERM-then-SIGKILL (we own the reap) rather than
758///     KILL_ON_JOB_CLOSE.
759///
760/// srv's stdin write-end is held for the launcher's lifetime — its EOF is
761/// srv's parent-death backstop if the launcher is SIGKILLed (the one case
762/// our signal handlers can't cover).
763#[cfg(not(target_os = "windows"))]
764async fn run_unix(
765    launcher_exe_dir: &std::path::Path,
766    real_exe: &std::path::Path,
767    args: &[String],
768) {
769    use tokio::signal::unix::{signal, SignalKind};
770
771    let version = env!("CARGO_PKG_VERSION");
772
773    // 1. Resolve + create data dirs (same authority as run_windows: srv +
774    //    host receive these via env so they can't drift).
775    let paths = match data_dir::resolve_paths(launcher_exe_dir, version) {
776        Ok(p) => p,
777        Err(e) => {
778            log(&format!("FATAL: path resolution failed: {}", e));
779            eprintln!("Failed to resolve AgentMux data directories: {}", e);
780            std::process::exit(1);
781        }
782    };
783    log(&format!(
784        "paths resolved: data={} config={} user_home={} portable={}",
785        paths.data_dir.display(),
786        paths.config_dir.display(),
787        paths.user_home_dir.display(),
788        paths.portable_root.is_some(),
789    ));
790    if let Err(e) = data_dir::ensure_dirs(&paths) {
791        log(&format!("FATAL: failed to create data dirs: {}", e));
792        eprintln!("{}", e);
793        std::process::exit(1);
794    }
795
796    // -----------------------------------------------------------------
797    // A1.1 — IPC server setup on Linux (the wire is up). Mirrors the
798    // Windows path at the equivalent point in `run_windows`. Once this
799    // lands the host's 17 `report_*` IPC calls reach the (already
800    // platform-neutral) reducer, the window-pool / single-instance /
801    // instance-numbering logic activates, sagas get persisted to the
802    // launcher_saga.db SQLite log, and `--diag sagas` reads something
803    // useful.
804    //
805    // Spec: docs/specs/SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.md §4
806    // -----------------------------------------------------------------
807
808    // Compute the socket path from a data-dir hash + version, identical
809    // scoping rule to the Windows pipe namespace. `pipe_name` is now
810    // pure on both platforms — the side-effecting ensure step lives
811    // in `ensure_ipc_socket_dir` and is invoked separately below so
812    // that any future read-only inspector (e.g. a Linux `--diag`
813    // port) can call `pipe_name` without mutating the filesystem.
814    let pipe_version = option_env!("AGENTMUX_BUILD_LABEL")
815        .unwrap_or(env!("CARGO_PKG_VERSION"));
816    let dir_hash = hash::data_dir_hash16(&paths.data_dir, pipe_version);
817    let socket_path = ipc::pipe_name(&dir_hash);
818    log(&format!(
819        "launcher IPC socket = {} (data_dir={} pipe_version={})",
820        socket_path,
821        paths.data_dir.display(),
822        pipe_version
823    ));
824    // Ensure the socket dir exists with safe ownership/perms BEFORE
825    // any bind attempts. This may call std::process::exit(2) on a
826    // hostile-dir-on-disk scenario (cross-user squatting attack).
827    let _ = ipc::ensure_ipc_socket_dir();
828
829    // Single-instance handshake (A1.6). The bind is the authoritative
830    // signal: a second launcher pointing at the same data dir gets
831    // `EADDRINUSE`. The Windows path enjoys atomic `first_pipe_
832    // instance(true)`; Unix bind isn't atomic with respect to stale-
833    // file recovery, so we serialize that recovery via an `flock(2)`
834    // lockfile (codex P1 + reagent P1 on PR #1288).
835    //
836    // Concurrent-cleanup race the lockfile defends against:
837    //   1. Stale socket file remains from a crashed prior launcher.
838    //   2. Launcher A and B start concurrently. Both `bind` returns
839    //      EADDRINUSE because the file exists.
840    //   3. Without the lock: both A and B `connect` → ECONNREFUSED
841    //      (no listener on the stale file), both `unlink`, both
842    //      `bind` succeeds → two live launchers for one data dir,
843    //      and B's `unlink` may have removed A's freshly-bound
844    //      socket file before B bound its own.
845    //   4. With the lock: A holds the lock → does probe + unlink +
846    //      rebind atomically. B blocks on the lock. When A releases,
847    //      B's retry-bind sees A's running listener (file exists) and
848    //      B's `connect` succeeds → B exits cleanly as second
849    //      instance. No double-recovery.
850    //
851    // The lockfile lives next to the socket as `<socket>.lock`. It is
852    // intentionally NOT removed on clean shutdown — the inode is
853    // tiny, and leaving it lets the next launcher reuse it without a
854    // create-race. (Stale-lock-file scenarios are bounded: flock(2)
855    // is auto-released on process exit even for SIGKILL.)
856    let first_socket = bind_socket_with_recovery(&socket_path, &paths.data_dir, &dir_hash);
857
858    // macOS: we are the first instance (second-instance launches exit inside
859    // bind_socket_with_recovery). Publish this instance's bound-socket identity
860    // to the splash's reopen handler so a Finder double-click / `open` (no -n)
861    // forwards open_new_window to exactly THIS (channel, version) instance —
862    // never a recomputed hash. SPEC_MACOS_REOPEN_NEW_WINDOW_2026_06_22.md.
863    #[cfg(target_os = "macos")]
864    splash_mac::set_reopen_target(paths.data_dir.clone(), dir_hash.clone());
865
866    // Broadcast bus for reducer-emitted events. Same capacity (1024)
867    // and rationale as the Windows path.
868    let (events_tx, _) =
869        tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(1024);
870
871    // Event log: in-memory ring + disk persistence at
872    // <data-dir>/launcher-events.log. Disk writer task spawned next.
873    let log_disk_path = paths.data_dir.join("launcher-events.log");
874    let event_log = std::sync::Arc::new(event_log::EventLog::new(Some(log_disk_path)));
875    let event_log_for_writer = std::sync::Arc::clone(&event_log);
876    let disk_writer_rx = events_tx.subscribe();
877    tokio::spawn(event_log::run_disk_writer(
878        event_log_for_writer,
879        disk_writer_rx,
880    ));
881
882    // Canonical state shared between IPC server + saga coordinator.
883    let state = std::sync::Arc::new(tokio::sync::Mutex::new(state::State::default()));
884
885    // Durable saga log at <data-dir>/db/launcher-sagas.db.
886    let saga_log_path = data_dir::launcher_saga_log_path(&paths.data_dir);
887    let saga_log = match saga::LauncherSagaLog::open(&saga_log_path) {
888        Ok(l) => std::sync::Arc::new(l),
889        Err(e) => {
890            log(&format!(
891                "FATAL: failed to open launcher saga log at {:?}: {}",
892                saga_log_path, e
893            ));
894            std::process::exit(2);
895        }
896    };
897
898    // Startup recovery walker: mark any saga left running from a prior
899    // crashed run as failed_compensation. Must run BEFORE coordinator
900    // spawn (LSD-3).
901    if let Err(e) = saga::compensate_unresolved_launcher_sagas(&saga_log).await {
902        log(&format!(
903            "[saga-recovery] WARN: walker failed: {} — coordinator will still spawn",
904            e
905        ));
906    }
907
908    // Startup retention vacuum (LSD-4).
909    let retention_days = config::load_saga_retention_days(&paths.user_home_dir, |w| log(w));
910    let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days);
911    match saga_log.vacuum_older_than(cutoff) {
912        Ok(removed) => log(&format!(
913            "[saga-log] vacuumed {} sagas older than {} (retention {} days)",
914            removed, cutoff, retention_days
915        )),
916        Err(e) => log(&format!("[saga-log] WARN: vacuum failed: {}", e)),
917    }
918
919    // Host pipe wrapper for saga-issued Commands → host. The IPC
920    // server's per-connection handler installs the host's writer once
921    // the host registers (see ipc/server.rs handle_connection's
922    // ClientKind::Host branch).
923    let host_pipe = std::sync::Arc::new(host_pipe::HostPipe::new(
924        events_tx.clone(),
925        std::sync::Arc::clone(&state),
926    ));
927
928    // Saga coordinator. Same construction + error handling as the
929    // Windows path; the coordinator itself is platform-neutral.
930    let saga_coord_inner =
931        saga::SagaCoordinator::new(events_tx.clone(), std::sync::Arc::clone(&state))
932            .with_log(std::sync::Arc::clone(&saga_log))
933            .unwrap_or_else(|e| {
934                log(&format!(
935                    "[main] FATAL: failed to seed saga_id allocator: {}",
936                    e
937                ));
938                std::process::exit(1);
939            })
940            .with_host_pipe(std::sync::Arc::clone(&host_pipe));
941    let saga_coord = std::sync::Arc::new(saga_coord_inner);
942    let saga_rx = events_tx.subscribe();
943    tokio::spawn(saga::run_coordinator(
944        std::sync::Arc::clone(&saga_coord),
945        saga_rx,
946    ));
947
948    let _ipc_handle = ipc::run_ipc_server(
949        socket_path.clone(),
950        first_socket,
951        ipc::server::ServerCtx {
952            launcher_pid: std::process::id(),
953            launcher_version: env!("CARGO_PKG_VERSION").to_string(),
954            state,
955            events_tx,
956            event_log,
957            host_pipe: std::sync::Arc::clone(&host_pipe),
958        },
959    );
960    log(&format!("IPC server started on {}", socket_path));
961
962    // 2. Spawn srv. The srv pipe path is the launcher-owned socket
963    //    path scope (srv will gain its own Unix-socket bind in a
964    //    follow-up; for now we still pass an empty string so srv's
965    //    Windows-only IPC code stays disabled).
966    let srv_pipe_path = String::new();
967    let (srv_result, mut srv_child) =
968        match srv_spawner::spawn_srv(launcher_exe_dir, &paths, &srv_pipe_path).await {
969            Ok(pair) => pair,
970            Err(e) => {
971                log(&format!("FATAL: srv spawn failed: {}", e));
972                eprintln!("Failed to start backend: {}", e);
973                std::process::exit(1);
974            }
975        };
976
977    // CRITICAL (same rationale as run_windows): take srv's stdin out of
978    // the Child so tokio's wait() can't close it and trip srv's
979    // parent-watch EOF. Held until launcher exit.
980    let _srv_stdin_keepalive = srv_child.stdin.take();
981
982    // 3. Spawn the host with srv endpoints in env.
983    // dir_hash was computed once above for socket_path; reuse it here
984    // instead of re-hashing the same paths.data_dir + version.
985    let mut host_env = paths.common.to_env_vars();
986    host_env.push(("AGENTMUX_IPC_HASH", std::ffi::OsString::from(&dir_hash)));
987    // A1.3 — IPC env handshake. Tell the host where to find the
988    // launcher socket. The env var name `AGENTMUX_LAUNCHER_PIPE` is
989    // reused from the Windows side even though the underlying resource
990    // is a Unix-domain socket — keeps the 17 `report_*` call sites in
991    // `agentmux-cef/src/launcher_ipc.rs` unchanged and avoids touching
992    // the host's connect-on-startup code in `agentmux-cef/src/app.rs`.
993    host_env.push((
994        "AGENTMUX_LAUNCHER_PIPE",
995        std::ffi::OsString::from(&socket_path),
996    ));
997    // AGENTMUX_HOME = the host's runtime directory (siblings of the
998    // host binary — libcef.so, paks, locales, etc). Mirrors the
999    // Windows path so the host's asset-resolution code finds its
1000    // co-located data without searching $PATH-like fallbacks.
1001    if let Some(host_runtime) = real_exe.parent() {
1002        host_env.push((
1003            "AGENTMUX_HOME",
1004            std::ffi::OsString::from(host_runtime),
1005        ));
1006    }
1007    let mut host_child = match spawn_host_unix(real_exe, args, &srv_result, &host_env, false) {
1008        Some(c) => c,
1009        None => {
1010            log("FATAL: could not start CEF host — terminating");
1011            eprintln!("Failed to launch AgentMux.");
1012            terminate_child_gracefully(&srv_child);
1013            let _ = srv_child.start_kill();
1014            std::process::exit(1);
1015        }
1016    };
1017
1018    // 4. Signal handlers for `kill <launcher-pid>` (a terminal Ctrl+C
1019    //    already signals the whole foreground group; these cover the
1020    //    launcher-only case and make Ctrl+C deterministic too).
1021    let mut sigint = signal(SignalKind::interrupt()).ok();
1022    let mut sigterm = signal(SignalKind::terminate()).ok();
1023    if sigint.is_none() || sigterm.is_none() {
1024        log("WARN: failed to install one or more signal handlers — \
1025             relying on default termination + srv stdin-EOF backstop");
1026    }
1027
1028    // 5. Supervised wait loop — host crash budget mirrors run_windows.
1029    log("entering supervised host + srv wait (unix)");
1030    let mut host_restarts: Vec<std::time::Instant> = Vec::new();
1031    // Separate budget for system-OOM host exits (memory-aware relaunch); see
1032    // mem_supervisor + SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16.
1033    let mut oom_restarts: Vec<std::time::Instant> = Vec::new();
1034    let mut last_abnormal_code: Option<i32> = None;
1035    let mut host_degraded = false;
1036    let exit_code = loop {
1037        tokio::select! {
1038            host_status = host_child.wait() => {
1039                use std::os::unix::process::ExitStatusExt;
1040                let status = match host_status {
1041                    Ok(s) => s,
1042                    Err(e) => {
1043                        log(&format!("FATAL: host wait failed: {}", e));
1044                        break 1;
1045                    }
1046                };
1047                // Host killed by a signal. On a terminal Ctrl+C the host gets
1048                // SIGINT DIRECTLY (it shares our foreground process group), so
1049                // host_child.wait() can win the select! race against our own
1050                // SIGINT arm. Without this guard the host's signal-death has no
1051                // exit code → unwrap_or(1) → it looks like a crash and we'd
1052                // relaunch a replacement host that the SIGINT arm then has to
1053                // kill (reagent #1193 P2). Treat the group-shutdown signals
1054                // (SIGINT/SIGTERM/SIGHUP) as a clean shutdown; real crash
1055                // signals (SIGSEGV, SIGABRT, …) still fall through to the
1056                // crash-budget relaunch below.
1057                if let Some(sig) = status.signal() {
1058                    if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP {
1059                        log(&format!("CEF host terminated by signal {} (group shutdown) — shutting down", sig));
1060                        break 0;
1061                    }
1062                    log(&format!("CEF host killed by signal {} (crash) — entering crash-budget relaunch", sig));
1063                }
1064                let code = status.code().unwrap_or(1);
1065                if code == 0 {
1066                    log("CEF host exited cleanly (code 0) — shutting down");
1067                    break 0;
1068                }
1069                // Classify system-OOM (wait it out) vs a genuine host fault
1070                // (existing fast budget), mirroring run_windows. SPEC_MEMORY_
1071                // PRESSURE_SUPERVISION_2026_06_16 §5.B. On Linux a kernel-OOM-kill
1072                // arrives as a SIGKILL (code 1 here) and is caught by the low-
1073                // commit reading (SPEC §9.4).
1074                let commit_free = mem_supervisor::commit_free_mb();
1075                match mem_supervisor::classify_host_exit(code, commit_free) {
1076                    mem_supervisor::HostExitClass::SystemOom => {
1077                        let now = std::time::Instant::now();
1078                        if mem_supervisor::budget_exhausted(
1079                            &mut oom_restarts,
1080                            now,
1081                            mem_supervisor::OOM_RESTART_WINDOW,
1082                            mem_supervisor::OOM_RESTART_BUDGET,
1083                        ) {
1084                            log(&format!(
1085                                "CEF host hit system OOM (code {}, {} MB commit-free); OOM restart \
1086                                 budget exhausted ({} in {}s) — giving up",
1087                                code,
1088                                commit_free,
1089                                mem_supervisor::OOM_RESTART_BUDGET,
1090                                mem_supervisor::OOM_RESTART_WINDOW.as_secs()
1091                            ));
1092                            show_fatal_dialog(
1093                                mem_supervisor::OOM_GIVEUP_TITLE,
1094                                mem_supervisor::OOM_GIVEUP_BODY,
1095                            );
1096                            break code;
1097                        }
1098                        log(&format!(
1099                            "CEF host hit system OOM (code {}, {} MB commit-free) — waiting for \
1100                             memory to recover before relaunch",
1101                            code, commit_free
1102                        ));
1103                        // Race the commit-recovery wait against shutdown + srv
1104                        // death so the supervisor stays responsive during the
1105                        // (up to OOM_RELAUNCH_DEADLINE) wait — without this the
1106                        // SIGINT/SIGTERM + srv arms are starved for the whole
1107                        // wait (reagent P2). Mirrors the outer select! arms.
1108                        let recovered = tokio::select! {
1109                            r = mem_supervisor::await_commit_recovery(log) => r,
1110                            srv_status = srv_child.wait() => {
1111                                use std::os::unix::process::ExitStatusExt;
1112                                match srv_status {
1113                                    Ok(s) => {
1114                                        let group_shutdown = matches!(
1115                                            s.signal(),
1116                                            Some(sig) if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP
1117                                        );
1118                                        if s.success() || group_shutdown {
1119                                            log("srv exited as part of shutdown (during OOM wait) — shutting down");
1120                                            break 0;
1121                                        }
1122                                        log(&format!(
1123                                            "srv exited UNEXPECTEDLY during OOM wait with code {} — terminating launcher",
1124                                            s.code().unwrap_or(1)
1125                                        ));
1126                                    }
1127                                    Err(e) => log(&format!("FATAL: srv wait failed during OOM wait: {}", e)),
1128                                }
1129                                break 1;
1130                            }
1131                            _ = next_signal(&mut sigint) => {
1132                                log("received SIGINT during OOM wait — shutting down");
1133                                break 0;
1134                            }
1135                            _ = next_signal(&mut sigterm) => {
1136                                log("received SIGTERM during OOM wait — shutting down");
1137                                break 0;
1138                            }
1139                        };
1140                        if !recovered {
1141                            show_fatal_dialog(
1142                                mem_supervisor::OOM_GIVEUP_TITLE,
1143                                mem_supervisor::OOM_GIVEUP_BODY,
1144                            );
1145                            break code;
1146                        }
1147                        match spawn_host_unix(real_exe, args, &srv_result, &host_env, true) {
1148                            Some(c) => host_child = c,
1149                            None => {
1150                                log("host relaunch failed to spawn — giving up");
1151                                break code;
1152                            }
1153                        }
1154                    }
1155                    mem_supervisor::HostExitClass::Abnormal => {
1156                        let now = std::time::Instant::now();
1157                        host_restarts.retain(|t| now.duration_since(*t) < HOST_RESTART_WINDOW);
1158                        if host_restarts.len() >= HOST_RESTART_BUDGET {
1159                            log(&format!(
1160                                "CEF host exited abnormally (code {}); restart budget exhausted \
1161                                 ({} in {}s) — giving up",
1162                                code,
1163                                host_restarts.len(),
1164                                HOST_RESTART_WINDOW.as_secs()
1165                            ));
1166                            break code;
1167                        }
1168                        host_restarts.push(now);
1169                        if last_abnormal_code == Some(code) {
1170                            host_degraded = true;
1171                        }
1172                        last_abnormal_code = Some(code);
1173                        log(&format!(
1174                            "CEF host exited abnormally (code {}) — relaunching (restart {}/{}{})",
1175                            code,
1176                            host_restarts.len(),
1177                            HOST_RESTART_BUDGET,
1178                            if host_degraded { ", degraded: --disable-gpu" } else { "" }
1179                        ));
1180                        match spawn_host_unix(real_exe, args, &srv_result, &host_env, host_degraded) {
1181                            Some(c) => host_child = c,
1182                            None => {
1183                                log("host relaunch failed to spawn — giving up");
1184                                break code;
1185                            }
1186                        }
1187                    }
1188                }
1189            }
1190            srv_status = srv_child.wait() => {
1191                use std::os::unix::process::ExitStatusExt;
1192                match srv_status {
1193                    Ok(s) => {
1194                        // Mirror the host arm's group-shutdown guard. On a
1195                        // terminal Ctrl+C srv gets SIGINT DIRECTLY (it shares
1196                        // our foreground process group); its own signal handler
1197                        // shuts it down gracefully and it exits with code 0
1198                        // (agentmux-srv/src/main.rs — SIGINT/SIGTERM → cancel
1199                        // token → clean exit). srv_child.wait() can win this
1200                        // select! race against our own SIGINT arm, so a clean
1201                        // (code 0) or signal-killed exit is a group teardown,
1202                        // NOT an unexpected srv death — don't log a scary
1203                        // message and break 1 (reagent #1193 P2).
1204                        let group_shutdown = matches!(
1205                            s.signal(),
1206                            Some(sig) if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP
1207                        );
1208                        if s.success() || group_shutdown {
1209                            log("srv exited as part of shutdown — shutting down");
1210                            break 0;
1211                        }
1212                        log(&format!(
1213                            "srv exited UNEXPECTEDLY (host still running) with code {} — terminating launcher",
1214                            s.code().unwrap_or(1)
1215                        ));
1216                    }
1217                    Err(e) => log(&format!("FATAL: srv wait failed: {}", e)),
1218                }
1219                break 1;
1220            }
1221            _ = next_signal(&mut sigint) => {
1222                log("received SIGINT — shutting down");
1223                break 0;
1224            }
1225            _ = next_signal(&mut sigterm) => {
1226                log("received SIGTERM — shutting down");
1227                break 0;
1228            }
1229        }
1230    };
1231
1232    // 6. Cleanup. SIGTERM both children so the host reaps its render
1233    //    subprocesses (and srv shuts down cleanly), wait a short grace
1234    //    window, then SIGKILL any survivor. Dropping the stdin keepalive
1235    //    is srv's secondary shutdown trigger (parent-watch EOF).
1236    log("terminating children (SIGTERM → grace → SIGKILL)");
1237    terminate_child_gracefully(&host_child);
1238    terminate_child_gracefully(&srv_child);
1239    drop(_srv_stdin_keepalive);
1240    let _ = tokio::time::timeout(
1241        std::time::Duration::from_millis(1500),
1242        async {
1243            let _ = host_child.wait().await;
1244            let _ = srv_child.wait().await;
1245        },
1246    )
1247    .await;
1248    let _ = host_child.start_kill();
1249    let _ = srv_child.start_kill();
1250    log(&format!("launcher exiting with code {}", exit_code));
1251    std::process::exit(exit_code);
1252}
1253
1254/// Windows main flow: resolve paths → create J0 → spawn srv → spawn
1255/// host with srv endpoints in env → supervised wait → cleanup.
1256#[cfg(target_os = "windows")]
1257async fn run_windows(
1258    launcher_exe_dir: &std::path::Path,
1259    real_exe: &std::path::Path,
1260    args: &[String],
1261) {
1262
1263    let version = env!("CARGO_PKG_VERSION");
1264
1265    // 1. Resolve data_dir / config_dir / user_home_dir. Both srv and
1266    // host receive these via env so they don't recompute (and so they
1267    // can't drift). Host's existing data_dir computation in sidecar.rs
1268    // still runs as a fallback for `task dev` mode where the launcher
1269    // is not in the loop.
1270    let paths = match data_dir::resolve_paths(launcher_exe_dir, version) {
1271        Ok(p) => p,
1272        Err(e) => {
1273            log(&format!("FATAL: path resolution failed: {}", e));
1274            eprintln!("Failed to resolve AgentMux data directories: {}", e);
1275            std::process::exit(1);
1276        }
1277    };
1278    log(&format!(
1279        "paths resolved: data={} config={} user_home={} portable={}",
1280        paths.data_dir.display(),
1281        paths.config_dir.display(),
1282        paths.user_home_dir.display(),
1283        paths.portable_root.is_some(),
1284    ));
1285    if let Err(e) = data_dir::ensure_dirs(&paths) {
1286        log(&format!("FATAL: failed to create data dirs: {}", e));
1287        eprintln!("{}", e);
1288        std::process::exit(1);
1289    }
1290
1291    // 2. Create the launcher's Job Object J0 BEFORE any spawn. Both
1292    // srv and host will be assigned to it (so they're siblings under
1293    // a single OS-enforced cleanup contract). Failure here drops us
1294    // into "degraded mode" — children spawn but won't be reaped on
1295    // launcher death.
1296    let job: Option<JobHandle> = match create_job_object() {
1297        Ok(handle) => {
1298            log("Job Object created (KILL_ON_JOB_CLOSE active)");
1299            Some(JobHandle(handle))
1300        }
1301        Err(e) => {
1302            log(&format!(
1303                "WARN: Job Object setup failed: {} (process-tree cleanup degraded)",
1304                e
1305            ));
1306            None
1307        }
1308    };
1309    let job_handle: windows_sys::Win32::Foundation::HANDLE =
1310        job.as_ref().map(|j| j.0).unwrap_or(std::ptr::null_mut());
1311
1312    // Phase B.2: start the named-pipe IPC server BEFORE spawning
1313    // any children. Host connects to this pipe at startup using the
1314    // AGENTMUX_LAUNCHER_PIPE env var the launcher passes below.
1315    //
1316    // The server runs in its own Tokio task; the JoinHandle is held
1317    // for the rest of run_windows so the task isn't cancelled mid-
1318    // accept. Server owns the namespace `\\.\pipe\agentmux-{hash}\
1319    // command` per data dir, so multi-instance launchers (different
1320    // data dirs) get distinct pipes.
1321    //
1322    // Phase B.6: the bind itself is the single-instance signal.
1323    // `bind_first_pipe_instance` synchronously reserves the pipe;
1324    // a second launcher pointing at the same data dir gets
1325    // ERROR_ACCESS_DENIED. We surface that to the user as
1326    // "AgentMux is already running for this data directory" and
1327    // exit cleanly BEFORE spawning srv/host (otherwise the second
1328    // host would briefly contend on the CEF cache lockfile).
1329    // For release builds, CARGO_PKG_VERSION (semver) is the isolation key —
1330    // two different versions on the same channel get distinct pipes.
1331    // For local builds, package.sh bakes AGENTMUX_BUILD_LABEL (which includes
1332    // a per-build timestamp stamp), so each successive `task package` run gets
1333    // its own single-instance domain and can start a fresh window even while a
1334    // previous local build is running. Session data is still shared (data_dir
1335    // is keyed on channel+semver, not the label), so agents/auth carry over.
1336    let pipe_version = option_env!("AGENTMUX_BUILD_LABEL")
1337        .unwrap_or(env!("CARGO_PKG_VERSION"));
1338    let dir_hash = hash::data_dir_hash16(&paths.data_dir, pipe_version);
1339    let pipe_path = ipc::pipe_name(&dir_hash);
1340    // Isolation telemetry: record exactly which keyed resources this instance
1341    // claims, so a cross-instance collision is diagnosable from the log alone
1342    // (two live PIDs claiming the same dir_hash) instead of inferred after a
1343    // vanished window. The launcher's job object is unnamed, so there is no
1344    // shared lifecycle handle to log. See
1345    // docs/specs/SPEC_MULTI_INSTANCE_ISOLATION_HARDENING_2026_06_03.md.
1346    log(&format!(
1347        "instance_claim pid={} version={} data_dir={} dir_hash={} pipe={}",
1348        std::process::id(),
1349        pipe_version,
1350        paths.data_dir.display(),
1351        dir_hash,
1352        pipe_path
1353    ));
1354    let first_pipe = match ipc::server::bind_first_pipe_instance(&pipe_path) {
1355        Ok(p) => p,
1356        Err(e) => {
1357            // ERROR_ACCESS_DENIED (5) means another launcher already
1358            // owns this pipe — i.e., another AgentMux is running for
1359            // this data dir. The user-facing behavior matches the
1360            // status-bar version popup's "new window": forward an
1361            // `open_new_window` IPC POST to the existing host and
1362            // exit 0. The named-pipe bind is the AUTHORITATIVE
1363            // single-instance signal; this HTTP call is just the
1364            // forwarding hint. Other errors (namespace misconfig,
1365            // security descriptor failure) genuinely fail — show
1366            // the dialog and exit 2.
1367            const ERROR_ACCESS_DENIED: i32 = 5;
1368            let already_running = e.raw_os_error() == Some(ERROR_ACCESS_DENIED);
1369            log(&format!(
1370                "pipe bind failed (already_running={}): {} pipe={}",
1371                already_running, e, pipe_path
1372            ));
1373            if already_running {
1374                match forward_open_new_window(&paths.data_dir, &dir_hash) {
1375                    Ok(()) => {
1376                        log("forwarded open_new_window to existing instance — exiting 0");
1377                        std::process::exit(0);
1378                    }
1379                    Err(ForwardError::Transient(reason)) => {
1380                        // Transient race: the host is alive (pipe is
1381                        // held by the first launcher) but its
1382                        // forwarding hint isn't readable yet —
1383                        // typically because the host is mid-CEF-init
1384                        // and hasn't written `<data-dir>/ipc-port`
1385                        // yet. Silent exit so the user isn't punished
1386                        // for double-clicking quickly.
1387                        log(&format!("forward transient: {} — exiting 0 silently", reason));
1388                        std::process::exit(0);
1389                    }
1390                    Err(ForwardError::Fatal(reason)) => {
1391                        // Fatal forward failure: the port file IS
1392                        // readable, so the host got far enough to
1393                        // publish it, but the HTTP path is dead
1394                        // (connect refused, write failed). Could be
1395                        // a hung host, a port collision, or
1396                        // ERROR_ACCESS_DENIED that wasn't really
1397                        // "another instance" (namespace conflict).
1398                        // Surface the dialog so the user sees that
1399                        // something is genuinely broken rather than
1400                        // a silent no-op. (codex P2 PR #598.)
1401                        log(&format!("forward fatal: {} — surfacing dialog", reason));
1402                        show_fatal_dialog(
1403                            "AgentMux",
1404                            &format!(
1405                                "AgentMux appears to already be running but isn't responding.\n\nData dir: {}\nReason: {}\n\nClose any leftover AgentMux processes and try again. If the problem persists, check the launcher log.",
1406                                paths.data_dir.display(),
1407                                reason
1408                            ),
1409                        );
1410                        std::process::exit(2);
1411                    }
1412                }
1413            }
1414            // Genuine bind failure (not "already running"). Surface
1415            // it loudly because it indicates a system-level problem.
1416            show_fatal_dialog(
1417                "AgentMux",
1418                &format!(
1419                    "AgentMux failed to start: could not bind IPC pipe.\n\nPipe: {}\nError: {}\n\nIf the problem persists, check the launcher log.",
1420                    pipe_path, e
1421                ),
1422            );
1423            std::process::exit(2);
1424        }
1425    };
1426    // Spawn the native pre-splash immediately after claiming the
1427    // single-instance pipe — before srv spawn and CEF init.
1428    // The event name is forwarded to the CEF host as
1429    // AGENTMUX_SPLASH_EVENT so it can signal dismiss from on_load_end.
1430    #[cfg(target_os = "windows")]
1431    let splash_event_name = if splash_config::splash_disabled() {
1432        None // splash:disabled / AGENTMUX_SPLASH=0 — no event, no window (SPEC §6)
1433    } else {
1434        splash::spawn_splash(&dir_hash)
1435    };
1436    #[cfg(not(target_os = "windows"))]
1437    let splash_event_name: Option<String> = None;
1438
1439    // Phase B.8 — broadcast bus for reducer-emitted events. Capacity
1440    // 1024 is comfortable headroom for the launcher's event volume
1441    // (~10–50 events per user action × handful of subscribers); a
1442    // lagging client gets `RecvError::Lagged` and reconnects.
1443    let (events_tx, _) = tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(1024);
1444
1445    // Phase D.2 — event log: in-memory ring (replay source for D.3's
1446    // GetEvents) + optional disk persistence at
1447    // `<data-dir>/launcher-events.log` for crash forensics.
1448    let log_disk_path = paths.data_dir.join("launcher-events.log");
1449    let event_log = std::sync::Arc::new(event_log::EventLog::new(Some(log_disk_path)));
1450    let event_log_for_writer = std::sync::Arc::clone(&event_log);
1451    let disk_writer_rx = events_tx.subscribe();
1452    tokio::spawn(event_log::run_disk_writer(event_log_for_writer, disk_writer_rx));
1453
1454    // Phase E.1a — canonical state shared between IPC server + saga
1455    // coordinator (and, in E.5, individual sagas). Single Mutex
1456    // owner, multiple readers via Arc.
1457    let state = std::sync::Arc::new(tokio::sync::Mutex::new(state::State::default()));
1458
1459    // LSD-2 — open the durable launcher saga log at
1460    // `<data-dir>/db/launcher-sagas.db` (separate file from
1461    // `launcher-events.log`; the saga log is structured SQLite, the
1462    // event log is append-only JSONL). Failure to open is a launcher
1463    // startup error — without the log, sagas have no crash-recovery
1464    // story (LSD-3 walks `unresolved_sagas` to mark interrupted
1465    // sagas `failed_compensation`). Spec
1466    // `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md` §3.1.
1467    //
1468    // `launcher_saga_log_path` performs the back-compat move from
1469    // the pre-AUDIT_SQLITE_SYSTEMS_2026_05_19.md location
1470    // (`<data-dir>/launcher-sagas.db` — outside `db/`) into the
1471    // canonical `db/` subdir alongside srv's SQLite files.
1472    let saga_log_path = data_dir::launcher_saga_log_path(&paths.data_dir);
1473    let saga_log = match saga::LauncherSagaLog::open(&saga_log_path) {
1474        Ok(l) => std::sync::Arc::new(l),
1475        Err(e) => {
1476            log(&format!(
1477                "FATAL: failed to open launcher saga log at {:?}: {}",
1478                saga_log_path, e
1479            ));
1480            std::process::exit(2);
1481        }
1482    };
1483
1484    // LSD-3 — startup recovery walker. Walks the durable saga log,
1485    // marks any saga still in `running` / `compensating` / `failed`
1486    // (left over from a crashed prior run) as `failed_compensation`
1487    // so operators see them in `--diag sagas` and the next coordinator
1488    // run can't accidentally double-act on partially-applied effects.
1489    // MUST run BEFORE `tokio::spawn(saga::run_coordinator(..))` below
1490    // (LSD spec §5 risk #5: don't spawn while recovery is in progress).
1491    // Runs BEFORE LSD-4 vacuum so just-recovered sagas land in their
1492    // failed_compensation state for the operator to see — vacuum
1493    // honors the 7-day retention window and won't immediately purge.
1494    // Spec `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md` §3.5.
1495    if let Err(e) = saga::compensate_unresolved_launcher_sagas(&saga_log).await {
1496        log(&format!(
1497            "[saga-recovery] WARN: walker failed: {} — coordinator will still spawn; prior crashed sagas remain unresolved until next restart",
1498            e
1499        ));
1500    }
1501
1502    // LSD-4 — startup retention vacuum. Runs once per launcher boot,
1503    // before the coordinator subscribes, so any rows it deletes are
1504    // already terminal and can't possibly belong to an in-flight saga
1505    // the coordinator is about to drive (see `vacuum_older_than` SQL —
1506    // `running` and `compensating` rows are unreachable by the DELETE
1507    // regardless of timing). Failure is non-fatal.
1508    // Spec §3.6.
1509    let retention_days =
1510        config::load_saga_retention_days(&paths.user_home_dir, |w| log(w));
1511    let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days);
1512    match saga_log.vacuum_older_than(cutoff) {
1513        Ok(removed) => log(&format!(
1514            "[saga-log] vacuumed {} sagas older than {} (retention {} days)",
1515            removed, cutoff, retention_days
1516        )),
1517        Err(e) => log(&format!("[saga-log] WARN: vacuum failed: {}", e)),
1518    }
1519
1520    // CPD-2 — launcher → host pipe wrapper. Owns the writer half of
1521    // the host's IPC connection (installed by the per-connection
1522    // handler in `ipc::server` once the host registers) and exposes
1523    // `send_command` / `send_event` to the rest of the launcher.
1524    // CPD-2 wires the wrapper + refactors event fanout for the host
1525    // connection to flow through here. CPD-3 wires this into the
1526    // saga coordinator's `apply_action` so `IssueCmd::Host` actions
1527    // dispatch live (no longer log-only).
1528    let host_pipe = std::sync::Arc::new(host_pipe::HostPipe::new(
1529        events_tx.clone(),
1530        std::sync::Arc::clone(&state),
1531    ));
1532
1533    // Phase E.1a — saga coordinator task. Subscribes to the broadcast
1534    // bus, drives in-flight sagas. E.1a registry is empty — framework
1535    // only. E.5 adds the first concrete saga consumer (tear-off).
1536    // LSD-2 — durable saga log is now installed; every lifecycle
1537    // transition is persisted.
1538    // CPD-3 — install `host_pipe` so saga `IssueCmd::Host` actions
1539    // dispatch through the launcher → host wire instead of being
1540    // log-only.
1541    //
1542    // Subscribe BEFORE spawning so the race window between construction
1543    // and first `recv()` doesn't drop early events. (reagent P2 PR #609.)
1544    // Same pattern as the disk writer above.
1545    // with_log() can fail if max_saga_id() fails (e.g. corrupted SQLite
1546    // file). Treat as fatal — continuing with a default next_saga_id=1
1547    // while the log is attached would let the coordinator silently
1548    // mutate prior saga history on restart. Better to crash loudly so
1549    // operators see + investigate. (codex P1 PR #645 round 2.)
1550    let saga_coord_inner = saga::SagaCoordinator::new(events_tx.clone(), std::sync::Arc::clone(&state))
1551        .with_log(std::sync::Arc::clone(&saga_log))
1552        .unwrap_or_else(|e| {
1553            log(&format!(
1554                "[main] FATAL: failed to seed saga_id allocator from launcher_saga.max(saga_id): {} — refusing to start with degraded coordinator",
1555                e
1556            ));
1557            std::process::exit(1);
1558        })
1559        .with_host_pipe(std::sync::Arc::clone(&host_pipe));
1560    let saga_coord = std::sync::Arc::new(saga_coord_inner);
1561    let saga_rx = events_tx.subscribe();
1562    tokio::spawn(saga::run_coordinator(
1563        std::sync::Arc::clone(&saga_coord),
1564        saga_rx,
1565    ));
1566
1567    let _ipc_handle = ipc::run_ipc_server(
1568        pipe_path.clone(),
1569        first_pipe,
1570        ipc::server::ServerCtx {
1571            launcher_pid: std::process::id(),
1572            launcher_version: env!("CARGO_PKG_VERSION").to_string(),
1573            state,
1574            events_tx,
1575            event_log,
1576            host_pipe: std::sync::Arc::clone(&host_pipe),
1577        },
1578    );
1579    log(&format!("IPC server started on {}", pipe_path));
1580
1581    // 3. Spawn srv first. Host needs srv's endpoints to skip its own
1582    // spawn_backend path. Srv signals readiness via AGENTMUXSRV-ESTART on
1583    // stderr; the spawner returns once we see that line (or after a
1584    // 30s timeout).
1585    // Phase E.1b — pre-compute srv's pipe path (same data-dir hash
1586    // as launcher's pipe) and pass via env so srv binds it on
1587    // startup. Launcher is the sole authority for the data-dir hash.
1588    let srv_pipe_path = ipc::srv_pipe_name(&dir_hash);
1589    log(&format!("[ipc] srv pipe path = {}", srv_pipe_path));
1590
1591    let (srv_result, mut srv_child) = match srv_spawner::spawn_srv(
1592        launcher_exe_dir,
1593        &paths,
1594        &srv_pipe_path,
1595        job_handle,
1596    )
1597    .await
1598    {
1599        Ok(pair) => pair,
1600        Err(e) => {
1601            log(&format!("FATAL: srv spawn failed: {}", e));
1602            eprintln!("Failed to start backend: {}", e);
1603            drop(job);
1604            std::process::exit(1);
1605        }
1606    };
1607
1608    // CRITICAL: tokio::process::Child::wait() proactively drops
1609    // self.stdin before waiting (tokio source comment: "Ensure stdin
1610    // is closed so the child can't read from it any more"). agentmux-
1611    // srv has a parent-watch loop on its own stdin — when stdin reads
1612    // 0 bytes (EOF from a closed write end), it interprets that as
1613    // "parent died" and shuts itself down. tokio's wait() would
1614    // trigger that within milliseconds, causing srv to exit before
1615    // the host even mounts its first browser. Move srv's stdin out
1616    // of the Child into a launcher-scope binding so tokio can't see
1617    // it (its take() returns None) and the pipe stays open for the
1618    // launcher's lifetime. (Smoke test on v0.33.447 caught this.)
1619    let _srv_stdin_keepalive = srv_child.stdin.take();
1620
1621    // 4-6. Spawn the host (suspended) → assign to J0 → resume, via
1622    // spawn_host_supervised(). The splash event is passed on every launch
1623    // (including restarts) so a relaunched host still dismisses a pending
1624    // splash if the first host crashed before its first frame.
1625    let mut host_env = paths.common.to_env_vars();
1626    // Pass the version-scoped IPC hash to the host so it writes the
1627    // port file to `ipc-port-{hash}` rather than the shared `ipc-port`.
1628    // Prevents two running releases from overwriting each other's port
1629    // file (codex P1 on #1227).
1630    host_env.push(("AGENTMUX_IPC_HASH", std::ffi::OsString::from(&dir_hash)));
1631    let mut host_child = match spawn_host_supervised(
1632        real_exe,
1633        args,
1634        &srv_result,
1635        &host_env,
1636        &pipe_path,
1637        job.is_some(),
1638        job_handle,
1639        splash_event_name.as_deref(),
1640        false,
1641    ) {
1642        Some(c) => c,
1643        None => {
1644            // First-launch failure is fatal. Happy path: drop(job) →
1645            // KILL_ON_JOB_CLOSE reaps srv. Degraded path (J0 absent):
1646            // kill srv explicitly or it orphans (kill_on_drop is false).
1647            log("FATAL: could not start CEF host — terminating");
1648            eprintln!("Failed to launch AgentMux.");
1649            if job.is_none() {
1650                let _ = srv_child.start_kill();
1651            }
1652            drop(job);
1653            std::process::exit(1);
1654        }
1655    };
1656
1657    // 7. Supervised wait loop (Phase 1 — host supervision). The host is
1658    // auto-restarted on abnormal exit, bounded by a crash budget so a
1659    // deterministic crash can't spin forever (spec §10-A). A clean host
1660    // exit (code 0) ends the loop. srv is NOT yet supervised — an srv
1661    // exit still terminates the launcher; srv supervision is Phase 2.
1662    //
1663    // We don't manually kill the surviving child in the happy path:
1664    // dropping `job` below triggers KILL_ON_JOB_CLOSE which reaps the
1665    // entire J0 membership. The explicit start_kill is the backstop for
1666    // degraded mode (job == None) only.
1667    log("entering supervised host + srv wait");
1668    let mut host_restarts: Vec<std::time::Instant> = Vec::new();
1669    // Separate budget for system-OOM host exits (memory-aware relaunch); see
1670    // mem_supervisor + SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16.
1671    let mut oom_restarts: Vec<std::time::Instant> = Vec::new();
1672    let mut last_abnormal_code: Option<i32> = None;
1673    let mut host_degraded = false;
1674    let exit_code = loop {
1675        tokio::select! {
1676            host_status = host_child.wait() => {
1677                let code = match host_status {
1678                    Ok(s) => s.code().unwrap_or(1),
1679                    Err(e) => {
1680                        log(&format!("FATAL: host wait failed: {}", e));
1681                        break 1;
1682                    }
1683                };
1684                if code == 0 {
1685                    log("CEF host exited cleanly (code 0) — shutting down");
1686                    break 0;
1687                }
1688                // Classify: a *system-OOM* exit (the OS ran out of commit) is
1689                // transient and must be WAITED OUT, not hammered into the same
1690                // wall on the fast wedged-host budget — that just re-OOMs and
1691                // burns the budget into a silent give-up
1692                // (docs/retro/retro-oom-crash-2026-06-16.md,
1693                // SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16 §5.B). A genuine
1694                // host fault still takes the existing path below, unchanged.
1695                let commit_free = mem_supervisor::commit_free_mb();
1696                match mem_supervisor::classify_host_exit(code, commit_free) {
1697                    mem_supervisor::HostExitClass::SystemOom => {
1698                        let now = std::time::Instant::now();
1699                        if mem_supervisor::budget_exhausted(
1700                            &mut oom_restarts,
1701                            now,
1702                            mem_supervisor::OOM_RESTART_WINDOW,
1703                            mem_supervisor::OOM_RESTART_BUDGET,
1704                        ) {
1705                            log(&format!(
1706                                "CEF host hit system OOM (code {}, {} MB commit-free); OOM restart \
1707                                 budget exhausted ({} in {}s) — giving up",
1708                                code,
1709                                commit_free,
1710                                mem_supervisor::OOM_RESTART_BUDGET,
1711                                mem_supervisor::OOM_RESTART_WINDOW.as_secs()
1712                            ));
1713                            show_fatal_dialog(
1714                                mem_supervisor::OOM_GIVEUP_TITLE,
1715                                mem_supervisor::OOM_GIVEUP_BODY,
1716                            );
1717                            break code;
1718                        }
1719                        log(&format!(
1720                            "CEF host hit system OOM (code {}, {} MB commit-free) — waiting for \
1721                             memory to recover before relaunch",
1722                            code, commit_free
1723                        ));
1724                        // Commit-gated, backed-off wait. Relaunching into a
1725                        // starved system just re-OOMs; waiting is the only lever.
1726                        // Race it against srv death so the supervisor isn't blind
1727                        // to a concurrent srv exit during the wait (reagent P2).
1728                        // run_windows has no signal arms (shutdown flows via the
1729                        // host/srv), so srv is the only concurrent event here.
1730                        let recovered = tokio::select! {
1731                            r = mem_supervisor::await_commit_recovery(log) => r,
1732                            srv_status = srv_child.wait() => {
1733                                match srv_status {
1734                                    Ok(s) => log(&format!(
1735                                        "srv exited UNEXPECTEDLY during OOM wait with code {} — terminating launcher",
1736                                        s.code().unwrap_or(1)
1737                                    )),
1738                                    Err(e) => log(&format!("FATAL: srv wait failed during OOM wait: {}", e)),
1739                                }
1740                                break 1;
1741                            }
1742                        };
1743                        if !recovered {
1744                            show_fatal_dialog(
1745                                mem_supervisor::OOM_GIVEUP_TITLE,
1746                                mem_supervisor::OOM_GIVEUP_BODY,
1747                            );
1748                            break code;
1749                        }
1750                        // Relaunch degraded: the GPU process is a large commit
1751                        // consumer, so skip straight to software rendering for an
1752                        // OOM relaunch (SPEC §5.B.4).
1753                        match spawn_host_supervised(
1754                            real_exe,
1755                            args,
1756                            &srv_result,
1757                            &host_env,
1758                            &pipe_path,
1759                            job.is_some(),
1760                            job_handle,
1761                            splash_event_name.as_deref(),
1762                            true, // disable_gpu
1763                        ) {
1764                            Some(c) => host_child = c,
1765                            None => {
1766                                log("host relaunch failed to spawn — giving up");
1767                                break code;
1768                            }
1769                        }
1770                    }
1771                    mem_supervisor::HostExitClass::Abnormal => {
1772                        // Abnormal exit — relaunch within the crash budget.
1773                        let now = std::time::Instant::now();
1774                        host_restarts.retain(|t| now.duration_since(*t) < HOST_RESTART_WINDOW);
1775                        if host_restarts.len() >= HOST_RESTART_BUDGET {
1776                            log(&format!(
1777                                "CEF host exited abnormally (code {}); restart budget exhausted \
1778                                 ({} in {}s) — giving up",
1779                                code,
1780                                host_restarts.len(),
1781                                HOST_RESTART_WINDOW.as_secs()
1782                            ));
1783                            break code;
1784                        }
1785                        host_restarts.push(now);
1786                        // Crash classification + retry ladder (spec §7): a crash that
1787                        // reproduces the previous abnormal exit code is deterministic —
1788                        // step down to a degraded (--disable-gpu) relaunch so the retry
1789                        // isn't "the same thing again". Degraded is sticky; the ladder
1790                        // only steps down.
1791                        if last_abnormal_code == Some(code) {
1792                            host_degraded = true;
1793                        }
1794                        last_abnormal_code = Some(code);
1795                        log(&format!(
1796                            "CEF host exited abnormally (code {}) — relaunching (restart {}/{}{})",
1797                            code,
1798                            host_restarts.len(),
1799                            HOST_RESTART_BUDGET,
1800                            if host_degraded { ", degraded: --disable-gpu" } else { "" }
1801                        ));
1802                        match spawn_host_supervised(
1803                            real_exe,
1804                            args,
1805                            &srv_result,
1806                            &host_env,
1807                            &pipe_path,
1808                            job.is_some(),
1809                            job_handle,
1810                            splash_event_name.as_deref(),
1811                            host_degraded,
1812                        ) {
1813                            Some(c) => host_child = c,
1814                            None => {
1815                                log("host relaunch failed to spawn — giving up");
1816                                break code;
1817                            }
1818                        }
1819                    }
1820                }
1821            }
1822            srv_status = srv_child.wait() => {
1823                match srv_status {
1824                    Ok(s) => log(&format!(
1825                        "srv exited UNEXPECTEDLY (host still running) with code {} — terminating launcher",
1826                        s.code().unwrap_or(1)
1827                    )),
1828                    Err(e) => log(&format!("FATAL: srv wait failed: {}", e)),
1829                }
1830                break 1;
1831            }
1832        }
1833    };
1834
1835    // 8. Cleanup. Happy path: drop(job) → KILL_ON_JOB_CLOSE reaps
1836    // the surviving child + CEF renderers. Degraded path (job is
1837    // None): explicit start_kill on both — neither will be reaped
1838    // by the OS, so we have to terminate them ourselves to avoid
1839    // orphans. (gemini PR #570 round-1 MEDIUM L105 / round-2 P1
1840    // backstop pattern.)
1841    if job.is_none() {
1842        log("WARN: J0 absent — explicitly killing surviving children");
1843        let _ = host_child.start_kill();
1844        let _ = srv_child.start_kill();
1845    }
1846    drop(job);
1847    log(&format!("launcher exiting with code {}", exit_code));
1848    std::process::exit(exit_code);
1849}
1850
1851/// Append a timestamped line to ~/.agentmux/logs/agentmux-launcher.log.
1852/// Best-effort — silently no-ops if the log dir doesn't exist yet.
1853pub(crate) fn log(msg: &str) {
1854    let log_dir = dirs_fallback_home().join(".agentmux").join("logs");
1855    let _ = std::fs::create_dir_all(&log_dir);
1856    let path = log_dir.join("agentmux-launcher.log");
1857    if let Ok(mut f) = std::fs::OpenOptions::new()
1858        .create(true)
1859        .append(true)
1860        .open(&path)
1861    {
1862        use std::io::Write;
1863        let secs = std::time::SystemTime::now()
1864            .duration_since(std::time::UNIX_EPOCH)
1865            .map(|d| d.as_secs())
1866            .unwrap_or(0);
1867        let _ = writeln!(f, "[{}] v{} {}", secs, env!("CARGO_PKG_VERSION"), msg);
1868    }
1869}
1870
1871/// Home dir without depending on `dirs` for THIS specific lookup.
1872/// Kept to avoid a dirs dep cycle from log() — log() is called from
1873/// data_dir::resolve_paths via failure paths, and we want it to work
1874/// even if `dirs` itself is mid-failure.
1875fn dirs_fallback_home() -> std::path::PathBuf {
1876    std::env::var("USERPROFILE")
1877        .or_else(|_| std::env::var("HOME"))
1878        .map(std::path::PathBuf::from)
1879        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1880}
1881
1882/// Owns a Windows Job Object handle. CloseHandle on drop. The job's
1883/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag means closing the last handle
1884/// terminates every assigned process — which is what we want as a backstop
1885/// if this launcher dies abruptly.
1886#[cfg(target_os = "windows")]
1887struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
1888
1889#[cfg(target_os = "windows")]
1890unsafe impl Send for JobHandle {}
1891
1892#[cfg(target_os = "windows")]
1893impl Drop for JobHandle {
1894    fn drop(&mut self) {
1895        if !self.0.is_null() {
1896            unsafe {
1897                windows_sys::Win32::Foundation::CloseHandle(self.0);
1898            }
1899        }
1900    }
1901}
1902
1903/// Create a Job Object J0 with `KILL_ON_JOB_CLOSE`. Caller assigns
1904/// processes to it via `srv_spawner::assign_pid_to_job(pid, job)`.
1905#[cfg(target_os = "windows")]
1906fn create_job_object() -> Result<windows_sys::Win32::Foundation::HANDLE, String> {
1907    use windows_sys::Win32::Foundation::CloseHandle;
1908    use windows_sys::Win32::System::JobObjects::*;
1909
1910    unsafe {
1911        let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
1912        if job.is_null() {
1913            return Err("CreateJobObjectW returned null".into());
1914        }
1915        let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
1916        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1917        let ok = SetInformationJobObject(
1918            job,
1919            JobObjectExtendedLimitInformation,
1920            &info as *const _ as *const std::ffi::c_void,
1921            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
1922        );
1923        if ok == 0 {
1924            CloseHandle(job);
1925            return Err("SetInformationJobObject failed".into());
1926        }
1927        Ok(job)
1928    }
1929}
1930
1931/// Resume the (single) main thread of a CREATE_SUSPENDED process.
1932///
1933/// Walks a Toolhelp32 thread snapshot to find the one thread belonging
1934/// to `pid` (a freshly-spawned suspended process has only its main
1935/// thread), opens it with THREAD_SUSPEND_RESUME, and ResumeThread's it.
1936///
1937/// Errors come from snapshot creation, OpenThread, or ResumeThread
1938/// returning `(DWORD)-1`. A `ResumeThread` return of 0 means the thread
1939/// was already running (impossible if the process was just created
1940/// suspended) — treated as success.
1941#[cfg(target_os = "windows")]
1942pub(crate) fn resume_main_thread(pid: u32) -> Result<(), String> {
1943    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1944    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1945        CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD,
1946        THREADENTRY32,
1947    };
1948    use windows_sys::Win32::System::Threading::{
1949        OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
1950    };
1951
1952    unsafe {
1953        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
1954        if snap == INVALID_HANDLE_VALUE {
1955            return Err("CreateToolhelp32Snapshot failed".into());
1956        }
1957
1958        let mut entry: THREADENTRY32 = std::mem::zeroed();
1959        entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1960
1961        let mut found = false;
1962        if Thread32First(snap, &mut entry) != 0 {
1963            loop {
1964                if entry.th32OwnerProcessID == pid {
1965                    let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID);
1966                    if !thread.is_null() {
1967                        let prev = ResumeThread(thread);
1968                        CloseHandle(thread);
1969                        if prev == u32::MAX {
1970                            CloseHandle(snap);
1971                            return Err(format!(
1972                                "ResumeThread failed for tid={}",
1973                                entry.th32ThreadID
1974                            ));
1975                        }
1976                        found = true;
1977                        break;
1978                    }
1979                }
1980                entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1981                if Thread32Next(snap, &mut entry) == 0 {
1982                    break;
1983                }
1984            }
1985        }
1986
1987        CloseHandle(snap);
1988        if !found {
1989            return Err(format!("no thread found for pid={}", pid));
1990        }
1991        Ok(())
1992    }
1993}
1994
1995/// Phase B.6 (post-fix) — forward an `open_new_window` request to
1996/// the already-running host and let this launcher exit 0.
1997///
1998/// The host writes `<data-dir>/ipc-port` after CEF init as
1999/// `port:token`. We open a TCP connection to 127.0.0.1:port, send a
2000/// minimal HTTP/1.1 POST to /ipc with the bearer token and a JSON
2001/// body, and bail. We deliberately do NOT pull in reqwest: the
2002/// launcher binary should stay tiny (~325 KB) and the protocol is
2003/// fixed, so a hand-rolled request is the right tool.
2004///
2005/// Failure classification (codex P2 PR #598):
2006/// - `Transient` — port file missing / unreadable / malformed.
2007///   The host is alive (pipe held) but mid-startup; caller exits
2008///   0 silently so the user isn't punished for double-clicking
2009///   quickly.
2010/// - `Fatal` — port file is readable, but the HTTP path failed
2011///   (connect refused, write failed, timeout). Either a hung
2012///   host or a non-running-instance source of
2013///   `ERROR_ACCESS_DENIED` (namespace conflict, security
2014///   descriptor failure). Caller surfaces the dialog so the user
2015///   sees a real problem rather than a silent no-op.
2016enum ForwardError {
2017    Transient(String),
2018    Fatal(String),
2019}
2020
2021fn forward_open_new_window(data_dir: &std::path::Path, dir_hash: &str) -> Result<(), ForwardError> {
2022    // Read the version-scoped port file so we reach THIS version's host,
2023    // not a concurrent release's host that may have overwritten "ipc-port".
2024    let port_file_name = format!("ipc-port-{}", dir_hash);
2025    let port_file = data_dir.join(&port_file_name);
2026    let contents = std::fs::read_to_string(&port_file).map_err(|e| {
2027        ForwardError::Transient(format!("read {}: {}", port_file.display(), e))
2028    })?;
2029    let trimmed = contents.trim();
2030    let (port_str, token) = trimmed.split_once(':').ok_or_else(|| {
2031        ForwardError::Transient(format!(
2032            "malformed port file (expected port:token): {}",
2033            trimmed
2034        ))
2035    })?;
2036    let port: u16 = port_str
2037        .parse()
2038        .map_err(|e| ForwardError::Transient(format!("invalid port {:?}: {}", port_str, e)))?;
2039
2040    // From here on the file was readable: any failure is a fatal
2041    // forward (the host got far enough to publish but isn't
2042    // serving the IPC port).
2043    let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into();
2044    let mut stream = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(2))
2045        .map_err(|e| ForwardError::Fatal(format!("connect 127.0.0.1:{}: {}", port, e)))?;
2046    stream
2047        .set_write_timeout(Some(std::time::Duration::from_secs(2)))
2048        .ok();
2049
2050    let body = r#"{"cmd":"open_new_window"}"#;
2051    let req = format!(
2052        "POST /ipc HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2053        token,
2054        body.len(),
2055        body
2056    );
2057    use std::io::{Read, Write};
2058    stream
2059        .write_all(req.as_bytes())
2060        .map_err(|e| ForwardError::Fatal(format!("write request: {}", e)))?;
2061    // CRITICAL: read at least the status line. The host's axum
2062    // handler is async — if the launcher closes the TCP socket
2063    // before axum has finished parsing + dispatching to
2064    // `open_new_window`, the request can be dropped (smoke caught
2065    // exactly this on v0.33.481: the launcher logged "forwarded"
2066    // but no second window appeared because the process exited
2067    // before axum ran the handler). We don't care about the body
2068    // — `Connection: close` lets the server drop the socket once
2069    // the response is written, so a single short read is enough
2070    // to keep the connection alive past handler dispatch.
2071    stream
2072        .set_read_timeout(Some(std::time::Duration::from_secs(2)))
2073        .ok();
2074    let mut sink = [0u8; 64];
2075    let _ = stream.read(&mut sink);
2076    Ok(())
2077}
2078
2079/// Best-effort `open_new_window` forward for the unix second-instance path.
2080/// Unlike the Windows path (which pops a dialog on a fatal forward), unix just
2081/// logs and lets the caller exit 0: the existing instance is alive (its socket
2082/// answered our connect probe), so a transient/fatal forward failure shouldn't
2083/// block — at worst the relaunch is a silent no-op instead of a new window.
2084/// SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
2085#[cfg(not(target_os = "windows"))]
2086fn forward_open_new_window_or_log(data_dir: &std::path::Path, dir_hash: &str) {
2087    match forward_open_new_window(data_dir, dir_hash) {
2088        Ok(()) => log("forwarded open_new_window to existing instance"),
2089        Err(ForwardError::Transient(reason)) => {
2090            log(&format!("open_new_window forward transient (host mid-startup?): {}", reason))
2091        }
2092        Err(ForwardError::Fatal(reason)) => {
2093            log(&format!("open_new_window forward failed: {}", reason))
2094        }
2095    }
2096}
2097
2098/// Show a modal error dialog before the launcher exits. Used for
2099/// genuine bind failures (NOT the "already running" path — that
2100/// silently forwards via `forward_open_new_window`). Without this,
2101/// the launcher exit is silent (it has the `windows` subsystem in
2102/// release, so eprintln! goes nowhere).
2103#[cfg(target_os = "windows")]
2104fn show_fatal_dialog(title: &str, body: &str) {
2105    use std::os::windows::ffi::OsStrExt;
2106    use windows_sys::Win32::UI::WindowsAndMessaging::{
2107        MessageBoxW, MB_ICONWARNING, MB_OK, MB_SETFOREGROUND, MB_TOPMOST,
2108    };
2109    let title_w: Vec<u16> = std::ffi::OsStr::new(title)
2110        .encode_wide()
2111        .chain(Some(0))
2112        .collect();
2113    let body_w: Vec<u16> = std::ffi::OsStr::new(body)
2114        .encode_wide()
2115        .chain(Some(0))
2116        .collect();
2117    unsafe {
2118        MessageBoxW(
2119            std::ptr::null_mut(),
2120            body_w.as_ptr(),
2121            title_w.as_ptr(),
2122            MB_OK | MB_ICONWARNING | MB_SETFOREGROUND | MB_TOPMOST,
2123        );
2124    }
2125}
2126
2127#[cfg(not(target_os = "windows"))]
2128fn show_fatal_dialog(_title: &str, body: &str) {
2129    eprintln!("{}", body);
2130}
2131
2132/// Find the CEF host binary in the runtime directory.
2133/// Tries versioned name first (agentmux-X.Y.Z.exe), then the old
2134/// agentmux-cef-X.Y.Z.exe pattern for backwards compat, then plain
2135/// agentmux-cef.exe (dev mode).
2136fn find_cef_binary(runtime_dir: &std::path::Path) -> std::path::PathBuf {
2137    let ext = if cfg!(target_os = "windows") { ".exe" } else { "" };
2138
2139    let versioned = format!("agentmux-{}{}", env!("CARGO_PKG_VERSION"), ext);
2140    let versioned_path = runtime_dir.join(&versioned);
2141    if versioned_path.exists() {
2142        return versioned_path;
2143    }
2144
2145    if let Ok(entries) = std::fs::read_dir(runtime_dir) {
2146        let prefix = "agentmux-";
2147        let cef_prefix = "agentmux-cef";
2148        for entry in entries.flatten() {
2149            let name = entry.file_name();
2150            let name = name.to_string_lossy();
2151            if name.starts_with(prefix)
2152                && !name.starts_with(cef_prefix)
2153                && !name.starts_with("agentmux-srv")
2154                // CRITICAL for the flat dev layout (macOS/Linux Phase 1):
2155                // launcher + host + srv share one dir, so the launcher
2156                // binary itself matches `agentmux-*`. Without this guard
2157                // the launcher resolves ITSELF as the host and spawns a
2158                // recursive launcher fork bomb. On Windows the launcher
2159                // lives at the root (not in runtime/), so this is a no-op.
2160                && !name.starts_with("agentmux-launcher")
2161                && name.ends_with(ext)
2162            {
2163                return entry.path();
2164            }
2165        }
2166    }
2167
2168    let versioned_old = format!("agentmux-cef-{}{}", env!("CARGO_PKG_VERSION"), ext);
2169    let versioned_old_path = runtime_dir.join(&versioned_old);
2170    if versioned_old_path.exists() {
2171        return versioned_old_path;
2172    }
2173
2174    runtime_dir.join(format!("agentmux-cef{}", ext))
2175}