Skip to main content

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