agentmux_common/
ipc.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase B.2 IPC wire protocol — shared between agentmux-launcher
5//! (server) and agentmux-cef (client). One source of truth so the
6//! Command / Event shapes can't drift between binaries on a
7//! version-skew release.
8//!
9//! See `specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md` §5.
10//!
11//! Wire format: newline-delimited JSON. One message per line,
12//! parsed via serde_json. Format chosen for debuggability —
13//! operators can `cat` / `nc` the named pipe and read traffic
14//! without a binary protocol decoder.
15//!
16//! Backward compat policy (B.2 baseline; harden in Phase D):
17//!   * Externally tagged enums (`{"cmd":"register",...}`) so adding
18//!     variants is non-breaking; clients send what they know.
19//!   * Unknown commands → server replies `Event::Error` rather than
20//!     crashing.
21//!   * `version: u64` on every Event lets Phase D's GetSnapshot /
22//!     resync detect skew. For B.2 it's set but not enforced.
23
24use serde::{Deserialize, Serialize};
25
26/// Stable identifier for a connected client. Tagged so the launcher
27/// can route replies + log who said what.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum ClientKind {
31    /// The CEF host process (one per launcher run).
32    Host,
33    /// A frontend renderer (proxied via the host's CEF JS bridge — Phase B.7).
34    Renderer,
35    /// The agentmux-srv backend (proxy connection used for Quit ack
36    /// + process-tree facts; the workspace data path stays on
37    /// HTTP/WS through host).
38    Srv,
39    /// External tooling (`agentmux.exe --diag` etc.).
40    Tool,
41}
42
43/// Commands flow client → launcher.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "cmd", rename_all = "snake_case")]
46pub enum Command {
47    /// Identifies the connection. MUST be the first command on every
48    /// new connection. Server enforces.
49    Register {
50        kind: ClientKind,
51        /// PID of the connecting process — used for cross-checking
52        /// against the launcher's `ProcessRecord` map and for
53        /// log correlation.
54        pid: u32,
55        /// Free-form version string of the client binary, for log
56        /// correlation across version skew.
57        version: String,
58    },
59    /// Health probe — server replies with `Event::Pong` carrying the
60    /// same nonce. NOT a polling heartbeat (per spec §4.3) — sent
61    /// only on demand by clients that need round-trip confirmation.
62    Ping {
63        nonce: u64,
64    },
65    /// Graceful disconnect. Server logs and closes the connection.
66    /// In B.3+ this becomes `Quit { reason }` with shutdown semantics;
67    /// for B.2 it's just a polite goodbye.
68    Goodbye,
69    /// Phase B.4: host reports that a real window has been created
70    /// (CEF `on_after_created` fired). Launcher records it in its
71    /// read-only mirror and broadcasts `Event::WindowOpened` to other
72    /// subscribers. Pool windows do NOT report via this command —
73    /// they get their own `ReportPool*` commands in a follow-up so
74    /// the mirror can distinguish user-visible windows from pool
75    /// inventory.
76    ReportWindowOpened {
77        /// Stable label assigned by the host (e.g. "main", "window-{uuid}").
78        label: String,
79        kind: WindowKind,
80        /// For `Subwindow` only: label of the `FullInstance` parent.
81        /// `None` for `FullInstance`.
82        parent_label: Option<String>,
83    },
84    /// Phase B.4: host reports a window is closing (`on_before_close`).
85    /// Launcher removes from mirror, broadcasts `Event::WindowClosed`.
86    /// Idempotent: a missing label is logged but not an error (covers
87    /// the close-before-launcher-saw-the-open race).
88    ReportWindowClosed {
89        label: String,
90    },
91    /// Phase B.4 follow-up — host reports a pre-warmed pool window
92    /// being added (`spawn_pool_window`). Pool windows live in a
93    /// SEPARATE map from the user-visible window mirror; the host
94    /// transitions them out of the pool with `ReportPoolWindowRemoved`
95    /// + `ReportWindowOpened` on promote, or just
96    /// `ReportPoolWindowRemoved` on pre-promote destroy.
97    ReportPoolWindowAdded {
98        label: String,
99        /// Phase CPD-1 (cross-process dispatch) — saga correlation
100        /// echo. `Some(N)` when the host is replying to a saga-issued
101        /// `Command::SpawnPoolWindow { saga_id: N }`; `None` for
102        /// organic refills (e.g. host's implicit `spawn_pool_window`
103        /// inside `promote_pool_window`). The launcher reducer
104        /// passes the value through to `Event::PoolWindowAdded` so
105        /// per-saga correlation (CPD-4) can match the response to
106        /// the originating saga.
107        ///
108        /// `#[serde(default)]` for forward-compat with hosts running
109        /// pre-CPD-1 builds — they emit no `saga_id` field, which
110        /// deserializes as `None` (organic). Removed once CPD-1+CPD-3
111        /// have soaked one release cycle.
112        #[serde(default)]
113        saga_id: Option<u64>,
114    },
115    /// Phase B.4 follow-up — host reports a pool window leaving the
116    /// pool (promote, destroy, or app exit).
117    ReportPoolWindowRemoved {
118        label: String,
119    },
120    /// Phase B.4 follow-up — drift detection (full snapshot). Host
121    /// sends its own post-mutation counts after each window-level
122    /// transition; the launcher reducer compares both dimensions to
123    /// its mirror counts and emits `Event::DriftDetected` per
124    /// disagreeing dimension. Sent in a separate command (rather
125    /// than embedded in each Report*) so the existing wire shapes
126    /// stay unchanged.
127    ///
128    /// Known limitation (B.4 observe-only): emissions originate
129    /// from multiple execution contexts (CEF UI thread for
130    /// `on_after_created`/`on_before_close`, IPC handler thread
131    /// for `promote_pool_window`). Cross-thread interleaving in
132    /// the outbound channel can produce a snapshot whose counts
133    /// were taken at a moment that doesn't match the channel
134    /// order seen by the reducer, occasionally emitting a
135    /// transient false `DriftDetected`. Acceptable for B.4
136    /// (drift is diagnostic — false positives are ephemeral and
137    /// self-correct on the next stable state). B.5 will tighten
138    /// with a transition-ID protocol once the launcher is
139    /// authoritative. (codex P2 PR #578 round-4 — accepted as
140    /// known limitation.)
141    ReportHostCounts {
142        /// User-visible top-level windows in the host's
143        /// authoritative store (`browsers` minus browser-pane
144        /// children minus unpromoted pool labels).
145        windows: u32,
146        /// Pre-promote pool inventory size.
147        pool: u32,
148    },
149    /// Phase B.4 follow-up — pool-dimension-only drift check. Used
150    /// by `spawn_pool_window` (pool transitions) where snapshotting
151    /// the windows dimension would produce transient false drift:
152    /// pool refill is triggered DURING `on_before_close` BEFORE the
153    /// matching `ReportWindowClosed` lands, so the host's window
154    /// count is mid-flight relative to the launcher mirror. Pool
155    /// count IS stable at that moment (the new pool label was just
156    /// added), so checking pool alone preserves the "check every
157    /// transition" guarantee for the dimension that actually
158    /// changed. (codex P2 PR #578 round-3.)
159    ReportHostPoolCount {
160        count: u32,
161    },
162    /// Phase B.5 (window_id_map step a) — host reports the
163    /// frontend's `register_backend_window` call: a window's label
164    /// → backend window ID (a srv-side UUID the frontend resolves
165    /// via `WOS.makeORef`). The launcher mirrors it for the same
166    /// reasons it mirrors `instance_registry`: host's authoritative
167    /// copy will be retired through the a→b→c→d→e ratchet.
168    ReportBackendWindowIdRegistered {
169        label: String,
170        window_id: String,
171    },
172    /// Phase B.5 (window_id_map step a) — host reports a window
173    /// closing, so the launcher should drop the label→window_id
174    /// mapping. Sent from the same close path that emits
175    /// `ReportWindowClosed`.
176    ReportBackendWindowIdUnregistered {
177        label: String,
178    },
179    /// Phase B.9.1 (WRR — Window Reality Reconciliation) — host
180    /// reports a Win32 top-level window was created. Hooked off
181    /// `EVENT_OBJECT_CREATE` (objid=`OBJID_WINDOW`) via
182    /// `SetWinEventHook(WINEVENT_OUTOFCONTEXT)`. The host filters
183    /// CEF subprocess HWNDs (renderer, GPU, plugin) at the hook
184    /// callback so the launcher only sees plausible app windows.
185    /// `label_hint` is the host's best guess from
186    /// `pending_window_creations` if it can disambiguate; `None`
187    /// when the create event arrives before the host has linked
188    /// the HWND back to a label (caught up later by the reducer's
189    /// pending_hwnds).
190    ReportHwndOpened {
191        hwnd: u64,
192        class_name: String,
193        title: String,
194        label_hint: Option<String>,
195    },
196    /// Phase B.9.1 — host reports a Win32 top-level window was
197    /// destroyed. Hooked off `EVENT_OBJECT_DESTROY`.
198    ReportHwndDestroyed {
199        hwnd: u64,
200    },
201    /// Phase B.9.1 — `EVENT_OBJECT_SHOW` / `EVENT_OBJECT_HIDE`.
202    ReportHwndVisibilityChanged {
203        hwnd: u64,
204        visible: bool,
205    },
206    /// Phase B.9.1 — `EVENT_SYSTEM_FOREGROUND`. Tells the launcher
207    /// the user actually saw this window (not just opened it).
208    /// Used to distinguish "opened but never foregrounded" (drift)
209    /// from "opened and shown".
210    ReportHwndForegroundChanged {
211        hwnd: u64,
212    },
213    /// Phase B.9.1 — `EVENT_SYSTEM_MINIMIZESTART` /
214    /// `EVENT_SYSTEM_MINIMIZEEND`.
215    ReportHwndIconicChanged {
216        hwnd: u64,
217        iconic: bool,
218    },
219    /// Phase B.9.1 — `WM_WINDOWPOSCHANGED` from the host's wndproc
220    /// wrapper. The host coalesces bursts (50ms debounce per HWND)
221    /// before sending so the wire stays light during topology
222    /// changes. Reducer compares `rect` against `state.monitors` to
223    /// classify off-monitor drift.
224    ReportHwndPositionChanged {
225        hwnd: u64,
226        rect: Rect,
227    },
228    /// Phase B.9.1 — `WM_DISPLAYCHANGE`. Replaces the launcher's
229    /// `state.monitors` wholesale; reducer re-evaluates every
230    /// known window's last_rect against the new topology and
231    /// emits `OffMonitor` drift for any that newly fall off.
232    ReportMonitorTopologyChanged {
233        rects: Vec<Rect>,
234    },
235    /// Phase D.1 — request a `Event::Snapshot` reply containing the
236    /// reducer's current canonical state. Used by `--diag wrr` for
237    /// state-now visibility, by the frontend reducer for mid-session
238    /// resync after disconnect, and by future Tool clients that need
239    /// to bootstrap without observing every prior event.
240    GetSnapshot,
241    /// Phase E.1b — srv-side equivalent of `GetSnapshot`. Routed to
242    /// the srv pipe; reducer replies with `Event::SrvSnapshot`.
243    /// Separate from `GetSnapshot` (launcher) per spec §4.3 — each
244    /// reducer is canonical for its domain and replies on its own
245    /// pipe.
246    ///
247    /// Reply contents grow with each phase: E.1b had lifecycle +
248    /// version only; E.2 added `workspaces`; E.2b+ will add tabs /
249    /// blocks / layouts. See `Event::SrvSnapshot` for the current
250    /// shape.
251    GetSrvSnapshot,
252    /// Phase E.2 — create a new workspace. The reducer assigns the
253    /// `oid` (UUID) and emits `Event::WorkspaceCreated`. In E.2 the
254    /// reducer is a session-only projection (no persist subscriber);
255    /// E.2c adds the persist subscriber + migrates HTTP/WS RPC
256    /// to flow through the reducer.
257    CreateWorkspace {
258        name: String,
259    },
260    /// Phase E.2 — delete a workspace. Reducer removes from canonical
261    /// state and emits `Event::WorkspaceDeleted`. Cascade-to-tabs +
262    /// SQLite write happen via wcore today (RPC path); migrating to
263    /// reducer-driven persistence is E.2c.
264    DeleteWorkspace {
265        workspace_id: String,
266        /// Step 5 PR 2 — provenance marker for the `delete_workspace`
267        /// saga. `true` means the dispatch is being driven by the
268        /// saga coordinator (per-tab DeleteTab dispatches already
269        /// happened in saga steps; this final cascade is just the
270        /// workspace row + window mappings). `false` for legacy /
271        /// internal compensation paths (e.g. `tear_off_tab` /
272        /// `tear_off_block` rolling back a freshly-created empty
273        /// workspace) which keep the existing cascade behaviour.
274        ///
275        /// The reducer's `handle_delete_workspace` cascades regardless
276        /// of `force` — the reducer is a pure mutator and the cascade
277        /// must always execute to keep state consistent. The flag is
278        /// recorded in the saga log purely for provenance, mirroring
279        /// the saga-as-narrator pattern documented in
280        /// `docs/retro/phase-fg-roadmap-2026-05-01.md`.
281        ///
282        /// Defaults to `false` via `#[serde(default)]` so all existing
283        /// producers (RPC, internal compensation) keep working.
284        #[serde(default)]
285        force: bool,
286    },
287    /// Phase E.2b — create a tab inside an existing workspace. The
288    /// reducer assigns the `tab_id` (UUID), appends to the workspace's
289    /// ordered tab list, and emits `Event::TabCreated`. Validates the
290    /// parent workspace exists; returns `Event::Error` if not.
291    /// Session-only projection (no persist subscriber yet).
292    CreateTab {
293        workspace_id: String,
294        name: String,
295    },
296    /// Phase E.2b — delete a tab from a workspace. Reducer removes the
297    /// tab from canonical state, removes its id from the workspace's
298    /// ordered tab list, and emits `Event::TabDeleted`. If the deleted
299    /// tab was the active tab, also emits `Event::ActiveTabChanged`
300    /// pointing at the new active (next-or-prev tab, or empty if the
301    /// workspace has no tabs left).
302    DeleteTab {
303        workspace_id: String,
304        tab_id: String,
305        /// (codex P2 PR #633 round 4 + codex P1 round 2.) Bypass the
306        /// reducer's atomic last-tab guard. `false` for user-facing
307        /// flows (close button, keyboard shortcut) — reducer rejects
308        /// last-tab deletes to keep workspaces non-empty. `true` for
309        /// internal compensation paths (`CreateTab` rollback,
310        /// `PromoteBlockToTab` compensation) where rolling back a
311        /// just-created tab requires deleting the only tab.
312        ///
313        /// Defaults to `false` via `#[serde(default)]` for backwards
314        /// compatibility — pre-existing producers that don't set the
315        /// field get the safe (guarded) behavior.
316        #[serde(default)]
317        force: bool,
318    },
319    /// Phase E.2b — set a workspace's active tab. No-op if already
320    /// active. Errors if the workspace doesn't exist or the tab isn't
321    /// in that workspace's tab list.
322    SetActiveTab {
323        workspace_id: String,
324        tab_id: String,
325    },
326    /// Phase E.2c.3b — reorder a tab within its workspace's
327    /// `tab_ids`. `new_index` is clamped to the list length; no-op
328    /// if the tab is already at that position. Errors if the
329    /// workspace doesn't exist or the tab isn't in its tab list.
330    ReorderTab {
331        workspace_id: String,
332        tab_id: String,
333        new_index: u32,
334    },
335    /// Phase E.5 — record a new window in the srv reducer's
336    /// `state.windows` map. Caller pre-assigns `window_id` (sagas
337    /// use a fresh UUID; RPC migration in PR 4 will likewise mint
338    /// the id at the RPC boundary). Validates the parent workspace
339    /// exists; errors otherwise. Used by CreateWindow + TearOff
340    /// sagas to track window↔workspace association.
341    CreateWindow {
342        window_id: String,
343        workspace_id: String,
344    },
345    /// Phase E.5 — remove a window's workspace mapping from the
346    /// reducer. Called by CloseWindow sagas after the host's CEF
347    /// window-close completes. Idempotent silent no-op on missing.
348    CloseWindowInternal {
349        window_id: String,
350    },
351    /// Phase E.5 — switch which workspace a window points at.
352    /// Errors if the window or destination workspace is unknown.
353    SwitchWorkspace {
354        window_id: String,
355        workspace_id: String,
356    },
357    /// Phase E.5.3 — replace a workspace's `tab_ids` with the given
358    /// list. The new list must be a permutation of the current set
359    /// (same elements, possibly different order); reducer errors
360    /// otherwise. Used by the drag-reorder UI's bulk-reorder path
361    /// (replaces wcore-direct `UpdateTabIds`).
362    ReorderTabsBulk {
363        workspace_id: String,
364        tab_ids: Vec<String>,
365    },
366    /// Phase E.5.3 — rename a workspace. Errors if the workspace
367    /// doesn't exist; no-op if the name is identical.
368    RenameWorkspace {
369        workspace_id: String,
370        name: String,
371    },
372    /// Phase E.5.3 — rename a tab. Errors if the tab doesn't exist;
373    /// no-op if identical.
374    RenameTab {
375        tab_id: String,
376        name: String,
377    },
378    /// Phase E.5.3 — apply a meta-patch to a workspace. The reducer
379    /// validates the entity exists and emits `Event::WorkspaceMetaUpdated`
380    /// with the patch payload; the persist subscriber performs the
381    /// actual merge against wstore. Reducer state does NOT track meta
382    /// in E.5.3 — pass-through preserves the reducer's small footprint
383    /// without losing the migration property (every mutation goes
384    /// through the reducer's broadcast bus).
385    UpdateWorkspaceMeta {
386        workspace_id: String,
387        meta_patch: serde_json::Value,
388    },
389    /// Phase E.5.3 — apply a meta-patch to a tab. Same pass-through
390    /// shape as `UpdateWorkspaceMeta`.
391    UpdateTabMeta {
392        tab_id: String,
393        meta_patch: serde_json::Value,
394    },
395    /// Phase E.5.3 — apply a meta-patch to a block. Same pass-through
396    /// shape as `UpdateWorkspaceMeta`.
397    UpdateBlockMeta {
398        block_id: String,
399        meta_patch: serde_json::Value,
400    },
401    /// Phase E.5.x — apply a meta-patch to a window's `meta` map.
402    /// Same pass-through shape as `UpdateWorkspaceMeta`. Migrated
403    /// through the reducer per issue #855 so `Event::WindowMetaUpdated`
404    /// lands on `srv_events_tx` and the WaveObjUpdate broadcast bridge
405    /// picks it up — replaces the wcore-direct fallback that bypassed
406    /// reducer + bridge entirely.
407    UpdateWindowMeta {
408        window_id: String,
409        meta_patch: serde_json::Value,
410    },
411    /// Phase E.5.5 — move a tab from one workspace to another.
412    /// Reducer:
413    /// * Removes `tab_id` from `src_workspace_id.tab_ids`.
414    /// * Updates `tab.workspace_id = dst_workspace_id`.
415    /// * Inserts `tab_id` at `dst_index` in `dst_workspace_id.tab_ids`,
416    ///   clamping to the dst list length.
417    /// * If `tab_id` was the source workspace's `active_tab_id`, the
418    ///   source's active reverts to its first remaining tab (or
419    ///   `None` if the source becomes empty).
420    /// Errors if any of: source workspace, dest workspace, or tab is
421    /// missing; if `tab.workspace_id != src_workspace_id`; or if
422    /// the tab is the source workspace's last tab AND no caller has
423    /// arranged a fallback (callers like tear-off should reject the
424    /// move at the saga layer if removing the tab would empty the
425    /// source — preserving the "workspaces have at least one tab"
426    /// invariant most UI paths assume). Used by the TearOffTab,
427    /// MoveTabToWorkspace, and RestoreTornOffTab sagas.
428    MoveTab {
429        tab_id: String,
430        src_workspace_id: String,
431        dst_workspace_id: String,
432        dst_index: u32,
433    },
434    /// Phase E.5.5 — move a block from one tab to another (or to a
435    /// different position in the same tab).
436    /// Reducer:
437    /// * Removes `block_id` from `src_tab_id.block_ids`.
438    /// * Updates `block.tab_id = dst_tab_id`.
439    /// * Inserts `block_id` at `dst_index` in `dst_tab_id.block_ids`,
440    ///   clamping to the dst list length.
441    /// Errors if source tab, dest tab, or block is missing, or if
442    /// `block.tab_id != src_tab_id`. Used by TearOffBlock and the
443    /// MoveBlockToTab saga.
444    MoveBlock {
445        block_id: String,
446        src_tab_id: String,
447        dst_tab_id: String,
448        dst_index: u32,
449    },
450    /// Phase E.3 — create a block inside an existing tab. Reducer
451    /// validates parent tab exists, assigns the `block_id` (UUID),
452    /// appends to the tab's `block_ids`, emits `Event::BlockCreated`.
453    /// Session-only projection (no persist subscriber yet).
454    CreateBlock {
455        tab_id: String,
456        /// Phase E.2c.4 — block metadata (`view`, layout hints, etc.)
457        /// passed through to the persisted Block row. The reducer
458        /// itself doesn't track meta — it forwards it untouched into
459        /// `Event::BlockCreated` so the persist subscriber writes
460        /// the Block with the correct meta map. `#[serde(default)]`
461        /// for forward-compat with old log entries that pre-date the
462        /// meta field.
463        #[serde(default)]
464        meta: serde_json::Value,
465    },
466    /// Phase E.3 — delete a block from a tab. Idempotent silent no-op
467    /// on missing tab or missing block.
468    DeleteBlock {
469        tab_id: String,
470        block_id: String,
471    },
472    /// Phase E.4 (Option A) — set a tab's `focusednodeid`. Errors if
473    /// the tab is unknown to the reducer; no-op short-circuit when the
474    /// value is already current. Empty `node_id` clears the field.
475    /// Routes through the reducer so the persist subscriber writes the
476    /// new value into `LayoutState.focusednodeid` for the tab. The
477    /// rest of `LayoutState` (rootnode/leaforder/pendingbackendactions)
478    /// keeps its existing wcore-direct path until Option B lands.
479    SetFocusedNode {
480        tab_id: String,
481        node_id: String,
482    },
483    /// Phase E.4 (Option A) — set a tab's `magnifiednodeid`. Same
484    /// shape as `SetFocusedNode`; empty `node_id` clears (toggle-off).
485    SetMagnifiedNode {
486        tab_id: String,
487        node_id: String,
488    },
489
490    // ── Phase E.4.B — Layout tree commands ──────────────────────────────
491    //
492    // Each command carries a `correlation_id` (UUID string) for the
493    // frontend optimistic-confirm pattern: the slice-#8 subscriber uses
494    // it to distinguish "my own command echoing back" from "a remote
495    // command I must apply locally".
496    //
497    // `focus_after` / `magnify_after` flags mirror the semantics of the
498    // frontend's `LayoutTreeActionType` (focused / magnified side effects
499    // on InsertNode, SplitH, SplitV, ReplaceNode).
500    //
501    // See docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md §5.
502
503    /// Insert a new node into the tree. If `parent_id` is `None`, the
504    /// heuristic `findNextInsertLocation` is used (first available slot);
505    /// `index` positions within the parent's children (None = append).
506    LayoutInsertNode {
507        tab_id: String,
508        node: crate::LayoutNode,
509        parent_id: Option<String>,
510        index: Option<usize>,
511        focus_after: bool,
512        magnify_after: bool,
513        correlation_id: String,
514    },
515    /// Insert at an exact index path through the tree (e.g. `[0, 2]`).
516    LayoutInsertNodeAtIndex {
517        tab_id: String,
518        node: crate::LayoutNode,
519        index_arr: Vec<usize>,
520        focus_after: bool,
521        magnify_after: bool,
522        correlation_id: String,
523    },
524    /// Remove a node by id; collapse empty parents.
525    LayoutDeleteNode {
526        tab_id: String,
527        node_id: String,
528        correlation_id: String,
529    },
530    /// Reparent a node to a new parent at the given child index.
531    LayoutMoveNode {
532        tab_id: String,
533        node_id: String,
534        new_parent_id: String,
535        index: usize,
536        correlation_id: String,
537    },
538    /// Swap two sibling (or cross-parent) nodes. Sizes travel with nodes.
539    LayoutSwapNodes {
540        tab_id: String,
541        node1_id: String,
542        node2_id: String,
543        correlation_id: String,
544    },
545    /// Apply N resize operations atomically. Rejected entirely if any
546    /// `size` is out of range (reducer validates; early-return on first
547    /// invalid op, matching the frontend's existing semantic).
548    LayoutResizeNodes {
549        tab_id: String,
550        ops: Vec<crate::ResizeOp>,
551        correlation_id: String,
552    },
553    /// Replace a node with a new one, preserving the target's flex size.
554    LayoutReplaceNode {
555        tab_id: String,
556        target_id: String,
557        new_node: crate::LayoutNode,
558        focus_after: bool,
559        correlation_id: String,
560    },
561    /// Horizontal split: inserts `new_node` before/after `target_id`
562    /// in a Row parent (or wraps them in a new Row group if parent is not Row).
563    LayoutSplitHorizontal {
564        tab_id: String,
565        target_id: String,
566        new_node: crate::LayoutNode,
567        position: crate::SplitPosition,
568        focus_after: bool,
569        correlation_id: String,
570    },
571    /// Vertical split: inserts `new_node` before/after `target_id`
572    /// in a Column parent (or wraps them in a new Column group).
573    LayoutSplitVertical {
574        tab_id: String,
575        target_id: String,
576        new_node: crate::LayoutNode,
577        position: crate::SplitPosition,
578        focus_after: bool,
579        correlation_id: String,
580    },
581    /// Wipe the entire tree (sets rootnode = None, clears focus/magnify).
582    LayoutClear {
583        tab_id: String,
584        correlation_id: String,
585    },
586    /// Bulk-replace the tree. Used during Phase 7a writer migration and
587    /// for tear-off where the whole subtree changes atomically.
588    LayoutSetTree {
589        tab_id: String,
590        new_tree: Option<crate::LayoutNode>,
591        correlation_id: String,
592    },
593
594    /// Phase D.3 — request an `Event::EventList` reply containing the
595    /// events the launcher has emitted with version > `since`. Used
596    /// by subscribers that hold a snapshot at version V and want to
597    /// catch up to the live stream by replaying missed events.
598    ///
599    /// Typical resync flow:
600    ///   1. `Register` → `Registered { version: V0 }`
601    ///   2. `GetSnapshot` → `Snapshot { version: V1 }` (V1 > V0)
602    ///      — apply the snapshot
603    ///   3. `GetEvents { since: V1 }` → `EventList { events, version: V2 }`
604    ///      — apply replay events to catch up to V2
605    ///   4. live broadcast events flow with version > V2
606    ///
607    /// Replay is best-effort: the launcher's in-memory ring is
608    /// bounded; if `since` is older than the oldest retained event,
609    /// the reply still contains all retained events but the
610    /// subscriber may have missed some. Caller should treat the
611    /// result as "everything I have for you" and re-fetch a snapshot
612    /// if state inconsistency is detected.
613    GetEvents {
614        /// Exclusive lower bound — events with `version > since` are
615        /// returned, in ascending order.
616        since: u64,
617    },
618    /// Phase F.5 — host explicitly reports that a pool window was
619    /// promoted to a user-visible top-level window (the
620    /// `promote_pool_window` flow in `agentmux-cef`). Sent BETWEEN the
621    /// `ReportPoolWindowRemoved` + `ReportWindowOpened` pair so the
622    /// launcher reducer has unambiguous evidence the transition was a
623    /// promote (vs a destroy followed by an unrelated open) and can
624    /// emit `Event::PoolWindowPromoted`. The pool-respawn saga
625    /// consumes the event to bracket the implicit refill in
626    /// `SagaStarted`/`SagaCompleted`.
627    ///
628    /// Host-only — same gate as `ReportPoolWindowRemoved`.
629    ReportPoolWindowPromoted {
630        label: String,
631    },
632    /// Phase F.5 — launcher-side saga coordinator asks the host to
633    /// spawn a fresh pool window (refill after promote).
634    ///
635    /// **Status: live.** Cross-process dispatch (CPD-1 through CPD-5)
636    /// shipped; the saga coordinator's `apply_action` for
637    /// `PipeTarget::Host` writes this command through `host_pipe`
638    /// (`agentmux-launcher/src/host_pipe/`) and waits on the
639    /// `Event::PoolWindowAdded { saga_id: Some(N) }` echo. Host's
640    /// implicit `spawn_pool_window` call inside `promote_pool_window`
641    /// remains the organic refill path for non-saga-driven
642    /// promotions (with `saga_id: None`).
643    ///
644    /// `saga_id`: every host-bound command carries the originating
645    /// saga's id so the host can echo it on the corresponding
646    /// `Report*` reply. `0` is reserved as "no saga" and treated as a
647    /// non-saga dispatch. `#[serde(default)]` retained for
648    /// forward-compat with any pre-CPD-1 deserializers (no real-world
649    /// consumer today; portable runtime is bundled per release).
650    SpawnPoolWindow {
651        #[serde(default)]
652        saga_id: u64,
653    },
654    /// Phase F.6 — host reports that all browser-pane HWNDs belonging
655    /// to a closing top-level window have been reaped. Emitted from
656    /// `client.rs::on_before_close` after the subwindow cascade and
657    /// pane lifecycle drain finish for the closing window.
658    ///
659    /// Distinct from `ReportWindowClosed` — that event marks the
660    /// CEF browser leaving the host's `browsers` map; this one marks
661    /// the host's pane bookkeeping (lifecycle entries, pane HWND map)
662    /// for that label being fully drained. Today both happen in the
663    /// same `on_before_close` body so the events arrive back-to-back,
664    /// but the saga distinguishes them so future fine-grained
665    /// reapers (e.g. async pane teardown for embedded browsers) can
666    /// land without rewriting the saga.
667    ///
668    /// Host-only — same gate as `ReportWindowClosed`.
669    ReportPanesReaped {
670        label: String,
671        /// Phase CPD-1 — saga correlation echo. `Some(N)` when the
672        /// host is replying to a saga-issued
673        /// `Command::ReapPanes { saga_id: N }`; `None` for organic
674        /// reports (e.g. host's existing implicit pane drain inside
675        /// `on_before_close` that wasn't saga-driven).
676        ///
677        /// `#[serde(default)]` for forward-compat with pre-CPD-1
678        /// hosts.
679        #[serde(default)]
680        saga_id: Option<u64>,
681    },
682    /// Phase F.6 — host reports the result of the post-close
683    /// drain-pool-if-last decision. `was_last == true` when the
684    /// closing window was the last user-visible window and the host
685    /// just kicked off the warm-pool drain (Stage 1 of the two-stage
686    /// close cascade in `client.rs::on_before_close`); `false` when
687    /// other user-visible windows remain and the pool stays warm.
688    ///
689    /// The launcher's window-cleanup-cascade saga uses this to close
690    /// out its bracket regardless of which branch fires (both are
691    /// terminal for the saga).
692    ///
693    /// Host-only.
694    ReportPoolDrainDecision {
695        label: String,
696        was_last: bool,
697        /// Phase CPD-1 — saga correlation echo. `Some(N)` when the
698        /// host is replying to a saga-issued
699        /// `Command::DrainPoolIfLast { saga_id: N }`; `None` for
700        /// organic reports.
701        ///
702        /// `#[serde(default)]` for forward-compat with pre-CPD-1
703        /// hosts.
704        #[serde(default)]
705        saga_id: Option<u64>,
706    },
707    /// Phase F.6 — launcher-side saga coordinator asks the host to
708    /// reap all browser-pane HWNDs for a window that just closed.
709    ///
710    /// **Status: live.** Wired through `host_pipe` post-CPD-3. The
711    /// saga issues this with `target = PipeTarget::Host`; the
712    /// coordinator's `apply_action` writes it through the host pipe
713    /// and waits for the `Event::PanesReaped { saga_id: Some(N) }`
714    /// echo. Host's organic pane drain inside `on_before_close` still
715    /// emits the same Report with `saga_id: None` for non-saga-driven
716    /// closes.
717    ///
718    /// `saga_id`: mandatory on the wire (host echoes back on
719    /// `ReportPanesReaped`). `#[serde(default)]` retained for
720    /// forward-compat (see `SpawnPoolWindow` for rationale).
721    ReapPanes {
722        label: String,
723        #[serde(default)]
724        saga_id: u64,
725    },
726    /// Phase F.6 — launcher-side saga coordinator asks the host to
727    /// drain the warm pool if the just-closed window was the last
728    /// user-visible window (i.e. trigger Stage 1 of the close
729    /// cascade).
730    ///
731    /// **Status: live** (same shipping path as `ReapPanes`). Wired
732    /// through `host_pipe` post-CPD-3; saga waits on
733    /// `Event::PoolDrained { saga_id: Some(N) }` /
734    /// `Event::PoolNotLast { saga_id: Some(N) }` echo. Host's
735    /// `on_before_close` still runs the equivalent inline check
736    /// organically (with `saga_id: None`).
737    ///
738    /// `saga_id`: mandatory on the wire. `#[serde(default)]` retained
739    /// for forward-compat.
740    DrainPoolIfLast {
741        label: String,
742        #[serde(default)]
743        saga_id: u64,
744    },
745    /// Phase CPD-1 — host-emitted report that a saga-issued action
746    /// failed (e.g. window not found, IPC error). Carries the
747    /// originating `saga_id` and a human-readable `reason`. The
748    /// launcher reducer translates this into `Event::SagaActionFailed`
749    /// so the saga coordinator can terminate the matching saga as
750    /// `SagaFailed`.
751    ///
752    /// Schema-only in CPD-1: hosts don't yet read commands from the
753    /// pipe (CPD-2 wires that), so no producer for this command
754    /// exists yet. The shape is added now so launcher reducer arms
755    /// + saga coordinator wiring can soak before CPD-3 makes the
756    /// dispatch live.
757    ReportSagaActionFailed {
758        saga_id: u64,
759        reason: String,
760    },
761}
762
763/// Phase B.9.1 — rectangle in Win32 screen coordinates (pixels).
764/// Matches Windows' `RECT` semantics: `right` and `bottom` are one
765/// past the last included pixel, so `right - left == width`.
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
767pub struct Rect {
768    pub left: i32,
769    pub top: i32,
770    pub right: i32,
771    pub bottom: i32,
772}
773
774/// Wire-side enum for `WindowKind`. Mirrors `agentmux-cef::state::WindowKind`
775/// — kept here so the launcher can deserialize without depending on the
776/// host crate. The host serializes its own type via `serde(rename_all =
777/// "snake_case")` so the JSON shape matches exactly.
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(rename_all = "snake_case")]
780pub enum WindowKind {
781    /// Independent AgentMux window. Appears in the Windows taskbar.
782    FullInstance,
783    /// Hidden from the taskbar; closes when its parent FullInstance closes.
784    Subwindow,
785}
786
787/// Events flow launcher → client. Versioned per spec §5.2 — every
788/// event carries a monotonic `version: u64` per launcher run, used
789/// by Phase D's resync protocol.
790///
791/// Phase B.3 introduces the first non-handshake events
792/// (ProcessSpawned, ProcessExited, LifecyclePhaseChanged) emitted
793/// by the launcher's reducer when commands transition state. B.4+
794/// adds the window-state events (WindowAdded, WindowStateChanged,
795/// WindowRemoved) per spec §5.2.
796/// Note: `Eq` is not derived because layout variants carry `LayoutNode`
797/// which contains `f32` (not `Eq`). `PartialEq` is sufficient for all
798/// current use-sites.
799#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
800#[serde(tag = "event", rename_all = "snake_case")]
801pub enum Event {
802    /// Reply to `Command::Register`. Acknowledges the client kind +
803    /// confirms the launcher's view of the world.
804    Registered {
805        client_id: u64,
806        launcher_pid: u32,
807        launcher_version: String,
808        version: u64,
809    },
810    /// Reply to `Command::Ping`. Echoes the nonce.
811    Pong {
812        nonce: u64,
813        version: u64,
814    },
815    /// Sent when an incoming command can't be parsed or violates an
816    /// invariant (e.g. Command before Register). Connection stays
817    /// open unless `fatal: true`.
818    Error {
819        code: ErrorCode,
820        message: String,
821        fatal: bool,
822        version: u64,
823    },
824    /// A process joined the launcher's canonical registry. Emitted
825    /// when a client first Registers (B.3) and, in B.4+, when the
826    /// launcher itself spawns a child.
827    ProcessSpawned {
828        pid: u32,
829        kind: ClientKind,
830        client_version: String,
831        version: u64,
832    },
833    /// A process exited or disconnected gracefully. Emitted on
834    /// Goodbye (B.3) and, in B.4+, on detected child exit.
835    ProcessExited {
836        pid: u32,
837        /// Exit code. 0 = clean Goodbye; non-zero = OS-reported
838        /// exit code or synthetic value for crashes.
839        code: i32,
840        version: u64,
841    },
842    /// The launcher's lifecycle phase changed. Spec §4 defines the
843    /// valid transitions: Starting → Running → Quitting → Dead.
844    /// Emitted at most once per transition.
845    LifecyclePhaseChanged {
846        from: LifecyclePhase,
847        to: LifecyclePhase,
848        version: u64,
849    },
850    /// Phase B.4: a window joined the launcher's mirror. Emitted in
851    /// response to `Command::ReportWindowOpened` from the host. Other
852    /// subscribers (Tool clients, eventually srv) receive this to
853    /// keep their own views consistent.
854    WindowOpened {
855        label: String,
856        kind: WindowKind,
857        parent_label: Option<String>,
858        version: u64,
859    },
860    /// Phase B.4: a window left the launcher's mirror. Emitted on
861    /// `Command::ReportWindowClosed`. Cascades for FullInstance
862    /// closures are NOT modeled here yet (B.5 tightens) — for now
863    /// the host emits one ReportWindowClosed per window even on
864    /// cascade closes, so subscribers see the same N events.
865    WindowClosed {
866        label: String,
867        version: u64,
868        /// (codex P1 PR #637.) `true` when the close was detected by
869        /// `wrr::apply_hwnd_destroyed` after a host/renderer crash —
870        /// no clean `on_before_close` ran, so the host did NOT send
871        /// the `ReportPanesReaped` / `ReportPoolDrainDecision` reports
872        /// the F.6 saga waits for. Subscribers that drive
873        /// cleanup-cascade sagas must filter on `!crash_detected` to
874        /// avoid spawning an in-flight saga that can never reach a
875        /// terminal state.
876        ///
877        /// `#[serde(default)]` so pre-existing producers default to
878        /// `false` (clean close).
879        #[serde(default)]
880        crash_detected: bool,
881    },
882    /// Phase B.4 follow-up — pool inventory transitioned. Emitted in
883    /// response to `ReportPoolWindow{Added,Removed}`. Subscribers
884    /// (Tool clients) use this to track pool warmth without polling.
885    PoolWindowAdded {
886        label: String,
887        version: u64,
888        /// Phase CPD-1 — saga correlation. Mirrors the `saga_id` that
889        /// arrived on the originating
890        /// `Command::ReportPoolWindowAdded { saga_id }`. `None` for
891        /// organic refills (no saga in flight). Subscribers (CPD-4
892        /// per-saga correlation) match on this to scope events to the
893        /// originating saga.
894        ///
895        /// `#[serde(default)]` for forward-compat with old
896        /// `launcher-events.log` entries that pre-date CPD-1.
897        #[serde(default)]
898        saga_id: Option<u64>,
899    },
900    PoolWindowRemoved {
901        label: String,
902        version: u64,
903    },
904    /// Phase F.5 — emitted by the launcher when the host explicitly
905    /// reports a pool window was promoted to a user-visible
906    /// top-level window (i.e. the pool→window handoff inside
907    /// `agentmux-cef::commands::window_pool::promote_pool_window`).
908    /// The pool-respawn saga (launcher-side coordinator) starts on
909    /// this event, brackets the implicit refill in
910    /// `SagaStarted`/`SagaCompleted`, and waits for the matching
911    /// `PoolWindowAdded` for a fresh pool label.
912    ///
913    /// Distinct from `PoolWindowRemoved` because that event also
914    /// fires on pre-promote destroy (closing without promoting), where
915    /// no refill saga should run.
916    PoolWindowPromoted {
917        label: String,
918        version: u64,
919    },
920    /// Phase F.6 — emitted by the launcher when the host reports that
921    /// all browser-pane HWNDs belonging to a closing top-level window
922    /// have been reaped (`Command::ReportPanesReaped`). Step-1
923    /// terminal signal for the window-cleanup-cascade saga.
924    PanesReaped {
925        label: String,
926        version: u64,
927        /// Phase CPD-1 — saga correlation. Mirrors `saga_id` from the
928        /// originating `ReportPanesReaped`. `None` for organic
929        /// reports (non-saga-driven pane drains).
930        ///
931        /// `#[serde(default)]` for forward-compat with pre-CPD-1
932        /// log entries.
933        #[serde(default)]
934        saga_id: Option<u64>,
935    },
936    /// Phase F.6 — emitted by the launcher when the host reports it
937    /// kicked off Stage 1 of the close-cascade pool drain (i.e. the
938    /// just-closed window was the last user-visible window).
939    /// Step-2 terminal signal (success branch) for the
940    /// window-cleanup-cascade saga.
941    ///
942    /// "Drained" here means "drain initiated"; the actual pool
943    /// teardown is async and surfaces as a series of
944    /// `PoolWindowRemoved` events as each pool browser's
945    /// `on_before_close` fires. The saga doesn't wait for those —
946    /// the bracket closes when drain is *decided*, not when it
947    /// completes.
948    PoolDrained {
949        label: String,
950        version: u64,
951        /// Phase CPD-1 — saga correlation. Mirrors `saga_id` from the
952        /// originating `ReportPoolDrainDecision`. `None` for organic
953        /// reports.
954        #[serde(default)]
955        saga_id: Option<u64>,
956    },
957    /// Phase F.6 — emitted by the launcher when the host reports a
958    /// close that did NOT trigger a pool drain (other user-visible
959    /// windows remain). Step-2 terminal signal (no-op branch) for
960    /// the window-cleanup-cascade saga; the bracket closes
961    /// successfully because nothing further is needed.
962    PoolNotLast {
963        label: String,
964        version: u64,
965        /// Phase CPD-1 — saga correlation. Mirrors `saga_id` from the
966        /// originating `ReportPoolDrainDecision`. `None` for organic
967        /// reports.
968        #[serde(default)]
969        saga_id: Option<u64>,
970    },
971    /// Phase B.5 (window_id_map step a) — launcher recorded the
972    /// label → backend window ID mapping. Subscribers (host's
973    /// shadow, eventually srv-side consumers) update their
974    /// projections.
975    BackendWindowIdRegistered {
976        label: String,
977        window_id: String,
978        version: u64,
979    },
980    /// Phase B.5 (window_id_map step a) — launcher dropped the
981    /// label → backend window ID mapping (window closed).
982    BackendWindowIdUnregistered {
983        label: String,
984        window_id: String,
985        version: u64,
986    },
987    /// Phase B.5 — sequential instance number assigned to a window
988    /// by the launcher's authoritative registry. Numbers start at 1
989    /// for "main" and increment for each subsequent open. Never
990    /// reused within a launcher run. The host caches these to
991    /// display window titles; B.5 step 2 will retire host's own
992    /// `WindowInstanceRegistry` in favor of this stream.
993    WindowInstanceAssigned {
994        label: String,
995        num: u32,
996        version: u64,
997    },
998    /// Phase B.5 — instance number released (window closed). The
999    /// launcher's authoritative registry drops the label; the slot
1000    /// is NOT reused (numbers monotonic per spec invariant: stable
1001    /// instance# across promotions / unregisters).
1002    WindowInstanceReleased {
1003        label: String,
1004        num: u32,
1005        version: u64,
1006    },
1007    /// Phase B.4 follow-up — emitted when the launcher's mirror
1008    /// disagrees with the host's reported counts. Logged at WARN
1009    /// level so operators see drift immediately. Drift in B.4 is
1010    /// a CONTRACT BUG (the host should report every state change);
1011    /// B.5 will turn drift into a hard failure once the mirror is
1012    /// authoritative.
1013    DriftDetected {
1014        kind: DriftKind,
1015        host_count: u32,
1016        mirror_count: u32,
1017        version: u64,
1018    },
1019    /// Phase B.9.1 (WRR) — emitted when the launcher's reducer
1020    /// detects a divergence between CEF browser identity (tracked
1021    /// in `state.windows` / `state.pool` via `ReportWindow*`) and
1022    /// Win32 reality (newly tracked via `ReportHwnd*`). Each variant
1023    /// of `kind` is emitted at the moment the OS event that
1024    /// surfaces it is dispatched through the reducer — there is no
1025    /// timer / heartbeat. See `docs/retro/wrr-design-2026-04-28.md`
1026    /// for the full classification table.
1027    HwndDriftDetected {
1028        kind: HwndDriftKind,
1029        /// Affected label, when known.
1030        label: Option<String>,
1031        /// Affected HWND, when known. `u64` to keep the wire format
1032        /// stable across pointer-width differences.
1033        hwnd: Option<u64>,
1034        /// Human-readable description for the launcher log + `--diag`
1035        /// output. Free-form; not parsed by any consumer.
1036        detail: String,
1037        severity: Severity,
1038        version: u64,
1039    },
1040    /// Phase B.9.2 — pure-reducer self-heal. Emitted alongside an
1041    /// `HwndDriftDetected` when the reducer is confident the bug
1042    /// happened at OPEN TIME (not from later user action) and a
1043    /// safe corrective rect is computable. The host's WRR
1044    /// subscriber listens for this and applies `SetWindowPos` on
1045    /// the UI thread. No timers — the trigger is the same OS
1046    /// event tick that surfaced the bug, so the correction lands
1047    /// before the user has time to notice the orphan window.
1048    ///
1049    /// Guard for emission (per the design, suppress over-correction):
1050    /// - The window's `mirror.foregrounded_since_open == false`
1051    ///   (we never auto-move a window the user has already
1052    ///   touched).
1053    /// - The reducer can compute a target rect from
1054    ///   `state.monitors` (i.e., monitors are known).
1055    CorrectiveWindowMove {
1056        /// Win32 HWND to move.
1057        hwnd: u64,
1058        /// Reducer-computed target rect. Default policy:
1059        /// primary-monitor-centered at default window size.
1060        target_rect: Rect,
1061        /// Why correction fired — surfaces in host log + audit.
1062        reason: HwndDriftKind,
1063        version: u64,
1064    },
1065    /// Phase B.9.3 — saga-style corrective. Reducer detected the
1066    /// `OrphanInstance` transition (last user-visible label
1067    /// removed from `state.windows`, host still Running). Host
1068    /// subscriber handles by reaping the warm pool and calling
1069    /// `quit_message_loop()`. ADVISORY, not a hard command —
1070    /// the host's handler should re-check `state.browsers`
1071    /// before actually quitting (the user could open a new
1072    /// window in the same dispatch tick race window). Event is
1073    /// idempotent: multiple emissions are safe; reaping pool +
1074    /// quit are themselves idempotent.
1075    HostShouldQuit {
1076        version: u64,
1077    },
1078    /// Phase D.1 — reply to `Command::GetSnapshot`. Carries the
1079    /// reducer's current canonical state (the projections subscribers
1080    /// most commonly need). The `version` field is the reducer's
1081    /// `event_version` at the moment the snapshot was taken; events
1082    /// the subscriber receives AFTER this snapshot have monotonically
1083    /// greater version numbers, letting the subscriber apply them as
1084    /// deltas without missing or duplicating updates.
1085    ///
1086    /// What's included: lifecycle, windows (label + kind + parent +
1087    /// HWND-observation axis), pool labels, instance numbers,
1088    /// backend window IDs, monitor topology. What's intentionally
1089    /// excluded: `processes` (PID metadata is launcher-internal),
1090    /// `pending_hwnds` (transient reconciliation state), event log
1091    /// (Phase D.2 adds a separate snapshot-with-replay variant).
1092    Snapshot {
1093        version: u64,
1094        lifecycle: LifecyclePhase,
1095        windows: Vec<WindowSnapshot>,
1096        pool: Vec<String>,
1097        instance_registry: Vec<(String, u32)>,
1098        backend_window_ids: Vec<(String, String)>,
1099        monitors: Vec<Rect>,
1100    },
1101    /// Phase D.3 — reply to `Command::GetEvents { since }`. Carries
1102    /// the events the launcher has emitted with `version > since`,
1103    /// in ascending version order. Subscribers apply them in order
1104    /// to catch up to the launcher's live stream.
1105    ///
1106    /// The `version` field on this event is the launcher's
1107    /// `event_version` at the moment the reply was assembled — not
1108    /// the highest version inside `events`. A subscriber treating
1109    /// this as the "as-of" point for subsequent events should use
1110    /// `events.last().version` (or fall back to this `version` if
1111    /// `events` is empty) to know where to resume the live stream.
1112    ///
1113    /// Replay is best-effort: if `since` predates the launcher's
1114    /// in-memory ring, `events` contains everything still retained
1115    /// but the subscriber should expect potential missed events
1116    /// before the first one in this reply.
1117    EventList {
1118        events: Vec<Event>,
1119        version: u64,
1120    },
1121    /// Phase E.1a — emitted by the saga coordinator when a new saga
1122    /// starts. `name` is the saga's static name (e.g. "tear_off_block")
1123    /// for `--diag` output. Subscribers (renderer especially) use
1124    /// `saga_id` to start buffering subsequent events with that id
1125    /// until the matching `SagaCompleted` or `SagaFailed` arrives.
1126    ///
1127    /// `saga_id` is monotonic per launcher run, allocated by the
1128    /// coordinator. Persisting saga state across launcher restarts
1129    /// is deferred (Phase F or beyond); restart abandons in-flight
1130    /// sagas, and renderer-side timeouts handle the visible
1131    /// consequence.
1132    SagaStarted {
1133        saga_id: u64,
1134        name: String,
1135        version: u64,
1136    },
1137    /// Phase E.1a — saga ended successfully. All events with this
1138    /// `saga_id` have been emitted; subscribers can flush their
1139    /// buffers and apply the changes atomically.
1140    SagaCompleted {
1141        saga_id: u64,
1142        version: u64,
1143    },
1144    /// Phase E.1a — saga ended in failure. `reason` is operator-
1145    /// readable; if compensation actions were issued, they appear
1146    /// as ordinary commands/events on the bus before this event.
1147    /// Renderers should discard their buffer for this `saga_id` —
1148    /// no atomic apply.
1149    SagaFailed {
1150        saga_id: u64,
1151        reason: String,
1152        version: u64,
1153    },
1154    /// Phase E.1b — srv-side snapshot reply. Phase E.2 populates
1155    /// `workspaces` (canonical Vec); subsequent sub-phases add
1156    /// `tabs`, `blocks`, `layouts`, etc.
1157    ///
1158    /// `version` is the srv reducer's `event_version` at snapshot
1159    /// time, monotonically distinct from prior srv events so
1160    /// subscribers know the "as-of" point for delta application.
1161    SrvSnapshot {
1162        version: u64,
1163        lifecycle: LifecyclePhase,
1164        /// Phase E.2 — sorted list of workspaces in the reducer's
1165        /// canonical state. (id, name) pairs for compactness; full
1166        /// state available via per-event subscription. Empty before
1167        /// E.2 lands.
1168        ///
1169        /// `#[serde(default)]` so old `srv-events.log` entries
1170        /// written by E.1b (which had no `workspaces` field) still
1171        /// deserialize when later sub-phases add bootstrap-replay
1172        /// from the on-disk log. Same forward-compat treatment will
1173        /// apply to E.3's `blocks`, etc. (reagent P2 #611.)
1174        #[serde(default)]
1175        workspaces: Vec<(String, String)>,
1176        /// Phase E.2b — sorted list of tabs in the reducer's canonical
1177        /// state. `(tab_id, workspace_id, name)` triples for
1178        /// compactness. `#[serde(default)]` for forward-compat with
1179        /// pre-E.2b log entries.
1180        #[serde(default)]
1181        tabs: Vec<(String, String, String)>,
1182        /// Phase E.2b — sorted list of `(workspace_id, active_tab_id)`
1183        /// pairs for workspaces that have an active tab set.
1184        /// Workspaces with no active tab are omitted.
1185        #[serde(default)]
1186        active_tabs: Vec<(String, String)>,
1187        /// Phase E.3 — sorted list of blocks in the reducer's
1188        /// canonical state. `(block_id, tab_id)` pairs for
1189        /// compactness. `#[serde(default)]` for forward-compat with
1190        /// pre-E.3 log entries.
1191        #[serde(default)]
1192        blocks: Vec<(String, String)>,
1193    },
1194    /// Phase E.2 — workspace was created. Carries the assigned
1195    /// `oid` and `name` so subscribers (renderer, persist) can
1196    /// apply the change without further round-trips.
1197    WorkspaceCreated {
1198        workspace_id: String,
1199        name: String,
1200        version: u64,
1201    },
1202    /// Phase E.2 — workspace was deleted.
1203    WorkspaceDeleted {
1204        workspace_id: String,
1205        version: u64,
1206    },
1207    /// Phase E.2b — tab was created inside a workspace. Carries the
1208    /// assigned `tab_id` and parent `workspace_id` so subscribers can
1209    /// place it in the correct workspace's tab list.
1210    TabCreated {
1211        workspace_id: String,
1212        tab_id: String,
1213        name: String,
1214        version: u64,
1215    },
1216    /// Phase E.2b — tab was deleted from a workspace.
1217    TabDeleted {
1218        workspace_id: String,
1219        tab_id: String,
1220        version: u64,
1221    },
1222    /// Phase E.2b — a workspace's active tab changed. `tab_id: None`
1223    /// means the workspace has no active tab (e.g., last tab deleted).
1224    ActiveTabChanged {
1225        workspace_id: String,
1226        tab_id: Option<String>,
1227        version: u64,
1228    },
1229    /// Phase E.2c.3b — a tab was reordered within its workspace's
1230    /// `tab_ids`. Subscribers should rewrite the workspace's tab
1231    /// order to match the reducer's authoritative list (which lives
1232    /// in the snapshot's `tabs` field; subscribers can also recompute
1233    /// from `tab_id` + `new_index` against their last-known order).
1234    TabReordered {
1235        workspace_id: String,
1236        tab_id: String,
1237        new_index: u32,
1238        version: u64,
1239    },
1240    /// Phase E.5 — srv-side window→workspace mapping established.
1241    /// Distinct from launcher's `WindowOpened` (which tracks CEF
1242    /// window lifecycle). Subscribers update their view of "which
1243    /// workspace is each window showing."
1244    SrvWindowOpened {
1245        window_id: String,
1246        workspace_id: String,
1247        version: u64,
1248    },
1249    /// Phase E.5 — srv-side window mapping removed. Distinct from
1250    /// launcher's `WindowClosed`.
1251    SrvWindowClosed {
1252        window_id: String,
1253        version: u64,
1254    },
1255    /// Phase E.5 — a window now points at a different workspace
1256    /// (used by the SwitchWorkspace command + the CloseWindow saga
1257    /// when reassigning during cleanup).
1258    SrvWindowWorkspaceChanged {
1259        window_id: String,
1260        workspace_id: String,
1261        version: u64,
1262    },
1263    /// Phase E.5.3 — workspace's `tab_ids` was replaced wholesale.
1264    /// Subscribers should rewrite the persistent `Workspace.tabids`
1265    /// to match the new list (preserving `pinnedtabids` separately —
1266    /// pinning is a Waveterm legacy and not in scope here).
1267    TabsReorderedBulk {
1268        workspace_id: String,
1269        tab_ids: Vec<String>,
1270        version: u64,
1271    },
1272    /// Phase E.5.3 — workspace was renamed.
1273    WorkspaceRenamed {
1274        workspace_id: String,
1275        name: String,
1276        version: u64,
1277    },
1278    /// Phase E.5.3 — tab was renamed.
1279    TabRenamed {
1280        tab_id: String,
1281        name: String,
1282        version: u64,
1283    },
1284    /// Phase E.5.3 — meta-patch applied to a workspace. Carries the
1285    /// patch (NOT the resolved meta map); subscribers merge against
1286    /// the workspace's existing meta. This shape lets sagas inspect
1287    /// what changed without needing the prior state.
1288    WorkspaceMetaUpdated {
1289        workspace_id: String,
1290        meta_patch: serde_json::Value,
1291        version: u64,
1292    },
1293    /// Phase E.5.x (issue #855) — meta-patch applied to a window's
1294    /// `meta` map. Same shape as `WorkspaceMetaUpdated`. Persist
1295    /// subscriber merges into wstore; WaveObjUpdate bridge translates
1296    /// to a frontend `waveobj:update` broadcast.
1297    WindowMetaUpdated {
1298        window_id: String,
1299        meta_patch: serde_json::Value,
1300        version: u64,
1301    },
1302    /// Phase E.5.3 — meta-patch applied to a tab.
1303    TabMetaUpdated {
1304        tab_id: String,
1305        meta_patch: serde_json::Value,
1306        version: u64,
1307    },
1308    /// Phase E.5.3 — meta-patch applied to a block.
1309    BlockMetaUpdated {
1310        block_id: String,
1311        meta_patch: serde_json::Value,
1312        version: u64,
1313    },
1314    /// Phase E.5.5 — a tab was moved from one workspace to another.
1315    /// Subscribers should rewrite both workspaces' `tabids` and the
1316    /// tab's `parentoref`/`workspaceid` to match the reducer's view.
1317    /// `dst_index` reflects the position in `dst_workspace_id.tab_ids`
1318    /// AFTER insertion (already clamped by the reducer).
1319    /// Carries enough information to re-derive the new state without
1320    /// reading the reducer (subscribers replay events post-Lagged).
1321    TabMoved {
1322        tab_id: String,
1323        src_workspace_id: String,
1324        dst_workspace_id: String,
1325        dst_index: u32,
1326        /// The source workspace's new `active_tab_id` after the move,
1327        /// or `None` if the source has no remaining tabs. Subscribers
1328        /// rewrite the source's `activetabid` to match.
1329        new_src_active_tab_id: Option<String>,
1330        /// The destination workspace's new `active_tab_id` after the
1331        /// move. Wcore behavior (`move_tab_to_workspace`) was to
1332        /// always set the moved tab as dst's active; the reducer
1333        /// mirrors that. `None` means "do not change dst.active_tab_id"
1334        /// — reserved for future flows where the moved tab shouldn't
1335        /// steal focus. Codex P2 #621.
1336        ///
1337        /// `#[serde(default)]` for forward-compat with pre-PR3
1338        /// `srv-events.log` entries (none in production yet, but the
1339        /// pattern is established).
1340        #[serde(default)]
1341        new_dst_active_tab_id: Option<String>,
1342        version: u64,
1343    },
1344    /// Phase E.5.5 — a block was moved from one tab to another (or
1345    /// repositioned within the same tab). Subscribers update both
1346    /// tabs' `blockids` and the block's `parentoref`. `dst_index`
1347    /// reflects post-insertion position.
1348    BlockMoved {
1349        block_id: String,
1350        src_tab_id: String,
1351        dst_tab_id: String,
1352        dst_index: u32,
1353        version: u64,
1354    },
1355    /// Phase E.3 — block was created inside a tab.
1356    BlockCreated {
1357        tab_id: String,
1358        block_id: String,
1359        /// Phase E.2c.4 — meta carried through from
1360        /// `Command::CreateBlock`. The persist subscriber writes
1361        /// the Block row with this meta map. `#[serde(default)]` for
1362        /// forward-compat with pre-E.2c.4 log entries.
1363        #[serde(default)]
1364        meta: serde_json::Value,
1365        version: u64,
1366    },
1367    /// Phase E.3 — block was deleted from a tab.
1368    BlockDeleted {
1369        tab_id: String,
1370        block_id: String,
1371        version: u64,
1372    },
1373    /// Phase E.4 (Option A) — a tab's `focusednodeid` changed via the
1374    /// reducer. Subscribers (persist, eventually the renderer's E.6
1375    /// dispatcher) update the tab's layout view. Empty `node_id`
1376    /// reflects a clear.
1377    FocusedNodeChanged {
1378        tab_id: String,
1379        node_id: String,
1380        version: u64,
1381    },
1382    /// Phase E.4 (Option A) — a tab's `magnifiednodeid` changed via
1383    /// the reducer. Empty `node_id` reflects a clear (toggle-off).
1384    MagnifiedNodeChanged {
1385        tab_id: String,
1386        node_id: String,
1387        version: u64,
1388    },
1389
1390    // ── Phase E.4.B — Layout tree events ───────────────────────────────
1391    //
1392    // Mirror of the 11 layout commands (§6 of the formal spec). Each
1393    // event carries a `correlation_id` matching its command and a
1394    // `version` for sequencing. The persist subscriber applies the same
1395    // tree mutation as the reducer used, making applies idempotent.
1396    //
1397    // See docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md §6.
1398
1399    LayoutNodeInserted {
1400        tab_id: String,
1401        node: crate::LayoutNode,
1402        parent_id: Option<String>,
1403        index: Option<usize>,
1404        correlation_id: String,
1405        version: u64,
1406    },
1407    LayoutNodeInsertedAtIndex {
1408        tab_id: String,
1409        node: crate::LayoutNode,
1410        index_arr: Vec<usize>,
1411        correlation_id: String,
1412        version: u64,
1413    },
1414    LayoutNodeDeleted {
1415        tab_id: String,
1416        node_id: String,
1417        /// True if the deleted node was the focused one (subscribers may
1418        /// need to refocus).
1419        was_focused: bool,
1420        /// True if the deleted node was the magnified one (subscribers
1421        /// may need to re-magnify or clear their magnification UI).
1422        /// Reagent P1 PR #715 round 3: reducer was clearing
1423        /// `magnified_node_id` internally but not reporting it.
1424        ///
1425        /// `#[serde(default)]` for forward-compat with replay /
1426        /// version-skewed senders that emit pre-round-3
1427        /// `LayoutNodeDeleted` JSON without this field (codex P2 PR
1428        /// #715 round 5).
1429        #[serde(default)]
1430        was_magnified: bool,
1431        correlation_id: String,
1432        version: u64,
1433    },
1434    LayoutNodeMoved {
1435        tab_id: String,
1436        node_id: String,
1437        new_parent_id: String,
1438        index: usize,
1439        correlation_id: String,
1440        version: u64,
1441    },
1442    LayoutNodesSwapped {
1443        tab_id: String,
1444        node1_id: String,
1445        node2_id: String,
1446        correlation_id: String,
1447        version: u64,
1448    },
1449    LayoutNodesResized {
1450        tab_id: String,
1451        ops: Vec<crate::ResizeOp>,
1452        correlation_id: String,
1453        version: u64,
1454    },
1455    LayoutNodeReplaced {
1456        tab_id: String,
1457        target_id: String,
1458        new_node: crate::LayoutNode,
1459        correlation_id: String,
1460        version: u64,
1461    },
1462    LayoutSplitHorizontalApplied {
1463        tab_id: String,
1464        target_id: String,
1465        new_node: crate::LayoutNode,
1466        position: crate::SplitPosition,
1467        correlation_id: String,
1468        version: u64,
1469    },
1470    LayoutSplitVerticalApplied {
1471        tab_id: String,
1472        target_id: String,
1473        new_node: crate::LayoutNode,
1474        position: crate::SplitPosition,
1475        correlation_id: String,
1476        version: u64,
1477    },
1478    LayoutCleared {
1479        tab_id: String,
1480        correlation_id: String,
1481        version: u64,
1482    },
1483    LayoutTreeReplaced {
1484        tab_id: String,
1485        new_tree: Option<crate::LayoutNode>,
1486        correlation_id: String,
1487        version: u64,
1488    },
1489
1490    /// Phase CPD-1 (cross-process dispatch) — emitted by the launcher
1491    /// when the host reports that a saga-issued action failed
1492    /// (`Command::ReportSagaActionFailed { saga_id, reason }`). The
1493    /// saga coordinator's bus loop will (in CPD-3) treat this as a
1494    /// terminal signal for the matching saga, emitting
1495    /// `Event::SagaFailed` and removing it from the in-flight
1496    /// registry. CPD-1 ships the wire shape only; no producer
1497    /// (host) and no consumer (saga coordinator) are wired yet.
1498    SagaActionFailed {
1499        saga_id: u64,
1500        reason: String,
1501        version: u64,
1502    },
1503}
1504
1505/// Phase CPD-1 (cross-process dispatch) — envelope enum for frames
1506/// sent over the launcher → host pipe direction. Today the host's
1507/// read loop only expects `Event` JSON; CPD-2 extends the read loop
1508/// to recognize this tagged union and dispatch by `kind`:
1509///
1510/// * `event` → existing event-handling code (state sync from
1511///   launcher reducer broadcasts).
1512/// * `command` → new command-handling code (saga-issued actions).
1513///
1514/// Newline-delimited JSON, one frame per line. `#[serde(tag = "kind",
1515/// rename_all = "snake_case")]` so the wire shape is e.g.
1516/// `{"kind":"event","event":"pool_window_added",...}` or
1517/// `{"kind":"command","cmd":"spawn_pool_window","saga_id":42}`.
1518///
1519/// Schema-only in CPD-1: introduced now so the launcher's host-pipe
1520/// writer (CPD-2) and the host's read loop (CPD-2/CPD-3) can be
1521/// built against a stable wire envelope without further schema
1522/// churn.
1523#[derive(Debug, Clone, Serialize, Deserialize)]
1524#[serde(tag = "kind", rename_all = "snake_case")]
1525pub enum HostFrame {
1526    /// Wraps a launcher-emitted Event being pushed down to the host
1527    /// (existing fanout path; CPD-2 refactors the writer to go
1528    /// through this envelope).
1529    Event(Event),
1530    /// Wraps a saga-issued Command being dispatched from the launcher
1531    /// to the host. Carries `saga_id` inside the Command payload.
1532    Command(Command),
1533}
1534
1535/// Phase D.1 — serializable view of one window in the launcher
1536/// reducer's canonical state. Maps 1:1 to the launcher's internal
1537/// `WindowMirror` minus `opened_at` (which is launcher-local clock
1538/// data not meaningful to subscribers).
1539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1540pub struct WindowSnapshot {
1541    pub label: String,
1542    pub kind: WindowKind,
1543    pub parent_label: Option<String>,
1544    pub hwnd: Option<u64>,
1545    pub visible: bool,
1546    pub iconic: bool,
1547    pub last_rect: Option<Rect>,
1548    pub foregrounded_since_open: bool,
1549}
1550
1551/// Phase B.9.1 — six classes of CEF↔Win32 disagreement the reducer
1552/// can detect at event-dispatch time. See the WRR design doc for
1553/// the per-kind triggering Command and reducer action.
1554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1555#[serde(rename_all = "snake_case")]
1556pub enum HwndDriftKind {
1557    /// `state.windows` has a label whose `hwnd` field never got
1558    /// populated, AND a follow-up event arrived that should have
1559    /// reconciled it. CEF says open, Win32 has no matching HWND.
1560    BrowserWithoutHwnd,
1561    /// HWND in the host's report doesn't map to any
1562    /// `state.windows` / `state.pool` label. Win32 has it, CEF
1563    /// didn't open it. The "stray taskbar" case.
1564    HwndWithoutBrowser,
1565    /// HWND for a known label was never SHOWN (no foreground
1566    /// event) since open and a subsequent state transition has
1567    /// elapsed. User can't see it.
1568    HiddenSinceOpen,
1569    /// Window rect doesn't intersect any monitor in
1570    /// `state.monitors`. Off-screen orphan.
1571    OffMonitor,
1572    /// `ReportHwndDestroyed` arrived without a preceding
1573    /// `ReportWindowClosed` for the matching label. Renderer
1574    /// crashed, took the HWND with it.
1575    OrphanDestroy,
1576    /// `ReportWindowClosed` arrived, but subsequent OS events for
1577    /// the HWND keep firing (it never went away on the Win32 side).
1578    LingeringHwnd,
1579    /// Phase B.9.3 — host process is alive and registered, but
1580    /// `state.windows` just transitioned to empty. The host's own
1581    /// close path doesn't reap the warm pool when the last
1582    /// user-visible window closes (pool windows hold
1583    /// `state.browsers` non-empty, so `quit_message_loop` never
1584    /// fires). The launcher's reducer is the only place that knows
1585    /// "all user-meaningful labels are gone" cleanly, so we
1586    /// surface the signal here. Paired with `Event::HostShouldQuit`
1587    /// emitted in the same reducer call (see B.9.3 saga).
1588    OrphanInstance,
1589}
1590
1591/// Phase B.9.1 — drift severity. Operator-tunable severity floor
1592/// in `WrrConfig.severity_floor` controls which events get
1593/// broadcast (ones below the floor are still logged at DEBUG so
1594/// they show up in `--diag wrr` post-mortem).
1595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
1596#[serde(rename_all = "lowercase")]
1597pub enum Severity {
1598    Info,
1599    Warn,
1600    Error,
1601}
1602
1603/// Coarse-grained launcher state. Spec §4: Starting → Running →
1604/// Quitting → Dead, no other transitions allowed. The reducer in
1605/// agentmux-launcher::reducer enforces this; a violation panics
1606/// (Job Object reaps via OS).
1607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1608#[serde(rename_all = "lowercase")]
1609pub enum LifecyclePhase {
1610    /// Initial state; launcher has not yet seen the host register.
1611    Starting,
1612    /// Host has registered and the canonical state is being
1613    /// maintained. Steady state.
1614    Running,
1615    /// Quit { reason } received, ack outstanding to subscribers.
1616    /// Phase B.3 keeps this state-shape only — the actual Quit
1617    /// command lands in a later sub-PR.
1618    Quitting,
1619    /// Cleanup done; launcher about to exit. Transient.
1620    Dead,
1621}
1622
1623/// Phase B.4 follow-up — which mirror diverged. Tagged so subscribers
1624/// can route alerts (windows-drift might page; pool-drift is more
1625/// ephemeral since the pool turns over fast).
1626#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1627#[serde(rename_all = "snake_case")]
1628pub enum DriftKind {
1629    Windows,
1630    Pool,
1631}
1632
1633/// Discriminant for `Event::Error` — keeps clients structured against
1634/// failure modes without parsing message text.
1635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1636#[serde(rename_all = "snake_case")]
1637pub enum ErrorCode {
1638    /// Couldn't deserialize the line into a Command.
1639    InvalidCommand,
1640    /// Command sent before Register.
1641    NotRegistered,
1642    /// Register sent twice on the same connection.
1643    AlreadyRegistered,
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648    use super::*;
1649
1650    #[test]
1651    fn command_register_roundtrip() {
1652        let c = Command::Register {
1653            kind: ClientKind::Host,
1654            pid: 12345,
1655            version: "0.33.449".into(),
1656        };
1657        let json = serde_json::to_string(&c).unwrap();
1658        assert!(json.contains("\"cmd\":\"register\""));
1659        assert!(json.contains("\"kind\":\"host\""));
1660        let back: Command = serde_json::from_str(&json).unwrap();
1661        if let Command::Register { kind, pid, version } = back {
1662            assert_eq!(kind, ClientKind::Host);
1663            assert_eq!(pid, 12345);
1664            assert_eq!(version, "0.33.449");
1665        } else {
1666            panic!("wrong variant");
1667        }
1668    }
1669
1670    #[test]
1671    fn event_registered_roundtrip() {
1672        let e = Event::Registered {
1673            client_id: 1,
1674            launcher_pid: 9999,
1675            launcher_version: "0.33.449".into(),
1676            version: 42,
1677        };
1678        let json = serde_json::to_string(&e).unwrap();
1679        assert!(json.contains("\"event\":\"registered\""));
1680        let back: Event = serde_json::from_str(&json).unwrap();
1681        if let Event::Registered { client_id, version, .. } = back {
1682            assert_eq!(client_id, 1);
1683            assert_eq!(version, 42);
1684        } else {
1685            panic!("wrong variant");
1686        }
1687    }
1688
1689    #[test]
1690    fn unknown_cmd_fails_to_deserialize() {
1691        let json = r#"{"cmd":"banana"}"#;
1692        let r: Result<Command, _> = serde_json::from_str(json);
1693        assert!(r.is_err());
1694    }
1695
1696    // ---------- Phase CPD-1 — saga_id schema additions ----------
1697
1698    #[test]
1699    fn cpd1_spawn_pool_window_round_trip_with_saga_id() {
1700        let c = Command::SpawnPoolWindow { saga_id: 42 };
1701        let json = serde_json::to_string(&c).unwrap();
1702        assert!(json.contains("\"cmd\":\"spawn_pool_window\""));
1703        assert!(json.contains("\"saga_id\":42"));
1704        let back: Command = serde_json::from_str(&json).unwrap();
1705        match back {
1706            Command::SpawnPoolWindow { saga_id } => assert_eq!(saga_id, 42),
1707            other => panic!("wrong variant: {:?}", other),
1708        }
1709    }
1710
1711    #[test]
1712    fn cpd1_spawn_pool_window_forward_compat_default_zero() {
1713        // Pre-CPD-1 hosts emit the bare command with no `saga_id`
1714        // field; serde_default fills in 0.
1715        let json = r#"{"cmd":"spawn_pool_window"}"#;
1716        let back: Command = serde_json::from_str(json).unwrap();
1717        match back {
1718            Command::SpawnPoolWindow { saga_id } => assert_eq!(saga_id, 0),
1719            other => panic!("wrong variant: {:?}", other),
1720        }
1721    }
1722
1723    #[test]
1724    fn cpd1_reap_panes_round_trip_with_saga_id() {
1725        let c = Command::ReapPanes {
1726            label: "main".into(),
1727            saga_id: 7,
1728        };
1729        let json = serde_json::to_string(&c).unwrap();
1730        assert!(json.contains("\"cmd\":\"reap_panes\""));
1731        assert!(json.contains("\"saga_id\":7"));
1732        let back: Command = serde_json::from_str(&json).unwrap();
1733        match back {
1734            Command::ReapPanes { label, saga_id } => {
1735                assert_eq!(label, "main");
1736                assert_eq!(saga_id, 7);
1737            }
1738            other => panic!("wrong variant: {:?}", other),
1739        }
1740    }
1741
1742    #[test]
1743    fn cpd1_reap_panes_forward_compat_default_zero() {
1744        let json = r#"{"cmd":"reap_panes","label":"main"}"#;
1745        let back: Command = serde_json::from_str(json).unwrap();
1746        match back {
1747            Command::ReapPanes { label, saga_id } => {
1748                assert_eq!(label, "main");
1749                assert_eq!(saga_id, 0);
1750            }
1751            other => panic!("wrong variant: {:?}", other),
1752        }
1753    }
1754
1755    #[test]
1756    fn cpd1_drain_pool_if_last_round_trip_with_saga_id() {
1757        let c = Command::DrainPoolIfLast {
1758            label: "main".into(),
1759            saga_id: 13,
1760        };
1761        let json = serde_json::to_string(&c).unwrap();
1762        assert!(json.contains("\"cmd\":\"drain_pool_if_last\""));
1763        assert!(json.contains("\"saga_id\":13"));
1764        let back: Command = serde_json::from_str(&json).unwrap();
1765        match back {
1766            Command::DrainPoolIfLast { label, saga_id } => {
1767                assert_eq!(label, "main");
1768                assert_eq!(saga_id, 13);
1769            }
1770            other => panic!("wrong variant: {:?}", other),
1771        }
1772    }
1773
1774    #[test]
1775    fn cpd1_drain_pool_if_last_forward_compat_default_zero() {
1776        let json = r#"{"cmd":"drain_pool_if_last","label":"main"}"#;
1777        let back: Command = serde_json::from_str(json).unwrap();
1778        match back {
1779            Command::DrainPoolIfLast { label, saga_id } => {
1780                assert_eq!(label, "main");
1781                assert_eq!(saga_id, 0);
1782            }
1783            other => panic!("wrong variant: {:?}", other),
1784        }
1785    }
1786
1787    #[test]
1788    fn cpd1_report_pool_window_added_round_trip_with_some() {
1789        let c = Command::ReportPoolWindowAdded {
1790            label: "pool-xyz".into(),
1791            saga_id: Some(99),
1792        };
1793        let json = serde_json::to_string(&c).unwrap();
1794        assert!(json.contains("\"saga_id\":99"));
1795        let back: Command = serde_json::from_str(&json).unwrap();
1796        match back {
1797            Command::ReportPoolWindowAdded { label, saga_id } => {
1798                assert_eq!(label, "pool-xyz");
1799                assert_eq!(saga_id, Some(99));
1800            }
1801            other => panic!("wrong variant: {:?}", other),
1802        }
1803    }
1804
1805    #[test]
1806    fn cpd1_report_pool_window_added_forward_compat_default_none() {
1807        // Pre-CPD-1 hosts omit `saga_id` entirely; deserializes to
1808        // `None`. JSON with explicit null is also valid.
1809        let json = r#"{"cmd":"report_pool_window_added","label":"pool-xyz"}"#;
1810        let back: Command = serde_json::from_str(json).unwrap();
1811        match back {
1812            Command::ReportPoolWindowAdded { label, saga_id } => {
1813                assert_eq!(label, "pool-xyz");
1814                assert_eq!(saga_id, None);
1815            }
1816            other => panic!("wrong variant: {:?}", other),
1817        }
1818    }
1819
1820    #[test]
1821    fn cpd1_report_panes_reaped_round_trip_with_some() {
1822        let c = Command::ReportPanesReaped {
1823            label: "main".into(),
1824            saga_id: Some(11),
1825        };
1826        let json = serde_json::to_string(&c).unwrap();
1827        let back: Command = serde_json::from_str(&json).unwrap();
1828        match back {
1829            Command::ReportPanesReaped { label, saga_id } => {
1830                assert_eq!(label, "main");
1831                assert_eq!(saga_id, Some(11));
1832            }
1833            other => panic!("wrong variant: {:?}", other),
1834        }
1835    }
1836
1837    #[test]
1838    fn cpd1_report_panes_reaped_forward_compat_default_none() {
1839        let json = r#"{"cmd":"report_panes_reaped","label":"main"}"#;
1840        let back: Command = serde_json::from_str(json).unwrap();
1841        match back {
1842            Command::ReportPanesReaped { label, saga_id } => {
1843                assert_eq!(label, "main");
1844                assert_eq!(saga_id, None);
1845            }
1846            other => panic!("wrong variant: {:?}", other),
1847        }
1848    }
1849
1850    #[test]
1851    fn cpd1_report_pool_drain_decision_round_trip_with_some() {
1852        let c = Command::ReportPoolDrainDecision {
1853            label: "main".into(),
1854            was_last: true,
1855            saga_id: Some(101),
1856        };
1857        let json = serde_json::to_string(&c).unwrap();
1858        let back: Command = serde_json::from_str(&json).unwrap();
1859        match back {
1860            Command::ReportPoolDrainDecision {
1861                label,
1862                was_last,
1863                saga_id,
1864            } => {
1865                assert_eq!(label, "main");
1866                assert!(was_last);
1867                assert_eq!(saga_id, Some(101));
1868            }
1869            other => panic!("wrong variant: {:?}", other),
1870        }
1871    }
1872
1873    #[test]
1874    fn cpd1_report_pool_drain_decision_forward_compat_default_none() {
1875        let json = r#"{"cmd":"report_pool_drain_decision","label":"main","was_last":false}"#;
1876        let back: Command = serde_json::from_str(json).unwrap();
1877        match back {
1878            Command::ReportPoolDrainDecision {
1879                label,
1880                was_last,
1881                saga_id,
1882            } => {
1883                assert_eq!(label, "main");
1884                assert!(!was_last);
1885                assert_eq!(saga_id, None);
1886            }
1887            other => panic!("wrong variant: {:?}", other),
1888        }
1889    }
1890
1891    #[test]
1892    fn cpd1_report_saga_action_failed_round_trip() {
1893        let c = Command::ReportSagaActionFailed {
1894            saga_id: 55,
1895            reason: "window not found".into(),
1896        };
1897        let json = serde_json::to_string(&c).unwrap();
1898        assert!(json.contains("\"cmd\":\"report_saga_action_failed\""));
1899        assert!(json.contains("\"saga_id\":55"));
1900        let back: Command = serde_json::from_str(&json).unwrap();
1901        match back {
1902            Command::ReportSagaActionFailed { saga_id, reason } => {
1903                assert_eq!(saga_id, 55);
1904                assert_eq!(reason, "window not found");
1905            }
1906            other => panic!("wrong variant: {:?}", other),
1907        }
1908    }
1909
1910    #[test]
1911    fn cpd1_event_pool_window_added_round_trip_with_saga_id() {
1912        let e = Event::PoolWindowAdded {
1913            label: "pool-xyz".into(),
1914            version: 7,
1915            saga_id: Some(42),
1916        };
1917        let json = serde_json::to_string(&e).unwrap();
1918        assert!(json.contains("\"saga_id\":42"));
1919        let back: Event = serde_json::from_str(&json).unwrap();
1920        assert_eq!(back, e);
1921    }
1922
1923    #[test]
1924    fn cpd1_event_panes_reaped_forward_compat_default_none() {
1925        // Pre-CPD-1 launcher-events.log entries lack `saga_id`.
1926        let json = r#"{"event":"panes_reaped","label":"main","version":1}"#;
1927        let back: Event = serde_json::from_str(json).unwrap();
1928        match back {
1929            Event::PanesReaped {
1930                label,
1931                version,
1932                saga_id,
1933            } => {
1934                assert_eq!(label, "main");
1935                assert_eq!(version, 1);
1936                assert_eq!(saga_id, None);
1937            }
1938            other => panic!("wrong variant: {:?}", other),
1939        }
1940    }
1941
1942    #[test]
1943    fn cpd1_event_pool_drained_round_trip_with_saga_id() {
1944        let e = Event::PoolDrained {
1945            label: "main".into(),
1946            version: 9,
1947            saga_id: Some(3),
1948        };
1949        let json = serde_json::to_string(&e).unwrap();
1950        let back: Event = serde_json::from_str(&json).unwrap();
1951        assert_eq!(back, e);
1952    }
1953
1954    #[test]
1955    fn cpd1_event_pool_not_last_forward_compat_default_none() {
1956        let json = r#"{"event":"pool_not_last","label":"main","version":3}"#;
1957        let back: Event = serde_json::from_str(json).unwrap();
1958        match back {
1959            Event::PoolNotLast {
1960                label,
1961                version,
1962                saga_id,
1963            } => {
1964                assert_eq!(label, "main");
1965                assert_eq!(version, 3);
1966                assert_eq!(saga_id, None);
1967            }
1968            other => panic!("wrong variant: {:?}", other),
1969        }
1970    }
1971
1972    #[test]
1973    fn cpd1_event_saga_action_failed_round_trip() {
1974        let e = Event::SagaActionFailed {
1975            saga_id: 12,
1976            reason: "host pipe broken".into(),
1977            version: 100,
1978        };
1979        let json = serde_json::to_string(&e).unwrap();
1980        assert!(json.contains("\"event\":\"saga_action_failed\""));
1981        assert!(json.contains("\"saga_id\":12"));
1982        let back: Event = serde_json::from_str(&json).unwrap();
1983        assert_eq!(back, e);
1984    }
1985
1986    #[test]
1987    fn cpd1_host_frame_event_round_trip() {
1988        let frame = HostFrame::Event(Event::PoolWindowAdded {
1989            label: "pool-xyz".into(),
1990            version: 5,
1991            saga_id: Some(7),
1992        });
1993        let json = serde_json::to_string(&frame).unwrap();
1994        assert!(json.contains("\"kind\":\"event\""));
1995        assert!(json.contains("\"event\":\"pool_window_added\""));
1996        let back: HostFrame = serde_json::from_str(&json).unwrap();
1997        match back {
1998            HostFrame::Event(Event::PoolWindowAdded {
1999                label,
2000                version,
2001                saga_id,
2002            }) => {
2003                assert_eq!(label, "pool-xyz");
2004                assert_eq!(version, 5);
2005                assert_eq!(saga_id, Some(7));
2006            }
2007            other => panic!("wrong frame: {:?}", other),
2008        }
2009    }
2010
2011    #[test]
2012    fn cpd1_host_frame_command_round_trip() {
2013        let frame = HostFrame::Command(Command::SpawnPoolWindow { saga_id: 31 });
2014        let json = serde_json::to_string(&frame).unwrap();
2015        assert!(json.contains("\"kind\":\"command\""));
2016        assert!(json.contains("\"cmd\":\"spawn_pool_window\""));
2017        assert!(json.contains("\"saga_id\":31"));
2018        let back: HostFrame = serde_json::from_str(&json).unwrap();
2019        match back {
2020            HostFrame::Command(Command::SpawnPoolWindow { saga_id }) => {
2021                assert_eq!(saga_id, 31);
2022            }
2023            other => panic!("wrong frame: {:?}", other),
2024        }
2025    }
2026
2027    #[test]
2028    fn cpd1_host_frame_unknown_kind_fails() {
2029        let json = r#"{"kind":"banana"}"#;
2030        let r: Result<HostFrame, _> = serde_json::from_str(json);
2031        assert!(r.is_err());
2032    }
2033}