agentmux_cef/
lib.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// AgentMux Host — Library entry point.
5//
6// This cdylib is the Windows DLL wrapper sandbox target (Phase 3, issue #1374).
7// `bootstrap.exe` (renamed `agentmux-cef.exe`) loads this DLL and calls
8// `RunWinMain` with a pre-initialized `cef_sandbox_info_t *` pointer, which we
9// forward into `run()`.
10//
11// On non-Windows or sandbox-off builds, only the `[[bin]]` target is used;
12// this file must still compile on those platforms.
13
14mod app;
15mod browser_api;
16mod browser_panes;
17mod client;
18mod commands;
19mod dev_authfile;
20mod drag_stash;
21mod events;
22mod ipc;
23mod launcher_event_bridge;
24mod launcher_ipc;
25mod parent_process;
26mod srv_event_bridge;
27mod srv_ipc;
28mod memory_heartbeat;
29mod memory_pressure;
30mod browser_pane;
31#[cfg(target_os = "windows")]
32mod floating_pane;
33mod reducer;
34mod saga_dispatch;
35mod sidecar;
36mod state;
37mod ui_tasks;
38mod wrr;
39#[cfg(target_os = "macos")]
40mod macos_menu;
41
42use std::sync::Arc;
43
44use cef::*;
45
46/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
47/// fault (a Chromium `LOG(FATAL)`, an `abort()`, a breakpoint) terminates the
48/// process immediately instead of wedging it behind a modal the user must
49/// dismiss. While that dialog is up the process is frozen and cannot be
50/// auto-recovered. No-op off Windows. Spec:
51/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
52#[cfg(target_os = "windows")]
53fn suppress_os_crash_dialogs() {
54    use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
55    use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
56    // Process-wide; also covers the CEF subprocesses.
57    unsafe {
58        // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
59        // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
60        // collection, the postmortem diagnostics this stability work needs.
61        // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
62        // reports" path.
63        let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
64        // SEM_FAILCRITICALERRORS suppresses the critical-error handler
65        // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
66        SetErrorMode(SEM_FAILCRITICALERRORS);
67    }
68}
69
70#[cfg(not(target_os = "windows"))]
71fn suppress_os_crash_dialogs() {}
72
73/// Resolve CEF's `browser_subprocess_path` (the executable CEF spawns for
74/// renderer / GPU / utility processes).
75///
76/// On a packaged macOS `.app`, return the dedicated `AgentMux Helper`
77/// executable inside `Contents/Frameworks/AgentMux Helper.app` — re-execing
78/// the main bundle binary for subprocesses is rejected by the macOS process
79/// model (every child would inherit the main bundle's identity), which makes
80/// the renderers crash-loop. When no Helper.app is present (dev: a bare binary
81/// with no bundle) fall back to the current exe — self-reexec works there.
82/// Windows/Linux always use the current exe (self-reexec is fine off-bundle).
83#[cfg(target_os = "macos")]
84fn resolve_browser_subprocess_path() -> String {
85    let exe = std::env::current_exe().unwrap();
86    // exe = AgentMux.app/Contents/MacOS/agentmux-cef
87    // → AgentMux.app/Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
88    if let Some(contents) = exe.parent().and_then(|macos| macos.parent()) {
89        let helper = contents
90            .join("Frameworks")
91            .join("AgentMux Helper.app")
92            .join("Contents")
93            .join("MacOS")
94            .join("AgentMux Helper");
95        if helper.exists() {
96            return helper.to_string_lossy().into_owned();
97        }
98    }
99    exe.to_string_lossy().into_owned()
100}
101
102#[cfg(not(target_os = "macos"))]
103fn resolve_browser_subprocess_path() -> String {
104    std::env::current_exe()
105        .ok()
106        .and_then(|p| p.to_str().map(|s| s.to_owned()))
107        .unwrap_or_default()
108}
109
110/// True when a launcher is THIS host's actual parent process this run.
111///
112/// The launcher (which spawns the host directly) stamps
113/// `AGENTMUX_LAUNCHER_PID` with its own pid; since we are its direct
114/// child, that pid equals our `getppid()`. This distinguishes a fresh,
115/// authoritative launcher hand-off from a STALE `AGENTMUX_BACKEND_WS`
116/// inherited down the environment from a parent agentmux pane (whose
117/// launcher pid will not match our real parent). Used only to relax the
118/// dev-build "ignore the env hand-off" rule for the macOS/Linux Phase 1
119/// launcher dev integration.
120///
121/// Unix-only — on other platforms it returns `false`, leaving the
122/// existing dev-build behavior unchanged.
123#[cfg(unix)]
124fn launcher_is_genuine_parent() -> bool {
125    match std::env::var("AGENTMUX_LAUNCHER_PID")
126        .ok()
127        .and_then(|s| s.trim().parse::<i32>().ok())
128    {
129        // SAFETY: getppid() takes no arguments, touches no memory, and is
130        // documented as always succeeding.
131        Some(pid) => pid == unsafe { libc::getppid() },
132        None => false,
133    }
134}
135
136#[cfg(not(unix))]
137fn launcher_is_genuine_parent() -> bool {
138    false
139}
140
141/// Run the AgentMux host.
142///
143/// `windows_sandbox_info` is the `cef_sandbox_info_t *` created by
144/// `bootstrap.exe` before loading this DLL (Windows + sandbox feature), or
145/// `null_mut()` on all other platforms / sandbox-off builds.
146pub fn run(windows_sandbox_info: *mut std::ffi::c_void) -> i32 {
147    // Phase 0 (service supervision & recovery): suppress the Windows crash
148    // modal so a fault terminates the process immediately instead of freezing
149    // it behind an "Application Error" dialog. Must be the first statement —
150    // set before anything can fault.
151    suppress_os_crash_dialogs();
152
153    // Set the DLL search path so CEF's runtime LoadLibrary calls (chrome_elf,
154    // libEGL, libGLESv2, d3dcompiler_47, …) resolve against the directory that
155    // actually holds libcef.dll. Two layouts exist:
156    //
157    //   Portable / installed: <root>/runtime/host.exe + libcef.dll alongside.
158    //                         The launcher (agentmux-launcher) already sets
159    //                         the path to <root>/runtime/ before spawning us;
160    //                         this block is a no-op safety net for that mode.
161    //
162    //   Dev (`task dev`):     dist/cef-dev/agentmux-cef.exe + libcef.dll
163    //                         alongside (flat layout). Taskfile launches the
164    //                         host directly with no launcher, so nothing else
165    //                         has set the DLL path. Without it, CEF's internal
166    //                         LoadLibrary chain can fail and `cef::initialize`
167    //                         returns 0 — the empty-chrome_debug.log mode.
168    //
169    // Fall back to the host's own directory whenever a runtime/ subdir isn't
170    // present. Idempotent in portable mode (launcher set it first), correct
171    // in dev mode.
172    #[cfg(target_os = "windows")]
173    {
174        if let Ok(exe) = std::env::current_exe() {
175            if let Some(dir) = exe.parent() {
176                let runtime_dir = dir.join("runtime");
177                let dll_dir = if runtime_dir.exists() {
178                    runtime_dir
179                } else {
180                    dir.to_path_buf()
181                };
182                unsafe {
183                    use std::os::windows::ffi::OsStrExt;
184                    let wide: Vec<u16> = dll_dir.as_os_str().encode_wide().chain(Some(0)).collect();
185                    windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
186                }
187            }
188        }
189    }
190
191    // Tracing is initialized after the subprocess check below — browser process
192    // gets dual file+stderr output; subprocesses exit before tracing is needed.
193
194    // Escape hatch — read once here so both macOS subprocess sandbox init
195    // (below) and the browser-process Settings construction share the same value.
196    let force_no_sandbox = std::env::var("AGENTMUX_UNSAFE_NOSANDBOX")
197        .map(|v| v.trim() == "1")
198        .unwrap_or(false);
199
200    // macOS sandbox availability: probe for libcef_sandbox.dylib to detect
201    // whether we are running inside a packaged .app bundle. Two uses:
202    //   1. Seatbelt init (subprocess only) — skip if dylib not reachable.
203    //   2. no_sandbox flag to CefInitialize — must be 1 when not in bundle,
204    //      otherwise CEF kills GPU/network/renderer with a sandbox error.
205    //
206    // The path is relative to current_exe().parent() and differs by role
207    // because the Host and Helper processes live at different depths:
208    //
209    //   Host   — Contents/MacOS/AgentMux
210    //            → ../Frameworks/Chromium Embedded Framework.framework/…
211    //
212    //   Helper — Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
213    //            → ../../../Chromium Embedded Framework.framework/…
214    //
215    // In task dev neither path exists (flat dist/cef-dev/ tree), so the probe
216    // returns false for both roles → sandbox skipped, no crash.
217    #[cfg(all(target_os = "macos", feature = "sandbox"))]
218    let macos_sandbox_available = {
219        let is_subprocess = std::env::args().any(|a| a.starts_with("--type="));
220        let rel: &str = if is_subprocess {
221            "../../../Chromium Embedded Framework.framework/Libraries/libcef_sandbox.dylib"
222        } else {
223            "../Frameworks/Chromium Embedded Framework.framework/Libraries/libcef_sandbox.dylib"
224        };
225        std::env::current_exe()
226            .ok()
227            .and_then(|p| p.parent().map(|d| d.join(rel)))
228            .map(|p| p.exists())
229            .unwrap_or(false)
230    };
231
232    // Non-macOS / sandbox-off: define macos_sandbox_available as a no-op
233    // placeholder so the no_sandbox computation below compiles on all targets.
234    // On those targets cfg!(all(target_os="macos",feature="sandbox")) is false,
235    // so the `&& !macos_sandbox_available` branch is never reached at runtime.
236    #[cfg(not(all(target_os = "macos", feature = "sandbox")))]
237    let macos_sandbox_available = true; // irrelevant — guarded by cfg!() in no_sandbox
238
239    // macOS: initialize Seatbelt sandbox context BEFORE the CEF framework is
240    // loaded. This is the order required by CEF (cefsimple/process_helper_mac.cc):
241    //   1. cef_sandbox_initialize     ← here, before dlopen
242    //   2. LoadInHelper / _library    ← CEF framework loaded within the sandbox
243    //   3. CefExecuteProcess
244    // Subprocess mode is detected from raw argv (--type=…) because cef::Args
245    // cannot be parsed until after the framework is loaded. Browser process
246    // skips this entirely — the host process is unsandboxed by design.
247    #[cfg(all(target_os = "macos", feature = "sandbox"))]
248    let _macos_sandbox = if std::env::args().any(|a| a.starts_with("--type="))
249        && !force_no_sandbox
250        && macos_sandbox_available
251    {
252        let raw = cef::args::Args::new();
253        let mut s = cef::sandbox::Sandbox::new();
254        s.initialize(raw.as_main_args());
255        Some(s)
256    } else {
257        None
258    };
259
260    // macOS: load the CEF framework library explicitly.
261    // For subprocesses this load happens within the Seatbelt policy established above.
262    #[cfg(target_os = "macos")]
263    let _library = {
264        let exe = std::env::current_exe().unwrap();
265        // Inside a packaged .app, renderer/GPU/utility subprocesses run as the
266        // bundled "AgentMux Helper" at
267        // …/Contents/Frameworks/AgentMux Helper.app/Contents/MacOS/AgentMux Helper
268        // — 4 levels below the framework — so it must resolve the framework via
269        // the deeper `../../../../Frameworks/…` path (helper=true). The main
270        // host (Contents/MacOS/) and the bare dev binary use `../Frameworks/…`
271        // (helper=false). Detect the helper by its exe path; the main bundle
272        // binary is never under Contents/Frameworks/. See
273        // docs/specs/SPEC_MACOS_PACKAGING_2026_05_30.md.
274        let is_helper = exe.to_string_lossy().contains(".app/Contents/Frameworks/");
275        let loader = library_loader::LibraryLoader::new(&exe, is_helper);
276        assert!(loader.load(), "Failed to load CEF framework");
277        loader
278    };
279
280    // Initialize the CEF API hash for version verification.
281    let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
282
283    // Parse command-line arguments.
284    let args = cef::args::Args::new();
285    let Some(cmd_line) = args.as_cmd_line() else {
286        eprintln!("agentmux-cef: Failed to parse command line arguments");
287        std::process::exit(1);
288    };
289
290    // Detect subprocess mode: CEF injects --type=renderer|gpu-process|utility
291    // for child processes. If --type is present, this is a subprocess.
292    let type_switch = CefString::from("type");
293    let is_browser_process = cmd_line.has_switch(Some(&type_switch)) != 1;
294
295    // Execute subprocess if applicable (exits here for non-browser processes).
296    let ret = execute_process(
297        Some(args.as_main_args()),
298        None, // App can be None for subprocess
299        windows_sandbox_info as *mut u8,
300    );
301
302    if is_browser_process {
303        // Browser process: execute_process returns -1, we continue with initialization.
304        assert_eq!(ret, -1, "execute_process should return -1 for browser process");
305    } else {
306        // Subprocess: execute_process returns the exit code.
307        let process_type = CefString::from(&cmd_line.switch_value(Some(&type_switch)));
308        eprintln!("agentmux-cef: subprocess exiting: type={}", process_type);
309        assert!(ret >= 0, "execute_process failed for subprocess");
310        std::process::exit(ret);
311    }
312
313    // -----------------------------------------------------------------------
314    // Browser process initialization
315    // -----------------------------------------------------------------------
316
317    // Set the Application User Model ID before any UI is created. This lets
318    // Windows group our windows under one pinned identity and is required for
319    // the `DeleteTab` + per-HWND AppID treatment used by the full-instance /
320    // sub-window model (see docs/specs/SPEC_MULTIWINDOW_TASKBAR_GROUPING.md).
321    // Use a VERSION-STABLE ID — never embed the patch number or pinning forks.
322    #[cfg(target_os = "windows")]
323    unsafe {
324        use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
325        let aumid: Vec<u16> = "AgentMuxCorp.AgentMux\0".encode_utf16().collect();
326        let _ = SetCurrentProcessExplicitAppUserModelID(aumid.as_ptr());
327    }
328
329    let version = env!("CARGO_PKG_VERSION");
330
331    // Read paths + mode from the launcher-injected env vars. Two
332    // reachable configurations:
333    //   a) Launcher-managed startup → env vars present, from_env()
334    //      returns Some.
335    //   b) Standalone `task dev` → env absent. We re-derive via
336    //      `RuntimeMode::current` + `DataPaths::resolve` (symmetric
337    //      with sidecar.rs::spawn_backend's fallback so they agree on
338    //      the disk layout).
339    let host_exe_dir = std::env::current_exe()
340        .ok()
341        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
342        .unwrap_or_default();
343    // Dev builds NEVER inherit AGENTMUX_* env vars from a parent process.
344    // `task dev` is routinely launched from inside an AgentMux terminal
345    // pane, which means the child host inherits the parent instance's
346    // AGENTMUX_DATA_DIR pointing at the parent's version-isolated dir.
347    // Without this guard the dev build would resolve its data dir to the
348    // running portable's path and trip CEF's process-singleton lock,
349    // routing every "open" back to the existing window — the user would
350    // never see the dev code run. Path-based detection is authoritative
351    // for dev builds; for installed/portable we still honor the
352    // launcher-provided env (it's the launcher's job to publish them).
353    let common_paths = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
354        let mode = agentmux_common::RuntimeMode::current_path_only(&host_exe_dir);
355        // resolve_path_only mirrors current_path_only's env-isolation:
356        // ignore inherited AGENTMUX_CHANNEL so a dev host launched from
357        // inside a parent agentmux instance doesn't redirect into the
358        // parent's channel (would trip the channel single-instance lock).
359        agentmux_common::DataPaths::resolve_path_only(version, &mode).ok()
360    } else {
361        agentmux_common::DataPaths::from_env().or_else(|| {
362            let mode = agentmux_common::RuntimeMode::current(&host_exe_dir);
363            agentmux_common::DataPaths::resolve(version, &mode).ok()
364        })
365    };
366    let is_dev = match &common_paths {
367        Some(p) => matches!(p.mode, agentmux_common::RuntimeMode::Dev { .. }),
368        None => false,
369    };
370
371    let (data_dir, log_dir) = match &common_paths {
372        Some(p) => (p.cef_cache_dir.clone(), p.logs_dir.clone()),
373        None => {
374            // Both env-read AND fallback resolution failed (no home
375            // dir on disk, or platform unsupported). Use a degraded
376            // path so log init at least works; the runtime-startup
377            // check below will surface the underlying error.
378            (
379                std::path::PathBuf::from("."),
380                dirs::home_dir()
381                    .unwrap_or_default()
382                    .join(".agentmux")
383                    .join("logs"),
384            )
385        }
386    };
387    std::fs::create_dir_all(&data_dir).ok();
388
389    // Initialize dual-output tracing: rolling log file + stderr.
390    // The log file guard must live for the entire process to ensure flushing.
391    let _log_guard = init_logging(&log_dir);
392
393    tracing::info!(
394        version,
395        runtime_mode = ?common_paths.as_ref().map(|p| p.mode.to_env_string()),
396        data_dir = %data_dir.display(),
397        log_dir = %log_dir.display(),
398        "Initializing CEF browser process"
399    );
400
401    // macOS 26 Tahoe compat: CEF 146 calls private NSApplication selectors
402    // (e.g. `isHandlingSendEvent`, `isSendingEvent`) during NSDraggingSession
403    // setup. Apple removed them in macOS 26, so the calls go through
404    // `___forwarding___`, find nothing, and `objc_exception_throw` fires —
405    // AppKit's default uncaught-exception handler then calls `_objc_terminate`
406    // and the host dies (EXC_BREAKPOINT / SIGTRAP on CrBrowserMain). Inject
407    // `+[NSApplication resolveInstanceMethod:]` to add typed stubs *before*
408    // the forwarding machinery is entered. Must run before `cef::initialize`.
409    // See docs/specs/SPEC_MACOS_TEAROFF_STABILITY_2026_05_29.md and PR #403.
410    #[cfg(target_os = "macos")]
411    unsafe { patch_nsapp_unrecognized_selector() };
412
413    // macOS tear-off polish: suppress AppKit's native drag slide-back
414    // ("poof" / fly-back-to-origin) animation. When a pane/tab is torn
415    // off, the pointer is released OUTSIDE any DOM drop target (the new
416    // floating window is created on mouseup), so blink hands AppKit
417    // NSDragOperationNone and AppKit animates the drag image sliding
418    // back into the source window — the very "rejection" animation the
419    // tear-off is trying to avoid. `preventUnhandled` (PR #1186) only
420    // covers in-document drops, not this out-of-window case. Disable the
421    // session-level animation flag globally; we never want a
422    // drop-rejected slide-back anywhere in the app.
423    #[cfg(target_os = "macos")]
424    unsafe { disable_macos_drag_slideback() };
425
426    // Name the app early (sets CFBundleName on the main bundle, which AppKit
427    // may read when it first builds a menu). Also re-run post-init below, since
428    // Chromium can reset the process name during cef::initialize.
429    #[cfg(target_os = "macos")]
430    unsafe { set_macos_app_display_name() };
431
432    // Phase B.6 (post-fix): the named-pipe bind in the launcher is
433    // the AUTHORITATIVE single-instance lock — a second launcher
434    // hits ERROR_ACCESS_DENIED and never reaches the host. We still
435    // publish `<launcher-shared-data-dir>/ipc-port` (port:token) so
436    // the second launcher can FORWARD an `open_new_window` request
437    // to the existing instance over HTTP and exit silently — the
438    // legacy forwarding UX users expect when double-clicking the
439    // exe twice. The pipe-bind-first ordering closes the stale-state
440    // defect (gap #8 in
441    // specs/ANALYSIS_WINDOW_PROCESS_STATE_INVENTORY_2026_04_27.md):
442    // a stale ipc-port file from a hard crash is irrelevant on the
443    // FIRST-instance path because pipe-bind succeeds and the file is
444    // overwritten; on the SECOND-instance path the live first
445    // instance wrote a fresh port:token, so forwarding lands.
446    //
447    // CRITICAL: write the port file at the LAUNCHER-shared data dir
448    // (`AGENTMUX_DATA_DIR`, == `paths.data_dir` in the launcher), NOT
449    // the host-local CEF cache dir (`<portable>/data/cef/`). The two
450    // diverge in portable mode (cef cache is one level deeper) and
451    // the launcher's `forward_open_new_window` reads the launcher-
452    // shared path. Falls back to the cef cache dir only when the env
453    // is unset (`task dev` mode without launcher), where forwarding
454    // wouldn't be wired anyway.
455    // Dev builds inherit AGENTMUX_DATA_DIR from the parent pane they were
456    // launched from. Writing ipc-port there would overwrite the parent
457    // instance's port:token and break its single-instance forwarding.
458    // In dev mode there is no launcher so port forwarding isn't wired
459    // anyway — use the dev data dir directly.
460    let port_file_dir = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
461        data_dir.clone()
462    } else {
463        std::env::var_os("AGENTMUX_DATA_DIR")
464            .map(std::path::PathBuf::from)
465            .unwrap_or_else(|| data_dir.clone())
466    };
467    let _ = std::fs::create_dir_all(&port_file_dir);
468    // Use a version-scoped filename so two concurrent release versions
469    // don't overwrite each other's port file (codex P1 on #1227).
470    // AGENTMUX_IPC_HASH is set by the launcher to hash(data_dir, version).
471    let port_file_name = std::env::var("AGENTMUX_IPC_HASH")
472        .ok()
473        .filter(|h| !h.is_empty())
474        .map(|h| format!("ipc-port-{}", h))
475        .unwrap_or_else(|| "ipc-port".to_string());
476    let port_file = port_file_dir.join(&port_file_name);
477
478    // Create shared application state.
479    let app_state = Arc::new(state::AppState::default());
480
481    // Start tokio runtime for async operations (IPC server, sidecar management).
482    let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
483
484    // Install the runtime Handle into browser_pane::auth so the
485    // CEF `get_auth_credentials` callback (which runs on CEF's IO
486    // thread) can spawn the parked-auth TTL timer. A bare
487    // `tokio::spawn` there would panic with "there is no reactor
488    // running" because that thread has no `Handle::current()`.
489    browser_pane::auth::set_runtime_handle(runtime.handle().clone());
490
491    // Start the IPC HTTP server and get the assigned port.
492    let ipc_port = runtime.block_on(ipc::start_ipc_server(app_state.clone()));
493    *app_state.ipc_port.lock() = ipc_port;
494
495    tracing::info!("IPC server started on port {}", ipc_port);
496
497    // Phase B.2: connect to launcher's named-pipe IPC (if launcher
498    // is in the loop) so the launcher can route Commands and Events
499    // through us. The handle is held in main scope for the host's
500    // lifetime — dropping it closes the pipe (logged by launcher).
501    // Failure to connect is non-fatal in B.2 (host can still run);
502    // B.5+ will tighten when the host depends on IPC for state.
503    //
504    // Env-isolation guard: a dev build inheriting
505    // `AGENTMUX_LAUNCHER_PIPE` from a parent AgentMux pane (e.g. a
506    // shell inside an active pane that re-invokes the host directly)
507    // would otherwise connect to that parent's launcher pipe and
508    // route its host events into the parent's launcher state.
509    //
510    // Discriminator: connect when our parent process IS the launcher
511    // (production portable, installed build, OR post-#SPEC_LAUNCHER_DEV_INTEGRATION
512    // `task dev` which spawns the host via the launcher). Skip when
513    // it isn't.
514    //
515    // Older path-only guard (`is_dev_build_exe`) over-fired in dev
516    // mode after launcher integration shipped — see
517    // docs/specs/SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md.
518    let parent_is_launcher = parent_process::parent_is_agentmux_launcher();
519    let should_connect_launcher = match parent_is_launcher {
520        Some(true) => true,
521        Some(false) => false,
522        // Parent detection failed — fall back to the path-based guard
523        // so production builds still connect (they would otherwise
524        // silently lose the launcher IPC) and dev builds still skip.
525        None => !agentmux_common::is_dev_build_exe(&host_exe_dir),
526    };
527    let _launcher_ipc = if should_connect_launcher {
528        runtime.block_on(launcher_ipc::connect_to_launcher(app_state.clone()))
529    } else {
530        None
531    };
532
533    // Phase E.2c.5a — connect to the srv reducer's pipe. Forwards
534    // srv events (workspace / tab / block lifecycle) to every
535    // top-level renderer via the JS bridge. Renderer-side handler
536    // (`window.__agentmux_srv_event`) lands in E.2c.5b. Non-fatal
537    // if absent: `AGENTMUX_SRV_PIPE_PATH` is only set on the srv
538    // child by the launcher (`agentmux-launcher/src/srv_spawner.rs`),
539    // not on the host spawn — so today the host never has the env
540    // var and `connect_to_srv` short-circuits to None at
541    // `srv_ipc.rs:62-68`. Path-based dev guard is the right gate
542    // for this branch; restoring full srv-IPC parity in dev needs
543    // the launcher to propagate the env var to the host first.
544    // See spec §11 of SPEC_DEV_MODE_LAUNCHER_IPC_2026_05_16.md.
545    let _srv_ipc = if agentmux_common::is_dev_build_exe(&host_exe_dir) {
546        None
547    } else {
548        runtime.block_on(srv_ipc::connect_to_srv(app_state.clone()))
549    };
550
551    // Phase B.1: if launcher already spawned srv (the normal portable
552    // / installed path post-PR-#570 + B.1), populate state from the
553    // env vars launcher set — no need to re-spawn srv. Falls back to
554    // spawn_backend() ONLY when env vars are absent (`task dev` mode
555    // where the host runs without the launcher).
556    //
557    // Spawn the backend sidecar SYNCHRONOUSLY — block until it
558    // signals ready (AGENTMUXSRV-ESTART) before creating the browser
559    // window. This eliminates the race condition where CEF loads the
560    // frontend before the backend is available, which causes a "raw
561    // browser" appearance on slow machines or first launch.
562    let backend_ready = runtime.block_on(async {
563        // Dev builds inherit AGENTMUX_BACKEND_WS from the parent pane.
564        // Consuming it would connect to the parent's srv instead of
565        // spawning our own, so the dev frontend runs against the wrong
566        // (parent's) backend and no dev-version srv is ever started.
567        //
568        // EXCEPTION (macOS/Linux Phase 1 launcher dev integration,
569        // SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30): when a launcher
570        // is our GENUINE parent this run (it stamped AGENTMUX_LAUNCHER_PID
571        // with its pid == our getppid), the env it set is fresh + ours —
572        // adopt its launcher-owned srv instead of double-spawning. The
573        // getppid match can't be satisfied by a value merely inherited
574        // down the env from a parent pane, so `task dev:standalone`
575        // (direct host invoke) still spawns its own srv.
576        let launcher_provided = if agentmux_common::is_dev_build_exe(&host_exe_dir)
577            && !launcher_is_genuine_parent()
578        {
579            None
580        } else {
581            sidecar::use_launcher_endpoints(&app_state)
582        };
583        let result = match launcher_provided {
584            Some(Ok(r)) => {
585                tracing::info!(
586                    "Using launcher-provided backend endpoints: ws={} web={} pid={}",
587                    r.ws_endpoint,
588                    r.web_endpoint,
589                    r.instance_id
590                );
591                Ok(r)
592            }
593            Some(Err(e)) => {
594                tracing::error!(
595                    "Launcher set AGENTMUX_BACKEND_WS but env was malformed: {} — refusing to fall back to spawn_backend (would fight launcher's srv)",
596                    e
597                );
598                Err(e)
599            }
600            None => {
601                tracing::info!("No launcher-provided backend env (dev mode) — spawning srv ourselves");
602                sidecar::spawn_backend(&app_state).await
603            }
604        };
605        match result {
606            Ok(result) => {
607                {
608                    let mut endpoints = app_state.backend_endpoints.lock();
609                    endpoints.ws_endpoint = result.ws_endpoint.clone();
610                    endpoints.web_endpoint = result.web_endpoint.clone();
611                }
612                tracing::info!(
613                    "Backend ready: ws={} web={}",
614                    result.ws_endpoint,
615                    result.web_endpoint
616                );
617                true
618            }
619            Err(e) => {
620                tracing::error!("Failed to set up backend: {}", e);
621                false
622            }
623        }
624    });
625
626    if !backend_ready {
627        tracing::error!("Backend failed to start — exiting");
628        std::process::exit(1);
629    }
630
631    // Dev-only: write authkey.dev so external test harnesses can call
632    // the service API without polling logs or driving the UI. Gate is
633    // Write authkey.dev for ALL runtime modes (dev, portable, installed).
634    // The file lets bench-term-echo.mjs and the PowerShell test harnesses
635    // discover the running instance without manual --ws-url / --auth-key flags.
636    // Security: the WS server is loopback-only; any same-user process already
637    // has equivalent TCP access. See SPEC_TEST_API_ACCESS.md §3 and
638    // SPEC_BENCHMARK_PORTABLE_DISCOVERY_2026_05_20.md for rationale.
639    {
640        let endpoints = app_state.backend_endpoints.lock().clone();
641        let auth_key = app_state.auth_key.lock().clone();
642        let ipc_token = app_state.ipc_token.clone();
643        let data_dir_str = app_state
644            .version_data_dir
645            .lock()
646            .clone()
647            .unwrap_or_default();
648        let data_dir_path = std::path::PathBuf::from(&data_dir_str);
649        let ipc_endpoint = format!("127.0.0.1:{}", ipc_port);
650        let instance = format!("v{}", env!("CARGO_PKG_VERSION"));
651        let host_pid = std::process::id();
652        match dev_authfile::write_dev_auth_file(
653            &data_dir_path,
654            &auth_key,
655            &endpoints.web_endpoint,
656            &endpoints.ws_endpoint,
657            &ipc_endpoint,
658            &ipc_token,
659            &instance,
660            host_pid,
661        ) {
662            Ok(p) => tracing::info!("Wrote authkey file: {}", p.display()),
663            Err(e) => tracing::warn!("Failed to write authkey file: {}", e),
664        }
665    }
666
667    // Create the App handler with state.
668    let mut cef_app = app::AgentMuxApp::new(app_state.clone(), ipc_port);
669
670    // Resolve resource directories for portable layout. In portable
671    // mode the CEF host is IN runtime/, so resources are flat
672    // alongside it. In dev mode they are also flat in dist/cef-dev/.
673    // Reuses `host_exe_dir` from the startup mode-detection block.
674    //
675    // macOS is the exception: Chromium ships icudtl.dat + the .pak
676    // files + locales inside the framework bundle's `Resources/`
677    // directory, not flat next to the binary. cef-rs's LibraryLoader
678    // already finds the framework via `../Frameworks/...`; we point
679    // CefSettings at the same place so Chromium's icu_util loader,
680    // pack loader, and locale loader resolve correctly. See
681    // docs/specs/SPEC_MACOS_CEF_FRAMEWORK_BUNDLING_2026_05_28.md.
682    let runtime_dir = host_exe_dir.join("runtime");
683    let base_dir = if runtime_dir.exists() {
684        runtime_dir
685    } else {
686        host_exe_dir.clone()
687    };
688    #[cfg(not(target_os = "macos"))]
689    let (resources_path, locales_path) = (base_dir.clone(), base_dir.join("locales"));
690    #[cfg(target_os = "macos")]
691    let (framework_path, resources_path, locales_path) = {
692        let framework = base_dir
693            .join("..")
694            .join("Frameworks")
695            .join("Chromium Embedded Framework.framework");
696        let resources = framework.join("Resources");
697        (framework, resources.clone(), resources)
698    };
699    let resources_dir = CefString::from(resources_path.to_str().unwrap_or(""));
700    let locales_dir = CefString::from(locales_path.to_str().unwrap_or(""));
701    #[cfg(target_os = "macos")]
702    let framework_dir = CefString::from(framework_path.to_str().unwrap_or(""));
703
704    // Reuse data_dir from single-instance check as CEF cache path.
705    // Remove stale lockfile from a previous killed run.
706    let lockfile = data_dir.join("lockfile");
707    if lockfile.exists() {
708        tracing::warn!("Removing stale CEF lockfile: {}", lockfile.display());
709        let _ = std::fs::remove_file(&lockfile);
710    }
711    tracing::info!("CEF cache dir: {}", data_dir.display());
712    let cache_dir = CefString::from(data_dir.to_str().unwrap_or(""));
713    // Expose root_cache_path so per-window RequestContexts root their cache_path
714    // UNDER it — CEF requires a descendant of root_cache_path, else it falls back
715    // to in-memory storage. SPEC_CEF_LOG_ROBUSTNESS_2026_06_20.md §1.
716    *app_state.cef_cache_dir.lock() = Some(data_dir.to_string_lossy().to_string());
717
718    // Configure CEF settings.
719    // Pick a FREE remote-debugging port instead of a fixed one: AgentMux runs
720    // many instances in parallel (isolation I1–I6), so a hardcoded port collides
721    // (WSAEADDRINUSE / 0x2740) and the 2nd+ instance gets no DevTools server →
722    // the browser DOM API (`/agentmux/browser/*`) can't connect. Prefer the
723    // conventional port (9223 dev / 9222 release) for muscle memory; fall back to
724    // an OS-assigned free port. Store the ACTUAL port so `browser_api` targets
725    // it. SPEC_CEF_LOG_ROBUSTNESS_2026_06_20.md §2.
726    let preferred: u16 = if is_dev { 9223 } else { 9222 };
727    let debug_port: u16 = {
728        use std::net::TcpListener;
729        if TcpListener::bind(("127.0.0.1", preferred)).is_ok() {
730            preferred
731        } else {
732            TcpListener::bind(("127.0.0.1", 0))
733                .and_then(|l| l.local_addr())
734                .map(|a| a.port())
735                .unwrap_or(preferred)
736        }
737    };
738    *app_state.debug_port.lock() = debug_port;
739    tracing::info!("CEF remote-debugging port: {} (preferred {})", debug_port, preferred);
740
741    // Route CEF's internal Chromium logging into our log dir alongside the
742    // tracing-subscriber file. Without this, init failures leave an empty
743    // chrome_debug.log in the cache dir and we have nothing to read. INFO is
744    // verbose enough to expose load-library / resource problems but quiet
745    // enough not to swamp the file in normal operation.
746    let cef_log_path = log_dir.join("cef-debug.log");
747    let cef_log_file = CefString::from(cef_log_path.to_str().unwrap_or(""));
748
749    if force_no_sandbox {
750        tracing::warn!(
751            "AGENTMUX_UNSAFE_NOSANDBOX=1: renderer sandbox disabled. \
752             Only use in environments where namespace/Seatbelt sandbox is known-incompatible."
753        );
754    }
755
756    // Sandbox active on macOS (Seatbelt via libcef_sandbox.dylib), Linux
757    // (kernel namespace isolation), and Windows (bootstrap.exe DLL wrapper,
758    // Phase 3) when built with `--features sandbox` (the default).
759    // Escape hatch: AGENTMUX_UNSAFE_NOSANDBOX=1 disables on all platforms.
760    let no_sandbox: i32 = if cfg!(not(feature = "sandbox"))
761        || force_no_sandbox
762        || cfg!(all(target_os = "macos", feature = "sandbox")) && !macos_sandbox_available
763    {
764        1
765    } else {
766        0
767    };
768
769    let settings = Settings {
770        no_sandbox,
771        // ARGB: alpha=0 → SK_AlphaTRANSPARENT → triggers the transparency
772        // cascade in the patched libcef.so (see cef commits b921ffe18 +
773        // 68e0dc668). The CSS layer's rgba(_,_,_,<1) body bg then composites
774        // with the desktop instead of being clamped to opaque white.
775        // Pair: BrowserSettings.background_color must also be 0 (app.rs).
776        // Pair: WindowDelegate must return is_frameless=true (already does
777        // for the main window).
778        // Spec: docs/research/cef-transparency-research-2026-05-10.md.
779        background_color: 0x00000000,
780        remote_debugging_port: debug_port as i32,
781        root_cache_path: cache_dir,
782        resources_dir_path: resources_dir,
783        locales_dir_path: locales_dir,
784        // macOS-only: tell CEF where the framework bundle lives so it can
785        // resolve icudtl.dat, *.pak, and helper-process binaries through
786        // NSBundle. Without this, Chromium's icu_util loader looks via
787        // [NSBundle mainBundle] (the host exe's pseudo-bundle) which has
788        // no Resources/ and fails with "icudtl.dat not found in bundle".
789        #[cfg(target_os = "macos")]
790        framework_dir_path: framework_dir,
791        log_file: cef_log_file,
792        log_severity: LogSeverity::INFO,
793        // CEF subprocess (renderer, GPU, utility) executable. On a packaged
794        // macOS .app this is the dedicated "AgentMux Helper" (distinct bundle
795        // id, LSUIElement) — re-execing the main bundle binary is rejected by
796        // the macOS process model and the renderers crash-loop. In dev (bare
797        // binary, no Helper.app) and on Windows/Linux it's the current exe
798        // (self-reexec, which works there). See
799        // docs/specs/SPEC_MACOS_PACKAGING_2026_05_30.md.
800        browser_subprocess_path: CefString::from(resolve_browser_subprocess_path().as_str()),
801        ..Default::default()
802    };
803
804    // Initialize CEF.
805    //
806    // CefInitialize returns 1 on success and 0 either on real init failure OR
807    // on "normal early exit" (process singleton, command-line forward, etc).
808    // We can only tell the two apart by calling cef_get_exit_code() and
809    // matching against cef_resultcode_t. Treat NORMAL_EXIT* codes as a clean
810    // exit; everything else is a real failure that we surface via panic.
811    //
812    // Common early-exit codes (cef_resultcode_t):
813    //   0  CEF_RESULT_CODE_NORMAL_EXIT
814    //   24 CEF_RESULT_CODE_NORMAL_EXIT_PROCESS_NOTIFIED  ← singleton relaunch
815    //   36 CEF_RESULT_CODE_NORMAL_EXIT_PACK_EXTENSION_SUCCESS
816    //   38 CEF_RESULT_CODE_NORMAL_EXIT_AUTO_DE_ELEVATED
817    let init_result = initialize(
818        Some(args.as_main_args()),
819        Some(&settings),
820        Some(&mut cef_app),
821        windows_sandbox_info as *mut u8,
822    );
823    if init_result != 1 {
824        let exit_code = get_exit_code();
825        // Sidecar was spawned before cef_initialize(); std::process::exit()
826        // bypasses the normal shutdown block, so kill it here first.
827        {
828            let mut sidecar = app_state.sidecar_child.lock();
829            if let Some(ref mut child) = *sidecar {
830                tracing::info!("CEF early exit: killing backend sidecar before exit");
831                let _ = child.kill();
832            }
833        }
834        match exit_code {
835            0 | 24 | 36 | 38 => {
836                tracing::info!(
837                    exit_code,
838                    "CEF early exit (process singleton or similar) — exiting cleanly"
839                );
840                std::process::exit(0);
841            }
842            _ => {
843                tracing::error!(
844                    exit_code,
845                    "CEF initialization failed; see ~/.agentmux/logs/cef-debug.log for details"
846                );
847                // Surface a user-facing error instead of a silent splash-then-exit.
848                // The most common cause is a bundled CEF runtime whose version does
849                // not match the linked `cef` crate (e.g. a stale libcef.dll) — that
850                // path logs "Request for unsupported CEF API version NNNNN" to
851                // cef-debug.log and would otherwise just vanish after the splash.
852                // See docs/specs/SPEC_WINDOWS_CEF_BUNDLE_VERSION_INTEGRITY_2026_06_03.md.
853                #[cfg(target_os = "windows")]
854                {
855                    use windows_sys::Win32::UI::WindowsAndMessaging::{
856                        MessageBoxW, MB_ICONERROR, MB_OK,
857                    };
858                    let title: Vec<u16> =
859                        "AgentMux — startup failed\0".encode_utf16().collect();
860                    let body = format!(
861                        "AgentMux couldn't start its browser engine (CEF init failed, code {}).\n\n\
862                         This usually means the bundled browser runtime is incompatible with this \
863                         build — for example a stale or mismatched libcef.dll from an incomplete build.\n\n\
864                         Details were written to:\n    %USERPROFILE%\\.agentmux\\logs\\cef-debug.log\n\n\
865                         If you built this locally, run:  task clean:cef && task build:host",
866                        exit_code
867                    );
868                    let body_w: Vec<u16> =
869                        body.encode_utf16().chain(std::iter::once(0)).collect();
870                    // SAFETY: null parent HWND with valid NUL-terminated wide strings.
871                    unsafe {
872                        MessageBoxW(
873                            std::ptr::null_mut(),
874                            body_w.as_ptr(),
875                            title.as_ptr(),
876                            MB_OK | MB_ICONERROR,
877                        );
878                    }
879                }
880                std::process::exit(exit_code);
881            }
882        }
883    }
884
885    tracing::info!("CEF initialized, entering message loop");
886
887    // Claim a Dock tile, THEN paint the icon onto it. A bare Mach-O launched
888    // outside an `.app` bundle (both `task dev` direct-invoke and the launcher
889    // flat layout) defaults to a background/accessory activation policy — no
890    // Dock tile, no menu bar — so the AgentMux instance never shows in the
891    // taskbar and the icon set below has nothing to land on. Force
892    // NSApplicationActivationPolicyRegular first so the instance is a normal,
893    // Dock-visible app. macOS-only; no-op elsewhere. Order matters: policy
894    // before icon.
895    #[cfg(target_os = "macos")]
896    unsafe {
897        // Friendly Dock + app-menu name ("AgentMux DEV" in dev) instead of the
898        // raw process name "agentmux-cef". Done POST-init: Chromium overwrites
899        // the process name during cef::initialize, so a pre-init set didn't
900        // stick — set it here, right before the activation policy + our menu
901        // bar are established.
902        set_macos_app_display_name();
903        set_macos_activation_policy_regular();
904        set_macos_dock_icon();
905        // Layer 1 of SPEC_MACOS_ACCESSIBILITY_ROBUSTNESS_2026-06-03: govern
906        // Chromium's macOS accessibility activation so a window manager / KVM
907        // poking the AX tree (Magnet, Synergy, …) can't force the crash-prone
908        // web-content AX mode (CEF #3512). Must run after cef::initialize so
909        // the CEF NSApplication subclass (which owns the legacy AX setter)
910        // exists.
911        install_macos_accessibility_governor();
912    }
913
914    // Native macOS menu bar (File/Edit/View/Window/Help) — Phase 1 of
915    // SPEC_MACOS_NATIVE_MENU_BAR_2026-06-03. After cef::initialize (NSApplication
916    // exists) and after set_macos_app_display_name (the app-menu title follows
917    // the process name). Standard Edit/Window items route to the focused web
918    // view; custom items dispatch through the frontend command registry.
919    #[cfg(target_os = "macos")]
920    macos_menu::install_menu_bar(app_state.clone());
921
922    // Reopen handler — a plain re-launch / Finder/Dock double-click of the
923    // running app opens a new window (Windows-parity) instead of just focusing
924    // it. Installs an NSApplication delegate (`applicationShouldHandleReopen:`);
925    // a raw NSAppleEventManager handler was inert because CEF re-registers its
926    // own. After menu install, NSApplication exists.
927    // SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
928    #[cfg(target_os = "macos")]
929    macos_menu::install_reopen_handler(app_state.clone());
930
931    // Start memory heartbeat — logs system/process memory stats every 20s.
932    // Provides forensic data if the process later crashes from OOM / VA
933    // exhaustion, and drives the debounced mem_pressure level + low-memory
934    // banner (SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16 §5.A/§5.F).
935    memory_heartbeat::start(app_state.clone());
936
937    // Phase B.6 (post-fix): publish port:token AFTER CEF init so a
938    // second launcher only forwards `open_new_window` when we're
939    // actually ready to handle it. Single-instance enforcement is
940    // the launcher's named-pipe bind — this file is purely a
941    // forwarding hint.
942    let _ = std::fs::write(
943        &port_file,
944        format!("{}:{}", ipc_port, app_state.ipc_token),
945    );
946
947    // Phase B.9.1 (WRR) — install Win32 event hooks. Must come
948    // AFTER `connect_to_launcher` so the report_hwnd_* sync APIs
949    // have a live `COMMAND_TX` to push into; AFTER CEF init so
950    // any HWNDs CEF creates during initialize() are missed
951    // (acceptable — they predate the user's session and are
952    // accounted for by main-window startup paths). Idempotent;
953    // safe to call multiple times. State arg lets the callback
954    // peek `pending_window_creations` for `label_hint`.
955    wrr::install_hooks(app_state.clone());
956
957    // Run the CEF message loop. This blocks until quit_message_loop() is called
958    // (triggered when all browser windows are closed in client.rs).
959    run_message_loop();
960
961    tracing::info!("CEF message loop exited, shutting down");
962
963    // Kill the backend sidecar. (The launcher's Job Object also reaps it once
964    // we exit; kill explicitly for promptness.)
965    {
966        let mut sidecar = app_state.sidecar_child.lock();
967        if let Some(ref mut child) = *sidecar {
968            tracing::info!("Killing backend sidecar");
969            let _ = child.kill();
970        }
971    }
972
973    // Phase B.6 (post-fix): clean up the forwarding hint so a stale file doesn't
974    // survive a graceful exit. (Hard crashes leave it behind; harmless because
975    // pipe-bind on next launch is authoritative.)
976    let _ = std::fs::remove_file(&port_file);
977
978    // Reaching here means run_message_loop() returned, which ONLY happens on the
979    // intended LastWindowClosed quit — so a hard exit(0) is correct here, NOT a
980    // crash. It is also NECESSARY. After a CEF Views last-window close the
981    // browsers are HIDDEN/recycled (the close never fires on_before_close), so
982    // they're still alive at shutdown; CEF's teardown then access-violates on
983    // Windows (`cef::shutdown()` / `UnhookWinEvent`, exit 0xC0000005) and wedges
984    // in the tokio runtime drop on macOS. Either way the launcher (which
985    // classifies host exit) sees an ABNORMAL exit and RELAUNCHES the instance —
986    // the "reopens on its own" symptom (Discussion #1680). exit(0) gives the
987    // launcher a clean code-0 shutdown; it reaps the host's children via its Job
988    // Object (KILL_ON_JOB_CLOSE).
989    // Unhook the Win32 event hooks before exit (no-op off Windows; a cheap,
990    // safe UnhookWinEvent — NOT a crash site).
991    wrr::uninstall_hooks();
992    #[cfg(target_os = "macos")]
993    {
994        // macOS keeps the prior sequence (works): cef::shutdown() then a
995        // non-blocking runtime teardown (#1268).
996        shutdown();
997        runtime.shutdown_background();
998    }
999    #[cfg(not(target_os = "macos"))]
1000    {
1001        // Windows/Linux: SKIP cef::shutdown() — the Windows crash site on the
1002        // still-alive recycled browsers. Drop the tokio runtime (safe +
1003        // non-blocking here) before the hard exit.
1004        drop(runtime);
1005    }
1006    tracing::info!("AgentMux host shutdown complete (fast exit)");
1007
1008    // On Windows, hard-terminate so the C-runtime / CEF-DLL static teardown
1009    // (atexit handlers + DLL_PROCESS_DETACH) does NOT run: with the recycled
1010    // browsers still alive, that teardown raises a fail-fast (exit 0xC0000602)
1011    // even though we reached this line, which the launcher classifies as an
1012    // ABNORMAL exit and RELAUNCHES ("reopens on its own"). `std::process::exit`
1013    // still runs that cleanup, so use `TerminateProcess(self, 0)` for an
1014    // immediate, clean code-0 termination; the launcher then shuts down and
1015    // reaps the host's children via its Job Object. See Discussion #1680.
1016    #[cfg(target_os = "windows")]
1017    unsafe {
1018        use windows_sys::Win32::System::Threading::{GetCurrentProcess, TerminateProcess};
1019        TerminateProcess(GetCurrentProcess(), 0);
1020    }
1021    std::process::exit(0)
1022}
1023
1024/// Windows DLL wrapper sandbox entry point (Phase 3, issue #1374).
1025///
1026/// `bootstrap.exe` (renamed `agentmux-cef.exe`) calls this after:
1027///   1. `cef_sandbox_info_create()` → `sandbox_info`
1028///   2. `LoadLibraryW("agentmux-cef.dll")` → this DLL
1029///   3. `GetProcAddress(hDll, "RunWinMain")` → this function
1030///
1031/// We forward `sandbox_info` into `run()` so CEF's `CefExecuteProcess` and
1032/// `CefInitialize` receive the pre-initialized sandbox context.
1033#[cfg(all(target_os = "windows", feature = "sandbox"))]
1034#[no_mangle]
1035pub unsafe extern "system" fn RunWinMain(
1036    _h_instance:   windows_sys::Win32::Foundation::HINSTANCE,
1037    _lp_cmd_line:  *mut u16,
1038    _n_cmd_show:   i32,
1039    sandbox_info:  *mut std::ffi::c_void,
1040    _version_info: *mut std::ffi::c_void,
1041) -> i32 {
1042    run(sandbox_info)
1043}
1044
1045/// macOS 26 Tahoe compatibility shim.
1046///
1047/// CEF 146 calls private `NSApplication` selectors (e.g. `isHandlingSendEvent`,
1048/// `isSendingEvent`) during `NSDraggingSession` setup that Apple removed in
1049/// macOS 26. Without a handler the ObjC runtime walks `___forwarding___`,
1050/// finds nothing, and `objc_exception_throw`s; AppKit's default uncaught
1051/// handler calls `_objc_terminate()` and the host dies with `EXC_BREAKPOINT`
1052/// before Rust panic machinery runs.
1053///
1054/// We hook `+[NSApplication resolveInstanceMethod:]` on the metaclass —
1055/// the earliest point in the ObjC dispatch chain — and install typed stubs
1056/// for any unknown selector *before* the forwarding machinery is entered.
1057/// Swizzling `doesNotRecognizeSelector:` would not work: that method is
1058/// invoked FROM inside `___forwarding___`, and returning normally without
1059/// throwing corrupts forwarding state and causes a secondary crash there.
1060///
1061/// Return-type matters: `isHandlingSendEvent` and `isSendingEvent` return
1062/// `BOOL` and callers act on the value. A `void` stub leaves `x0 = self`
1063/// (truthy) on ARM64, making CEF think the app is already inside a
1064/// `sendEvent:` call and skip normal event routing — which breaks window
1065/// drag silently. A maintained allowlist of `BOOL`-returning selectors
1066/// gets a `BOOL_no_stub` returning `0` (NO); everything else gets a void
1067/// stub, which is safe for the unbounded set of removed Apple-private APIs.
1068///
1069/// Safety: Called once, before `cef::initialize`. `NSApplication` is a
1070/// singleton; adding a `+resolveInstanceMethod:` implementation on its
1071/// metaclass at startup is documented Apple behavior. No allocations, no
1072/// crossings of language boundaries that hold Rust references.
1073///
1074/// Ported from PR #403 (a5af, 2026-04-15) with rationale comments expanded.
1075/// See `docs/specs/SPEC_MACOS_TEAROFF_STABILITY_2026_05_29.md` and
1076/// `docs/analysis/REPORT_MACOS_TEAROFF_DRAG_CRASH_2026_05_29.md`.
1077#[cfg(target_os = "macos")]
1078unsafe fn patch_nsapp_unrecognized_selector() {
1079    use std::ffi::{c_char, c_void};
1080
1081    type Id    = *mut c_void;
1082    type Sel   = *const c_void;
1083    type Class = *mut c_void;
1084
1085    extern "C" {
1086        fn objc_getClass(name: *const c_char) -> Class;
1087        fn object_getClass(obj: Id) -> Class; // on a Class obj → returns metaclass
1088        fn sel_registerName(name: *const c_char) -> Sel;
1089        fn sel_getName(sel: Sel) -> *const c_char;
1090        fn class_addMethod(cls: Class, sel: Sel, imp: usize, types: *const c_char) -> u8;
1091    }
1092
1093    // Void stub — safe for unknown selectors whose return value isn't read.
1094    unsafe extern "C" fn void_stub(_self: Id, _cmd: Sel) {}
1095
1096    // BOOL stub returning 0 (NO). On ARM64 the return value lives in `x0`;
1097    // a void stub leaves `x0 = self` (truthy), breaking CEF's sendEvent: guard.
1098    unsafe extern "C" fn bool_no_stub(_self: Id, _cmd: Sel) -> u8 { 0 }
1099
1100    // +resolveInstanceMethod: injected onto NSApplication's metaclass.
1101    // The ObjC runtime calls us the first time a selector is sent to an
1102    // NSApplication instance that has no implementation. We `class_addMethod`
1103    // a typed stub and return YES; the runtime retries the original message
1104    // against the freshly added method.
1105    unsafe extern "C" fn resolve_instance_method_impl(
1106        cls:  Class,
1107        _cmd: Sel,
1108        sel:  Sel,
1109    ) -> u8 {
1110        let name = {
1111            let ptr = sel_getName(sel);
1112            if ptr.is_null() {
1113                "<unknown>".to_owned()
1114            } else {
1115                std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
1116            }
1117        };
1118
1119        // BOOL-returning getters whose value callers act on. The truthy
1120        // garbage a void stub would leave in x0 breaks event routing.
1121        const BOOL_NO_SELECTORS: &[&str] = &[
1122            "isHandlingSendEvent",
1123            "isSendingEvent",
1124        ];
1125
1126        if BOOL_NO_SELECTORS.contains(&name.as_str()) {
1127            tracing::warn!(selector = %name, "macOS 26 compat: adding BOOL(NO) stub");
1128            class_addMethod(cls, sel, bool_no_stub as usize, b"c@:\0".as_ptr() as _);
1129        } else {
1130            tracing::warn!(selector = %name, "macOS 26 compat: adding void stub");
1131            class_addMethod(cls, sel, void_stub as usize, b"v@:\0".as_ptr() as _);
1132        }
1133        1 // YES — resolved; runtime retries the original send
1134    }
1135
1136    let cls = objc_getClass(b"NSApplication\0".as_ptr() as _);
1137    if cls.is_null() {
1138        tracing::warn!("macOS 26 compat: NSApplication class not found");
1139        return;
1140    }
1141
1142    // +resolveInstanceMethod: is a class method; it lives on the metaclass.
1143    let metacls = object_getClass(cls as Id);
1144    if metacls.is_null() {
1145        tracing::warn!("macOS 26 compat: NSApplication metaclass not found");
1146        return;
1147    }
1148
1149    let sel = sel_registerName(b"resolveInstanceMethod:\0".as_ptr() as _);
1150    // "c@::" = BOOL return, id (Class), SEL (cmd), SEL (queried selector)
1151    let added = class_addMethod(
1152        metacls,
1153        sel,
1154        resolve_instance_method_impl as usize,
1155        b"c@::\0".as_ptr() as _,
1156    );
1157    if added != 0 {
1158        tracing::info!("macOS 26 compat: injected resolveInstanceMethod: into NSApplication metaclass");
1159    } else {
1160        tracing::warn!("macOS 26 compat: class_addMethod failed (method already exists?)");
1161    }
1162}
1163
1164/// Suppress AppKit's native drag slide-back animation app-wide.
1165///
1166/// When an `NSDraggingSession` ends without a successful drop (the drag
1167/// operation resolves to `NSDragOperationNone`), AppKit animates the drag
1168/// image sliding back to where the drag began. For a pane/tab tear-off the
1169/// pointer is released outside any DOM drop target — the floating window is
1170/// created on mouseup — so blink reports `NSDragOperationNone` and the user
1171/// sees the drag image fly back into the source window before the new
1172/// window appears. That "rejection" animation is exactly what tear-off
1173/// wants gone; the frontend `preventUnhandled` guard (PR #1186) only
1174/// suppresses the WebKit-level snapback for *in-document* drops and can't
1175/// reach this AppKit-level animation.
1176///
1177/// `NSDraggingSession` exposes `animatesToStartingPositionsOnCancelOrFail`
1178/// (default `YES`) to control exactly this. CEF/Chromium starts every drag
1179/// via `-[NSView beginDraggingSessionWithItems:event:source:]` and never
1180/// flips the flag, so we swizzle that method: call the original, then set
1181/// the flag to `NO` on the returned session. Done at the `NSView` level so
1182/// it covers whichever Chromium content view initiates the drag. The flag
1183/// only affects the cancel/fail slide-back — successful in-window drops
1184/// (e.g. tab reorder) are unaffected — so disabling it globally is safe;
1185/// there is no place in the app where a drop-rejected slide-back is wanted.
1186///
1187/// Safety: called once at startup, before `cef::initialize`, on the main
1188/// thread. Mirrors the raw-libobjc FFI of `patch_nsapp_unrecognized_selector`.
1189#[cfg(target_os = "macos")]
1190unsafe fn disable_macos_drag_slideback() {
1191    use std::ffi::{c_char, c_void};
1192
1193    type Id     = *mut c_void;
1194    type Sel    = *const c_void;
1195    type Class  = *mut c_void;
1196    type Method = *mut c_void;
1197    type Imp    = *const c_void;
1198
1199    extern "C" {
1200        fn objc_getClass(name: *const c_char) -> Class;
1201        fn sel_registerName(name: *const c_char) -> Sel;
1202        fn class_getInstanceMethod(cls: Class, sel: Sel) -> Method;
1203        fn method_getImplementation(m: Method) -> Imp;
1204        fn method_setImplementation(m: Method, imp: Imp) -> Imp;
1205        fn objc_msgSend();
1206    }
1207
1208    // IMP of the original beginDraggingSessionWithItems:event:source:, saved
1209    // so our replacement can chain to it. Single-threaded startup write +
1210    // main-thread-only drag reads, so a plain static is sufficient.
1211    static mut ORIGINAL_BEGIN_DRAG: Imp = std::ptr::null();
1212
1213    // Replacement IMP: call the original to create the session, then clear
1214    // the slide-back flag on it before returning.
1215    unsafe extern "C" fn begin_drag_no_slideback(
1216        this:   Id,
1217        cmd:    Sel,
1218        items:  Id,
1219        event:  Id,
1220        source: Id,
1221    ) -> Id {
1222        let orig: extern "C" fn(Id, Sel, Id, Id, Id) -> Id =
1223            std::mem::transmute(ORIGINAL_BEGIN_DRAG);
1224        let session = orig(this, cmd, items, event, source);
1225        if !session.is_null() {
1226            // [session setAnimatesToStartingPositionsOnCancelOrFail:NO]
1227            let sel = sel_registerName(
1228                b"setAnimatesToStartingPositionsOnCancelOrFail:\0".as_ptr() as _,
1229            );
1230            let set_flag: extern "C" fn(Id, Sel, u8) =
1231                std::mem::transmute(objc_msgSend as *const c_void);
1232            set_flag(session, sel, 0); // NO
1233        }
1234        session
1235    }
1236
1237    let cls = objc_getClass(b"NSView\0".as_ptr() as _);
1238    if cls.is_null() {
1239        tracing::warn!("drag slide-back: NSView class not found");
1240        return;
1241    }
1242    let sel = sel_registerName(
1243        b"beginDraggingSessionWithItems:event:source:\0".as_ptr() as _,
1244    );
1245    let method = class_getInstanceMethod(cls, sel);
1246    if method.is_null() {
1247        tracing::warn!("drag slide-back: beginDraggingSessionWithItems:event:source: not found");
1248        return;
1249    }
1250    ORIGINAL_BEGIN_DRAG = method_getImplementation(method);
1251    method_setImplementation(method, begin_drag_no_slideback as Imp);
1252    tracing::info!(
1253        "macOS drag polish: swizzled NSView beginDraggingSession to disable cancel/fail slide-back"
1254    );
1255}
1256
1257/// Set the macOS app display name (Dock tile + app-menu title).
1258///
1259/// A bundle-less binary (`task dev` direct-invoke, the launcher's flat
1260/// `dist/cef-dev/` layout) has no `Info.plist`, so AppKit derives the app name
1261/// from the process name — `agentmux-cef` — which is what shows in the Dock and
1262/// the menu bar's app menu. Override it with a friendly name: **dev** builds get
1263/// `AgentMux DEV` (so they're visibly distinct from a packaged `AgentMux` and
1264/// from each other when several instances run), everything else gets `AgentMux`.
1265/// A packaged `.app` carries `CFBundleName` in its `Info.plist`; this still runs
1266/// and simply matches it.
1267///
1268/// Uses `-[NSProcessInfo setProcessName:]`, which AppKit reads for the Dock
1269/// label and the app-menu title. Must run AFTER `cef::initialize` — Chromium
1270/// overwrites the process name during init, so a pre-init set is clobbered;
1271/// setting it here (right before our menu bar is built) is what sticks. Raw
1272/// libobjc FFI, mirroring the other macOS shims.
1273#[cfg(target_os = "macos")]
1274unsafe fn set_macos_app_display_name() {
1275    use std::ffi::{c_char, c_void};
1276
1277    type Id    = *mut c_void;
1278    type Sel   = *const c_void;
1279    type Class = *mut c_void;
1280
1281    extern "C" {
1282        fn objc_getClass(name: *const c_char) -> Class;
1283        fn sel_registerName(name: *const c_char) -> Sel;
1284        fn objc_msgSend();
1285    }
1286
1287    // Resolve dev vs. not by the exe PATH (`is_dev_self`), matching
1288    // `commands::platform::get_is_dev` and the menu name in `macos_menu.rs`.
1289    // NOT `AGENTMUX_RUNTIME_MODE`: a parent dev AgentMux leaks that env into
1290    // descendants, which would otherwise set the Dock / app-menu process name
1291    // to "AgentMux DEV" on a packaged build launched from inside a dev
1292    // instance. Build identity is a property of the binary on disk.
1293    let name = if agentmux_common::is_dev_self() { "AgentMux DEV" } else { "AgentMux" };
1294
1295    // NSString *ns = [NSString stringWithUTF8String:name]
1296    let cls_str = objc_getClass(b"NSString\0".as_ptr() as _);
1297    if cls_str.is_null() {
1298        return;
1299    }
1300    let sel_with = sel_registerName(b"stringWithUTF8String:\0".as_ptr() as _);
1301    let make: extern "C" fn(Class, Sel, *const c_char) -> Id =
1302        std::mem::transmute(objc_msgSend as *const c_void);
1303    let cname = match std::ffi::CString::new(name) {
1304        Ok(c) => c,
1305        Err(_) => return,
1306    };
1307    let ns_name = make(cls_str, sel_with, cname.as_ptr());
1308    if ns_name.is_null() {
1309        return;
1310    }
1311
1312    // pi = [NSProcessInfo processInfo]
1313    let cls_pi = objc_getClass(b"NSProcessInfo\0".as_ptr() as _);
1314    if cls_pi.is_null() {
1315        return;
1316    }
1317    let sel_pi = sel_registerName(b"processInfo\0".as_ptr() as _);
1318    let get_pi: extern "C" fn(Class, Sel) -> Id =
1319        std::mem::transmute(objc_msgSend as *const c_void);
1320    let pi = get_pi(cls_pi, sel_pi);
1321    if pi.is_null() {
1322        return;
1323    }
1324
1325    // [pi setProcessName:ns_name]
1326    let sel_set = sel_registerName(b"setProcessName:\0".as_ptr() as _);
1327    let set_name: extern "C" fn(Id, Sel, Id) =
1328        std::mem::transmute(objc_msgSend as *const c_void);
1329    set_name(pi, sel_set, ns_name);
1330
1331    // The app-menu title + Dock label for an unbundled binary come from the
1332    // main bundle's CFBundleName/CFBundleDisplayName, NOT the process name
1333    // (setProcessName above doesn't move them). Set them on the main bundle's
1334    // info dictionary, which is backed by a mutable dictionary. Guard on
1335    // isKindOfClass:NSMutableDictionary so an unexpected immutable dict is a
1336    // skip rather than a throw (an uncaught ObjC exception would terminate the
1337    // host on macOS 26).
1338    let cls_bundle = objc_getClass(b"NSBundle\0".as_ptr() as _);
1339    if !cls_bundle.is_null() {
1340        let sel_main = sel_registerName(b"mainBundle\0".as_ptr() as _);
1341        let get_main: extern "C" fn(Class, Sel) -> Id =
1342            std::mem::transmute(objc_msgSend as *const c_void);
1343        let bundle = get_main(cls_bundle, sel_main);
1344        if !bundle.is_null() {
1345            let sel_info = sel_registerName(b"infoDictionary\0".as_ptr() as _);
1346            let get_info: extern "C" fn(Id, Sel) -> Id =
1347                std::mem::transmute(objc_msgSend as *const c_void);
1348            let info = get_info(bundle, sel_info);
1349            let cls_mut = objc_getClass(b"NSMutableDictionary\0".as_ptr() as _);
1350            let sel_kind = sel_registerName(b"isKindOfClass:\0".as_ptr() as _);
1351            let is_kind: extern "C" fn(Id, Sel, Class) -> u8 =
1352                std::mem::transmute(objc_msgSend as *const c_void);
1353            if !info.is_null() && !cls_mut.is_null() && is_kind(info, sel_kind, cls_mut) != 0 {
1354                let sel_set_obj = sel_registerName(b"setObject:forKey:\0".as_ptr() as _);
1355                let set_obj: extern "C" fn(Id, Sel, Id, Id) =
1356                    std::mem::transmute(objc_msgSend as *const c_void);
1357                let k_name = make(cls_str, sel_with, b"CFBundleName\0".as_ptr() as _);
1358                let k_disp = make(cls_str, sel_with, b"CFBundleDisplayName\0".as_ptr() as _);
1359                set_obj(info, sel_set_obj, ns_name, k_name);
1360                set_obj(info, sel_set_obj, ns_name, k_disp);
1361                tracing::info!("macOS: set CFBundleName/CFBundleDisplayName on main bundle");
1362            } else {
1363                tracing::warn!("macOS: main bundle info dict not mutable; app name unchanged");
1364            }
1365        }
1366    }
1367
1368    tracing::info!(app_name = name, "macOS: set app display name (Dock + app menu)");
1369}
1370
1371/// macOS accessibility activation governor — Layer 1 of
1372/// `docs/specs/SPEC_MACOS_ACCESSIBILITY_ROBUSTNESS_2026-06-03.md`.
1373///
1374/// Chromium enables its web-content accessibility tree the moment an AX client
1375/// sets `AXEnhancedUserInterface` on the application. That tree's macOS
1376/// implementation faults under external iteration on macOS-26 / CEF M114+
1377/// (CEF #3512: `AXPlatformNodeCocoa::AXChildren()` SEGV; here it surfaced as an
1378/// `EXC_BREAKPOINT` through the legacy `NSAccessibility…` accessor when a user
1379/// clicked the title-bar menu). The trigger attribute is **overloaded**:
1380/// VoiceOver sets it, but so do ordinary window managers / KVMs — Magnet,
1381/// Synergy — see Firefox bug 1664992. So a window manager merely doing its job
1382/// forces AgentMux into the crash-prone full-AX mode.
1383///
1384/// This swizzles the application's legacy `accessibilitySetValue:forAttribute:`
1385/// and applies a policy:
1386///   * `AXEnhancedUserInterface` (the window-manager/KVM path) does **not**
1387///     auto-enable web-content AX — unless `AGENTMUX_A11Y_HONOR_ENHANCED=1`.
1388///   * `AXManualAccessibility` (explicit assistive-technology / app intent —
1389///     the path Electron documents for enabling AX) **is** honored.
1390///   * every set is logged so the real activation path is observable in the
1391///     field (this is also how we confirm the fix against Magnet/Synergy).
1392///
1393/// Window-level AX (windows, buttons, title) is unaffected — only the descent
1394/// into the crash-prone web-content tree is gated. Full screen-reader support
1395/// returns unconditionally once the AX path itself is made non-fatal (Phase 2 /
1396/// Layer 2 of the spec). Not a blanket `--disable-renderer-accessibility`: that
1397/// would make AgentMux permanently inaccessible; this keeps the explicit
1398/// (`AXManualAccessibility`) enable path working.
1399///
1400/// Must run AFTER `cef::initialize` — the CEF `NSApplication` subclass that
1401/// implements the legacy AX setter only exists then. FFI mirrors
1402/// `disable_macos_drag_slideback`. Idempotent enough for once-at-startup.
1403#[cfg(target_os = "macos")]
1404unsafe fn install_macos_accessibility_governor() {
1405    use std::ffi::{c_char, c_void};
1406
1407    type Id     = *mut c_void;
1408    type Sel    = *const c_void;
1409    type Class  = *mut c_void;
1410    type Method = *mut c_void;
1411    type Imp    = *const c_void;
1412
1413    extern "C" {
1414        fn objc_getClass(name: *const c_char) -> Class;
1415        fn object_getClass(obj: Id) -> Class;
1416        fn class_getName(cls: Class) -> *const c_char;
1417        fn sel_registerName(name: *const c_char) -> Sel;
1418        fn class_getInstanceMethod(cls: Class, sel: Sel) -> Method;
1419        fn method_getImplementation(m: Method) -> Imp;
1420        fn method_setImplementation(m: Method, imp: Imp) -> Imp;
1421        fn objc_msgSend();
1422    }
1423
1424    // Original Chromium IMP, saved so the governed replacement can chain to it.
1425    // Single-threaded startup write; main-thread-only AX reads afterward.
1426    static mut ORIGINAL_SET_AX: Imp = std::ptr::null();
1427    // Read the override once at install (env reads in the hot path are wasteful
1428    // and AX sets are rare, but a static keeps the IMP allocation-free).
1429    static mut HONOR_ENHANCED: bool = false;
1430
1431    // [attr isEqualToString:@literal] without bringing in a string crate.
1432    unsafe fn attr_is(attr: Id, literal: &[u8]) -> bool {
1433        if attr.is_null() {
1434            return false;
1435        }
1436        let objc_get_class: extern "C" fn(*const c_char) -> Class =
1437            std::mem::transmute(objc_getClass as *const c_void);
1438        let cls = objc_get_class(b"NSString\0".as_ptr() as _);
1439        if cls.is_null() {
1440            return false;
1441        }
1442        let sel_with = sel_registerName(b"stringWithUTF8String:\0".as_ptr() as _);
1443        let make: extern "C" fn(Class, Sel, *const c_char) -> Id =
1444            std::mem::transmute(objc_msgSend as *const c_void);
1445        let lit = make(cls, sel_with, literal.as_ptr() as *const c_char);
1446        if lit.is_null() {
1447            return false;
1448        }
1449        let sel_eq = sel_registerName(b"isEqualToString:\0".as_ptr() as _);
1450        let eq: extern "C" fn(Id, Sel, Id) -> u8 =
1451            std::mem::transmute(objc_msgSend as *const c_void);
1452        eq(attr, sel_eq, lit) != 0
1453    }
1454
1455    // Replacement for -[<app> accessibilitySetValue:(id)value forAttribute:(NSString*)attr].
1456    unsafe extern "C" fn governed_set_ax(this: Id, cmd: Sel, value: Id, attribute: Id) {
1457        if attr_is(attribute, b"AXEnhancedUserInterface\0") {
1458            if !HONOR_ENHANCED {
1459                tracing::warn!(
1460                    "a11y governor: blocked AXEnhancedUserInterface activation \
1461                     (window-manager/KVM path — CEF #3512). \
1462                     Set AGENTMUX_A11Y_HONOR_ENHANCED=1 to allow."
1463                );
1464                return; // swallow → web-content AX stays off
1465            }
1466            tracing::warn!("a11y governor: honoring AXEnhancedUserInterface (override enabled)");
1467        } else if attr_is(attribute, b"AXManualAccessibility\0") {
1468            tracing::info!("a11y governor: honoring AXManualAccessibility (explicit enable)");
1469        } else {
1470            tracing::debug!("a11y governor: passthrough accessibilitySetValue:forAttribute:");
1471        }
1472        let orig: extern "C" fn(Id, Sel, Id, Id) = std::mem::transmute(ORIGINAL_SET_AX);
1473        orig(this, cmd, value, attribute);
1474    }
1475
1476    // Honor only an explicit truthy value — keying on presence would make
1477    // `AGENTMUX_A11Y_HONOR_ENHANCED=0` *enable* the override (reagent P2).
1478    HONOR_ENHANCED = std::env::var("AGENTMUX_A11Y_HONOR_ENHANCED")
1479        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1480        .unwrap_or(false);
1481
1482    // app = [NSApplication sharedApplication]
1483    let cls_nsapp = objc_getClass(b"NSApplication\0".as_ptr() as _);
1484    if cls_nsapp.is_null() {
1485        tracing::warn!("a11y governor: NSApplication class not found; skipping");
1486        return;
1487    }
1488    let sel_shared = sel_registerName(b"sharedApplication\0".as_ptr() as _);
1489    let shared: extern "C" fn(Class, Sel) -> Id =
1490        std::mem::transmute(objc_msgSend as *const c_void);
1491    let app = shared(cls_nsapp, sel_shared);
1492    if app.is_null() {
1493        tracing::warn!("a11y governor: sharedApplication nil; skipping");
1494        return;
1495    }
1496
1497    let app_cls = object_getClass(app);
1498    let cls_name = {
1499        let p = class_getName(app_cls);
1500        if p.is_null() {
1501            "<unknown>".to_owned()
1502        } else {
1503            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
1504        }
1505    };
1506
1507    // --- Layer 2a guard (LOAD-BEARING): -[NSApplication accessibilityParent] → nil ---
1508    //
1509    // Installed FIRST and INDEPENDENTLY of the Layer 1 setter swizzle below, so a
1510    // macOS/CEF build that lacks the legacy setter still gets this fix (reagent P1
1511    // — the setter was previously a gate that skipped this guard on early return).
1512    //
1513    // The observed crash (EXC_BREAKPOINT, both reports) is an *incoming* AX READ —
1514    // an external client (Magnet/Synergy) calls CopyAttributeValue on the app,
1515    // which routes through:
1516    //   -[NSApplication accessibilityParent]
1517    //     → NSAccessibilityGetObjectForAttributeUsingLegacyAPI
1518    //     → NSAccessibilitySetUnsupportedAttributeError
1519    //     → +[NSString stringWithFormat:]  → CF string trap (with a CEF AX object
1520    //        as the %@ arg — CEF #3512).
1521    // NSApplication is the AX root; its parent is legitimately nil. Returning nil
1522    // DIRECTLY short-circuits before the crashy legacy bridge runs. Safe and
1523    // semantically correct, and it does not disable accessibility — windows/title
1524    // still answer.
1525    unsafe extern "C" fn accessibility_parent_nil(_this: Id, _cmd: Sel) -> Id {
1526        std::ptr::null_mut()
1527    }
1528    let sel_parent = sel_registerName(b"accessibilityParent\0".as_ptr() as _);
1529    let m_parent = class_getInstanceMethod(app_cls, sel_parent);
1530    if !m_parent.is_null() {
1531        method_setImplementation(m_parent, accessibility_parent_nil as Imp);
1532        tracing::info!(app_class = %cls_name, "a11y governor: guarded -[NSApplication accessibilityParent] → nil (SPEC L2a)");
1533    } else {
1534        tracing::warn!(app_class = %cls_name, "a11y governor: accessibilityParent not found on app class");
1535    }
1536
1537    // --- Layer 1 (defense in depth): govern AXEnhancedUserInterface activation ---
1538    // Independent of L2a above; if the legacy setter is absent we just log and the
1539    // load-bearing parent guard still stands.
1540    let sel_set = sel_registerName(b"accessibilitySetValue:forAttribute:\0".as_ptr() as _);
1541    let method = class_getInstanceMethod(app_cls, sel_set);
1542    if !method.is_null() {
1543        ORIGINAL_SET_AX = method_getImplementation(method);
1544        method_setImplementation(method, governed_set_ax as Imp);
1545        tracing::info!(
1546            honor_enhanced = HONOR_ENHANCED,
1547            "a11y governor: swizzled accessibilitySetValue:forAttribute: (SPEC L1)"
1548        );
1549    } else {
1550        tracing::warn!(
1551            "a11y governor: accessibilitySetValue:forAttribute: not found on app class — \
1552             activation governor inactive (parent guard still installed)"
1553        );
1554    }
1555}
1556
1557/// Promote the process to a regular, Dock-visible macOS app.
1558///
1559/// A bare Mach-O launched outside an `.app` bundle — both `task dev`
1560/// direct-invoke and the launcher's flat `dist/cef-dev/` layout — has no
1561/// `Info.plist`, so LaunchServices leaves it as a background/accessory
1562/// process: it can open windows but gets **no Dock tile and no menu bar**, so
1563/// the AgentMux instance is invisible in the taskbar (`lsappinfo` reports it
1564/// `type="BackgroundOnly"`). `-[NSApplication setActivationPolicy:]` with
1565/// `NSApplicationActivationPolicyRegular` (raw value `0`) flips it to a normal
1566/// foreground app so it shows in the Dock; this must run before
1567/// `set_macos_dock_icon` (the icon needs a tile to land on). Harmless in a
1568/// future packaged `.app` (already regular there). Idempotent.
1569///
1570/// Must run on the main thread after `cef::initialize` (NSApplication exists
1571/// by then). FFI mirrors `set_macos_dock_icon` — raw libobjc, no extra crate.
1572#[cfg(target_os = "macos")]
1573unsafe fn set_macos_activation_policy_regular() {
1574    use std::ffi::{c_char, c_void};
1575
1576    type Id    = *mut c_void;
1577    type Sel   = *const c_void;
1578    type Class = *mut c_void;
1579
1580    // NSApplicationActivationPolicyRegular == 0 (Accessory == 1, Prohibited == 2).
1581    const NS_ACTIVATION_POLICY_REGULAR: isize = 0;
1582
1583    extern "C" {
1584        fn objc_getClass(name: *const c_char) -> Class;
1585        fn sel_registerName(name: *const c_char) -> Sel;
1586        fn objc_msgSend();
1587    }
1588
1589    let cls_nsapp = objc_getClass(b"NSApplication\0".as_ptr() as _);
1590    if cls_nsapp.is_null() {
1591        tracing::warn!("activation-policy: NSApplication class not found; skipping");
1592        return;
1593    }
1594
1595    // NSApplication *app = [NSApplication sharedApplication]
1596    let sel_shared = sel_registerName(b"sharedApplication\0".as_ptr() as _);
1597    let msg_shared: extern "C" fn(Class, Sel) -> Id =
1598        std::mem::transmute(objc_msgSend as *const ());
1599    let app = msg_shared(cls_nsapp, sel_shared);
1600    if app.is_null() {
1601        tracing::warn!("activation-policy: NSApplication.sharedApplication unavailable");
1602        return;
1603    }
1604
1605    // BOOL ok = [app setActivationPolicy:NSApplicationActivationPolicyRegular]
1606    let sel_set = sel_registerName(b"setActivationPolicy:\0".as_ptr() as _);
1607    let msg_set: extern "C" fn(Id, Sel, isize) -> u8 =
1608        std::mem::transmute(objc_msgSend as *const ());
1609    let ok = msg_set(app, sel_set, NS_ACTIVATION_POLICY_REGULAR);
1610    tracing::info!(ok = ok != 0, "activation-policy: set NSApplication to Regular (Dock-visible)");
1611}
1612
1613/// Set the macOS Dock icon for the running process.
1614///
1615/// `task dev` launches the bare `agentmux-cef` Mach-O directly (not inside an
1616/// `.app` bundle — see `Taskfile.yml::dev:serve`), so macOS has no
1617/// `CFBundleIconFile` to read and shows the generic executable tile in the
1618/// Dock. Rather than restructure the dev launch around a bundle, we set the
1619/// icon at runtime via `-[NSApplication setApplicationIconImage:]`, which
1620/// works whether or not we're in a bundle and also overrides a bundle icon
1621/// in any future packaged build — one code path for all launch modes.
1622///
1623/// The PNG is embedded at compile time (`include_bytes!`) so there's no
1624/// dependency on the `dist/` layout or a resource-path lookup at runtime.
1625/// It's the SAME normal AgentMux logo the Linux taskbar uses
1626/// (`assets/linux/icons/hicolor/512x512/apps/agentmux.png`, wired up in
1627/// `scripts/install-linux-desktop.sh`), keeping the Dock/taskbar icon
1628/// identical across platforms.
1629///
1630/// Must run on the main thread after `cef::initialize` (NSApplication exists
1631/// by then). FFI mirrors `patch_nsapp_unrecognized_selector` — raw libobjc,
1632/// no `objc2`/`cocoa` crate dependency. The created NSImage is intentionally
1633/// leaked (one per process lifetime): `setApplicationIconImage:` retains it
1634/// and the icon lives as long as the app does.
1635#[cfg(target_os = "macos")]
1636unsafe fn set_macos_dock_icon() {
1637    use std::ffi::{c_char, c_void};
1638
1639    type Id    = *mut c_void;
1640    type Sel   = *const c_void;
1641    type Class = *mut c_void;
1642
1643    // The normal AgentMux logo (panel layout, not the brain-alternate),
1644    // matching the Linux taskbar source.
1645    const ICON_PNG: &[u8] =
1646        include_bytes!("../../assets/linux/icons/hicolor/512x512/apps/agentmux.png");
1647
1648    extern "C" {
1649        fn objc_getClass(name: *const c_char) -> Class;
1650        fn sel_registerName(name: *const c_char) -> Sel;
1651        // objc_msgSend is declared bare and transmuted to the exact prototype
1652        // at each call site — the ARM64 calling convention requires the real
1653        // signature, not a variadic stand-in.
1654        fn objc_msgSend();
1655    }
1656
1657    let cls_nsdata  = objc_getClass(b"NSData\0".as_ptr() as _);
1658    let cls_nsimage = objc_getClass(b"NSImage\0".as_ptr() as _);
1659    let cls_nsapp   = objc_getClass(b"NSApplication\0".as_ptr() as _);
1660    if cls_nsdata.is_null() || cls_nsimage.is_null() || cls_nsapp.is_null() {
1661        tracing::warn!("dock-icon: an AppKit class was not found; skipping");
1662        return;
1663    }
1664
1665    // NSData *data = [NSData dataWithBytes:ICON_PNG.ptr length:ICON_PNG.len]
1666    let sel_data = sel_registerName(b"dataWithBytes:length:\0".as_ptr() as _);
1667    let msg_data: extern "C" fn(Class, Sel, *const c_void, usize) -> Id =
1668        std::mem::transmute(objc_msgSend as *const ());
1669    let data = msg_data(cls_nsdata, sel_data, ICON_PNG.as_ptr() as *const c_void, ICON_PNG.len());
1670    if data.is_null() {
1671        tracing::warn!("dock-icon: NSData creation failed");
1672        return;
1673    }
1674
1675    // NSImage *img = [[NSImage alloc] initWithData:data]
1676    let sel_alloc = sel_registerName(b"alloc\0".as_ptr() as _);
1677    let msg_alloc: extern "C" fn(Class, Sel) -> Id =
1678        std::mem::transmute(objc_msgSend as *const ());
1679    let img_alloc = msg_alloc(cls_nsimage, sel_alloc);
1680    let sel_init = sel_registerName(b"initWithData:\0".as_ptr() as _);
1681    let msg_init: extern "C" fn(Id, Sel, Id) -> Id =
1682        std::mem::transmute(objc_msgSend as *const ());
1683    let image = msg_init(img_alloc, sel_init, data);
1684    if image.is_null() {
1685        tracing::warn!("dock-icon: NSImage creation failed (corrupt PNG?)");
1686        return;
1687    }
1688
1689    // NSApplication *app = [NSApplication sharedApplication]
1690    let sel_shared = sel_registerName(b"sharedApplication\0".as_ptr() as _);
1691    let msg_shared: extern "C" fn(Class, Sel) -> Id =
1692        std::mem::transmute(objc_msgSend as *const ());
1693    let app = msg_shared(cls_nsapp, sel_shared);
1694    if app.is_null() {
1695        tracing::warn!("dock-icon: NSApplication.sharedApplication unavailable");
1696        return;
1697    }
1698
1699    // [app setApplicationIconImage:img]
1700    let sel_set = sel_registerName(b"setApplicationIconImage:\0".as_ptr() as _);
1701    let msg_set: extern "C" fn(Id, Sel, Id) =
1702        std::mem::transmute(objc_msgSend as *const ());
1703    msg_set(app, sel_set, image);
1704
1705    tracing::info!("dock-icon: set NSApplication icon to embedded AgentMux logo");
1706}
1707
1708/// Initialize tracing with dual output: rolling daily log file + human-readable stderr.
1709/// `log_dir` is resolved by the caller: `<portable-root>/data/logs/` in portable mode,
1710/// `~/.agentmux/logs/` in installed mode.
1711/// Returns a guard that must be held for the lifetime of the process to ensure log flushing.
1712fn init_logging(log_dir: &std::path::Path) -> tracing_appender::non_blocking::WorkerGuard {
1713    use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
1714
1715    let version = env!("CARGO_PKG_VERSION");
1716    let _ = std::fs::create_dir_all(log_dir);
1717
1718    // Delete log files older than 7 days to prevent unbounded growth.
1719    cleanup_old_logs(&log_dir, 7);
1720
1721    let log_prefix = format!("agentmux-host-v{}.log", version);
1722    let file_appender = tracing_appender::rolling::daily(&log_dir, &log_prefix);
1723    let (non_blocking_file, guard) = tracing_appender::non_blocking(file_appender);
1724
1725    // Write pointer to current log file for zero-lookup agent discovery.
1726    // Version-qualified name so multi-instance doesn't clobber pointers.
1727    // Uses UTC to match tracing_appender::rolling::daily's date suffix.
1728    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
1729    let current_filename = format!("{}.{}", log_prefix, today);
1730    let absolute_path = log_dir.join(&current_filename);
1731    let pointer_name = format!("current-host-v{}.path", version);
1732
1733    // Pointer #1: local — inside the instance's log dir. The basename
1734    // is enough here since the reader is colocated.
1735    let _ = std::fs::write(log_dir.join(&pointer_name), &current_filename);
1736
1737    // Pointer #2: global — at `<root>/logs/<pointer_name>`. Writes the
1738    // ABSOLUTE PATH so legacy tooling (`muxlog host`) that lives outside
1739    // the instance dir can `cat $pointer | xargs tail -f` and reach the
1740    // real file. Skipped silently if the global dir can't be derived
1741    // (e.g. AGENTMUX_HOME_OVERRIDE unset in some test setups).
1742    if let Some(global_logs_dir) = log_dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.join("logs")) {
1743        let _ = std::fs::create_dir_all(&global_logs_dir);
1744        let _ = std::fs::write(
1745            global_logs_dir.join(&pointer_name),
1746            absolute_path.to_string_lossy().as_bytes(),
1747        );
1748    }
1749
1750    // Synchronous init sentinel: append a single line directly to the
1751    // expected log path BEFORE the tracing subscriber is wired up. Without
1752    // this, a hang between subscriber-setup and the non-blocking writer's
1753    // first flush leaves the pointer file pointing at a never-created log
1754    // file (observed 2026-05-02 freeze investigation). The sentinel
1755    // guarantees the file exists once init_logging has run past
1756    // pointer-write — if the file is missing afterwards, we know
1757    // init_logging itself didn't get past this point.
1758    let sentinel_path = log_dir.join(&current_filename);
1759    let sentinel_line = format!(
1760        "{} INIT-SENTINEL agentmux-host v={} pid={} os={} arch={}\n",
1761        chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
1762        version,
1763        std::process::id(),
1764        std::env::consts::OS,
1765        std::env::consts::ARCH,
1766    );
1767    if let Ok(mut f) = std::fs::OpenOptions::new()
1768        .create(true)
1769        .append(true)
1770        .open(&sentinel_path)
1771    {
1772        use std::io::Write;
1773        let _ = f.write_all(sentinel_line.as_bytes());
1774        let _ = f.flush();
1775    }
1776
1777    let subscriber = tracing_subscriber::registry()
1778        .with(
1779            EnvFilter::try_from_default_env()
1780                .unwrap_or_else(|_| EnvFilter::new("info")),
1781        )
1782        .with(
1783            fmt::layer()
1784                .json()
1785                .with_writer(non_blocking_file)
1786                .with_target(true)
1787                .with_thread_ids(true),
1788        )
1789        .with(
1790            fmt::layer()
1791                .with_writer(std::io::stderr)
1792                .with_ansi(true),
1793        );
1794
1795    tracing::subscriber::set_global_default(subscriber).ok();
1796
1797    tracing::info!(
1798        version,
1799        os = std::env::consts::OS,
1800        arch = std::env::consts::ARCH,
1801        log_dir = %log_dir.display(),
1802        "AgentMux host starting"
1803    );
1804
1805    guard
1806}
1807
1808fn cleanup_old_logs(log_dir: &std::path::Path, days: u64) {
1809    let cutoff = std::time::SystemTime::now()
1810        - std::time::Duration::from_secs(days * 86400);
1811    let Ok(entries) = std::fs::read_dir(log_dir) else { return };
1812    for entry in entries.flatten() {
1813        let path = entry.path();
1814        if !path.to_string_lossy().contains(".log.") {
1815            continue;
1816        }
1817        if let Ok(meta) = entry.metadata() {
1818            if let Ok(modified) = meta.modified() {
1819                if modified < cutoff {
1820                    let _ = std::fs::remove_file(&path);
1821                }
1822            }
1823        }
1824    }
1825}