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// docs/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 autostart;
27mod background_config;
28mod binary_resolution;
29mod data_dir;
30mod diag;
31mod event_log;
32mod hash;
33mod host_pipe;
34mod host_spawn;
35mod ipc;
36mod job_object;
37mod logging;
38mod mem_supervisor;
39// OS toast presenter (SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24). Started by the
40// Windows and Linux supervisors; macOS has no backend yet (§6.2).
41#[cfg_attr(target_os = "macos", allow(dead_code))]
42mod notify;
43mod start_at_login;
44mod other_instances;
45mod reducer;
46mod saga;
47mod second_instance;
48mod supervisor;
49mod teardown_backstop;
50mod tray;
51mod ui_liveness;
52// Windows-only consumer (supervisor/windows.rs); pure parts are tested everywhere.
53#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
54mod host_hang_dump;
55mod upgrade;
56#[cfg(target_os = "windows")]
57mod splash;
58#[cfg(target_os = "macos")]
59mod splash_mac;
60#[cfg(target_os = "linux")]
61mod splash_linux;
62// Splash footer support. The baked font + software text blitter are only used by
63// the software-buffer backends (Linux, Windows); macOS renders native text.
64#[cfg(any(target_os = "linux", target_os = "windows"))]
65mod splash_font;
66#[cfg(any(target_os = "linux", target_os = "windows"))]
67mod splash_text;
68mod splash_config;
69mod splash_core;
70mod splash_info;
71mod startup_events;
72// srv hang-while-alive detection plugs into srv recycle-on-exit
73// (SRV_RESTART_BUDGET, supervisor/mod.rs), which is Windows-only today.
74#[cfg(target_os = "windows")]
75mod srv_liveness;
76mod srv_spawner;
77#[cfg(unix)]
78mod stale_srv;
79mod state;
80mod wrr;
81
82use binary_resolution::find_cef_binary;
83use logging::log;
84
85/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
86/// fault terminates the process immediately instead of wedging it behind a
87/// modal. No-op off Windows. Spec:
88/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
89#[cfg(target_os = "windows")]
90fn suppress_os_crash_dialogs() {
91 use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
92 use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
93 unsafe {
94 // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
95 // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
96 // collection, the postmortem diagnostics this stability work needs.
97 // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
98 // reports" path.
99 let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
100 // SEM_FAILCRITICALERRORS suppresses the critical-error handler
101 // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
102 SetErrorMode(SEM_FAILCRITICALERRORS);
103 }
104}
105
106#[cfg(not(target_os = "windows"))]
107fn suppress_os_crash_dialogs() {}
108
109/// Process entry point. `suppress_os_crash_dialogs()` runs FIRST — before the
110/// Tokio runtime is built. The runtime is built explicitly here (rather than
111/// via `#[tokio::main]`, whose generated wrapper would construct it before any
112/// of our code runs) so a fault during runtime construction can't surface the
113/// Windows crash modal either. Spec:
114/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
115fn main() {
116 suppress_os_crash_dialogs();
117
118 // Dev/demo affordance: `--splash-selftest` shows the splash in isolation
119 // (no srv/host), holds it briefly, then exits — for eyeballing the footer +
120 // layout. See SPEC_SPLASH_USERINFO_AND_DISABLE_2026_06_21.md.
121 if std::env::args().any(|a| a == "--splash-selftest") {
122 splash_selftest();
123 return;
124 }
125
126 // Auto-start control verbs (issue #2977 WS2). Handled before any heavy
127 // startup work — they must not spawn srv/host or disturb a running
128 // instance. Gives an uninstaller a callable removal path, which is what
129 // Workstream 4 requires.
130 {
131 // Before anything reads it, and before any child could inherit it.
132 autostart::take_stable_exe_env();
133 let args: Vec<String> = std::env::args().collect();
134 if autostart::handle_cli(&args) {
135 return;
136 }
137 // Background-service mode + tray: resolve from env / `--background` /
138 // settings and export into OUR env, before any thread or child exists.
139 // Everything below (macOS headless pump, tray, host spawn) reads it.
140 // SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md §4.1.
141 background_config::apply(&args);
142 }
143
144 // macOS: paint the splash FIRST, on the main thread, before any heavy work
145 // — this is the whole reason the splash lives in the small fast launcher
146 // rather than the slow CEF host. AppKit must own the main thread, so the
147 // srv+host supervisor (`launcher_main`) runs on a worker thread with its
148 // own Tokio runtime; the splash pumps a CoreFoundation runloop on main
149 // until the host signals first paint. See `splash_mac`.
150 #[cfg(target_os = "macos")]
151 {
152 if splash_config::splash_disabled() {
153 if tray::should_enable(tray::tray_opt_in_from_env(), tray::background_service_from_env()) {
154 // Splash disabled BUT the menu-bar item is on (the default, and
155 // always in background-service mode): the main thread must
156 // still pump AppKit, or there is no menu-bar item and no reopen
157 // delegate once every window is closed (design doc §7.5.1).
158 // Same thread layout as the splash path, minus the window.
159 splash_mac::prepare_headless_app();
160 spawn_supervisor_thread(None);
161 splash_mac::pump_forever();
162 }
163 // Splash disabled and no menu-bar item → no AppKit at all; run the
164 // supervisor directly on the main thread (there's no runloop to
165 // pump without a window or a tray).
166 tokio::runtime::Runtime::new()
167 .expect("failed to build Tokio runtime")
168 .block_on(launcher_main(None));
169 return;
170 }
171 // Create the startup-event sink/receiver here (before the splash
172 // window exists) and thread the sink into launcher_main on the
173 // worker thread, the receiver into the splash on the main thread —
174 // mirrors the Linux branch below. Closes the gap where macOS
175 // previously always passed None and the receiver got dropped
176 // unread in run_unix. See SPEC_MACOS_LAUNCH_SPEED_AND_SPLASH_
177 // TELEMETRY_2026_07_02.md §B.3-B.4.
178 let (startup_sink, startup_rx) = startup_events::StartupEventSink::new();
179 let splash = splash_mac::Splash::show(startup_rx);
180 spawn_supervisor_thread(Some(startup_sink));
181 splash.run_until_dismissed(); // pumps the runloop, then parks forever
182 return;
183 }
184
185 #[cfg(not(target_os = "macos"))]
186 {
187 // Linux: paint the splash before any heavy work (mirrors the macOS path,
188 // but on its own thread since neither X11 nor Wayland needs the main
189 // thread). spawn() sets AGENTMUX_SPLASH_READY_FILE so the host — spawned
190 // later inside launcher_main — inherits it and signals first paint.
191 // Windows keeps spawning its splash inside launcher_main (event-name
192 // model). See splash_linux/.
193 #[cfg(target_os = "linux")]
194 let linux_startup_sink = {
195 let (sink, rx) = startup_events::StartupEventSink::new();
196 if !splash_config::splash_disabled() {
197 splash_linux::spawn(rx);
198 } else {
199 drop(rx);
200 }
201 Some(sink)
202 };
203 #[cfg(not(target_os = "linux"))]
204 let linux_startup_sink: Option<startup_events::StartupEventSink> = None;
205
206 tokio::runtime::Runtime::new()
207 .expect("failed to build Tokio runtime")
208 .block_on(launcher_main(linux_startup_sink));
209 }
210}
211
212/// macOS: run the srv+host supervisor (`launcher_main`) on a worker thread with
213/// its own Tokio runtime, leaving the main thread to AppKit. The thread owns
214/// process lifetime: it `exit`s when the supervisor returns, which is what ends
215/// the main thread's pump loop.
216#[cfg(target_os = "macos")]
217fn spawn_supervisor_thread(startup_sink: Option<startup_events::StartupEventSink>) {
218 std::thread::Builder::new()
219 .name("launcher-supervisor".into())
220 .spawn(move || {
221 // Catch panics so a supervisor crash always exits the process
222 // rather than leaving the main-thread AppKit runloop spinning
223 // as an invisible orphan.
224 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
225 tokio::runtime::Runtime::new()
226 .expect("failed to build Tokio runtime")
227 .block_on(launcher_main(startup_sink));
228 }));
229 if result.is_err() {
230 eprintln!("AgentMux launcher supervisor panicked — exiting");
231 std::process::exit(1);
232 }
233 // Supervisor finished cleanly (host exited / fatal).
234 std::process::exit(0);
235 })
236 .expect("failed to spawn launcher supervisor thread");
237}
238
239/// `--splash-selftest`: show the splash with no srv/host behind it, hold it for a
240/// few seconds (or `AGENTMUX_SPLASH_HOLD_MS`), then exit. A demo/dev affordance
241/// for eyeballing the footer + centering without launching the whole app.
242fn splash_selftest() {
243 let hold = std::env::var("AGENTMUX_SPLASH_HOLD_MS")
244 .ok()
245 .and_then(|s| s.parse::<u64>().ok())
246 .map(|ms| std::time::Duration::from_millis(ms.max(3000)))
247 .unwrap_or_else(|| std::time::Duration::from_secs(6));
248
249 #[cfg(target_os = "linux")]
250 {
251 let (sink, rx) = startup_events::StartupEventSink::new();
252 // Fire fake startup events so the stage list is exercised.
253 std::thread::spawn(move || {
254 std::thread::sleep(std::time::Duration::from_millis(300));
255 sink.stage_begin("prep", "Launcher setup");
256 std::thread::sleep(std::time::Duration::from_millis(120));
257 sink.stage_end("prep", 120, startup_events::StartupStatus::Ok, None);
258 std::thread::sleep(std::time::Duration::from_millis(100));
259 sink.stage_begin("migrations", "Migrations");
260 sink.sub_begin("migrations", "0009", "cron_schema");
261 std::thread::sleep(std::time::Duration::from_millis(80));
262 sink.sub_end("migrations", "0009", 80, startup_events::StartupStatus::Ok, None);
263 sink.sub_begin("migrations", "0010", "identity_dedup");
264 std::thread::sleep(std::time::Duration::from_millis(40));
265 sink.sub_end("migrations", "0010", 40, startup_events::StartupStatus::Ok, None);
266 sink.stage_end("migrations", 220, startup_events::StartupStatus::Ok, None);
267 std::thread::sleep(std::time::Duration::from_millis(200));
268 sink.stage_begin("backend", "Backend startup");
269 std::thread::sleep(std::time::Duration::from_millis(1500));
270 sink.stage_end("backend", 1500, startup_events::StartupStatus::Ok, None);
271 });
272 splash_linux::spawn(rx);
273 std::thread::sleep(hold);
274 }
275 #[cfg(target_os = "macos")]
276 {
277 let (sink, rx) = startup_events::StartupEventSink::new();
278 // Fire fake startup events so the stage panel is exercised — same
279 // fixture shape as the Linux branch above.
280 std::thread::spawn(move || {
281 std::thread::sleep(std::time::Duration::from_millis(300));
282 sink.stage_begin("prep", "Launcher setup");
283 std::thread::sleep(std::time::Duration::from_millis(120));
284 sink.stage_end("prep", 120, startup_events::StartupStatus::Ok, None);
285 std::thread::sleep(std::time::Duration::from_millis(100));
286 sink.stage_begin("migrations", "Migrations");
287 sink.sub_begin("migrations", "0009", "cron_schema");
288 std::thread::sleep(std::time::Duration::from_millis(80));
289 sink.sub_end("migrations", "0009", 80, startup_events::StartupStatus::Ok, None);
290 sink.sub_begin("migrations", "0010", "identity_dedup");
291 std::thread::sleep(std::time::Duration::from_millis(40));
292 sink.sub_end("migrations", "0010", 40, startup_events::StartupStatus::Ok, None);
293 sink.stage_end("migrations", 220, startup_events::StartupStatus::Ok, None);
294 std::thread::sleep(std::time::Duration::from_millis(200));
295 sink.stage_begin("backend", "Backend startup");
296 std::thread::sleep(std::time::Duration::from_millis(1500));
297 sink.stage_end("backend", 1500, startup_events::StartupStatus::Ok, None);
298 std::thread::sleep(std::time::Duration::from_millis(100));
299 sink.stage_begin("host", "Host startup");
300 std::thread::sleep(std::time::Duration::from_millis(90));
301 sink.stage_end("host", 90, startup_events::StartupStatus::Ok, None);
302 });
303 let splash = splash_mac::Splash::show(rx);
304 if let Ok(p) = std::env::var("AGENTMUX_SPLASH_DUMP_PNG") {
305 splash.dump_png(&p);
306 }
307 let _ = splash; // run_until_dismissed parks; selftest just holds then exits
308 std::thread::sleep(hold);
309 }
310 #[cfg(target_os = "windows")]
311 {
312 let (_sink, rx) = startup_events::StartupEventSink::new();
313 let _ = splash::spawn_splash("selftest", rx, false);
314 std::thread::sleep(hold);
315 }
316}
317
318async fn launcher_main(startup_sink: Option<startup_events::StartupEventSink>) {
319 let exe_path = std::env::current_exe().expect("cannot resolve exe path");
320 let exe_dir = exe_path.parent().expect("exe has no parent directory");
321 // Production + Windows dev use a `runtime/` subdir (launcher at root,
322 // host + libs + srv under runtime/). The macOS/Linux `task dev` flat
323 // layout (Phase 1, SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30)
324 // drops the launcher next to the host in dist/cef-dev/ so the host's
325 // `../Frameworks` resolution and asset anchoring are byte-identical to
326 // the legacy direct-invoke path — no `runtime/` to descend into. Fall
327 // back to exe_dir when there's no runtime/ subdir. Windows always has
328 // one, so its behavior is unchanged.
329 let runtime_dir = {
330 let rt = exe_dir.join("runtime");
331 if rt.is_dir() {
332 rt
333 } else {
334 exe_dir.to_path_buf()
335 }
336 };
337
338 log(&format!(
339 "starting — exe={} runtime={}",
340 exe_path.display(),
341 runtime_dir.display()
342 ));
343
344 // Set DLL search path so libcef.dll (in runtime/) is found by the
345 // CEF host's load-time linker. SetDllDirectoryW is process-local
346 // and inherited by child processes — both srv (which doesn't
347 // need libcef but harmless) and host (which absolutely does).
348 #[cfg(target_os = "windows")]
349 {
350 use std::os::windows::ffi::OsStrExt;
351 let wide: Vec<u16> = runtime_dir
352 .as_os_str()
353 .encode_wide()
354 .chain(Some(0))
355 .collect();
356 unsafe {
357 windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
358 }
359 }
360 log("SetDllDirectoryW done");
361
362 let args: Vec<String> = std::env::args().skip(1).collect();
363
364 // LSD-3 — `agentmux.exe --diag sagas` is OFFLINE: it reads the
365 // launcher saga SQLite log directly, with no IPC and no running
366 // launcher. So it MUST run BEFORE the CEF runtime existence
367 // check below — the offline-diagnostic value is most needed
368 // exactly when the launcher won't start (e.g. corrupt runtime
369 // folder). (codex P1 + reagent P1 PR #647 round 3.)
370 if matches!(
371 (args.first().map(String::as_str), args.get(1).map(String::as_str)),
372 (Some("--diag"), Some("sagas"))
373 ) {
374 match diag::run_sagas_diag(exe_dir).await {
375 Ok(()) => std::process::exit(0),
376 Err(msg) => {
377 eprintln!("--diag sagas failed: {}", msg);
378 std::process::exit(1);
379 }
380 }
381 }
382
383 let real_exe = find_cef_binary(&runtime_dir);
384 log(&format!("resolved CEF binary: {}", real_exe.display()));
385 // Self-spawn guard: if host resolution ever points back at the
386 // launcher's own binary (the flat dev layout's failure mode —
387 // launcher + host co-located), spawning it would recurse into an
388 // unbounded launcher fork bomb. find_cef_binary excludes
389 // `agentmux-launcher` by name; this is the loud backstop in case a
390 // future binary slips past that filter. Compare canonicalized paths
391 // so symlink/`./` differences don't defeat the check.
392 if let (Ok(a), Ok(b)) = (
393 std::fs::canonicalize(&real_exe),
394 std::fs::canonicalize(&exe_path),
395 ) {
396 if a == b {
397 log(&format!(
398 "FATAL: host resolved to the launcher's own binary ({}) — refusing to self-spawn",
399 a.display()
400 ));
401 eprintln!("AgentMux runtime is misconfigured (host == launcher). Aborting.");
402 std::process::exit(1);
403 }
404 }
405 if !real_exe.exists() {
406 log(&format!(
407 "FATAL: CEF binary not found at {}",
408 real_exe.display()
409 ));
410 eprintln!(
411 "AgentMux runtime not found in: {}\nMake sure the runtime/ folder is intact.",
412 runtime_dir.display()
413 );
414 std::process::exit(1);
415 }
416
417 log(&format!("forwarding {} CLI args to host", args.len()));
418
419 // Phase B.8 — `agentmux.exe --diag wrr` and `--diag srv` Tool
420 // clients. Connect to the running launcher (or srv) over IPC,
421 // capture events for a short window, print summary, exit.
422 // (Note: --diag sagas is handled above, before the CEF runtime
423 // check, since it doesn't need IPC.)
424 if matches!(args.first().map(String::as_str), Some("--diag")) {
425 let topic = args.get(1).map(String::as_str).unwrap_or("");
426 match topic {
427 "wrr" => match diag::run_wrr_diag(exe_dir).await {
428 Ok(()) => std::process::exit(0),
429 Err(msg) => {
430 eprintln!("--diag wrr failed: {}", msg);
431 std::process::exit(1);
432 }
433 },
434 // Phase E.7 — operator visibility into the srv reducer's
435 // canonical state (workspaces / tabs / blocks / sagas) +
436 // recent activity. Same `Tool` IPC pattern as `--diag wrr`,
437 // talks to the srv pipe instead of the launcher pipe.
438 "srv" => match diag::run_srv_diag(exe_dir).await {
439 Ok(()) => std::process::exit(0),
440 Err(msg) => {
441 eprintln!("--diag srv failed: {}", msg);
442 std::process::exit(1);
443 }
444 },
445 // sagas is handled above, before the runtime check.
446 "sagas" => {
447 // Should never reach here — `sagas` is matched + handled
448 // above the CEF runtime check. Kept for completeness.
449 unreachable!("--diag sagas is handled before runtime check");
450 }
451 "" => {
452 eprintln!("usage: agentmux.exe --diag <topic>\nknown topics: wrr, srv, sagas");
453 std::process::exit(2);
454 }
455 other => {
456 eprintln!("unknown --diag topic: {} (known: wrr, srv, sagas)", other);
457 std::process::exit(2);
458 }
459 }
460 }
461
462 #[cfg(target_os = "windows")]
463 {
464 let _ = startup_sink;
465 supervisor::run_windows(exe_dir, &real_exe, &args).await;
466 }
467
468 #[cfg(not(target_os = "windows"))]
469 {
470 // Phase 1 (SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30):
471 // the launcher now owns srv + host on macOS/Linux too — it
472 // spawns the backend, hands the host its endpoints via env,
473 // and supervises both with the same crash budget Windows uses.
474 // The legacy exec-into-host escape hatch lives in
475 // `task dev:standalone` (host invoked directly, no launcher).
476 supervisor::run_unix(exe_dir, &real_exe, &args, startup_sink).await;
477 }
478}
479
480/// Show a modal error dialog before the launcher exits. Used for
481/// genuine bind failures (NOT the "already running" path — that
482/// silently forwards via `forward_open_new_window`). Without this,
483/// the launcher exit is silent (it has the `windows` subsystem in
484/// release, so eprintln! goes nowhere).
485#[cfg(target_os = "windows")]
486pub(crate) fn show_fatal_dialog(title: &str, body: &str) {
487 use std::os::windows::ffi::OsStrExt;
488 use windows_sys::Win32::UI::WindowsAndMessaging::{
489 MessageBoxW, MB_ICONWARNING, MB_OK, MB_SETFOREGROUND, MB_TOPMOST,
490 };
491 let title_w: Vec<u16> = std::ffi::OsStr::new(title)
492 .encode_wide()
493 .chain(Some(0))
494 .collect();
495 let body_w: Vec<u16> = std::ffi::OsStr::new(body)
496 .encode_wide()
497 .chain(Some(0))
498 .collect();
499 unsafe {
500 MessageBoxW(
501 std::ptr::null_mut(),
502 body_w.as_ptr(),
503 title_w.as_ptr(),
504 MB_OK | MB_ICONWARNING | MB_SETFOREGROUND | MB_TOPMOST,
505 );
506 }
507}
508
509#[cfg(not(target_os = "windows"))]
510pub(crate) fn show_fatal_dialog(_title: &str, body: &str) {
511 eprintln!("{}", body);
512}
513