agentmux_cef/
state.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Shared application state for the CEF host.
5
6use std::collections::HashMap;
7use parking_lot::Mutex;
8
9use cef::Browser;
10
11// ── Cross-window drag types (ported from src-tauri/src/state.rs) ─────────
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum DragType {
16    Pane,
17    Tab,
18}
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct DragPayload {
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub block_id: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub tab_id: Option<String>,
27}
28
29#[derive(Debug, Clone, serde::Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct DragSession {
32    pub drag_id: String,
33    pub drag_type: DragType,
34    pub source_window: String,
35    pub source_workspace_id: String,
36    pub source_tab_id: String,
37    pub payload: DragPayload,
38    pub started_at: u64,
39}
40
41// Phase B.5e — `WindowInstanceRegistry` struct deleted. Sequential
42// instance numbers are now owned by the launcher's reducer
43// (`agentmux-launcher::state::State.instance_registry`). Host's
44// projection lives in `AppState.shadow_instance_registry`, fed by
45// `Event::WindowInstanceAssigned` / `WindowInstanceReleased`. See
46// docs/retro/migration-pattern.md for the a→b→c→d→e ratchet.
47
48// Phase B.1: removed `JobHandle` wrapper. Host no longer owns a Job
49// Object on srv; the launcher's J0 covers srv directly via
50// AssignProcessToJobObject. The same RAII pattern lives in
51// agentmux-launcher/src/main.rs::JobHandle.
52
53/// Backend (agentmux-srv) connection endpoints.
54#[derive(Default, Clone, serde::Serialize)]
55pub struct BackendEndpoints {
56    pub ws_endpoint: String,
57    pub web_endpoint: String,
58}
59
60/// Window role in the AgentMux multi-window model.
61///
62/// Two distinct types with different taskbar treatment:
63/// - `FullInstance`: independent AgentMux window (like Chrome/VS Code new window).
64///   Appears in the Windows taskbar. All user-facing "new window" paths (status-bar
65///   version click, second `agentmux.exe` launch, `Ctrl+Shift+N`) create one.
66/// - `Subwindow`: hidden from the taskbar via `ITaskbarList::DeleteTab`. Only
67///   reachable through the backend `open_subwindow` API — reserved for agent /
68///   internal use cases (transient auxiliary views, tool-spawned panels). Closes
69///   when its parent full instance closes.
70#[derive(Copy, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum WindowKind {
73    FullInstance,
74    Subwindow,
75}
76
77/// Per-window metadata held alongside the CEF `Browser`. See `WindowKind` for
78/// the semantics of `kind` and `parent_instance_id`.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct WindowMeta {
81    pub label: String,
82    pub kind: WindowKind,
83    /// For `Subwindow` only: label of the `FullInstance` that owns this window.
84    /// `None` for `FullInstance`.
85    pub parent_instance_id: Option<String>,
86}
87
88/// Phase B.5 (window_meta step d) — pre-create handoff. Caller
89/// (`drag.rs::tear_off`, `commands/window.rs::open_new_window`,
90/// `window_pool.rs::spawn_pool_window`, `pane/creation.rs`) pushes
91/// one entry per window CEF is about to create; `client.rs::on_after_created`
92/// pops the head entry and uses `kind` for the Subwindow
93/// taskbar-hide branch + as the payload for `ReportWindowOpened`.
94///
95/// Replaces the previous `pending_window_labels: VecDeque<String>`
96/// queue + parallel caller-side `window_meta` writes that used to
97/// act as the kind/parent channel. Collapsing them into a single
98/// tuple eliminates the parallel-write race; on_after_created
99/// performs the single canonical `window_meta.insert` from the
100/// popped entry (kept as a synchronous host-side cache for
101/// open_subwindow's parent liveness check + cascade-close
102/// enumeration in `task dev` mode where launcher IPC is absent).
103#[derive(Clone, Debug)]
104pub struct PendingWindowCreation {
105    pub label: String,
106    pub kind: WindowKind,
107    pub parent_instance_id: Option<String>,
108}
109
110// ── Phase H — host reducer buildout ──────────────────────────────────────
111//
112// All types below are reducer-only state. PR #1 (h1-foundations) declares
113// them; subsequent PRs (#2-#5) wire callers through the reducer per the
114// a→b→c→d→e migration ratchet. See:
115//   docs/specs/SPEC_HOST_REDUCER_5PR_PLAN_2026-05-02.md
116//   docs/specs/SPEC_HOST_REDUCER_PHASE_H_2026-05-02.md
117//
118// These types intentionally have `#[allow(dead_code)]` because PR #1 ships
119// the scaffolding without callers — fields are populated by reducer arms but
120// no production code reads them yet. Subsequent PRs lift the allow as they
121// wire each migration.
122
123// ── Pane lifecycle (H.1) ─────────────────────────────────────────────────
124
125/// Lifecycle state of a browser pane (the `defwidget@browser` widget). Held
126/// inside `HostState.browser_panes` keyed by `block_id`. Mirrors the existing
127/// `PaneStateMachine::BrowserPaneLifecycle` (pane/lifecycle.rs:28); the existing
128/// type stays during PR #2's a→e migration. PR #2 step e deletes the
129/// pane/lifecycle.rs version and migrates all readers to this one.
130#[derive(Clone, Debug, PartialEq, Eq)]
131#[allow(dead_code)]
132pub enum BrowserPaneLifecycle {
133    /// Pane is alive and accepting operations (focus, resize, navigate).
134    Live,
135    /// Close requested; awaiting CEF on_before_close to fully tear down.
136    /// `since` carries the request timestamp for diagnostic purposes only;
137    /// nothing in the reducer is timer-driven.
138    Closing { since: std::time::Instant },
139}
140
141/// Per-pane reducer-managed entry. Replaces `pane::lifecycle::BrowserPaneEntry`
142/// (lifecycle.rs:42) at PR #2 step e.
143#[derive(Clone, Debug)]
144#[allow(dead_code)]
145pub struct BrowserPaneEntry {
146    pub block_id: String,
147    pub label: String,
148    pub lifecycle: BrowserPaneLifecycle,
149}
150
151// ── Browser handle registry (H.2) ────────────────────────────────────────
152
153/// Wrapped CEF Browser handle stored in `HostState.browsers`. Replaces the
154/// raw `Mutex<HashMap<String, Browser>>` at `state.rs::AppState.browsers`
155/// at PR #2 step e.
156///
157/// `cef::Browser` is `Clone` (refcounted FFI handle) and safe to store
158/// inside the reducer's mutex-guarded state. Doesn't impl Debug, hence
159/// the manual `impl Debug` below for `BrowserHandle`.
160#[derive(Clone)]
161#[allow(dead_code)]
162pub struct BrowserHandle {
163    pub label: String,
164    pub browser: Browser,
165    pub kind: BrowserKind,
166    pub registered_at: std::time::Instant,
167}
168
169impl std::fmt::Debug for BrowserHandle {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("BrowserHandle")
172            .field("label", &self.label)
173            .field("kind", &self.kind)
174            .field("registered_at", &self.registered_at)
175            .field("browser", &"<cef::Browser>")
176            .finish()
177    }
178}
179
180/// Distinguishes top-level CEF Browsers (full-instance windows + pool
181/// windows) from pane CEF Browsers (children of a top-level). Determines
182/// taskbar treatment, lifecycle ownership, etc.
183#[derive(Clone, Debug)]
184#[allow(dead_code)]
185pub enum BrowserKind {
186    /// Top-level window. `is_pool=true` while the window is in the warm
187    /// pool; cleared on promote.
188    TopLevel { is_pool: bool },
189    /// Browser pane child window. `block_id` correlates with the
190    /// `HostState.browser_panes` entry.
191    Pane { block_id: String },
192}
193
194// ── Pool state (H.4) ─────────────────────────────────────────────────────
195
196/// Pre-warmed window pool state. Replaces three separate fields on
197/// `AppState`: `window_pool: Mutex<VecDeque<String>>`,
198/// `unpromoted_pool_labels: Mutex<HashSet<String>>`, and
199/// `window_pool_respawn_in_flight: AtomicBool`. PR #3 migrates each
200/// caller through the a→e ratchet.
201#[derive(Default, Clone, Debug)]
202#[allow(dead_code)]
203pub struct PoolState {
204    /// Labels of pool windows whose renderer signaled ready (eligible
205    /// for promotion).
206    pub queue: std::collections::VecDeque<String>,
207    /// Labels spawned but not yet renderer-ready (and therefore not yet
208    /// in `queue`). Used for taskbar/exclusion filters during the spawn
209    /// → ready window.
210    pub unpromoted: std::collections::HashSet<String>,
211    /// Single-flight semaphore: true while a respawn task is in flight,
212    /// preventing stacked refills.
213    pub respawn_in_flight: bool,
214}
215
216// ── Quit state (H.5) ─────────────────────────────────────────────────────
217
218/// Host process quit lifecycle. Replaces `is_quitting: AtomicBool` at
219/// `state.rs::AppState`. Three states; transitions are monotonic
220/// (Running → Draining → Quit, no regression).
221#[derive(Clone, Debug, PartialEq, Eq)]
222#[allow(dead_code)]
223pub enum QuitState {
224    /// Normal operation. All commands accepted (subject to per-arm rules).
225    Running,
226    /// `BeginDrain` dispatched. Pool refills suppressed; awaiting pool +
227    /// browsers to drain.
228    Draining { reason: QuitReason },
229    /// `ConfirmDrained` dispatched. Host quitting; no further commands.
230    Quit,
231}
232
233#[derive(Clone, Debug, PartialEq, Eq)]
234#[allow(dead_code)]
235pub enum QuitReason {
236    /// User closed the last user-visible top-level window. Standard exit.
237    LastWindowClosed,
238    /// Launcher signaled HostShouldQuit (cross-process shutdown).
239    LauncherRequested,
240    /// External force-quit (Win32 WM_QUIT, signal, etc.).
241    External,
242}
243
244impl Default for QuitState {
245    fn default() -> Self { QuitState::Running }
246}
247
248// ── Top-level window creation runner (H.6) ───────────────────────────────
249
250/// A request to create a top-level window. Pushed to
251/// `HostState.top_level_creation.queue` via `EnqueueTopLevelWindow`.
252/// Carries the full spec the effect handler needs to call
253/// `ui_tasks::post_create_window`.
254#[derive(Clone, Debug)]
255#[allow(dead_code)]
256pub struct TopLevelCreationRequest {
257    pub label: String,
258    pub kind: WindowKind,
259    pub parent_instance_id: Option<String>,
260    pub url: String,
261    pub pos: (i32, i32),
262    pub size: (i32, i32),
263    pub frameless: bool,
264    /// `User`-initiated (fail-fast on contention) vs `Background` (pool
265    /// refill — may queue silently).
266    pub source: TopLevelSource,
267}
268
269/// Distinguishes user-facing creation requests (which fail-fast on
270/// contention) from background ones (pool refill — may queue indefinitely).
271#[derive(Clone, Debug, PartialEq, Eq)]
272#[allow(dead_code)]
273pub enum TopLevelSource {
274    /// Triggered by a user action (click "open new window", tear off a
275    /// tab, etc.). Reducer rejects with error if in-flight slot is
276    /// occupied — caller propagates a visible error to the frontend.
277    User,
278    /// Triggered by the runner itself (pool refill, recovery). Queues
279    /// behind in-flight; no caller waiting on completion.
280    Background,
281}
282
283/// The single in-flight top-level creation. Singleton invariant enforced
284/// by the reducer (at most one Some across all action sequences).
285///
286/// **No `deadline` field. No watchdog.** The reducer reacts only to
287/// observable CEF callbacks: `on_after_created` (success),
288/// `on_render_process_terminated` (renderer crash), `on_before_close`
289/// (cancel mid-create). If none fire, the slot stays occupied
290/// permanently — user-initiated creates fail-fast with visible error
291/// per the no-timer directive.
292#[derive(Clone, Debug)]
293#[allow(dead_code)]
294pub struct InFlightCreation {
295    pub creation_id: u64,
296    pub label: String,
297    pub started_at: std::time::Instant,
298    pub phase: CreationPhase,
299}
300
301/// Phase progression of an in-flight top-level creation. Monotonic;
302/// `AdvanceCreationPhase` (if added later) refuses regression.
303#[derive(Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord)]
304#[repr(u8)]
305#[allow(dead_code)]
306pub enum CreationPhase {
307    /// Effect handler has dispatched `post_create_window`; CEF has not
308    /// yet fired any callback.
309    Started = 0,
310    /// CEF `on_after_created` fired — Browser exists, renderer alive.
311    BrowserCallbackFired = 1,
312}
313
314/// Archived completion record for the runner's history ring buffer.
315/// Bounded at `TOP_LEVEL_CREATION_HISTORY_CAP` (50); oldest evicted FIFO.
316#[derive(Clone, Debug)]
317#[allow(dead_code)]
318pub struct CompletedCreation {
319    pub creation_id: u64,
320    pub label: String,
321    pub outcome: TopLevelCreationOutcome,
322    pub started_at: std::time::Instant,
323    pub finished_at: std::time::Instant,
324    pub last_phase: CreationPhase,
325}
326
327/// Why a top-level creation completed. `Completed` is happy-path; the
328/// other variants are observable failure modes.
329#[derive(Clone, Debug)]
330#[allow(dead_code)]
331pub enum TopLevelCreationOutcome {
332    /// CEF `on_after_created` fired; browser registered. Normal completion.
333    Completed,
334    /// CEF `on_render_process_terminated` fired during creation. Renderer
335    /// process died (crash, OOM, killed).
336    RendererTerminated { status: String },
337    /// CEF `on_before_close` fired during creation. Browser closed
338    /// externally (user action, parent close, etc.).
339    ExternallyClosed,
340}
341
342/// Reducer-managed runner state. Owns the queue, in-flight slot, history,
343/// and id allocator.
344#[derive(Default, Clone, Debug)]
345#[allow(dead_code)]
346pub struct TopLevelCreationState {
347    pub queue: std::collections::VecDeque<TopLevelCreationRequest>,
348    pub in_flight: Option<InFlightCreation>,
349    pub history: std::collections::VecDeque<CompletedCreation>,
350    pub next_creation_id: u64,
351}
352
353// ── Effects (carrier for side-effect-bearing events) ─────────────────────
354
355/// Side-effect descriptor emitted by reducer arms. Carried inside
356/// `HostEvent::Effect(EffectKind)`. The effects executor in
357/// `AppState::host_dispatch_with_effects` dispatches each kind to the
358/// appropriate imperative handler (e.g., posting a CEF UI task).
359///
360/// Reducer arms emit effects but never execute them; this preserves the
361/// pure-functional discipline of `update()`. Manual Debug impl below
362/// because `cef::Browser` doesn't impl Debug.
363#[derive(Clone)]
364#[allow(dead_code)]
365pub enum EffectKind {
366    /// Begin top-level window creation by posting `ui_tasks::post_create_window`.
367    /// Carried by `HostEvent::TopLevelCreationStarted`'s effect path.
368    PostCreateWindow { request: TopLevelCreationRequest, creation_id: u64 },
369    /// Spawn a pool window (PR #3 wires this when pool drops below TARGET_SIZE).
370    SpawnPoolWindow,
371    /// Close an orphan CEF browser whose label doesn't match any in-flight
372    /// or registered entry. Used by H.6's mismatched-callback handler to
373    /// prevent label collision.
374    CloseOrphanBrowser { browser: Browser },
375}
376
377impl std::fmt::Debug for EffectKind {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        match self {
380            EffectKind::PostCreateWindow { request, creation_id } => f
381                .debug_struct("PostCreateWindow")
382                .field("creation_id", creation_id)
383                .field("label", &request.label)
384                .finish(),
385            EffectKind::SpawnPoolWindow => f.write_str("SpawnPoolWindow"),
386            EffectKind::CloseOrphanBrowser { .. } => f
387                .debug_struct("CloseOrphanBrowser")
388                .field("browser", &"<cef::Browser>")
389                .finish(),
390        }
391    }
392}
393
394/// Shared application state for the CEF host.
395///
396/// Unlike the Tauri version, this uses `Arc<AppState>` directly instead of
397/// `tauri::State<AppState>`. The sidecar child is `std::process::Child` instead
398/// of `tauri_plugin_shell::process::CommandChild`.
399pub struct AppState {
400    // Phase B.5 (window_id_map step e) — `window_id_map` field
401    // deleted. Authoritative copy lives in the launcher's
402    // `state.backend_window_ids`. Host's projection is in
403    // `shadow_backend_window_ids` (below).
404
405    /// Auth key for backend communication
406    pub auth_key: Mutex<String>,
407
408    /// Backend (agentmux-srv) connection endpoints
409    pub backend_endpoints: Mutex<BackendEndpoints>,
410
411    /// Handle to the sidecar child process for graceful shutdown
412    pub sidecar_child: Mutex<Option<std::process::Child>>,
413
414    /// Backend process PID (set after spawn)
415    pub backend_pid: Mutex<Option<u32>>,
416
417    /// Backend process start time as ISO 8601 string
418    pub backend_started_at: Mutex<Option<String>>,
419
420    /// Current zoom factor
421    pub zoom_factor: Mutex<f64>,
422
423    /// Client ID (set after querying backend on startup)
424    pub client_id: Mutex<Option<String>>,
425
426    /// Window ID (set after querying/creating window via backend)
427    pub window_id: Mutex<Option<String>>,
428
429    /// Active tab ID (set after querying/creating default tab via backend)
430    pub active_tab_id: Mutex<Option<String>>,
431
432    /// Window initialization status ("ready" or "wave-ready")
433    pub window_init_status: Mutex<String>,
434
435    /// Phase B.5e — host's projection of the launcher's
436    /// authoritative `state.instance_registry`. Fed by
437    /// `Event::WindowInstanceAssigned` /
438    /// `Event::WindowInstanceReleased` via
439    /// `launcher_ipc::apply_event_to_shadow`. Host code never
440    /// mutates this directly — reads only. Pre-seeded with
441    /// `{"main": 1}` to mirror the launcher's pre-seed (avoids a
442    /// spurious first-event mismatch during startup before the
443    /// `WindowInstanceAssigned { label: "main", num: 1 }` event
444    /// arrives).
445    pub shadow_instance_registry: Mutex<HashMap<String, u32>>,
446
447    /// Phase B.5 (window_id_map) — host's projection of the
448    /// launcher's authoritative `state.backend_window_ids`. Fed by
449    /// `Event::BackendWindowIdRegistered` /
450    /// `Event::BackendWindowIdUnregistered` via
451    /// `apply_event_to_shadow`. Sole source of truth post-step-e
452    /// (host's `window_id_map` was deleted). Read via
453    /// `Self::backend_window_id`; never mutated directly by host
454    /// code.
455    pub shadow_backend_window_ids: Mutex<HashMap<String, String>>,
456
457    /// Phase B.5 (window_meta step b) — host's projection of the
458    /// launcher's authoritative `state.windows: HashMap<String,
459    /// WindowMirror>` (B.4). The launcher already mirrors all
460    /// `WindowMeta` data via `Event::WindowOpened`/`WindowClosed`
461    /// (carrying `{label, kind, parent_label}` since B.4); this
462    /// host-side cache makes it readable without a launcher
463    /// round-trip. Maintained in parallel to `window_meta` until
464    /// step c flips reads, step d drops mutations, step e deletes
465    /// the host field. Drift logged via
466    /// `target = "launcher-ipc:drift"`.
467    pub shadow_window_meta: Mutex<HashMap<String, WindowMeta>>,
468
469    /// Cancellation channel for an in-progress CLI login process
470    pub cli_login_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
471
472    /// PID of an in-progress PTY-backed CLI login child. The pipe
473    /// path uses `cli_login_cancel` + `tokio::select!` + Tokio's
474    /// `kill_on_drop` Child to terminate, but the PTY path moves
475    /// the `portable_pty` child into a `spawn_blocking` task that
476    /// outlives the outer abort — so cancel needs a PID + platform
477    /// kill to actually stop the subprocess. Populated by
478    /// `run_cli_login_pty`, cleared when the child exits naturally.
479    pub cli_login_pty_pid: Mutex<Option<u32>>,
480
481    /// Stdin handle for the running CLI login child process. Two
482    /// variants because some providers (OpenClaw) require an
483    /// interactive TTY for their auth subcommand and we spawn them via
484    /// `portable_pty` instead of `tokio::process::Command`. Written to
485    /// by `set_provider_auth` to deliver an OAuth device code or
486    /// pasted token. See `commands::platform::CliLoginStdin`.
487    pub cli_login_stdin: Mutex<Option<crate::commands::platform::CliLoginStdin>>,
488
489    /// IPC HTTP server port
490    pub ipc_port: Mutex<u16>,
491
492    /// IPC bearer token — injected into the page alongside the port.
493    /// Verified on every IPC request to prevent unauthorized local access.
494    pub ipc_token: String,
495
496    // Phase H.2.e (PR #4) — `pub browsers: Mutex<HashMap<String, Browser>>`
497    // deleted. Authoritative storage is now `HostState.browsers` (the host
498    // reducer's map). Read access goes through `AppState::get_browser`,
499    // `list_browsers`, etc. (state.rs:704-742). The H.2 ratchet
500    // (a → parallel writes, b → reads with fallback, c → flip reads,
501    // d → drop legacy writes, e → delete) is complete for browsers.
502    // Pane lifecycle (`PaneStateMachine`) follows in PR #5.
503
504    /// Per-window metadata (kind, parent linkage).
505    ///
506    /// **Phase B status**: synchronous host-side cache mirroring the
507    /// launcher's `state.windows` mirror. Single canonical mutation site:
508    /// inserted in `client.rs::on_after_created` from the popped
509    /// `PendingWindowCreation` entry, removed in `on_before_close`.
510    /// Required for synchronous lookups that can't tolerate the launcher
511    /// round-trip lag (`open_subwindow` parent-liveness check; cascade-close
512    /// child enumeration). See `docs/retro/migration-pattern.md` for the
513    /// sync-cache exception and `b5-migration-architecture-2026-04-28.md`
514    /// for why step e ≠ delete here.
515    pub window_meta: Mutex<HashMap<String, WindowMeta>>,
516
517    /// Phase F.1 — host reducer state.
518    ///
519    /// Owns `pending_window_creations` (formerly a top-level
520    /// `Mutex<VecDeque<PendingWindowCreation>>` field on AppState).
521    /// All mutations go through `host_dispatch`; reads use the
522    /// `peek_back_pending_window_creation` snapshot helper.
523    /// Future PRs will migrate `active_drag` and tear-off-hook state
524    /// here too. See `agentmux-cef/src/reducer.rs` and
525    /// `docs/specs/SPEC_PHASE_F_HOST_REDUCER_2026-05-01.md`.
526    pub host_state: Mutex<crate::reducer::HostState>,
527
528    /// Phase F.7 host-bridge dedup. Keyed by `"{event_kind}|{label}|{hwnd}"`,
529    /// value is the highest launcher-event version dispatched to renderers
530    /// for that key. The bridge skips dispatch if the incoming event's
531    /// version is `<=` the cached version — preserving subscriber
532    /// idempotency under §8.14 even if the renderer-side guard fails
533    /// (multiple V8 contexts, fresh-renderer post-crash, race during
534    /// init, etc.).
535    ///
536    /// Originally proposed in `ANALYSIS_DRIFT_STORM_RENDERER_CRASH_2026-05-06.md`
537    /// §4.4 / master spec §9.8 as Phase F.7. Implementation forced when
538    /// v0.33.688 smoke surfaced a 164× amplification of a single
539    /// `HiddenSinceOpen` drift event v=78 — launcher cap (PR #721)
540    /// prevented multi-emit but the bridge fanned the one event into
541    /// many V8 contexts, exhausting the renderer.
542    ///
543    /// Bounded at 4096 keys with FIFO eviction (insertion order). A
544    /// re-arrival for an evicted key bypasses dedup once but the
545    /// renderer guard still catches it.
546    pub launcher_bridge_dedup: Mutex<crate::launcher_event_bridge::DedupCache>,
547
548    /// Linux/macOS only — registry of live top-level `CefWindow` handles,
549    /// keyed by window label ("main" for the primary, otherwise the label
550    /// CreateWindowTask assigns to a sub-window). Populated by
551    /// `AgentMuxWindowDelegate::on_window_created` and cleared by
552    /// `on_window_destroyed`. Used by the browser-pane Views path to find
553    /// the right parent Window when attaching a pane via
554    /// `Window::add_overlay_view` — without this map, panes opened from a
555    /// non-main window were silently routed to the main window (PR #682
556    /// follow-up: "the browser opens in the wrong window" bug).
557    /// The Windows pane path uses native HWND lookup instead and never
558    /// reads this field, so it is cfg-gated to avoid carrying unused
559    /// state on Windows.
560    #[cfg(not(target_os = "windows"))]
561    pub windows: Mutex<std::collections::HashMap<String, cef::Window>>,
562
563    /// Linux/macOS only — `CefOverlayController` handles for live browser
564    /// panes, keyed by pane label (the same label used in `host_state.browsers`).
565    /// Each entry pairs the controller with the `window_label` of the
566    /// parent CefWindow it was attached to (looked up from `state.windows`
567    /// at create time), so resize / detach / overlay-clip calls can find
568    /// the right Window for `layout()` / `remove_child_view`.
569    ///
570    /// Populated by `browser_pane/creation_views.rs` after the BrowserView
571    /// has been added to its parent Window via `add_overlay_view`. We use
572    /// AddOverlayView (not AddChildView) because AddChildView cohabitates
573    /// poorly with the host UI's BrowserView under the Window's default
574    /// FillLayout — both children get full-parent-size bounds and the pane
575    /// stacks on top of the host UI at full size with per-frame redraw
576    /// flashing (verified empirically during the spike). OverlayController
577    /// has its own explicit `set_size` / `set_position` / `set_visible`
578    /// /`destroy` that aren't subject to the parent's auto-layout.
579    /// Windows panes use HWND ops instead and never touch this map.
580    #[cfg(not(target_os = "windows"))]
581    pub browser_pane_overlays:
582        Mutex<std::collections::HashMap<String, (String, cef::OverlayController)>>,
583
584    /// Linux/macOS only — latest overlay-clip rects per window_label.
585    ///
586    /// `browser_panes_set_overlay_clip` IPC publishes here so that BOTH the
587    /// pane-airspace task (`SetPaneOverlayClipViewsTask`) and the per-pane
588    /// resize task (`resize_browser_pane_view`) can compute pane visibility
589    /// from the same authoritative state. Without this, resize would be the
590    /// sole visibility authority on its path and `set_visible(1)` on a
591    /// non-zero resize (e.g. user dragging a splitter while a DOM modal is
592    /// open) would clobber the airspace's `set_visible(0)` — the
593    /// "borderless DOM appears above modal" repro Codex caught on PR #881.
594    ///
595    /// Empty `Vec` for a key means "no overlays open in this window" →
596    /// panes are visible (subject to their own rect being non-zero).
597    /// Stale entries for closed windows are harmless: they only affect
598    /// panes still attached to that window.
599    #[cfg(not(target_os = "windows"))]
600    pub pane_overlay_rects:
601        Mutex<std::collections::HashMap<String, Vec<(i32, i32, i32, i32)>>>,
602
603    /// Linux/macOS only — OverlayControllers awaiting deferred destroy.
604    ///
605    /// Calling `OverlayController::destroy()` synchronously on the same UI
606    /// tick as `BrowserHost::close_browser(force=1)` races CEF/Chromium's
607    /// internal Views focus traversal — pending tasks hold
608    /// `base::WeakPtr<View>` to the BrowserView, and yanking the view out
609    /// of the Window's hierarchy before those tasks drain trips
610    /// `weak_ptr.h:250 Check failed: ref_.IsValid()` and FATALs the host.
611    /// Two confirmed reproducers: closing a pane while a pool window is
612    /// being spawned (PR #743 follow-up) and tearing off a tab (a tear-off
613    /// closes the pane in the source workspace then immediately creates a
614    /// new top-level window for the torn tab).
615    ///
616    /// Fix: detach moves the controller out of `browser_pane_overlays`
617    /// (so future resize/clip calls miss), calls `close_browser(force=1)`,
618    /// and stashes the controller here. `on_before_close_browser_pane`
619    /// then drains this map and runs `destroy()` — by that point the
620    /// Browser is fully torn down and Chromium has drained the queued
621    /// tasks that referenced its View, so destroy can't race anything.
622    #[cfg(not(target_os = "windows"))]
623    pub pending_overlay_destroy:
624        Mutex<std::collections::HashMap<String, cef::OverlayController>>,
625
626    /// Tear-off Phase 6 — pre-warmed pool of hidden CEF windows ready for
627    /// instant promotion on tear-off. Each entry is a label of a window
628    /// that's already painted, has its renderer connected, and is sitting
629    /// in pool-mode (`?pool=1` URL flag) waiting to be assigned a workspace.
630    /// On tear-off: pop a label, reposition + show + emit `pool:promote`.
631    // PR #5 H.4 — `window_pool`, `unpromoted_pool_labels`, and
632    // `window_pool_respawn_in_flight` deleted; the host reducer's
633    // `HostState.pool: PoolState` is the sole source of truth. Reads
634    // go through `AppState::pool_queue_size`,
635    // `unpromoted_pool_labels_snapshot`, `is_unpromoted_pool_label`;
636    // mutations through `HostCommand::PoolWindowSpawnStart`,
637    // `PoolWindowReady`, `PoolWindowDestroyedBeforePromote`,
638    // `PopAndPromoteFrontPoolWindow`, `PoolDrainAll`.
639
640    // PR #5 H.5 — `is_quitting` AtomicBool deleted; the host reducer's
641    // `HostState.quit_state: QuitState` is the sole source of truth.
642    // Reads go through `AppState::is_quitting()`; transitions through
643    // `HostCommand::BeginDrain` and `ConfirmDrained`.
644
645    /// Version-specific data directory (e.g. ai.agentmux.cef.v0-32-111/)
646    pub version_data_dir: Mutex<Option<String>>,
647
648    /// Version-specific config directory
649    pub version_config_dir: Mutex<Option<String>>,
650
651    /// User data home used by the frontend's `agentmuxHome()` helper to
652    /// construct per-agent paths (working dir, `GH_CONFIG_DIR`, etc.).
653    ///
654    /// - Portable: `<portable>/data/` — keeps agent state inside the portable
655    ///   folder so two coexisting portables don't clobber each other on the
656    ///   same `~/.agentmux/agents/<slug>/` path (slugs are unique per Forge
657    ///   DB, not globally; see `docs/specs/portable-agent-working-dirs.md`).
658    /// - Installed: `~/.agentmux/` — preserves existing behavior.
659    ///
660    /// `AGENTMUX_DATA_HOME` env var, if set, overrides both.
661    pub user_home_dir: Mutex<Option<String>>,
662
663    // PR #5 H.3 — `active_drag` field deleted; the host reducer's
664    // `HostState.active_drag` is the sole source of truth. Reads go
665    // through `AppState::get_drag_session`; mutations through
666    // `HostCommand::StartDrag` / `EndDrag`.
667
668    /// Embedded browser panes (native CefBrowserView per pane).
669    pub browser_panes: crate::browser_panes::BrowserPaneManager,
670
671    /// Browser DOM API state — CDP target cache + future connection
672    /// pool. See `crate::browser_api`.
673    pub browser_api: crate::browser_api::BrowserApiState,
674
675    /// CEF remote debugging port (9223 dev / 9222 release). Populated
676    /// by `main.rs` from the same `is_dev` branch that sets
677    /// `Settings.remote_debugging_port`. Used by the browser DOM API
678    /// (`/agentmux/browser/*`) to open CDP WebSocket connections to
679    /// pane targets. See `docs/specs/SPEC_BROWSER_DOM_API.md` §6.
680    pub debug_port: Mutex<u16>,
681
682    // Phase B.1 removed `job_handle` (was Windows-only). Launcher
683    // owns J0 wrapping srv now; host no longer needs its own job.
684
685    /// Per-window opacity HWND registry. Populated by `set_window_init_status`
686    /// once the window is fully shown (CEF Views returns NULL at on_after_created
687    /// time). Stored as `isize` (the raw HWND value) so the map is `Send`.
688    /// Read by `set_window_opacity` to target exactly one HWND instead of
689    /// enumerating all process windows. See SPEC_PER_WINDOW_OPACITY_2026-05-14.md §5.
690    #[cfg(target_os = "windows")]
691    pub window_hwnds: Mutex<HashMap<String, isize>>,
692}
693
694impl Default for AppState {
695    fn default() -> Self {
696        Self {
697            auth_key: Mutex::new(uuid::Uuid::new_v4().to_string()),
698            backend_endpoints: Mutex::new(BackendEndpoints::default()),
699            sidecar_child: Mutex::new(None),
700            backend_pid: Mutex::new(None),
701            backend_started_at: Mutex::new(None),
702            zoom_factor: Mutex::new(1.0),
703            client_id: Mutex::new(None),
704            window_id: Mutex::new(None),
705            active_tab_id: Mutex::new(None),
706            window_init_status: Mutex::new(String::new()),
707            shadow_backend_window_ids: Mutex::new(HashMap::new()),
708            shadow_window_meta: Mutex::new(HashMap::new()),
709            shadow_instance_registry: Mutex::new({
710                // Pre-seed with main=1 to mirror the launcher's
711                // pre-seeded `instance_registry` (B.5a). Without
712                // this, the very first drift compare would log a
713                // spurious mismatch for "main" before any event
714                // arrives.
715                let mut m = HashMap::new();
716                m.insert("main".to_string(), 1);
717                m
718            }),
719            cli_login_cancel: Mutex::new(None),
720            cli_login_pty_pid: Mutex::new(None),
721            cli_login_stdin: Mutex::new(None),
722            ipc_port: Mutex::new(0),
723            ipc_token: uuid::Uuid::new_v4().to_string(),
724            // browsers field removed in H.2.e — see comment near struct decl.
725            window_meta: Mutex::new(HashMap::new()),
726            host_state: Mutex::new(crate::reducer::HostState::default()),
727            launcher_bridge_dedup: Mutex::new(crate::launcher_event_bridge::DedupCache::new()),
728            #[cfg(not(target_os = "windows"))]
729            windows: Mutex::new(HashMap::new()),
730            #[cfg(not(target_os = "windows"))]
731            browser_pane_overlays: Mutex::new(HashMap::new()),
732            #[cfg(not(target_os = "windows"))]
733            pane_overlay_rects: Mutex::new(HashMap::new()),
734            #[cfg(not(target_os = "windows"))]
735            pending_overlay_destroy: Mutex::new(HashMap::new()),
736            // window_pool / unpromoted_pool_labels / window_pool_respawn_in_flight
737            // deleted (PR #5 H.4) — see HostState.pool.
738            // is_quitting deleted (PR #5 H.5) — see HostState.quit_state.
739            version_data_dir: Mutex::new(None),
740            version_config_dir: Mutex::new(None),
741            user_home_dir: Mutex::new(None),
742            // active_drag deleted (PR #5 H.3) — see HostState.active_drag.
743            browser_panes: crate::browser_panes::BrowserPaneManager::new(),
744            browser_api: crate::browser_api::BrowserApiState::new(),
745            debug_port: Mutex::new(0),
746            #[cfg(target_os = "windows")]
747            window_hwnds: Mutex::new(HashMap::new()),
748        }
749    }
750}
751
752impl AppState {
753    /// Phase F.1 — dispatch a command through the host reducer.
754    ///
755    /// Locks `host_state`, applies the command via `reducer::update`,
756    /// logs emitted events via tracing, and returns the dispatch
757    /// output (which contains the events plus the dequeued entry for
758    /// `DequeuePendingWindowCreation`).
759    ///
760    /// Lock-hold time: pure-function reducer call, no I/O — typically
761    /// sub-microsecond. Never held across a `SendMessage`, CEF
762    /// callback, or any blocking call (snapshot-and-drop discipline,
763    /// see `docs/specs/SPEC_PHASE_F_HOST_REDUCER_2026-05-01.md` §6).
764    pub fn host_dispatch(&self, cmd: crate::reducer::HostCommand) -> crate::reducer::DispatchOutput {
765        let out = {
766            let mut state = self.host_state.lock();
767            crate::reducer::update(&mut state, cmd)
768        };
769        for ev in &out.events {
770            log_host_event(ev);
771        }
772        out
773    }
774
775    /// Phase F.1 — non-mutating peek at the back of the
776    /// `pending_window_creations` queue.
777    ///
778    /// Used by `wrr/win_event.rs::handle_event` to label OS-level
779    /// `WM_CREATE` events with the upcoming window's label. CEF's
780    /// `OnAfterCreated` (which becomes the dequeue) fires AFTER this
781    /// OS event, but the host pushed the entry BEFORE calling
782    /// `post_create_window`, so back-of-queue is the right answer at
783    /// this moment.
784    ///
785    /// Snapshot-and-drop: takes the lock, clones the entry, drops
786    /// the lock. Callers never hold the lock past this call.
787    pub fn peek_back_pending_window_creation(&self) -> Option<PendingWindowCreation> {
788        self.host_state.lock().pending_window_creations.back().cloned()
789    }
790
791    // ── Phase H.2 — browser read helpers (reducer-only, post-flip) ──────
792    //
793    // After PR #4's flip step (H.1.c + H.2.c), `HostState.browsers` /
794    // `HostState.browser_panes` are the sole source of truth. Legacy reads,
795    // fallback paths, and drift logging removed — production smoke
796    // verified zero drift across 50 reducer events with a balanced
797    // BrowserRegistered/Unregistered count (18/18) and BrowserPaneCreate/
798    // BrowserPaneClosed count (7/7) before this flip.
799    //
800    // Snapshot-and-drop: each helper takes the relevant lock briefly,
801    // clones what's needed, drops the lock. Callers never hold the
802    // lock across CEF FFI calls.
803
804    /// Get a browser handle by label.
805    pub fn get_browser(&self, label: &str) -> Option<Browser> {
806        self.host_state
807            .lock()
808            .browsers
809            .get(label)
810            .map(|h| h.browser.clone())
811    }
812
813    /// Check whether a browser is registered under the given label.
814    pub fn has_browser(&self, label: &str) -> bool {
815        self.host_state.lock().browsers.contains_key(label)
816    }
817
818    /// Are there any registered browsers?
819    pub fn browsers_is_empty(&self) -> bool {
820        self.host_state.lock().browsers.is_empty()
821    }
822
823    /// Snapshot of all registered browser labels (HashMap iteration order;
824    /// stable per-HashMap-instance, same characteristics as the original
825    /// `state.browsers.lock().keys()` pattern these helpers replaced).
826    pub fn list_browser_labels(&self) -> Vec<String> {
827        self.host_state
828            .lock()
829            .browsers
830            .keys()
831            .cloned()
832            .collect()
833    }
834
835    /// Snapshot of all registered browsers as (label, Browser) pairs.
836    /// Ordering is HashMap iteration order; callers must sort if order
837    /// matters.
838    pub fn list_browsers(&self) -> Vec<(String, Browser)> {
839        self.host_state
840            .lock()
841            .browsers
842            .iter()
843            .map(|(k, h)| (k.clone(), h.browser.clone()))
844            .collect()
845    }
846
847    /// Snapshot of TOP-LEVEL browsers only — excludes `BrowserKind::Pane`
848    /// child browsers whose main frame is loading untrusted remote
849    /// content. Callers emitting JS-injected host events must use this
850    /// (or `emit_event_to_window`) so a hostile page in one pane can't
851    /// observe events meant for the host frontend.
852    pub fn list_top_level_browsers(&self) -> Vec<(String, Browser)> {
853        self.host_state
854            .lock()
855            .browsers
856            .iter()
857            .filter(|(_, h)| matches!(h.kind, BrowserKind::TopLevel { .. }))
858            .map(|(k, h)| (k.clone(), h.browser.clone()))
859            .collect()
860    }
861
862    /// First registered browser (for "any browser" callers like command
863    /// palette routing). Returns the label + Browser pair, or None if
864    /// the registry is empty.
865    pub fn first_browser(&self) -> Option<(String, Browser)> {
866        self.host_state
867            .lock()
868            .browsers
869            .iter()
870            .next()
871            .map(|(k, h)| (k.clone(), h.browser.clone()))
872    }
873
874    // ── Phase H.1 — pane lifecycle read helpers (reducer-only, post-flip)
875
876    /// Returns the pane's label iff entry is `Live`. Used by op gates
877    /// (focus/resize/navigate) — `None` indicates the pane is missing or
878    /// in `Closing`, in which case the caller must short-circuit rather
879    /// than touch the (possibly destroyed) HWND.
880    pub fn live_browser_pane_label(&self, block_id: &str) -> Option<String> {
881        self.host_state
882            .lock()
883            .browser_panes
884            .get(block_id)
885            .filter(|e| e.lifecycle == BrowserPaneLifecycle::Live)
886            .map(|e| e.label.clone())
887    }
888
889    /// Snapshot of all `Live` pane labels. Used by `defocus_all` etc.
890    pub fn live_browser_pane_labels(&self) -> Vec<String> {
891        self.host_state
892            .lock()
893            .browser_panes
894            .values()
895            .filter(|e| e.lifecycle == BrowserPaneLifecycle::Live)
896            .map(|e| e.label.clone())
897            .collect()
898    }
899
900    /// PR #5 H.3 — read-side helper for `commands::drag::update_cross_drag`.
901    /// Returns the active drag session iff its drag_id matches `drag_id`.
902    /// Snapshot-and-drop: clones under lock, drops the lock.
903    pub fn get_drag_session(&self, drag_id: &str) -> Option<DragSession> {
904        self.host_state
905            .lock()
906            .active_drag
907            .as_ref()
908            .filter(|s| s.drag_id == drag_id)
909            .cloned()
910    }
911
912    // ── PR #5 H.4 — pool read helpers ───────────────────────────────────
913    //
914    // Replace the legacy `state.unpromoted_pool_labels: Mutex<HashSet>` /
915    // `state.window_pool: Mutex<VecDeque>` reads. All under one
916    // `host_state` lock, snapshot-and-drop discipline.
917
918    /// Atomic snapshot of (user_window_count, unpromoted_pool_count)
919    /// for the launcher drift-detection report. Both reads taken
920    /// under ONE `host_state` lock; a two-lock variant races
921    /// against `promote_pool_window` and would let queued pool
922    /// windows leak into the user-window count.
923    ///
924    /// Filter rules (mirror `list_windows` / `dispatch_to_renderers`):
925    /// - exclude pool inventory (`pool.unpromoted` ∪ `pool.queue`)
926    /// - exclude `browser-pane-*` child HWNDs
927    ///
928    /// `pool_count` is the size of the pool **inventory** (unpromoted
929    /// ∪ queue) — NOT just unpromoted. The launcher's `state.pool`
930    /// mirror is built from `ReportPoolWindowAdded` / `Removed` /
931    /// `Promoted` events. On the host's unpromoted→queue transition
932    /// (when `pool_ready` fires) NO event is emitted, so the
933    /// launcher mirror retains the queued label. Reporting just
934    /// `unpromoted.len()` would under-count and trigger spurious
935    /// pool drift while the warm pool is idle and ready.
936    pub fn host_counts_snapshot(&self) -> (u32, u32) {
937        let st = self.host_state.lock();
938        let pool_inventory: std::collections::HashSet<&str> = st
939            .pool
940            .unpromoted
941            .iter()
942            .map(String::as_str)
943            .chain(st.pool.queue.iter().map(String::as_str))
944            .collect();
945        let pool = pool_inventory.len() as u32;
946        let windows = st
947            .browsers
948            .keys()
949            .filter(|k| !k.starts_with("browser-pane-") && !pool_inventory.contains(k.as_str()))
950            .count() as u32;
951        (windows, pool)
952    }
953
954    /// Snapshot of unpromoted pool labels. Used by orphan
955    /// reconciliation and the pool-count report after spawning a
956    /// new pool slot. Caller can `.contains()` against the set or
957    /// iterate.
958    pub fn unpromoted_pool_labels_snapshot(&self) -> std::collections::HashSet<String> {
959        self.host_state
960            .lock()
961            .pool
962            .unpromoted
963            .iter()
964            .cloned()
965            .collect()
966    }
967
968    /// Single-label check against unpromoted pool set. Used by
969    /// `client.rs::on_after_created` BrowserKind classification.
970    pub fn is_unpromoted_pool_label(&self, label: &str) -> bool {
971        self.host_state.lock().pool.unpromoted.contains(label)
972    }
973
974    /// Number of pool windows currently in the renderer-ready queue
975    /// (NOT including unpromoted). Used by `init_pool` to decide
976    /// whether to bootstrap more pool windows.
977    pub fn pool_queue_size(&self) -> usize {
978        self.host_state.lock().pool.queue.len()
979    }
980
981    /// Atomic snapshot for user-visibility filtering: pool inventory
982    /// (`pool.unpromoted` ∪ `pool.queue`) AND the browser registry,
983    /// taken under ONE `host_state` lock acquisition.
984    ///
985    /// Two-lock variants (one snapshot for the pool, one for
986    /// `list_browsers()`) race against `promote_pool_window`:
987    /// between the reads, a label can move from pool.queue to
988    /// promoted, leaving the stale inventory excluding a now-real
989    /// user window. Atomic snapshot eliminates the gap.
990    ///
991    /// Returns:
992    /// - `pool_inventory`: labels in `pool.unpromoted` ∪ `pool.queue`
993    ///   (host-internal, no user UI yet — exclude from user-visible
994    ///   filters and from launcher-event dispatch).
995    /// - `browsers`: every label → Browser pair currently registered
996    ///   (cheap clone — `Browser` is a CEF refcounted wrapper).
997    pub fn user_visibility_snapshot(&self) -> (
998        std::collections::HashSet<String>,
999        Vec<(String, Browser)>,
1000    ) {
1001        let st = self.host_state.lock();
1002        let pool_inventory: std::collections::HashSet<String> = st
1003            .pool
1004            .unpromoted
1005            .iter()
1006            .cloned()
1007            .chain(st.pool.queue.iter().cloned())
1008            .collect();
1009        let browsers: Vec<(String, Browser)> = st
1010            .browsers
1011            .iter()
1012            .map(|(k, h)| (k.clone(), h.browser.clone()))
1013            .collect();
1014        (pool_inventory, browsers)
1015    }
1016
1017    /// PR #5 H.5 — read-side helper for the legacy `is_quitting` check.
1018    /// Returns true iff the host has begun draining (BeginDrain
1019    /// dispatched) OR has fully quit. Replaces the AtomicBool.
1020    pub fn is_quitting(&self) -> bool {
1021        !matches!(
1022            self.host_state.lock().quit_state,
1023            crate::state::QuitState::Running
1024        )
1025    }
1026
1027    /// PR #6 H.7 — cross-state invariant for the 2026-05-02 freeze.
1028    ///
1029    /// Returns true iff ANY pane is in `Closing`. Top-level window
1030    /// creation paths (open_new_window, open_window_at_position,
1031    /// spawn_pool_window) MUST refuse while this is true: empirically
1032    /// (`SPEC_WINDOW_FLEET_REDUCER_2026-05-02.md`), creating a CEF
1033    /// top-level mid-pane-close hits a Chromium v146 deadlock that
1034    /// wedges the message loop with HiddenSinceOpen + IPC backpressure
1035    /// (`pending=N` rising) and never recovers.
1036    ///
1037    /// The check is small enough to inline at each call site with no
1038    /// async surface. If it turns out the gate needs to widen
1039    /// ("any pane present" rather than "any pane Closing"), that's a
1040    /// one-line edit. Spec §5 escape hatch.
1041    pub fn any_browser_pane_closing(&self) -> bool {
1042        self.host_state
1043            .lock()
1044            .browser_panes
1045            .values()
1046            .any(|e| matches!(e.lifecycle, BrowserPaneLifecycle::Closing { .. }))
1047    }
1048
1049    /// Phase B.5e — authoritative instance-number lookup. Reads
1050    /// the launcher-fed `shadow_instance_registry`, which is the
1051    /// sole source of truth post-B.5e (host's
1052    /// `WindowInstanceRegistry` was deleted). The shadow is
1053    /// pre-seeded with `{"main": 1}` so the very first lookup
1054    /// during startup resolves before the launcher's first
1055    /// `WindowInstanceAssigned` event arrives.
1056    pub fn instance_num(&self, label: &str) -> Option<u32> {
1057        self.shadow_instance_registry.lock().get(label).copied()
1058    }
1059
1060    /// Phase B.5 (window_id_map step e) — authoritative
1061    /// label→backend_window_id lookup. Reads from the
1062    /// launcher-fed `shadow_backend_window_ids`. Sole source of
1063    /// truth post-step-e (host's `window_id_map` was deleted).
1064    pub fn backend_window_id(&self, label: &str) -> Option<String> {
1065        self.shadow_backend_window_ids.lock().get(label).cloned()
1066    }
1067
1068    /// Phase B.5 (window_meta step c) — authoritative WindowMeta
1069    /// lookup. Prefers the launcher-fed `shadow_window_meta`; falls
1070    /// back to host's local `window_meta` for the race window
1071    /// where host has just inserted the pre-create handoff but
1072    /// the launcher's `WindowOpened` event hasn't returned yet.
1073    /// Same prefer-shadow pattern as `instance_num` and
1074    /// `backend_window_id`.
1075    pub fn window_meta(&self, label: &str) -> Option<WindowMeta> {
1076        if let Some(meta) = self.shadow_window_meta.lock().get(label).cloned() {
1077            return Some(meta);
1078        }
1079        let fallback = self.window_meta.lock().get(label).cloned();
1080        if fallback.is_some() {
1081            tracing::debug!(
1082                target: "launcher-ipc:fallback",
1083                label = %label,
1084                "[window_meta] shadow miss — falling back to host's window_meta (B.5c transitional)"
1085            );
1086        }
1087        fallback
1088    }
1089
1090    /// Phase B.5 (window_meta step c) — collect labels of Subwindows
1091    /// whose `parent_instance_id` points to `parent_label`. Used by
1092    /// `on_before_close`'s cascade-close logic.
1093    ///
1094    /// Returns the **union** of matches from `shadow_window_meta`
1095    /// (the launcher-fed projection) and host's `window_meta` (the
1096    /// eager pre-create handoff). The union covers a critical race:
1097    /// a parent may already have one mirrored subwindow AND a newly
1098    /// opened sibling whose `WindowOpened` event hasn't returned to
1099    /// host yet. The newer sibling lives in host's `window_meta`
1100    /// only; the mirrored one lives in shadow only (post-step-d
1101    /// the shadow becomes the sole source). Cascade-close MUST
1102    /// catch both — short-circuiting on shadow-non-empty would
1103    /// leave the race-window sibling orphaned. (codex P1 PR #591
1104    /// round-1.)
1105    ///
1106    /// Dedup is by label; if a label is in both, it's reported
1107    /// once. Labels collected via a HashSet to dedup, returned as
1108    /// a Vec.
1109    pub fn subwindow_children_of(&self, parent_label: &str) -> Vec<String> {
1110        let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
1111        for m in self.shadow_window_meta.lock().values() {
1112            if m.kind == WindowKind::Subwindow
1113                && m.parent_instance_id.as_deref() == Some(parent_label)
1114            {
1115                out.insert(m.label.clone());
1116            }
1117        }
1118        for m in self.window_meta.lock().values() {
1119            if m.kind == WindowKind::Subwindow
1120                && m.parent_instance_id.as_deref() == Some(parent_label)
1121            {
1122                out.insert(m.label.clone());
1123            }
1124        }
1125        out.into_iter().collect()
1126    }
1127}
1128
1129/// Phase F.1 — observability hook for host-reducer events.
1130///
1131/// Called by `AppState::host_dispatch` after every `update()` call.
1132/// F.1 logs via tracing only; future PRs may add a broadcast channel
1133/// here when an event consumer (cross-process saga, frontend
1134/// dispatcher) appears.
1135fn log_host_event(ev: &crate::reducer::HostEvent) {
1136    use crate::reducer::HostEvent;
1137    match ev {
1138        HostEvent::PendingWindowEnqueued {
1139            label,
1140            queue_len_after,
1141            version,
1142        } => tracing::debug!(
1143            target: "host-reducer",
1144            event = "PendingWindowEnqueued",
1145            label = %label,
1146            queue_len_after,
1147            version,
1148        ),
1149        HostEvent::PendingWindowDequeued {
1150            label,
1151            queue_len_after,
1152            version,
1153        } => tracing::debug!(
1154            target: "host-reducer",
1155            event = "PendingWindowDequeued",
1156            label = %label,
1157            queue_len_after,
1158            version,
1159        ),
1160        HostEvent::PendingWindowQueueEmpty { version } => tracing::warn!(
1161            target: "host-reducer",
1162            event = "PendingWindowQueueEmpty",
1163            version,
1164            "[host-reducer] dequeue on empty queue — caller will fall back",
1165        ),
1166        // ── H.1 panes ────────────────────────────────────────────────────
1167        HostEvent::BrowserPaneCreateRequested { block_id, label, version } => tracing::info!(
1168            target: "host-reducer",
1169            event = "BrowserPaneCreateRequested",
1170            block_id = %block_id, label = %label, version,
1171        ),
1172        HostEvent::BrowserPaneLive { block_id, label, version } => tracing::info!(
1173            target: "host-reducer",
1174            event = "BrowserPaneLive",
1175            block_id = %block_id, label = %label, version,
1176        ),
1177        HostEvent::BrowserPaneClosing { block_id, version } => tracing::info!(
1178            target: "host-reducer",
1179            event = "BrowserPaneClosing",
1180            block_id = %block_id, version,
1181        ),
1182        HostEvent::BrowserPaneClosed { block_id, version } => tracing::info!(
1183            target: "host-reducer",
1184            event = "BrowserPaneClosed",
1185            block_id = %block_id, version,
1186        ),
1187        HostEvent::BrowserPaneCreationFailed { block_id, reason, version } => tracing::warn!(
1188            target: "host-reducer",
1189            event = "BrowserPaneCreationFailed",
1190            block_id = %block_id, reason = %reason, version,
1191        ),
1192        // ── H.2 browsers ─────────────────────────────────────────────────
1193        HostEvent::BrowserRegistered { label, kind, version } => tracing::info!(
1194            target: "host-reducer",
1195            event = "BrowserRegistered",
1196            label = %label, kind = ?kind, version,
1197        ),
1198        HostEvent::BrowserUnregistered { label, version } => tracing::info!(
1199            target: "host-reducer",
1200            event = "BrowserUnregistered",
1201            label = %label, version,
1202        ),
1203        // ── H.3 drag ─────────────────────────────────────────────────────
1204        HostEvent::DragStarted { drag_id, source_window, version } => tracing::info!(
1205            target: "host-reducer",
1206            event = "DragStarted",
1207            drag_id = %drag_id, source_window = %source_window, version,
1208        ),
1209        HostEvent::DragEnded { drag_id, outcome, version } => tracing::info!(
1210            target: "host-reducer",
1211            event = "DragEnded",
1212            drag_id = %drag_id, outcome = ?outcome, version,
1213        ),
1214        // ── H.4 pool ─────────────────────────────────────────────────────
1215        HostEvent::PoolWindowEntered { label, queue_len_after, version } => tracing::info!(
1216            target: "host-reducer",
1217            event = "PoolWindowEntered",
1218            label = %label, queue_len_after, version,
1219        ),
1220        HostEvent::PoolWindowLeft { label, queue_len_after, reason, version } => tracing::info!(
1221            target: "host-reducer",
1222            event = "PoolWindowLeft",
1223            label = %label, queue_len_after, reason = ?reason, version,
1224        ),
1225        HostEvent::PoolEmpty { version } => tracing::info!(
1226            target: "host-reducer",
1227            event = "PoolEmpty",
1228            version,
1229        ),
1230        // ── H.5 quit ─────────────────────────────────────────────────────
1231        HostEvent::QuitDraining { reason, version } => tracing::warn!(
1232            target: "host-reducer",
1233            event = "QuitDraining",
1234            reason = ?reason, version,
1235            "[host-reducer] entering drain mode",
1236        ),
1237        HostEvent::QuitReady { version } => tracing::warn!(
1238            target: "host-reducer",
1239            event = "QuitReady",
1240            version,
1241            "[host-reducer] drain complete; host quitting",
1242        ),
1243        // ── H.6 top-level runner ─────────────────────────────────────────
1244        HostEvent::TopLevelCreationRequested {
1245            creation_id, source, label, version,
1246        } => tracing::info!(
1247            target: "host-reducer",
1248            event = "TopLevelCreationRequested",
1249            creation_id, source = ?source, label = %label, version,
1250        ),
1251        HostEvent::TopLevelCreationStarted { creation_id, label, version } => tracing::info!(
1252            target: "host-reducer",
1253            event = "TopLevelCreationStarted",
1254            creation_id, label = %label, version,
1255        ),
1256        HostEvent::TopLevelCreationCompleted {
1257            creation_id, label, latency_ms, version,
1258        } => tracing::info!(
1259            target: "host-reducer",
1260            event = "TopLevelCreationCompleted",
1261            creation_id, label = %label, latency_ms, version,
1262        ),
1263        HostEvent::TopLevelCreationFailed {
1264            creation_id, label, outcome, version,
1265        } => tracing::error!(
1266            target: "host-reducer",
1267            event = "TopLevelCreationFailed",
1268            creation_id, label = %label, outcome = ?outcome, version,
1269        ),
1270        HostEvent::TopLevelQueueLengthChanged { len, version } => tracing::debug!(
1271            target: "host-reducer",
1272            event = "TopLevelQueueLengthChanged",
1273            len, version,
1274        ),
1275        // ── Opacity ──────────────────────────────────────────────────────
1276        HostEvent::WindowOpacityApplied { label, opacity, version } => tracing::debug!(
1277            target: "host-reducer",
1278            event = "WindowOpacityApplied",
1279            label = %label, opacity, version,
1280        ),
1281        HostEvent::WindowOpacityCleared { label, version } => tracing::debug!(
1282            target: "host-reducer",
1283            event = "WindowOpacityCleared",
1284            label = %label, version,
1285        ),
1286        // ── Effect carrier ───────────────────────────────────────────────
1287        HostEvent::Effect { effect, version } => tracing::debug!(
1288            target: "host-reducer",
1289            event = "Effect",
1290            effect = ?effect, version,
1291        ),
1292        HostEvent::Error { message, version } => tracing::warn!(
1293            target: "host-reducer",
1294            event = "Error",
1295            version,
1296            "[host-reducer] {}", message,
1297        ),
1298    }
1299}