Skip to main content

forge_workspace/
protocol.rs

1//! Command + SessionUpdate channel envelopes between a view and
2//! forge-workspace. A view dispatches Commands; workspace per-session
3//! tasks emit SessionUpdates back through the workspace's fan-out.
4//!
5//! ## Dual Command shape (deliberate)
6//!
7//! Two `Command` enums exist in the workspace: this one, keyed by
8//! [`SessionSlot`], and [`forge_primitives::AgentCommand`], keyed by
9//! `session_id: String`. They overlap on variant names (Prompt,
10//! Cancel, SetMode, …) but serve different boundary layers:
11//!
12//! - **`forge_workspace::protocol::Command`** is the view ↔ workspace
13//!   envelope. SessionSlot routing, App-level variants (SpawnProject),
14//!   the workspace-internal Respond* cluster.
15//! - **`forge_primitives::AgentCommand`** is the workspace ↔ agent
16//!   envelope. session_id-keyed, raw shapes the AgentHandle
17//!   dispatcher recognises.
18//!
19//! Collapsing them would force the AgentHandle dispatcher to handle
20//! App-level variants it has no business in (SpawnProject is a
21//! workspace concern; SessionSlot is a routing concern; neither
22//! belongs in the agent layer). The current split keeps each
23//! envelope minimal at its respective boundary. The translation
24//! happens in `session_task::execute_command_via_handle`.
25
26use std::path::PathBuf;
27
28use forge_agent::client::SessionLaunchSettings;
29use forge_primitives::cloud::service_status::ServiceSeverity;
30use forge_primitives::error::AppError;
31use forge_primitives::permission::PermissionMode;
32use forge_primitives::permission_interaction::{PermissionOutcome, PermissionRequest};
33use forge_primitives::plugins::{
34    PluginUpdateRun, PluginUpdateTrigger, PluginsCliActionSuccess, PluginsInventorySnapshot,
35};
36use forge_primitives::question::{QuestionOutcome, QuestionRequest};
37use forge_primitives::review::{ReviewStatus, ReviewThread};
38use forge_primitives::runtime::{AvailableModel, CurrentModel, ModeState, TerminalReason};
39use forge_primitives::{ImageAttachment, McpOperationError, McpServerStatus, Message, SessionId};
40use serde::{Deserialize, Serialize};
41use tokio::sync::oneshot;
42use uuid::Uuid;
43
44use crate::SessionSlot;
45use crate::mcp::peers::types::WrappedPrompt;
46
47// `TurnErrorClass` lives in forge-primitives so the classifier (in
48// forge-agent) and consumers (views, via this protocol module)
49// share one enum. Re-exported here so existing call sites keep
50// resolving via `forge_workspace::protocol::TurnErrorClass`.
51pub use forge_primitives::TurnErrorClass;
52
53/// One pending interaction response slot. Workspace stores these
54/// keyed by `tool_id` in `DomainSession.pending_interactions`.
55/// `Command::RespondPermission` / `RespondQuestion` look up the
56/// matching slot and send the outcome down the oneshot.
57///
58/// The request rides beside the sender so a view that attached after the
59/// prompt landed still draws what it offers. The stream is a mirror with no
60/// backlog, so the update that carried it is gone by then, and the dock's
61/// whole point is answering from a view that was not there when it arrived.
62pub enum PendingInteractionSlot {
63    Permission { tx: oneshot::Sender<PermissionOutcome>, request: Box<PermissionRequest> },
64    Question { tx: oneshot::Sender<QuestionOutcome>, request: Box<QuestionRequest> },
65}
66
67/// What a seat is held on, as the core kept it: the prompt a view that
68/// attached late has no other way to read.
69///
70/// The kinds are every member of the category, not the two the dock
71/// happened to draw first: a Slack draft parks on a reply the same way a
72/// permission and a question do - and a browser hand-off parks on a person -
73/// so a record carrying two of them told a builder the category was covered
74/// when it was not.
75#[derive(Clone)]
76pub enum PendingAsk {
77    Permission(Box<PermissionRequest>),
78    Question(Box<QuestionRequest>),
79    SlackDraft(Box<forge_primitives::slack::SlackDraft>),
80    BrowserHandOff(Box<forge_primitives::browser::HandOff>),
81    TerminalHandOff(Box<forge_primitives::terminal::TerminalHandOff>),
82    DocumentHandOff(Box<forge_primitives::document::DocumentHandOffAsk>),
83    SettingsWrite(Box<forge_primitives::settings::SettingsWriteAsk>),
84    Vault(Box<forge_primitives::vault::VaultDraft>),
85}
86
87impl PendingAsk {
88    /// The tool call this prompt is about, which is the id an answer names.
89    ///
90    /// `None` for a Slack draft and a hand-off: each is answered by its own
91    /// command with its own id, and names no tool call at all.
92    pub fn tool_id(&self) -> Option<&str> {
93        match self {
94            Self::Permission(request) => Some(&request.tool_call.tool_call_id),
95            Self::Question(request) => Some(&request.tool_call.tool_call_id),
96            Self::SlackDraft(_)
97            | Self::BrowserHandOff(_)
98            | Self::TerminalHandOff(_)
99            | Self::DocumentHandOff(_)
100            | Self::SettingsWrite(_)
101            | Self::Vault(_) => None,
102        }
103    }
104}
105
106impl PendingInteractionSlot {
107    /// The request this slot is holding, which is what it offers.
108    pub fn ask(&self) -> PendingAsk {
109        match self {
110            Self::Permission { request, .. } => PendingAsk::Permission(request.clone()),
111            Self::Question { request, .. } => PendingAsk::Question(request.clone()),
112        }
113    }
114}
115
116impl std::fmt::Debug for PendingInteractionSlot {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Permission { .. } => f.write_str("PendingInteractionSlot::Permission"),
120            Self::Question { .. } => f.write_str("PendingInteractionSlot::Question"),
121        }
122    }
123}
124
125/// Synchronous return from `Command::SpawnWorker` - the session_id
126/// that the new worker was issued and the tag value applied. Threaded
127/// back to the calling `agents__spawn` Tool impl via the oneshot
128/// receiver so the LLM sees `{session_id, tag}` in the tool result.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub struct WorkerSpawnReply {
132    pub session_id: String,
133    pub tag: String,
134    /// The worker's stored MCP-family selection, canonicalised; `None`
135    /// means every family. The spawn tool echoes it so the lead sees
136    /// exactly which families this worker got.
137    pub mcp_families: Option<Vec<String>>,
138    /// Set to the account name when the walk had to take an account
139    /// that is saturated or bailed, because no other account in the pin
140    /// declares the project's model. The spawn tool surfaces it as a
141    /// `notice`, so the lead sees at spawn that the worker may hit a
142    /// 429 right away instead of only finding out when it stalls.
143    pub rate_limited_account: Option<String>,
144    /// Set when persisting the worker's durable row failed (the store
145    /// couldn't open, or the write errored). The worker still spawns, but
146    /// it won't survive a forge restart. The spawn tool surfaces it as a
147    /// warning so the lead knows the "durable" promise didn't hold for
148    /// this one. Mirrors `worktree_cleanup_warning` on despawn.
149    pub durability_warning: Option<String>,
150    /// Where the worker landed: its worktree, or `None` when the project
151    /// is not a git repo and the worker runs in the project root. Set by
152    /// the spawn handler, which is the only place that knows both.
153    pub worktree: Option<String>,
154    /// Which session the spawn landed on. The handler states what its own
155    /// arguments say; the MCP facade restates it with the resolution it
156    /// made, which is the only place a `resume_session` fallback is known.
157    pub session_choice: SessionChoice,
158}
159
160/// Which session a worker spawn landed on, as the spawn tool reports it.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum SessionChoice {
164    /// The label's prior session was found and resumed.
165    Resumed,
166    /// A new session, because the caller did not ask to resume.
167    Fresh,
168    /// A new session, because the caller asked to resume and the label
169    /// had no prior session to resume.
170    FreshWithoutPrior,
171}
172
173/// Outcome of a [`Command::DespawnWorker`], sent back to the calling
174/// `agents__despawn` Tool via the command's `respond` oneshot.
175#[derive(Debug, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum DespawnResult {
178    /// The worker was torn down (subprocess killed, dropped from
179    /// `live_workers`). `worktree_cleanup_warning`
180    /// is `Some` when the post-teardown `git worktree remove` failed -
181    /// the worker is still gone; only the worktree directory lingers.
182    /// Teardown and worktree cleanup are independent: a cleanup failure
183    /// never rolls back the kill.
184    ///
185    /// `branch_cleanup_warning` is `Some` when the worker's
186    /// `worktree-<label>` branch was left in place - it holds commits
187    /// reachable from no other ref, or the check itself failed.
188    Despawned { worktree_cleanup_warning: Option<String>, branch_cleanup_warning: Option<String> },
189    /// Despawn refused: the worktree has uncommitted/untracked changes
190    /// or unpushed commits and `force` was not set. Nothing was torn
191    /// down; the worker stays live. `reason` names what is dirty.
192    Blocked { reason: String },
193    /// No live worker matched `label` (already gone or never existed).
194    NotFound,
195    /// The despawn could not be carried out: the worker's durable row
196    /// could not be read or removed, so whether one is there is unknown.
197    /// Distinct from [`Self::NotFound`], which claims there is none.
198    Failed { reason: String },
199}
200
201/// Mutation kind for a `SessionUpdate::WorkerStatusChanged` event.
202/// `Added` and `StatusChanged` carry a fresh `WorkerStatus` snapshot;
203/// `Removed` carries the last-known snapshot for symmetry but a view's
204/// reducer treats it as a delete.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum WorkerStatusAction {
208    Added,
209    Removed,
210    StatusChanged,
211}
212
213/// What has happened to a worker's git worktree as of the
214/// `SessionUpdate::WorkerStatusChanged` event carrying it. Only the
215/// `agents__despawn` path ever removes one. Among the spawn
216/// rollbacks, the dividing line is `Connected`: one that fires before
217/// the subprocess connected reports [`Self::Absent`], because claude
218/// never ran to create a worktree, while a rollback after `Connected`
219/// (a failed tag-write) reports [`Self::untouched`], because by then
220/// the worktree is on disk. Every other emitter reports
221/// [`Self::untouched`].
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum WorktreeDisposition {
225    /// Nothing to report on: either the worker was spawned outside a
226    /// git repo, or its spawn failed before it had a worktree.
227    Absent,
228    /// On disk and untouched.
229    Intact,
230    /// Gone from disk by the time the despawn finished, whether or not
231    /// the despawn is what removed it.
232    Removed,
233    /// The despawn's removal failed and the worktree is still on disk.
234    RemovalFailed,
235}
236
237/// A reply channel for a command that carries none.
238///
239/// A command off the socket has no channel to answer down - its reply is a
240/// socket message instead - so the handler is given this rather than being
241/// made to ask: sending into it reports the closed channel it is, which
242/// every call site already discards.
243pub fn unanswerable<T>() -> oneshot::Sender<T> {
244    oneshot::channel().0
245}
246
247impl WorktreeDisposition {
248    /// The disposition when nothing has touched the worktree.
249    pub fn untouched(is_git_repo_at_spawn: bool) -> Self {
250        if is_git_repo_at_spawn { Self::Intact } else { Self::Absent }
251    }
252}
253
254/// Command envelope: a view -> forge-workspace.
255///
256/// Every variant carries a `SessionSlot` identifying the target
257/// session task. `Workspace::dispatch` fans the variant into the
258/// matching task's command receiver.
259///
260/// A reply channel cannot cross a socket, so the fields carrying one are
261/// `Option` and skipped on the wire: `#[serde(skip)]` reconstructs a field
262/// on deserialize, which needs a `Default`, and a bare `oneshot::Sender` has
263/// none while an `Option` does.
264#[derive(Serialize, Deserialize)]
265#[serde(rename_all = "snake_case")]
266pub enum Command {
267    Prompt {
268        key: SessionSlot,
269        text: String,
270        attachments: Vec<ImageAttachment>,
271    },
272    /// [`Self::Prompt`], under an id the caller already minted.
273    ///
274    /// A view mints its own so it can draw the prompt's row and key it to the
275    /// CLI's `command_lifecycle` frames by id, without matching on order or
276    /// text - two sends with a cron fire between them cannot be told apart any
277    /// other way. Every other dispatcher uses [`Self::Prompt`] and lets the
278    /// core mint.
279    PromptUnder {
280        key: SessionSlot,
281        text: String,
282        attachments: Vec<ImageAttachment>,
283        uuid: String,
284        /// Who dispatched it, for the queued row's label.
285        source: PromptSource,
286        /// The pressing view's own token for this press, like
287        /// [`Self::MarketplaceAdd`]'s: a refusal of this prompt echoes it, so
288        /// a composer with sends out on two seats marks the one that failed
289        /// (#1583). `None` from a view that minted none.
290        #[serde(default)]
291        dispatch_id: Option<u64>,
292    },
293    Cancel {
294        key: SessionSlot,
295    },
296    /// Drop one prompt still in the CLI's queue, by the uuid it was sent under.
297    CancelQueuedPrompt {
298        key: SessionSlot,
299        uuid: String,
300    },
301    /// A view opened the row of a finished call and asks for the command's
302    /// own output: the tail of the file the CLI streamed it to.
303    ///
304    /// Answered by the transport from its held conversation, which carries
305    /// the frame that named the call's `output_file`. The answer is
306    /// [`SessionUpdate::CallOutput`] - the tail, or a named reason there is
307    /// none. Asking again re-reads, which is the whole of what a running
308    /// tail would need.
309    ReadCallOutput {
310        key: SessionSlot,
311        call_id: String,
312    },
313    SetMode {
314        key: SessionSlot,
315        mode: PermissionMode,
316    },
317    SetModel {
318        key: SessionSlot,
319        model: String,
320    },
321    NewSession {
322        key: SessionSlot,
323        cwd: String,
324        launch_settings: SessionLaunchSettings,
325        /// The id the new occupant must run under. `None` mints one, which
326        /// is what `/new` wants; a handoff supplies the id it already
327        /// recorded, because its row names the pair before the spawn.
328        #[serde(default)]
329        session_id: Option<String>,
330    },
331    ResumeSession {
332        key: SessionSlot,
333        session_id: String,
334        cwd: String,
335        launch_settings: SessionLaunchSettings,
336    },
337    /// Hand this seat over to a new session that continues its work.
338    ///
339    /// **Two halves of one handoff.** `brief: None` is the entry: the seat is
340    /// asked for the judgment half in the procedure turn. `brief: Some(note)`
341    /// is the exit: the turn's note, accepted only while a handoff is
342    /// running. Either way the successor is started the way `/new` starts one.
343    Handoff {
344        key: SessionSlot,
345        brief: Option<String>,
346    },
347    RespondPermission {
348        key: SessionSlot,
349        tool_id: String,
350        outcome: PermissionOutcome,
351    },
352    /// Answer a held Slack draft. The blocked Slack handler is awaiting
353    /// this answer: approving sends it, declining sends nothing, and a
354    /// send-back carries the reader's words to the agent instead.
355    /// App-level command (`key()` returns `None`): the registry is
356    /// workspace state, and `key` names the owner the answer is
357    /// checked against, not a SessionTask route.
358    RespondSlackPost {
359        key: SessionSlot,
360        id: uuid::Uuid,
361        answer: forge_primitives::slack::SlackDraftAnswer,
362    },
363    /// Answer a parked browser hand-off. The blocked `browser_hand_off`
364    /// handler is awaiting this answer: what it means, the door in the client
365    /// that sent it, and - stamped by the server - the connection that carried
366    /// it. App-level like `RespondSlackPost`: the registry is workspace state,
367    /// and `key` names the owner the answer is checked against.
368    RespondBrowserHandOff {
369        key: SessionSlot,
370        id: uuid::Uuid,
371        answer: forge_primitives::browser::HandOffAnswer,
372    },
373    /// Answer a parked terminal hand-off. The blocked `terminal__hand_off`
374    /// handler is awaiting this answer: what it means, the door in the client
375    /// that sent it, and - stamped by the server - the connection that carried
376    /// it. App-level like `RespondBrowserHandOff`: the registry is workspace
377    /// state, and `key` names the owner the answer is checked against.
378    RespondTerminalHandOff {
379        key: SessionSlot,
380        id: uuid::Uuid,
381        answer: forge_primitives::terminal::TerminalHandOffAnswer,
382    },
383    /// Answer a parked document hand-off: `NotNow` declines it, `Approve`
384    /// and `Feedback` release it with the reader's decision, note and the
385    /// review the finish filed. The blocked `hand_off_document` call is
386    /// awaiting this; `key` names the owner the answer is checked against.
387    AnswerDocumentHandOff {
388        key: SessionSlot,
389        id: uuid::Uuid,
390        answer: forge_primitives::document::DocumentHandOffEnding,
391    },
392    /// Answer a held configuration write. The blocked `settings__set` /
393    /// `settings__unset` handler is awaiting this decision; `approved:
394    /// false` means nothing is written. App-level like `RespondSlackPost`:
395    /// the registry is workspace state, and `key` names the owner the
396    /// answer is checked against.
397    RespondSettingsWrite {
398        key: SessionSlot,
399        id: uuid::Uuid,
400        approved: bool,
401    },
402    /// Answer a held vault call. The blocked MCP handler is awaiting this
403    /// answer; a decline runs nothing, a send-back carries the reader's note
404    /// to the agent, and an approval runs the call after unlocking the shared
405    /// session when the card asked for the master password. App-level like
406    /// `RespondSettingsWrite`: the registry is workspace state, and `key`
407    /// names the owner the answer is checked against.
408    RespondVaultAsk {
409        key: SessionSlot,
410        id: uuid::Uuid,
411        answer: forge_primitives::vault::VaultAskAnswer,
412    },
413    /// A client enrolled one purpose's wrapped secret under its device's
414    /// enrolment. The blob is opaque: the client wrapped it under the platform
415    /// authenticator's key, and the core stores the bytes without decoding
416    /// them.
417    ///
418    /// `enrolment` is the id the client minted for the device; an id the core
419    /// has not seen starts the enrolment, and a known one adds or replaces the
420    /// purpose's blob - a rotation's rewrite is the same command again.
421    /// App-level (`key()` returns `None`); the outcome rides
422    /// [`SessionUpdate::BiometricEnrolmentsChanged`].
423    BiometricEnrol {
424        enrolment: uuid::Uuid,
425        /// The device's own name for itself, as the settings page draws it.
426        device: String,
427        /// The consumer the blob belongs to (`"vault"` in v1).
428        purpose: String,
429        /// The wrapped secret, base64.
430        blob: String,
431        /// Answers `true` when the blob was stored. **A store failure is a
432        /// dispatch refusal, never `false`**, so the two cannot be mistaken
433        /// for each other. The rotation rewrite reads the answer to decide
434        /// whether the vault must lock (decision 15).
435        #[serde(skip)]
436        respond: Option<oneshot::Sender<bool>>,
437    },
438    /// A rotation's rewrite of an **existing** enrolment: the same purpose
439    /// and a fresh blob on an id the core already holds.
440    ///
441    /// **The person's press is what creates a row**; this may only update
442    /// one. A device the person revoked has no row, so its rewrite answers
443    /// `false` - the client then locks rather than re-registering itself.
444    /// Answers `true` when the blob was replaced, `false` when the id or the
445    /// purpose is gone, and a store failure is a dispatch refusal. App-level;
446    /// the outcome rides [`SessionUpdate::BiometricEnrolmentsChanged`].
447    BiometricRewrite {
448        enrolment: uuid::Uuid,
449        purpose: String,
450        blob: String,
451        #[serde(skip)]
452        respond: Option<oneshot::Sender<bool>>,
453    },
454    /// A client asks for the blob it enrolled for one purpose, so its gate can
455    /// unwrap it. Answers through `reply_to` with the blob, or `null` when that
456    /// purpose is not enrolled on this device - the state a client falls back
457    /// to the password on. A store failure is a dispatch refusal instead, so
458    /// the two cannot read alike.
459    BiometricRelease {
460        enrolment: uuid::Uuid,
461        purpose: String,
462        #[serde(skip)]
463        respond: Option<oneshot::Sender<Option<forge_primitives::biometric::BiometricBlob>>>,
464    },
465    /// The reader dropped one purpose's enrolment, or (`purpose: None`) the
466    /// device's whole enrolment. App-level; the outcome rides
467    /// [`SessionUpdate::BiometricEnrolmentsChanged`].
468    BiometricRevoke {
469        enrolment: uuid::Uuid,
470        purpose: Option<String>,
471    },
472    /// The reader locked the shared vault now, rather than waiting for the
473    /// idle clock. **App-level**: the process holds one vault session, so the
474    /// settings page's vault card is where the press comes from and no seat
475    /// has a better claim to it than another. Workspace state rather than
476    /// agent traffic, and the parked cards are re-stamped so a dock that
477    /// already drew one asks for the password again.
478    VaultLock,
479    /// The material a client wraps to enrol this device: the shared session's
480    /// current refresh token and the account's user key, base64. Answers
481    /// through `reply_to` with [`forge_primitives::vault::VaultReleased`],
482    /// and `null` while the session is locked - the state a device cannot
483    /// enrol from.
484    ///
485    /// **It is the vault's material, not the biometric gate's.** The gate
486    /// stores and releases whatever blob a consumer wraps; only the vault
487    /// knows what its own material is, so the command lives on the vault's
488    /// surface.
489    VaultEnrolmentMaterial {
490        #[serde(skip)]
491        respond: Option<oneshot::Sender<Option<forge_primitives::vault::VaultReleased>>>,
492    },
493    RespondQuestion {
494        key: SessionSlot,
495        tool_id: String,
496        outcome: QuestionOutcome,
497    },
498    /// Set one `/dictate` overlay axis for this session, or clear
499    /// every axis at once. Workspace state on the `DomainSession`,
500    /// never routed to the agent; the echo lands as
501    /// `SessionUpdate::DictateOverrides`.
502    SetDictateOverride {
503        key: SessionSlot,
504        update: crate::dictate::DictateOverrideUpdate,
505    },
506    /// Clear every `/dictate` override this session holds.
507    ResetDictateOverrides {
508        key: SessionSlot,
509    },
510    /// Check the model catalogue now. App-level command (`key()` returns
511    /// `None`); routed inline. Fire-and-forget: the outcome rides
512    /// [`SessionUpdate::DictateModelsChanged`], so a client watches its
513    /// own subscription rather than a reply.
514    DictateCatalogueCheck,
515    /// Download a feed variant into the models directory. App-level
516    /// (`key()` returns `None`), and its outcome rides
517    /// [`SessionUpdate::DictateModelsChanged`] the way a check's does.
518    ///
519    /// The variant is the feed's own verb for the model; its doc supplies
520    /// the download link, and nothing here constructs one.
521    DictateInstall {
522        variant: String,
523    },
524    /// Load one installed model and make it the role's active model on the
525    /// running forge. App-level, and its outcome rides
526    /// [`SessionUpdate::DictateModelsChanged`].
527    DictateActivate {
528        role: crate::dictate::DictateRole,
529        file: String,
530    },
531    /// Return one role to its `[dictate]` key or its compiled default.
532    /// App-level, same outcome channel as activation.
533    DictateDeactivate {
534        role: crate::dictate::DictateRole,
535    },
536    /// Run one model over one tier of this machine's own material.
537    /// App-level, and its outcome rides
538    /// [`SessionUpdate::DictateModelsChanged`] like the others: ticks as
539    /// it runs, then the result or the reason.
540    DictateBench {
541        target: crate::bench::BenchTarget,
542        tier: forge_dictate::bench::Tier,
543    },
544    /// Stop the bench in flight. What ran so far is discarded: a partial
545    /// corpus is not a result.
546    DictateBenchStop,
547    /// Score the speaking loop on the public corpus: synthesize each
548    /// transcript, hear it back through the transcribing and cleanup pair,
549    /// and report the round-trip words, the first-audio latency and the
550    /// render's pace. App-level, same outcome channel as the words bench.
551    VoiceBenchStart,
552    /// Stop the speaking bench in flight: a partial corpus is not a result.
553    VoiceBenchStop,
554    /// Begin recording the read-aloud set: the connection that sent this
555    /// feeds the audio as binary frames, like a take a client captures, and
556    /// no session owns it. App-level; the recording's own state rides the
557    /// models read.
558    DictateReadAloudStart {
559        /// The connection that sent this, stamped by the transport that
560        /// received it, as [`Command::DictateStream`]'s is: only that
561        /// connection can feed or stop the recording.
562        #[serde(skip)]
563        initiator: Option<u64>,
564    },
565    /// Stop the read-aloud recording. `keep` writes it into the set; a stop
566    /// that does not keeps nothing, which is the page's cancel.
567    DictateReadAloudStop {
568        keep: bool,
569        /// Stamped like [`Command::DictateReadAloudStart`]'s.
570        #[serde(skip)]
571        initiator: Option<u64>,
572    },
573    /// Drop one recording from the read-aloud set, by the id the page read.
574    DictateReadAloudDelete {
575        id: String,
576    },
577    /// Drop one saved bench result, named by its own key: the target, the
578    /// tier, and the corpus it ran over.
579    DictateBenchDelete {
580        target: crate::bench::BenchTarget,
581        tier: forge_dictate::bench::Tier,
582        corpus: String,
583    },
584    /// Remove one downloaded model - the file and its record - which is what
585    /// a sweep that scored a candidate it did not adopt leaves behind.
586    DictateUninstall {
587        file: String,
588    },
589    // --- Plugins / marketplaces (app-level; the Extensions page) ---
590    //
591    // These variants carry the work through the command bus so a view
592    // can act. Every one is app-level (`key()` returns `None`), serialized
593    // one at a time, and reports through the `Plugins*` updates a view
594    // already listens to.
595    /// Install one plugin at a scope. `project` names a declared project
596    /// for the `project`/`local` scopes and is ignored for `user`, which
597    /// runs in the home dir; it is a NAME rather than a path so the
598    /// working directory always comes from `forge.toml`.
599    PluginInstall {
600        plugin_id: String,
601        scope: String,
602        project: Option<String>,
603    },
604    /// Update one installed plugin in its own scope and project, which the
605    /// registry lookup resolves - the caller states no cwd.
606    PluginUpdate {
607        plugin_id: String,
608        scope: String,
609    },
610    /// Update exactly the stale installed plugins, one CLI call per entry,
611    /// continuing past failures. The set is computed from a fresh scan at
612    /// dispatch, so it is what the marketplace reports NOW.
613    PluginUpdateAll,
614    /// Refresh the inventory through the CLI and report which installed
615    /// plugins have a newer marketplace version, applying nothing.
616    PluginCheckUpdates,
617    /// Add a marketplace by `owner/repo` or URL, at user scope.
618    ///
619    /// `dispatch_id` is the pressing view's own token for this press - the
620    /// add is the one plugin action whose answer disposes a draft a page is
621    /// holding - and it rides back on the refusal of this dispatch and on
622    /// the outcome frames that answer it, so the press can attribute its own
623    /// answer to itself.
624    MarketplaceAdd {
625        source: String,
626        dispatch_id: Option<u64>,
627    },
628    /// Repair a drifted marketplace with the CLI's remove-and-re-add pair.
629    MarketplaceRepair {
630        name: String,
631    },
632    /// User clicked an inactive project to wake it. No `key` - the
633    /// session doesn't exist yet; workspace resolves the id it will run
634    /// under and emits `SessionUpdate::Spawning` then `::Connected` on
635    /// it. This is an App-level Command, not per-session.
636    SpawnProject {
637        project_name: String,
638        launch_settings: SessionLaunchSettings,
639    },
640    /// The user's verdict on a row waiting on their look. App-level (no
641    /// session route): the board is the user's, and the row names its
642    /// own project. `approve` completes it (past the verify gate - this
643    /// IS the verification); a send-back returns it to its owner with
644    /// `words` appended to the detail and one message into that session.
645    TaskVerdict {
646        project: String,
647        id: String,
648        approve: bool,
649        words: Option<String>,
650    },
651    /// The user answers a row waiting on a question: the words go into
652    /// the detail, the row resumes in progress, and its owner hears once.
653    TaskAnswer {
654        project: String,
655        id: String,
656        words: String,
657    },
658    /// The user moves a row in its queue.
659    TaskRank {
660        project: String,
661        id: String,
662        to: RankMove,
663    },
664    /// The user gives a row an owner (a label in the project) or takes
665    /// it back.
666    TaskAssign {
667        project: String,
668        id: String,
669        owner: Option<String>,
670    },
671    /// The user cuts a row from the board.
672    TaskCreate {
673        project: String,
674        subject: String,
675        parent: Option<String>,
676    },
677    /// The user moves a row's state directly - the drag across the board.
678    /// `place` carries where a drop landed, so the position the user chose
679    /// is the priority the row keeps; a keyboard lane step carries none.
680    TaskMove {
681        project: String,
682        id: String,
683        to: forge_primitives::tasks::TaskStatus,
684        #[serde(default)]
685        place: Option<RankMove>,
686    },
687    /// The user strikes a lead's attestation: the look gate is re-minted
688    /// and the row parks on the user again. App-level like `TaskVerdict`.
689    TaskReopen {
690        project: String,
691        id: String,
692    },
693    /// The user reserves a row's pick for themselves (or releases it): the
694    /// sweep leaves a reserved row alone rather than dispatching it.
695    TaskReserve {
696        project: String,
697        id: String,
698        reserved: bool,
699    },
700    /// Cross-project delivery (#114 v1). Dispatched by the
701    /// `mcp__forge__agents__send_message` tool impl via
702    /// `WorkspaceFacade::deliver_peer_prompt`. Routed to
703    /// `spawn::handle_deliver_peer_prompt` which: (a) resolves
704    /// `target_project` to a `SessionSlot`; (b) if target is running,
705    /// dispatches a plain `Command::Prompt` carrying the wrapper
706    /// prose; (c) if sleeping, parks `wrapped` for target's owner and
707    /// dispatches `Command::SpawnProject`.
708    ///
709    /// App-level command (`key()` returns `None`); workspace routes
710    /// to the App-level handler in `spawn.rs`. The `caller` field is
711    /// the source session's key, carried so a parking that never lands
712    /// can route its delivery notice back.
713    DeliverPeerPrompt {
714        caller: SessionSlot,
715        target_project: String,
716        wrapped: WrappedPrompt,
717        /// What happened to the prompt: `Ok(())` once it was accepted -
718        /// handed to the target's running session, or parked for a spawn
719        /// this handler kicked off - and `Err(reason)` when nothing could
720        /// take it. The sender's ack is built from this answer alone, the
721        /// way the worker engine's is (#2342).
722        #[serde(skip)]
723        respond: Option<oneshot::Sender<Result<(), String>>>,
724    },
725    /// Spawn a new worker session in `project_key`. Dispatched by
726    /// the `agents__spawn` MCP Tool impl after caller-tag validation,
727    /// or by the lead Connected hook when reviving
728    /// across-restart workers.
729    ///
730    /// `resume_existing`: when `Some(session_id)`, the handler resumes
731    /// the named session via `SessionTarget::Session` instead of
732    /// spawning fresh. The session_id MUST already carry the
733    /// `forge:worker:<label>` tag (the lead Connected hook verifies
734    /// this before dispatching). `WorkerEntry::needs_tag` is set false
735    /// on the resume path since the tag is already on disk. `None`
736    /// preserves the original fresh-spawn path used by `agents__spawn`.
737    ///
738    /// `return_to` carries the spawn result back to the calling tool
739    /// invocation; `Ok((session_id, tag))` on success, `Err(message)`
740    /// on failure (e.g. tag-write failed; see spawn handler). On the
741    /// resume path the lead Connected hook ignores the reply (the
742    /// worker is for the lead's benefit, not in response to a tool
743    /// call).
744    SpawnWorker {
745        project_key: crate::ProjectKey,
746        label: String,
747        charter: String,
748        /// The slot of the session issuing the spawn - normally the
749        /// project's lead.
750        spawned_by: SessionSlot,
751        resume_existing: Option<String>,
752        /// First message delivered as the worker's user turn on Connected,
753        /// via the rate-limited kick dispatcher. Either `agents__spawn`'s
754        /// `kick`, or - on a re-spawn - the row's `resume_kick` or the
755        /// generic restart note, which is the only thing that wakes a
756        /// resuming worker. `None` -> no kick (the worker idles until
757        /// told).
758        kick: Option<String>,
759        /// Re-orient message delivered instead of the generic restart
760        /// note when this worker is later resumed. `None` on a re-spawn
761        /// keeps whatever the row already holds.
762        resume_kick: Option<String>,
763        /// Whether this worker keeps the built-in `AskUserQuestion`
764        /// tool. Read from the persisted row on a re-spawn, so it
765        /// survives a forge restart.
766        interactive: bool,
767        /// The MCP families this spawn selects, exactly as
768        /// `agents__spawn` validated them; `None`/empty means every
769        /// family. Written to the row on a first spawn and read back
770        /// from it on a re-spawn, so the selection survives restarts.
771        mcp_families: Option<Vec<String>>,
772        /// The tree this spawn runs the worker in, already resolved to
773        /// its repo root by the facade: `None` means the project's own
774        /// path. Written to the row, which is what every read-back
775        /// composes from - so a defaulted field reads exactly as a
776        /// spawn written before the key existed.
777        #[serde(default)]
778        cwd: Option<String>,
779        /// True only for boot/reconnect re-spawns of persisted
780        /// workers, which bypass the project's worker cap:
781        /// they restore state the user already had, and their reply is
782        /// dropped, so a refusal there would strand rows with no
783        /// caller to hear it.
784        from_boot_respawn: bool,
785        #[serde(skip)]
786        return_to: Option<oneshot::Sender<Result<WorkerSpawnReply, String>>>,
787    },
788    /// Close (terminate agent + remove from `live_workers`) the
789    /// worker identified by `label` in `project_key`. Dispatched by
790    /// a view's per-row close click. If duplicates exist, the latest-
791    /// spawned matching entry is removed.
792    CloseWorker {
793        project_key: crate::ProjectKey,
794        label: String,
795    },
796    /// Despawn the worker identified by `label` in `project_key`:
797    /// terminate its agent, drop it from `live_workers`, AND clean up
798    /// its git worktree. Dispatched by the
799    /// `agents__despawn` MCP tool (lead-only). Unlike `CloseWorker`
800    /// (a view's X-button), this also removes the worker's git worktree:
801    /// a clean worktree is removed; a dirty one (uncommitted/untracked
802    /// or unpushed commits) blocks the despawn unless `force`. The
803    /// outcome flows back via `respond`.
804    DespawnWorker {
805        project_key: crate::ProjectKey,
806        label: String,
807        force: bool,
808        #[serde(skip)]
809        respond: Option<oneshot::Sender<DespawnResult>>,
810    },
811    /// Deliver a wrapped peer-style prompt to a worker. Same envelope
812    /// as `DeliverPeerPrompt` but addressed by worker label within
813    /// the caller's project rather than by cross-project name.
814    ///
815    /// `respond` carries what actually happened to the prompt: `Ok(())`
816    /// once it was accepted - handed to the target's session queue, held
817    /// for its turn end, or parked for its connect - and `Err(reason)`
818    /// when nothing could take it. The sender's ack is built from this
819    /// answer alone, so it can only claim what the delivery really did.
820    DeliverWorkerPrompt {
821        caller: SessionSlot,
822        project_key: crate::ProjectKey,
823        target_label: String,
824        wrapped: WrappedPrompt,
825        #[serde(skip)]
826        respond: Option<oneshot::Sender<Result<(), String>>>,
827    },
828    /// Deliver a wrapped prompt from a worker back to its lead.
829    /// Dispatched by the `agents__send_message` Tool impl
830    /// when the caller addresses `label="lead"`. The target
831    /// `SessionSlot` is resolved at Tool dispatch time from the
832    /// worker's `spawned_by_session_id` so the handler can deliver
833    /// directly without re-doing the lookup against a possibly-mutated
834    /// `live_workers` map. Wire shape is identical to
835    /// `DeliverWorkerPrompt` (same PeerEnvelopeAppended echo + same
836    /// `Command::Prompt` dispatch into the target session), and its
837    /// `respond` carries the same acceptance answer.
838    DeliverWorkerPromptToLead {
839        caller: SessionSlot,
840        target_lead_key: SessionSlot,
841        wrapped: WrappedPrompt,
842        #[serde(skip)]
843        respond: Option<oneshot::Sender<Result<(), String>>>,
844    },
845    /// Deliver a matched Gotify notification into `project` as a plain
846    /// user turn (spawning the project if asleep, exactly like a cron
847    /// prompt). `team_role` targets a durable team worker when `Some`;
848    /// `None` targets the project lead. Dispatched by the
849    /// `GotifyHost::deliver` port impl, one per matching subscription;
850    /// handled by `spawn::deliver_gotify_message`. `notification` carries
851    /// the resolved app name, title, message, and priority - its
852    /// `to_prose()` is the user-turn text, and the same struct drives the
853    /// chat-echo. App-level command (`key()` returns `None`).
854    DeliverGotifyMessage {
855        project: String,
856        team_role: Option<String>,
857        notification: crate::mcp::gotify::types::GotifyNotification,
858    },
859    /// Begin a take the CLIENT captures: the connection that sent this
860    /// feeds the audio as binary frames, so no device is opened here.
861    /// Registered synchronously by the dispatch that receives it, so the
862    /// frame a client sends next on the same ordered socket finds it.
863    ///
864    /// `options` are the axes that client's panel was showing; they
865    /// normalize this take, where an in-process take reads the
866    /// session's stored overrides at its stop.
867    DictateStream {
868        key: SessionSlot,
869        options: crate::dictate::DictateAxes,
870        /// The surface whose own capture began this take - `composer`,
871        /// `review`, `files`, `doc` - carried so the landed words reach the
872        /// surface that began them and no other. Absent from a client older
873        /// than the field, which is the one case every surface reads as "the
874        /// old rules decide", as it did before.
875        ///
876        /// **Read as an opaque name, never validated.** The vocabulary is the
877        /// client's own, and under forge's trust model a client that misnames
878        /// its take only mislabels its own words.
879        #[serde(default)]
880        surface: Option<String>,
881        /// The connection that sent this, stamped by the transport that
882        /// received it: the take belongs to that connection, so every
883        /// update about it goes back there alone. `None` is this
884        /// process's own take.
885        #[serde(skip)]
886        initiator: Option<u64>,
887    },
888    /// Submit (`submit = true`) or abandon the take started by `key`.
889    /// During recording this is release-to-submit vs discard; during a
890    /// transcription in flight it abandons the ticket.
891    DictateStop {
892        key: SessionSlot,
893        submit: bool,
894        /// The connection whose take this stop addresses, stamped like
895        /// [`Command::DictateStream`]'s: only the connection a take
896        /// belongs to can stop it. `None` is this process's own.
897        #[serde(skip)]
898        initiator: Option<u64>,
899    },
900    /// Set the status of one review thread by id, bumping its
901    /// `updated_at`. App-level command (`key()` returns `None`);
902    /// routed inline.
903    SetReviewThreadStatus {
904        project: String,
905        branch: String,
906        thread_id: String,
907        status: ReviewStatus,
908    },
909    /// Release the session `session_key` (cascade-aware: a project
910    /// lead's workers terminate first). Dispatched by a view's
911    /// per-row close click; a view removes its own bucket around the
912    /// dispatch. App-level command (`key()` returns `None`); routed
913    /// inline.
914    CloseSession {
915        session_key: SessionSlot,
916    },
917    /// Insert or replace one review thread by id in `(project, branch)`.
918    /// `respond` carries whether the write was confirmed, so the
919    /// overlay's at-risk durability flag stays honest (the same
920    /// value-returning shape as `SpawnWorker.return_to`). The handler
921    /// runs inline in dispatch, so the response is present the moment
922    /// dispatch returns. App-level command (`key()` returns `None`).
923    UpsertReviewThread {
924        project: String,
925        branch: String,
926        thread: ReviewThread,
927        /// The seat whose words this write sends: a landed take's words are
928        /// the seat's until a write carries them, and this is what tells the
929        /// composer which take the comment spends (#2283). `None` from a
930        /// client older than the field, and from a write no seat made.
931        #[serde(default)]
932        origin: Option<SessionSlot>,
933        /// **The words this press sent**, as the ask's `answered_with` states
934        /// its own: the thread carries every turn it ever had, so the composer
935        /// cannot tell the new comment from an old one that happens to match a
936        /// landed take (#2283). `None` from a client older than the field.
937        #[serde(default)]
938        sent_words: Option<String>,
939        #[serde(skip)]
940        respond: Option<oneshot::Sender<bool>>,
941    },
942    /// Delete one review thread from `(project, branch)` - the reviewer's
943    /// own retraction, wherever its card is drawn. `respond` carries
944    /// whether the store confirmed; `None` when the write failed.
945    DeleteReviewThread {
946        project: String,
947        branch: String,
948        thread_id: String,
949        #[serde(skip)]
950        respond: Option<oneshot::Sender<bool>>,
951    },
952    /// Submit (seal) the listed threads as one review round.
953    /// `respond` carries the minted review, `None` when the store
954    /// write failed. App-level command (`key()` returns `None`).
955    SubmitReview {
956        project: String,
957        branch: String,
958        summary: Option<String>,
959        thread_ids: Vec<String>,
960        origin: SessionSlot,
961        #[serde(skip)]
962        respond: Option<oneshot::Sender<Option<forge_primitives::ReviewSet>>>,
963    },
964    /// A client turned voice on or off for the seat. The listener set is per
965    /// connection, so the transport stamps which one.
966    VoiceMode {
967        key: SessionSlot,
968        on: bool,
969        /// Whether this off is the reader's own, which is what tells the
970        /// session it has stopped listening. A move to another seat pauses
971        /// without the note, so a seat switch does not toggle the agent.
972        #[serde(default)]
973        explicit: bool,
974        #[serde(skip)]
975        initiator: Option<u64>,
976    },
977    /// Play one voice on the seat's own stream, so it can be heard before it
978    /// is chosen. Refused when that seat's speaker switch is off.
979    VoicePreview {
980        key: SessionSlot,
981        voice: String,
982    },
983}
984
985impl Command {
986    /// The `SessionSlot` this command routes to, or `None` for
987    /// App-level commands (`SpawnProject`). `Workspace::dispatch`
988    /// routes `None` commands to its app-level handler (which resolves
989    /// the id the new session will run under and spawns the agent);
990    /// `Some(key)` commands route to the matching SessionTask.
991    pub fn key(&self) -> Option<&SessionSlot> {
992        match self {
993            Self::Prompt { key, .. }
994            | Self::PromptUnder { key, .. }
995            | Self::Cancel { key }
996            | Self::CancelQueuedPrompt { key, .. }
997            | Self::ReadCallOutput { key, .. }
998            | Self::SetMode { key, .. }
999            | Self::SetModel { key, .. }
1000            | Self::NewSession { key, .. }
1001            | Self::ResumeSession { key, .. }
1002            | Self::Handoff { key, .. }
1003            | Self::RespondPermission { key, .. }
1004            | Self::RespondQuestion { key, .. }
1005            | Self::SetDictateOverride { key, .. }
1006            | Self::ResetDictateOverrides { key }
1007            | Self::VoiceMode { key, .. }
1008            | Self::VoicePreview { key, .. } => Some(key),
1009            Self::SpawnProject { .. }
1010            | Self::DictateStream { .. }
1011            | Self::DictateStop { .. }
1012            | Self::DeliverPeerPrompt { .. }
1013            | Self::SpawnWorker { .. }
1014            | Self::CloseWorker { .. }
1015            | Self::DespawnWorker { .. }
1016            | Self::DeliverWorkerPrompt { .. }
1017            | Self::DeliverWorkerPromptToLead { .. }
1018            | Self::DeliverGotifyMessage { .. }
1019            | Self::DictateCatalogueCheck
1020            | Self::DictateInstall { .. }
1021            | Self::DictateActivate { .. }
1022            | Self::DictateDeactivate { .. }
1023            | Self::DictateBench { .. }
1024            | Self::DictateBenchStop
1025            | Self::VoiceBenchStart
1026            | Self::VoiceBenchStop
1027            | Self::DictateReadAloudStart { .. }
1028            | Self::DictateReadAloudStop { .. }
1029            | Self::DictateReadAloudDelete { .. }
1030            | Self::DictateBenchDelete { .. }
1031            | Self::DictateUninstall { .. }
1032            | Self::SetReviewThreadStatus { .. }
1033            | Self::CloseSession { .. }
1034            | Self::UpsertReviewThread { .. }
1035            | Self::DeleteReviewThread { .. }
1036            | Self::RespondSlackPost { .. }
1037            | Self::RespondBrowserHandOff { .. }
1038            | Self::RespondTerminalHandOff { .. }
1039            | Self::AnswerDocumentHandOff { .. }
1040            | Self::RespondSettingsWrite { .. }
1041            | Self::RespondVaultAsk { .. }
1042            | Self::SubmitReview { .. }
1043            | Self::TaskVerdict { .. }
1044            | Self::TaskAnswer { .. }
1045            | Self::TaskRank { .. }
1046            | Self::TaskAssign { .. }
1047            | Self::TaskCreate { .. }
1048            | Self::TaskMove { .. }
1049            | Self::TaskReopen { .. }
1050            | Self::TaskReserve { .. }
1051            | Self::PluginInstall { .. }
1052            | Self::PluginUpdate { .. }
1053            | Self::PluginUpdateAll
1054            | Self::PluginCheckUpdates
1055            | Self::MarketplaceAdd { .. }
1056            | Self::MarketplaceRepair { .. }
1057            | Self::BiometricEnrol { .. }
1058            | Self::BiometricRelease { .. }
1059            | Self::BiometricRevoke { .. }
1060            | Self::BiometricRewrite { .. }
1061            | Self::VaultLock
1062            | Self::VaultEnrolmentMaterial { .. } => None,
1063        }
1064    }
1065
1066    /// The dispatch id the press that made this command minted, when it
1067    /// named one: what a refusal echoes and the outcome frames carry back.
1068    pub fn dispatch_id(&self) -> Option<u64> {
1069        match self {
1070            Self::MarketplaceAdd { dispatch_id, .. } | Self::PromptUnder { dispatch_id, .. } => {
1071                *dispatch_id
1072            }
1073            _ => None,
1074        }
1075    }
1076}
1077
1078impl std::fmt::Debug for Command {
1079    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1080        // Custom Debug: discriminant + routing key (and a few cheap
1081        // identifying fields) only. `oneshot::Sender` on `SpawnWorker`
1082        // isn't Debug, so deriving isn't an option; payloads can be
1083        // bulky and aren't useful in trace output.
1084        match self {
1085            Self::Prompt { key, .. } => {
1086                f.debug_struct("Prompt").field("key", key).finish_non_exhaustive()
1087            }
1088            Self::PromptUnder { key, uuid, .. } => f
1089                .debug_struct("PromptUnder")
1090                .field("key", key)
1091                .field("uuid", uuid)
1092                .finish_non_exhaustive(),
1093            Self::Cancel { key } => f.debug_struct("Cancel").field("key", key).finish(),
1094            Self::CancelQueuedPrompt { key, uuid } => {
1095                f.debug_struct("CancelQueuedPrompt").field("key", key).field("uuid", uuid).finish()
1096            }
1097            Self::ReadCallOutput { key, call_id } => f
1098                .debug_struct("ReadCallOutput")
1099                .field("key", key)
1100                .field("call_id", call_id)
1101                .finish(),
1102            Self::SetMode { key, mode } => {
1103                f.debug_struct("SetMode").field("key", key).field("mode", mode).finish()
1104            }
1105            Self::SetModel { key, model } => {
1106                f.debug_struct("SetModel").field("key", key).field("model", model).finish()
1107            }
1108            Self::NewSession { key, cwd, .. } => f
1109                .debug_struct("NewSession")
1110                .field("key", key)
1111                .field("cwd", cwd)
1112                .finish_non_exhaustive(),
1113            Self::ResumeSession { key, session_id, cwd, .. } => f
1114                .debug_struct("ResumeSession")
1115                .field("key", key)
1116                .field("session_id", session_id)
1117                .field("cwd", cwd)
1118                .finish_non_exhaustive(),
1119            Self::Handoff { key, brief } => f
1120                .debug_struct("Handoff")
1121                .field("key", key)
1122                .field("brief", &brief.as_ref().map(|_| "exit"))
1123                .finish(),
1124            Self::RespondPermission { key, tool_id, .. } => f
1125                .debug_struct("RespondPermission")
1126                .field("key", key)
1127                .field("tool_id", tool_id)
1128                .finish_non_exhaustive(),
1129            Self::RespondQuestion { key, tool_id, .. } => f
1130                .debug_struct("RespondQuestion")
1131                .field("key", key)
1132                .field("tool_id", tool_id)
1133                .finish_non_exhaustive(),
1134            Self::RespondSlackPost { key, id, answer } => f
1135                .debug_struct("RespondSlackPost")
1136                .field("key", key)
1137                .field("id", id)
1138                .field("answer", answer)
1139                .finish_non_exhaustive(),
1140            Self::RespondBrowserHandOff { key, id, answer } => f
1141                .debug_struct("RespondBrowserHandOff")
1142                .field("key", key)
1143                .field("id", id)
1144                .field("answer", answer)
1145                .finish_non_exhaustive(),
1146            Self::RespondTerminalHandOff { key, id, answer } => f
1147                .debug_struct("RespondTerminalHandOff")
1148                .field("key", key)
1149                .field("id", id)
1150                .field("answer", answer)
1151                .finish_non_exhaustive(),
1152            Self::RespondSettingsWrite { key, id, approved } => f
1153                .debug_struct("RespondSettingsWrite")
1154                .field("key", key)
1155                .field("id", id)
1156                .field("approved", approved)
1157                .finish(),
1158            Self::RespondVaultAsk { key, id, answer } => f
1159                .debug_struct("RespondVaultAsk")
1160                .field("key", key)
1161                .field("id", id)
1162                .field("answer", answer)
1163                .finish_non_exhaustive(),
1164            Self::BiometricEnrol { enrolment, device, purpose, .. } => f
1165                .debug_struct("BiometricEnrol")
1166                .field("enrolment", enrolment)
1167                .field("device", device)
1168                .field("purpose", purpose)
1169                .finish_non_exhaustive(),
1170            Self::BiometricRelease { enrolment, purpose, .. } => f
1171                .debug_struct("BiometricRelease")
1172                .field("enrolment", enrolment)
1173                .field("purpose", purpose)
1174                .finish_non_exhaustive(),
1175            Self::BiometricRevoke { enrolment, purpose } => f
1176                .debug_struct("BiometricRevoke")
1177                .field("enrolment", enrolment)
1178                .field("purpose", purpose)
1179                .finish(),
1180            Self::BiometricRewrite { enrolment, purpose, .. } => f
1181                .debug_struct("BiometricRewrite")
1182                .field("enrolment", enrolment)
1183                .field("purpose", purpose)
1184                .finish_non_exhaustive(),
1185            Self::VaultLock => f.write_str("VaultLock"),
1186            Self::VaultEnrolmentMaterial { .. } => f.write_str("VaultEnrolmentMaterial"),
1187            Self::AnswerDocumentHandOff { key, id, answer } => f
1188                .debug_struct("AnswerDocumentHandOff")
1189                .field("key", key)
1190                .field("id", id)
1191                .field("answer", answer)
1192                .finish_non_exhaustive(),
1193            Self::SetDictateOverride { key, .. } => {
1194                f.debug_struct("SetDictateOverride").field("key", key).finish_non_exhaustive()
1195            }
1196            Self::ResetDictateOverrides { key } => {
1197                f.debug_struct("ResetDictateOverrides").field("key", key).finish()
1198            }
1199            Self::SpawnProject { project_name, .. } => f
1200                .debug_struct("SpawnProject")
1201                .field("project_name", project_name)
1202                .finish_non_exhaustive(),
1203            Self::DeliverPeerPrompt { caller, target_project, .. } => f
1204                .debug_struct("DeliverPeerPrompt")
1205                .field("caller", caller)
1206                .field("target_project", target_project)
1207                .finish_non_exhaustive(),
1208            Self::SpawnWorker { project_key, label, spawned_by, .. } => f
1209                .debug_struct("SpawnWorker")
1210                .field("project_key", project_key)
1211                .field("label", label)
1212                .field("spawned_by", spawned_by)
1213                .field("return_to", &"<oneshot::Sender>")
1214                .finish_non_exhaustive(),
1215            Self::CloseWorker { project_key, label } => f
1216                .debug_struct("CloseWorker")
1217                .field("project_key", project_key)
1218                .field("label", label)
1219                .finish(),
1220            Self::DespawnWorker { project_key, label, force, .. } => f
1221                .debug_struct("DespawnWorker")
1222                .field("project_key", project_key)
1223                .field("label", label)
1224                .field("force", force)
1225                .finish_non_exhaustive(),
1226            Self::DeliverWorkerPrompt { caller, project_key, target_label, .. } => f
1227                .debug_struct("DeliverWorkerPrompt")
1228                .field("caller", caller)
1229                .field("project_key", project_key)
1230                .field("target_label", target_label)
1231                .finish_non_exhaustive(),
1232            Self::DeliverWorkerPromptToLead { caller, target_lead_key, .. } => f
1233                .debug_struct("DeliverWorkerPromptToLead")
1234                .field("caller", caller)
1235                .field("target_lead_key", target_lead_key)
1236                .finish_non_exhaustive(),
1237            Self::DeliverGotifyMessage { project, team_role, notification } => f
1238                .debug_struct("DeliverGotifyMessage")
1239                .field("project", project)
1240                .field("team_role", team_role)
1241                .field("app", &notification.app)
1242                .field("priority", &notification.priority)
1243                .finish_non_exhaustive(),
1244            Self::DictateCatalogueCheck => f.write_str("DictateCatalogueCheck"),
1245            Self::DictateInstall { variant } => {
1246                f.debug_struct("DictateInstall").field("variant", variant).finish()
1247            }
1248            Self::DictateActivate { role, file } => {
1249                f.debug_struct("DictateActivate").field("role", role).field("file", file).finish()
1250            }
1251            Self::DictateDeactivate { role } => {
1252                f.debug_struct("DictateDeactivate").field("role", role).finish()
1253            }
1254            Self::DictateBench { target, tier } => {
1255                f.debug_struct("DictateBench").field("target", target).field("tier", tier).finish()
1256            }
1257            Self::DictateBenchStop => f.write_str("DictateBenchStop"),
1258            Self::VoiceBenchStart => f.write_str("VoiceBenchStart"),
1259            Self::VoiceBenchStop => f.write_str("VoiceBenchStop"),
1260            Self::DictateReadAloudStart { .. } => f.write_str("DictateReadAloudStart"),
1261            Self::DictateReadAloudStop { keep, .. } => {
1262                f.debug_struct("DictateReadAloudStop").field("keep", keep).finish()
1263            }
1264            Self::DictateReadAloudDelete { id } => {
1265                f.debug_struct("DictateReadAloudDelete").field("id", id).finish()
1266            }
1267            Self::DictateUninstall { file } => {
1268                f.debug_struct("DictateUninstall").field("file", file).finish()
1269            }
1270            Self::DictateBenchDelete { target, tier, corpus } => f
1271                .debug_struct("DictateBenchDelete")
1272                .field("target", target)
1273                .field("tier", tier)
1274                .field("corpus", corpus)
1275                .finish(),
1276            Self::DictateStream { key, .. } => {
1277                f.debug_struct("DictateStream").field("key", key).finish_non_exhaustive()
1278            }
1279            Self::DictateStop { key, submit, .. } => {
1280                f.debug_struct("DictateStop").field("key", key).field("submit", submit).finish()
1281            }
1282            Self::SetReviewThreadStatus { project, branch, thread_id, status } => f
1283                .debug_struct("SetReviewThreadStatus")
1284                .field("project", project)
1285                .field("branch", branch)
1286                .field("thread_id", thread_id)
1287                .field("status", status)
1288                .finish(),
1289            Self::CloseSession { session_key } => {
1290                f.debug_struct("CloseSession").field("session_key", session_key).finish()
1291            }
1292            Self::UpsertReviewThread { project, branch, thread, .. } => f
1293                .debug_struct("UpsertReviewThread")
1294                .field("project", project)
1295                .field("branch", branch)
1296                .field("thread_id", &thread.id)
1297                .finish_non_exhaustive(),
1298            Self::DeleteReviewThread { project, branch, thread_id, .. } => f
1299                .debug_struct("DeleteReviewThread")
1300                .field("project", project)
1301                .field("branch", branch)
1302                .field("thread_id", thread_id)
1303                .finish_non_exhaustive(),
1304            Self::SubmitReview { project, branch, thread_ids, .. } => f
1305                .debug_struct("SubmitReview")
1306                .field("project", project)
1307                .field("branch", branch)
1308                .field("thread_ids", thread_ids)
1309                .finish_non_exhaustive(),
1310            Self::TaskVerdict { project, id, approve, .. } => f
1311                .debug_struct("TaskVerdict")
1312                .field("project", project)
1313                .field("id", id)
1314                .field("approve", approve)
1315                .finish_non_exhaustive(),
1316            Self::TaskAnswer { project, id, .. } => f
1317                .debug_struct("TaskAnswer")
1318                .field("project", project)
1319                .field("id", id)
1320                .finish_non_exhaustive(),
1321            Self::TaskRank { project, id, to } => f
1322                .debug_struct("TaskRank")
1323                .field("project", project)
1324                .field("id", id)
1325                .field("to", to)
1326                .finish_non_exhaustive(),
1327            Self::TaskAssign { project, id, owner } => f
1328                .debug_struct("TaskAssign")
1329                .field("project", project)
1330                .field("id", id)
1331                .field("owner", owner)
1332                .finish_non_exhaustive(),
1333            Self::TaskCreate { project, subject, .. } => f
1334                .debug_struct("TaskCreate")
1335                .field("project", project)
1336                .field("subject", subject)
1337                .finish_non_exhaustive(),
1338            Self::TaskMove { project, id, to, place } => f
1339                .debug_struct("TaskMove")
1340                .field("project", project)
1341                .field("id", id)
1342                .field("to", to)
1343                .field("place", place)
1344                .finish_non_exhaustive(),
1345            Self::TaskReopen { project, id } => f
1346                .debug_struct("TaskReopen")
1347                .field("project", project)
1348                .field("id", id)
1349                .finish_non_exhaustive(),
1350            Self::TaskReserve { project, id, reserved } => f
1351                .debug_struct("TaskReserve")
1352                .field("project", project)
1353                .field("id", id)
1354                .field("reserved", reserved)
1355                .finish_non_exhaustive(),
1356            Self::PluginInstall { plugin_id, scope, project } => f
1357                .debug_struct("PluginInstall")
1358                .field("plugin_id", plugin_id)
1359                .field("scope", scope)
1360                .field("project", project)
1361                .finish(),
1362            Self::PluginUpdate { plugin_id, scope } => f
1363                .debug_struct("PluginUpdate")
1364                .field("plugin_id", plugin_id)
1365                .field("scope", scope)
1366                .finish(),
1367            Self::PluginUpdateAll => f.write_str("PluginUpdateAll"),
1368            Self::PluginCheckUpdates => f.write_str("PluginCheckUpdates"),
1369            Self::MarketplaceAdd { source, dispatch_id } => f
1370                .debug_struct("MarketplaceAdd")
1371                .field("source", source)
1372                .field("dispatch_id", dispatch_id)
1373                .finish(),
1374            Self::MarketplaceRepair { name } => {
1375                f.debug_struct("MarketplaceRepair").field("name", name).finish()
1376            }
1377            Self::VoiceMode { key, on, .. } => {
1378                f.debug_struct("VoiceMode").field("key", key).field("on", on).finish()
1379            }
1380            Self::VoicePreview { key, voice } => {
1381                f.debug_struct("VoicePreview").field("key", key).field("voice", voice).finish()
1382            }
1383        }
1384    }
1385}
1386
1387/// Read a no-signal peak - a level's, or the one a `no_audio` outcome
1388/// carries - taking `null` as no signal rather than as a decode failure.
1389///
1390/// The counterpart of serde writing a non-finite float as `null`: a
1391/// window that heard nothing peaks at exactly zero, whose dBFS is
1392/// negative infinity. Without this, a Rust reader cannot decode the
1393/// message at all, and the failure names the type rather than the case.
1394fn no_signal_reads_as_negative_infinity<'de, D>(deserializer: D) -> Result<f32, D::Error>
1395where
1396    D: serde::Deserializer<'de>,
1397{
1398    Ok(Option::<f32>::deserialize(deserializer)?.unwrap_or(f32::NEG_INFINITY))
1399}
1400
1401/// What one finished dictation take produced. Plain data rather than
1402/// [`forge_dictate::Outcome`]: a view words the notices, so it gets
1403/// the observations and keeps the crate's error shapes.
1404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1405#[serde(rename_all = "snake_case")]
1406pub enum DictateOutcome {
1407    /// Words to insert at the composer's caret. `truncated` means the
1408    /// take hit the capture cap or the decode budget and is partial.
1409    Landed { text: String, truncated: bool },
1410    /// The audio normalised to nothing - a valid answer, not a failure.
1411    Empty,
1412    /// Nothing rose above the silence floor. A finite `peak_db` is a
1413    /// quiet room and a retry is reasonable; negative infinity means
1414    /// every sample was exactly zero, which is structural and sticky.
1415    NoAudio {
1416        #[serde(default, deserialize_with = "no_signal_reads_as_negative_infinity")]
1417        peak_db: f32,
1418        seconds: u64,
1419    },
1420    /// The take never happened. Covers a busy microphone, a device that
1421    /// would not open, and dictation not being ready.
1422    Refused { message: String },
1423    /// Recognition failed. The response is the same whichever way.
1424    Failed,
1425    /// The user abandoned the take. Resets silently.
1426    Cancelled,
1427}
1428
1429/// The role a caller states for a spawn: a project's lead, or a worker
1430/// named by its label.
1431///
1432/// Both the session's tool surface and its slot's label come from this
1433/// one value, so they cannot disagree - a caller that says `Worker`
1434/// gives a worker's tool surface AND a worker's slot. Every spawn
1435/// states one; forge has no keyless form, because a role it cannot
1436/// state is one it would have to guess.
1437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1438#[serde(rename_all = "snake_case")]
1439pub enum SpawnRole {
1440    Lead,
1441    Worker {
1442        label: String,
1443        /// Whether this spawn wrote the worker's durable row rather than
1444        /// adopting one that was already there. A rollback may take the
1445        /// row away only when it did: a resume and a boot re-spawn are
1446        /// handed a row that holds the worker's charter, kick and the id
1447        /// being resumed, so the row is the worker, not this spawn's
1448        /// leftover.
1449        wrote_row: bool,
1450        /// The stored MCP-family selection this spawn composes its tool
1451        /// surface from; `None`/empty means every family. Carried here
1452        /// because the server is built inside `get_agent_handle_at_key`,
1453        /// which reads no store of its own.
1454        mcp_families: Option<Vec<String>>,
1455    },
1456}
1457
1458/// Who dispatched a prompt, for a queued row's label.
1459///
1460/// This one is about the sender, and every frame forge writes as a
1461/// user message gets one. Serialised as the label itself.
1462#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
1463#[serde(rename_all = "snake_case")]
1464pub enum PromptSource {
1465    /// A person's own send - this view's or another's.
1466    You,
1467    Cron,
1468    Gotify,
1469    Slack,
1470    Peer,
1471    /// forge's own machinery: a worker kick, an auto-continue, a replayed
1472    /// parked delivery whose kind is no longer known.
1473    Forge,
1474}
1475
1476/// Where the user moved a row in its queue.
1477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1478#[serde(rename_all = "snake_case")]
1479pub enum RankMove {
1480    /// Above everything ranked.
1481    Top,
1482    /// One place toward the front.
1483    Up,
1484    /// One place toward the back.
1485    Down,
1486    /// Where a drag landed: the row this one should sit above in the queue,
1487    /// or nothing for the end of it.
1488    Before(Option<String>),
1489}
1490
1491/// One prompt waiting in the CLI's queue, as a view reads it.
1492///
1493/// The pile holds only prompts that are still waiting: an entry is appended
1494/// where the prompt is sent (the only place that knows its source and words)
1495/// and leaves on a lifecycle state the core NAMES as settled.
1496#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
1497pub struct QueuedPrompt {
1498    /// The uuid the prompt was sent under, which its lifecycle frames carry.
1499    pub uuid: String,
1500    /// Who dispatched it.
1501    pub source: PromptSource,
1502    /// The prompt's words, as they were written.
1503    pub text: String,
1504    /// What the prompt carried besides its words.
1505    ///
1506    /// **Core-side only, never on the wire.** The row is drawn at the CLI's
1507    /// start, long after the command that carried these is gone, so the task
1508    /// keeps them here to build that frame; the queue a view reads is the
1509    /// three fields above, and a base64 payload has no business riding every
1510    /// seat record read.
1511    #[serde(skip)]
1512    pub attachments: Vec<forge_primitives::ImageAttachment>,
1513}
1514
1515/// How loudly a [`SessionUpdate::Notice`] reads.
1516///
1517/// Two levels rather than a scale: the core's own answer about a seat, and
1518/// the failures it owes the reader - a command that did not run, a session
1519/// that ended.
1520#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
1521#[serde(rename_all = "snake_case")]
1522pub enum NoticeSeverity {
1523    /// A command's own answer.
1524    Info,
1525    /// One that did not run.
1526    Error,
1527}
1528
1529/// Update envelope: forge-workspace -> a view.
1530///
1531/// Permission/Question variants do NOT carry response oneshots -
1532/// responses flow back via `Command::Respond*`. The workspace stores
1533/// the oneshot in `DomainSession.pending_interactions` when emitting
1534/// these variants.
1535///
1536/// `Clone` is what lets the fan-out hand one emit to more than one
1537/// subscriber, so every variant's payload must stay cloneable.
1538#[derive(Clone, Serialize, Deserialize)]
1539#[serde(rename_all = "snake_case")]
1540pub enum SessionUpdate {
1541    /// Workspace is spawning a session (in response to
1542    /// `Command::SpawnProject`). A view creates a placeholder
1543    /// under `key` and shows the "Waking {display_name}…" message. The
1544    /// matching `Connected` lands soon after, under the same key.
1545    Spawning {
1546        key: SessionSlot,
1547        project_name: String,
1548        cwd: String,
1549        display_name: String,
1550    },
1551    Connected {
1552        key: SessionSlot,
1553        session_id: SessionId,
1554        cwd: String,
1555        current_model: CurrentModel,
1556        available_models: Vec<AvailableModel>,
1557        mode: Option<ModeState>,
1558        history: Vec<Message>,
1559        /// Messages the read carried that `history`'s window left behind its
1560        /// front, which is the floor a page cursor below the window is
1561        /// measured against.
1562        ///
1563        /// **Carried because the window cannot say it.** The read is windowed
1564        /// before it crosses - a resume's whole transcript is gigabytes - so a
1565        /// consumer holding the window alone reads it as the conversation's
1566        /// front, and a walk below it would stop there instead of crossing
1567        /// into the transcript. Additive on the wire, and zero for a read the
1568        /// window holds whole.
1569        dropped: usize,
1570        /// Compactions the resumed transcript records. Seeds the
1571        /// per-session count, which has no other durable source.
1572        compaction_count: u32,
1573    },
1574    /// A call's own output, asked for by `Command::ReadCallOutput`: the tail
1575    /// of the file the CLI streamed the call's output to, or a named reason
1576    /// there is none.
1577    ///
1578    /// Emitted by the transport from its held copy of the seat's
1579    /// conversation, which carries the frame naming the call's `output_file`.
1580    CallOutput {
1581        key: SessionSlot,
1582        call_id: String,
1583        output: forge_primitives::CallOutput,
1584    },
1585    /// The slot's occupant changed under it - a `/new`, a `/resume`, a
1586    /// login or a logout. The slot keeps its bucket, in its place on
1587    /// screen; its contents reset, meaning the rendered conversation,
1588    /// the history, the per-session overrides and any waiter holding a
1589    /// tool call that is never coming back. `session_id` is the new
1590    /// occupant.
1591    SessionReplaced {
1592        key: SessionSlot,
1593        session_id: SessionId,
1594        cwd: String,
1595        current_model: CurrentModel,
1596        available_models: Vec<AvailableModel>,
1597        mode: Option<ModeState>,
1598        history: Vec<Message>,
1599        /// Messages the read carried that `history`'s window left behind its
1600        /// front: the same floor a `Connected` carries, for the same reason.
1601        dropped: usize,
1602        /// Compactions the resumed transcript records. Seeds the
1603        /// per-session count, which has no other durable source.
1604        compaction_count: u32,
1605    },
1606    /// The seat's occupant was replaced by a handoff rather than by a person
1607    /// asking for one, and this is the record of it - emitted before any
1608    /// frame from the new occupant, so the seat learns the swap before the
1609    /// successor's transcript starts. No view draws a row from it: the
1610    /// successor's own procedure row carries the handoff.
1611    Handoff {
1612        key: SessionSlot,
1613        handoff: forge_primitives::HandoffRecord,
1614    },
1615    /// The machine turn running on this seat changed: a handoff beginning or
1616    /// ending, and `None` for a seat taking input.
1617    ///
1618    /// **A record read is not pushed.** The record carries the same fact for a
1619    /// page that attaches mid-turn, but a page already open learns it here -
1620    /// and its composer stands down for exactly that span.
1621    MachineTurn {
1622        key: SessionSlot,
1623        machine: Option<forge_primitives::runtime::MachineTurn>,
1624    },
1625    ConnectionFailed {
1626        key: SessionSlot,
1627        message: String,
1628        fatal: bool,
1629    },
1630    /// The seat's release has begun: its session is being taken down, and a
1631    /// view draws the seat as going to sleep until the roster lands it asleep
1632    /// or gone.
1633    ///
1634    /// **Announced at the START of the release, not when the handles drop.**
1635    /// The teardown is what takes the seconds, so a viewer that hears only the
1636    /// end keeps the row reading as working through the whole gesture - and a
1637    /// close made from another view is heard by no one at all without this.
1638    Releasing {
1639        key: SessionSlot,
1640    },
1641    /// One line the core has for a view about `key`, which nothing else
1642    /// carries.
1643    ///
1644    /// A command answered where both views dispatch has no other way to say
1645    /// anything: the CLI emits no frame for a command it never saw, and the
1646    /// line is the core's own answer rather than a view's opinion - what a
1647    /// command found, or why it refused.
1648    ///
1649    /// **Live only, and that is intended.** The CLI writes no transcript row
1650    /// for a line it never produced, so a view draws this on arrival and a
1651    /// page that attaches afterwards has nothing to read it from. It is a
1652    /// statement about the moment a command ran, not a record of the
1653    /// conversation.
1654    Notice {
1655        key: SessionSlot,
1656        severity: NoticeSeverity,
1657        text: String,
1658    },
1659    /// The CLI refused (or failed) a `set_permission_mode` control
1660    /// request for `key`. `message` carries the underlying error text;
1661    /// the view rolls the optimistic mode chip back and surfaces it.
1662    SetModeFailed {
1663        key: SessionSlot,
1664        mode: PermissionMode,
1665        message: String,
1666    },
1667    /// The CLI refused (or failed) a `set_model` control request for
1668    /// `key`. `message` carries the underlying error text; the view
1669    /// rolls the optimistic model change back and surfaces it.
1670    SetModelFailed {
1671        key: SessionSlot,
1672        model: String,
1673        message: String,
1674    },
1675    /// Permission prompt. No response_tx - a view replies via
1676    /// `Command::RespondPermission { tool_id, outcome }`.
1677    PermissionRequest {
1678        key: SessionSlot,
1679        tool_id: String,
1680        request: PermissionRequest,
1681    },
1682    /// AskUserQuestion prompt. Same shape; reply via
1683    /// `Command::RespondQuestion { tool_id, outcome }`.
1684    QuestionRequest {
1685        key: SessionSlot,
1686        tool_id: String,
1687        request: QuestionRequest,
1688    },
1689    /// The interaction `tool_id` was answered, so every view holding the
1690    /// prompt can drop it. Answering leaves the pending set either way,
1691    /// and this is the only thing that says so on the stream: without it a
1692    /// second view keeps drawing a prompt that is already settled. A lone
1693    /// view never meets that, because it is the only one.
1694    ///
1695    /// **A question names its round as well as its call.** A batch reuses
1696    /// one tool call id and advances `question_index`, and the next round's
1697    /// request can land before this round's resolution - so a clear keyed
1698    /// on the id alone dropped the ask that had just parked, and every
1699    /// round after the first lost its opening question (Ved's live find;
1700    /// #1717). `None` on every interaction that is not a question, and on
1701    /// the frames of a core older than this field.
1702    PendingInteractionResolved {
1703        key: SessionSlot,
1704        tool_id: String,
1705        /// Absent from an older core's frames, so the default carries that.
1706        #[serde(default)]
1707        question_index: Option<u64>,
1708        /// The words the answer carried, where it carried any: the box's own
1709        /// notes, typed or dictated (#1360). `None` for a pick with no words,
1710        /// which is most of them.
1711        #[serde(default)]
1712        answered_with: Option<String>,
1713    },
1714    McpOperationError {
1715        key: SessionSlot,
1716        error: McpOperationError,
1717    },
1718    TurnComplete {
1719        key: SessionSlot,
1720        terminal_reason: Option<TerminalReason>,
1721    },
1722    TurnCancelled {
1723        key: SessionSlot,
1724    },
1725    TurnError {
1726        key: SessionSlot,
1727        message: String,
1728        /// The failure's class, where the producer that built the event could
1729        /// name one. **`None` is "not classified here" rather than "not
1730        /// auth"**: a producer that never classified and an auth failure are
1731        /// different facts, and a reader that folds them together holds a
1732        /// session on `/login` for an error that was never about login.
1733        class: Option<TurnErrorClass>,
1734        terminal_reason: Option<TerminalReason>,
1735    },
1736    ChatAppended {
1737        key: SessionSlot,
1738        msg: Message,
1739        /// Who dispatched the prompt this frame writes as a user turn: the
1740        /// same [`PromptSource`] the queue's own row carries.
1741        ///
1742        /// **This is the arm a reader's follow reads**, because the words'
1743        /// shape cannot say it: forge's own machinery - an auto-continue, a
1744        /// worker kick, a board notice - forges prose as the reader's own row
1745        /// on this frame, and arming for one carries a parked reader to the
1746        /// foot for words that are nobody's (#2198). Only `you` is a person's
1747        /// send.
1748        ///
1749        /// `None` on every frame the CLI sent; the turns a delivery forges
1750        /// carry the delivery's own kind, since their envelope row is not the
1751        /// reader's either.
1752        #[serde(default)]
1753        source: Option<PromptSource>,
1754    },
1755    HookObservation {
1756        key: SessionSlot,
1757        tool_use_id: Option<String>,
1758        permission_mode: Option<String>,
1759        effort: Option<String>,
1760        agent_id: Option<String>,
1761        agent_type: Option<String>,
1762    },
1763    ForgeAccountIdentity {
1764        key: SessionSlot,
1765        display_name: String,
1766    },
1767    /// The full override set a session holds after a `/dictate` edit
1768    /// landed. Sent after every `SetDictateOverride` and
1769    /// `ResetDictateOverrides` so the dialog's markers and its reset
1770    /// row read from this, not from a view-side copy.
1771    DictateOverrides {
1772        key: SessionSlot,
1773        overrides: crate::dictate::DictateOverrides,
1774    },
1775    ContextUsageSnapshot {
1776        key: SessionSlot,
1777        percentage: Option<u8>,
1778        /// Raw model context-window size in tokens (e.g. `1_000_000`
1779        /// for Opus 1M). `None` until the upstream probe reports it.
1780        max_tokens: Option<u64>,
1781    },
1782    McpSnapshot {
1783        key: SessionSlot,
1784        servers: Vec<McpServerStatus>,
1785        error: Option<String>,
1786    },
1787    /// The seat's process tree moved, as the whole walk the core holds.
1788    ///
1789    /// **Pushed rather than read.** The walk is a `sysinfo` refresh; the
1790    /// core runs it once a second for every seat a view is showing, and this
1791    /// carries the answer to whoever is drawing it. Emitted only when the
1792    /// tree's own entries moved - a walk that found the same processes says
1793    /// nothing.
1794    ProcessesChanged {
1795        key: SessionSlot,
1796        snapshot: forge_agent::env::processes::ProcessSnapshot,
1797    },
1798    /// The seat's monitors moved, as the whole set the core holds.
1799    ///
1800    /// **Pushed rather than read.** The set changes on discrete task frames -
1801    /// a `Monitor` call entering, a task stamping its id, a frame settling
1802    /// one, the drain that empties it - and never per output chunk, so the
1803    /// frame that moved it announces it and a frame that moved nothing says
1804    /// nothing.
1805    MonitorsChanged {
1806        key: SessionSlot,
1807        monitors: Vec<forge_primitives::MonitorRecord>,
1808    },
1809    /// The seat's live task set moved, as the whole set.
1810    ///
1811    /// Both halves move it: the CLI announces its background registry whole on
1812    /// each change, and a long foreground call enters on its `task_started`
1813    /// and leaves on its `task_notification`, a terminal `task_updated`, or
1814    /// the end of the turn that ran it. So this mirrors a task entering,
1815    /// leaving or being cleared by a dead connection.
1816    BackgroundTasksChanged {
1817        key: SessionSlot,
1818        tasks: Vec<crate::BackgroundTask>,
1819    },
1820    /// The seat's working tree moved, as the row a view draws it.
1821    ///
1822    /// **Pushed rather than read.** The three fields are the record's own -
1823    /// the branch and count, the open PR on the pushed sha, and the issues it
1824    /// closes - so a client applies them to the fields it already narrows. A
1825    /// seat nobody is showing is not scanned, so this is only sent for a seat
1826    /// somebody holds.
1827    WorkChanged {
1828        key: SessionSlot,
1829        work: crate::work::WorkState,
1830        /// The tree behind the row's depth: the uncommitted files with
1831        /// their marks and counts, the branch's chain ahead of its
1832        /// default, and which branch that is.
1833        git: forge_primitives::git_diff::GitWorkView,
1834        pr: Option<forge_primitives::git::GitPrInfo>,
1835        closes: Vec<forge_primitives::git::GitIssueRef>,
1836    },
1837    /// The project's task list moved, as the whole set the core holds.
1838    ///
1839    /// **Pushed rather than read.** The `tasks__*` writes are the only movers
1840    /// and the section a view draws sits on the board's project row - keyed by
1841    /// the project's lead seat, which exists for every declared project - so
1842    /// the write announces the set rather than leaving the row to the next
1843    /// unrelated read.
1844    TasksChanged {
1845        key: SessionSlot,
1846        tasks: Vec<forge_primitives::tasks::Task>,
1847    },
1848    /// The project's schedules moved, as the whole set the core holds.
1849    ///
1850    /// **Pushed rather than read.** Three doors move it - the `cron__*`
1851    /// writes, a fire that advances or removes an entry, and a worker's
1852    /// teardown - and the section draws the countdown, so each announces the
1853    /// set it left. `key` is the project's lead seat.
1854    CronSchedulesChanged {
1855        key: SessionSlot,
1856        crons: Vec<forge_primitives::CronEntry>,
1857    },
1858    /// The project's inbound connector subscriptions moved, as the two sets
1859    /// the core holds.
1860    ///
1861    /// **Pushed rather than read.** The `gotify__*` / `slack__*` writes move
1862    /// them, and so do the doors no tool call reaches: a mention's
1863    /// auto-subscribe, a watched conversation's name healing on its first
1864    /// message, and a worker's teardown taking its own with it. One update
1865    /// carries both connectors because they are one section of the board's
1866    /// project row, and a reader that patches it needs the pair. `key` is the
1867    /// project's lead seat.
1868    ConnectorSubscriptionsChanged {
1869        key: SessionSlot,
1870        gotify: Vec<forge_primitives::GotifySubscription>,
1871        slack: Vec<forge_primitives::slack::SlackSubscription>,
1872    },
1873    /// The `/` menu's catalogue moved, as the whole list the core holds.
1874    ///
1875    /// **Pushed rather than read.** The CLI advertises it on a turn's init and
1876    /// re-sends it when a plugin changes it (`commands_changed`), so the frame
1877    /// that moved it announces it and a turn's init repeating the same list
1878    /// says nothing.
1879    SlashCommandsChanged {
1880        key: SessionSlot,
1881        commands: Vec<forge_primitives::runtime::AvailableCommand>,
1882    },
1883    /// The agent-type catalogue moved, as the whole list the core holds.
1884    ///
1885    /// **Pushed rather than read.** The CLI advertises it on a turn's init,
1886    /// once per turn, so this is that one frame - and an init that repeats the
1887    /// list the last turn advertised says nothing.
1888    SubagentsChanged {
1889        key: SessionSlot,
1890        subagents: Vec<forge_primitives::runtime::AvailableAgent>,
1891    },
1892    /// The session's sub-agent instances moved, as the whole list the core
1893    /// folds from the frames it has seen.
1894    ///
1895    /// **Pushed rather than read.** The join lives in
1896    /// `forge_workspace::subagent_cards` and folds a frame at a time, so a
1897    /// live session announces the list it now holds rather than re-walking
1898    /// its conversation per frame. A client draws the instances from this -
1899    /// the raw frames stay on the conversation for whoever folds something
1900    /// else - and a page attaching later reads the same list off the record.
1901    SubagentCardsChanged {
1902        key: SessionSlot,
1903        cards: Vec<forge_primitives::runtime::SubagentCard>,
1904    },
1905    /// The tree's file index moved, as the whole index the core holds.
1906    ///
1907    /// **Pushed rather than read, and throttled here rather than by the
1908    /// socket.** The socket's batch folds only consecutive token appends, so
1909    /// a burst of index frames would go out one per change: the walk runs at
1910    /// most once per poke while the watch says the tree is moving - so a
1911    /// build's writes inside one poke become one walk - and once per
1912    /// `INDEX_STALENESS` when it is still. The frame goes out only when the
1913    /// index actually differs from the one last announced.
1914    FileIndexChanged {
1915        key: SessionSlot,
1916        index: std::sync::Arc<crate::file_index::FileIndex>,
1917    },
1918    ServiceStatus {
1919        severity: ServiceSeverity,
1920        message: String,
1921    },
1922    /// The background catalog scan finished and `Workspace`'s project
1923    /// catalog is populated. A view reads the roster itself, so nothing
1924    /// needs carrying: the event exists to wake the render loop so
1925    /// session counts appear when the scan lands rather than on the next
1926    /// unrelated frame.
1927    CatalogLoaded,
1928    /// A claude version probe landed and moved what the views draw. The
1929    /// snapshot itself is not carried, so this is only the wake-up: a view
1930    /// re-reads through the surface's `cli_version` verb.
1931    CliVersionChanged,
1932    /// The account pool moved: an account's loading state, its last probe
1933    /// failure, its cached usage snapshot, or the gateway listener's
1934    /// readiness.
1935    ///
1936    /// **The frame carries the pool's drawn state**, flattened so its fields
1937    /// are the ones `HomeWire::accounts` states - the same values, built by
1938    /// [`crate::Workspace::account_pool_view`], so a client that folds the frame
1939    /// and a client that reads the snapshot cannot disagree (rule 25). That
1940    /// is what lets a usage move reach the badge without a home read.
1941    ///
1942    /// It carries no seat because the pool belongs to none, which is also
1943    /// what routes it to a home subscriber and to nobody else.
1944    AccountsChanged {
1945        #[serde(flatten)]
1946        accounts: crate::views::AccountPoolView,
1947    },
1948    PluginsInventoryUpdated {
1949        cwd_raw: String,
1950        snapshot: PluginsInventorySnapshot,
1951        claude_path: PathBuf,
1952    },
1953    PluginsInventoryRefreshFailed {
1954        cwd_raw: String,
1955        message: String,
1956        /// Whether the failed refresh served a boot auto-update run -
1957        /// app-scoped, so its failure bypasses the cwd gate.
1958        trigger: PluginUpdateTrigger,
1959    },
1960    PluginsCliActionSucceeded {
1961        cwd_raw: String,
1962        result: PluginsCliActionSuccess,
1963        /// The dispatch this outcome answers, when the press that made it
1964        /// named one - `None` for an internal dispatch no press owns.
1965        dispatch_id: Option<u64>,
1966    },
1967    PluginsCliActionFailed {
1968        cwd_raw: String,
1969        message: String,
1970        /// As on the succeeded frame above: the dispatch this failure
1971        /// answers, when the press that made it named one.
1972        dispatch_id: Option<u64>,
1973    },
1974    /// A section-level update run or check moved: one or more rows
1975    /// changed state. Carries the whole run so the pane replaces its
1976    /// copy wholesale.
1977    PluginsUpdateRunProgress {
1978        cwd_raw: String,
1979        run: PluginUpdateRun,
1980    },
1981    /// The run (or check) is over. The record batch is persisted by
1982    /// the run task itself; this event is reporting only.
1983    PluginsUpdateRunFinished {
1984        cwd_raw: String,
1985        run: PluginUpdateRun,
1986        snapshot: Option<PluginsInventorySnapshot>,
1987        claude_path: Option<PathBuf>,
1988    },
1989    PluginsRollbackSucceeded {
1990        cwd_raw: String,
1991        plugin_id: String,
1992        scope: String,
1993        message: String,
1994        snapshot: PluginsInventorySnapshot,
1995        claude_path: PathBuf,
1996    },
1997    PluginsRollbackFailed {
1998        cwd_raw: String,
1999        plugin_id: String,
2000        message: String,
2001        /// The refreshed inventory when the rollback ran but did not
2002        /// verify, so the pane still reflects the real state.
2003        snapshot: Option<PluginsInventorySnapshot>,
2004    },
2005    /// Workspace pushed a change to `live_workers[project_key]`. A
2006    /// view's reducer updates the projects pane's tree-children based
2007    /// on `action`. `status` is the snapshot at the moment of the
2008    /// change (relevant for Added and StatusChanged; ignored for
2009    /// Removed but carried for symmetry). `worktree` is what has
2010    /// become of the worker's worktree, read on `Removed` events
2011    /// (after which the `WorkerEntry` is gone from `live_workers`, so a
2012    /// lookup-by-label would fail).
2013    WorkerStatusChanged {
2014        project_key: crate::ProjectKey,
2015        action: WorkerStatusAction,
2016        status: forge_primitives::WorkerStatus,
2017        worktree: WorktreeDisposition,
2018    },
2019    /// A peer-coordination envelope arrived at `key`.
2020    /// Carries the typed `WrappedPrompt` so a view's reducer can build
2021    /// the chat-side echo from real fields instead of having the
2022    /// workspace forge a `Message::User` carrying prose for a view to
2023    /// re-parse (audit I11). The recipient's LLM still receives the
2024    /// prose via a separate `Command::Prompt` dispatch - that's the
2025    /// CLI's input channel and stays text-shaped.
2026    PeerEnvelopeAppended {
2027        key: SessionSlot,
2028        wrapped: crate::mcp::peers::types::WrappedPrompt,
2029        /// The id the delivered prompt was dispatched under, which the
2030        /// drawn row carries: a view holds the row while that prompt waits in
2031        /// the pile and draws it when it starts.
2032        uuid: String,
2033    },
2034    /// A matched Gotify notification arrived at `key`.
2035    /// Carries the typed `GotifyNotification` so a view's reducer builds
2036    /// the chat-side echo from real fields (mirrors PeerEnvelopeAppended).
2037    /// The session's LLM receives the same prose via a separate
2038    /// `Command::Prompt` - this update only drives the visible echo.
2039    GotifyNotificationAppended {
2040        key: SessionSlot,
2041        notification: crate::mcp::gotify::types::GotifyNotification,
2042        /// The id the delivered prompt was dispatched under, which the drawn
2043        /// row carries - see `PeerEnvelopeAppended`.
2044        uuid: String,
2045    },
2046    /// A due cron fired into `key`. Carries the fired
2047    /// prompt text so a view's reducer builds the chat-side echo (mirrors
2048    /// GotifyNotificationAppended). The session's LLM receives the same
2049    /// text via a separate `Command::Prompt` - this update only drives
2050    /// the visible echo.
2051    CronPromptAppended {
2052        key: SessionSlot,
2053        text: String,
2054        /// The id the delivered prompt was dispatched under, which the drawn
2055        /// row carries - see `PeerEnvelopeAppended`.
2056        uuid: String,
2057        /// The cron entry that fired, so a view can name the schedule the
2058        /// prompt came from - the entry is gone from the store by the time
2059        /// a run-once fire is drawn.
2060        cron_id: String,
2061        /// The entry's human summary; `None` when none was registered, and
2062        /// the row falls back to the prompt's first line.
2063        description: Option<String>,
2064    },
2065    /// A matched Slack message arrived at `key`. Carries the
2066    /// prose rather than the typed message: the prose builder is `pub(crate)`
2067    /// to forge-workspace, so a typed message would force a view to rebuild
2068    /// the very format it parses. The session's LLM receives the same prose
2069    /// via a separate `Command::Prompt` - this update only drives the visible
2070    /// echo (mirrors GotifyNotificationAppended).
2071    SlackMessageAppended {
2072        key: SessionSlot,
2073        prose: String,
2074        /// The id the delivered prompt was dispatched under, which the drawn
2075        /// row carries - see `PeerEnvelopeAppended`.
2076        uuid: String,
2077    },
2078    /// A composed Slack message is waiting for the user's decision in the
2079    /// dock prompt. The authoring tool handler is blocked on a oneshot
2080    /// until `Command::RespondSlackPost` answers it, so nothing posts
2081    /// while this is outstanding.
2082    SlackPostPending {
2083        key: SessionSlot,
2084        draft: forge_primitives::slack::SlackDraft,
2085    },
2086    /// A held Slack draft left the core's registry: answered in some view,
2087    /// or its asking session gone. Each view keeps its own copy of the
2088    /// parked draft, so this is the only update that clears it - and
2089    /// `ending` is what a view that did not answer it says happened.
2090    SlackDraftResolved {
2091        key: SessionSlot,
2092        id: Uuid,
2093        ending: forge_primitives::slack::SlackDraftEnding,
2094    },
2095    /// A browser hand-off waits for the person at a client: the session asked
2096    /// them to act in a browser tab, and its tool handler is blocked on a
2097    /// oneshot until `Command::RespondBrowserHandOff` answers it.
2098    ///
2099    /// **No timeout rides this one.** The ask dock's own timelines say a held
2100    /// prompt sits for hours, and the session is meant to wait as long as it
2101    /// takes; the handler's drop, not a clock, is what ends the wait.
2102    BrowserHandOffPending {
2103        key: SessionSlot,
2104        handoff: forge_primitives::browser::HandOff,
2105    },
2106    /// A browser hand-off left the core's registry: answered in some view, or
2107    /// its asking session gone. `answer` is the ending, the door that sent it
2108    /// and the connection that carried it - what a view that did not answer it
2109    /// reads, and what the log names.
2110    BrowserHandOffResolved {
2111        key: SessionSlot,
2112        id: Uuid,
2113        answer: forge_primitives::browser::HandOffAnswer,
2114    },
2115    /// A terminal hand-off waits for the person at a client: the session asked
2116    /// them to type into the seat's terminal, and its tool handler is blocked
2117    /// on a oneshot until `Command::RespondTerminalHandOff` answers it.
2118    ///
2119    /// **No timeout rides this one**, and the agent is blind while it is
2120    /// parked: `terminal__read` and `terminal__write` are refused.
2121    TerminalHandOffPending {
2122        key: SessionSlot,
2123        handoff: forge_primitives::terminal::TerminalHandOff,
2124    },
2125    /// A terminal hand-off left the core's registry: answered in some view, or
2126    /// its asking session gone. `answer` is the ending, the door that sent it
2127    /// and the connection that carried it.
2128    TerminalHandOffResolved {
2129        key: SessionSlot,
2130        id: Uuid,
2131        answer: forge_primitives::terminal::TerminalHandOffAnswer,
2132    },
2133    /// A seat's terminal opened, changed or closed, as the strip and the
2134    /// session record read it. `None` means the terminal is gone.
2135    TerminalChanged {
2136        key: SessionSlot,
2137        terminal: Option<forge_primitives::terminal::TerminalSummary>,
2138    },
2139    /// A prompt entered the CLI's queue: the row a view draws as waiting,
2140    /// with the words and the sender the lifecycle frames do not carry.
2141    ///
2142    /// Emitted where the prompt is dispatched, keyed by the uuid it was sent
2143    /// under - so a view keys its own optimistic row to this one by id rather
2144    /// than by order or text.
2145    PromptQueued {
2146        key: SessionSlot,
2147        uuid: String,
2148        source: PromptSource,
2149        text: String,
2150    },
2151    /// Where one prompt is in the CLI's queue, as the CLI itself reports it:
2152    /// `queued`, `started`, `completed` and the terminal states
2153    /// (`cancelled`, `discarded`, `refused`). Keyed by the uuid the prompt was
2154    /// sent under, which is what lets a view key its queued row to its own
2155    /// send by id rather than by order or text.
2156    ///
2157    /// `state` stays a free-form string so a state this build has not seen
2158    /// reaches a view rather than being dropped.
2159    PromptLifecycle {
2160        key: SessionSlot,
2161        uuid: String,
2162        state: String,
2163    },
2164    /// The CLI answered a `cancel_async_message`: `cancelled: false` means the
2165    /// prompt was already dequeued for execution, so a reader that asked to
2166    /// drop it has to say it is on its way instead.
2167    PromptCancelResolved {
2168        key: SessionSlot,
2169        uuid: String,
2170        cancelled: bool,
2171    },
2172    /// A worker's review turn addressed review comments; `key` is the
2173    /// session that authored the review (the submit origin). A view drops
2174    /// `message` as a system line into that session's chat so the reviewer
2175    /// sees the batched tally, and parks `waiting` - how many threads on
2176    /// `branch` now await a reviewer turn - as the persistent signal both
2177    /// the Inspector GIT badge and the NEEDS ATTENTION band read.
2178    ReviewActivityNotice {
2179        key: SessionSlot,
2180        branch: String,
2181        waiting: usize,
2182        message: String,
2183    },
2184    /// Dictation models are loaded and the composer may offer to
2185    /// dictate. App-global (no key): the engine is process-wide and
2186    /// every session's composer shares the availability; the event's
2187    /// existence is the signal. Never emitted when `[dictate]` is
2188    /// disabled, so sessions that cannot dictate render nothing.
2189    DictateAvailability,
2190    /// A recording started for the composer at `key`. `floor_db` is the
2191    /// silence floor the level meter maps onto its zero glyph, so the
2192    /// bar and the `NoAudio` verdict agree by construction. `generation`
2193    /// identifies this take among the key's takes: a resolver that
2194    /// arrives after a newer take started carries a stale one, and the
2195    /// composer resets on its own generation only.
2196    ///
2197    /// `initiator` names the connection the take belongs to, as its
2198    /// `dictate_stream` carried it: every `Dictate*` update about a take
2199    /// goes to that connection alone, so no other subscriber draws its
2200    /// meter, its phases or its words. `None` is this process's own
2201    /// take.
2202    DictateStarted {
2203        key: SessionSlot,
2204        floor_db: f32,
2205        generation: u64,
2206        #[serde(skip)]
2207        initiator: Option<u64>,
2208    },
2209    /// One level reading for the recording at `key`: the peak over the
2210    /// window since the previous reading, in dBFS. Emitted on the
2211    /// meter clock, not the repaint clock.
2212    ///
2213    /// **A window with no signal reads as `null` on the wire, and `null`
2214    /// reads back as negative infinity.** A peak that heard nothing is
2215    /// exactly zero, whose dBFS is negative infinity, and serde writes a
2216    /// non-finite float as `null` - so a reader that takes the field as an
2217    /// `f32` cannot decode the message at all, which is the state this
2218    /// tolerance exists to close. Nothing about the value a view draws
2219    /// changes: the client narrows `null` itself, and this only lets a
2220    /// Rust reader do the same.
2221    DictateLevel {
2222        key: SessionSlot,
2223        #[serde(default, deserialize_with = "no_signal_reads_as_negative_infinity")]
2224        peak_db: f32,
2225        /// The take's own, as [`SessionUpdate::DictateStarted`] announced it:
2226        /// a reading that arrives after a newer take started is the older
2227        /// one's and belongs in no live meter.
2228        generation: u64,
2229        /// The take's own, as [`SessionUpdate::DictateStarted`] announced
2230        /// it.
2231        #[serde(skip)]
2232        initiator: Option<u64>,
2233    },
2234    /// The take from `key` was submitted and a transcript is in flight.
2235    DictateTranscribing {
2236        key: SessionSlot,
2237        /// The take's own, as [`SessionUpdate::DictateStarted`] announced it:
2238        /// a stale one must not move a newer take to `transcribing` while its
2239        /// microphone still records.
2240        generation: u64,
2241        /// The take's own, as [`SessionUpdate::DictateStarted`] announced
2242        /// it.
2243        #[serde(skip)]
2244        initiator: Option<u64>,
2245    },
2246    /// A take from `key` has settled `done` segments of its
2247    /// transcription. `total` is `None` while the recording is still
2248    /// open, because a live take cannot know how many segments it will
2249    /// produce, and the final count once the take was stopped and its
2250    /// tail submitted. Single-segment takes report at stop only; a
2251    /// composer renders only what it wants to. `generation` is the
2252    /// take's own, as handed out by [`SessionUpdate::DictateStarted`].
2253    DictateProgress {
2254        key: SessionSlot,
2255        generation: u64,
2256        done: usize,
2257        total: Option<usize>,
2258        /// The take's own, as [`SessionUpdate::DictateStarted`] announced
2259        /// it.
2260        #[serde(skip)]
2261        initiator: Option<u64>,
2262    },
2263    /// A take from `key` is done: insert, notice or reset per
2264    /// [`DictateOutcome`]. `generation` is the take's own, as handed
2265    /// out by [`SessionUpdate::DictateStarted`].
2266    DictateEnded {
2267        key: SessionSlot,
2268        outcome: DictateOutcome,
2269        generation: u64,
2270        /// The surface whose own capture began the take, as
2271        /// [`Command::DictateStream`] carried it: the landed words are that
2272        /// surface's to place, and no other's. `None` for a take
2273        /// begun in process or by a client older than the field.
2274        #[serde(default)]
2275        surface: Option<String>,
2276        /// The take's own - and for a refusal, the connection that was
2277        /// refused, so only it learns why. `None` is this process's own.
2278        #[serde(skip)]
2279        initiator: Option<u64>,
2280    },
2281    /// The core's fatal: the app is going away. `message` is what a
2282    /// view draws on exit - `AppError::user_message()` - so a view that
2283    /// draws the line words it the same way without a second copy.
2284    FatalError {
2285        error: AppError,
2286        message: String,
2287    },
2288    /// The catalogue check landed: the models page's whole view, re-read.
2289    /// Carries no slot - the feed belongs to the machine, not a seat - and
2290    /// a session page ignores it. Sentinel rows the page draws (the
2291    /// check's freshness, every candidate row, the per-model updates) are
2292    /// all here, so a subscriber that was already attached never has to
2293    /// ask again for what this frame just changed.
2294    DictateModelsChanged {
2295        models: crate::catalogue::DictateModelsSnapshot,
2296    },
2297    /// A session handed the reader a document: the ask a client draws -
2298    /// **open** or **not now** - with the resolved path it opens at.
2299    ///
2300    /// **The call parks on this**, like the browser hand-off: `id` names the
2301    /// parked call, and the reader's answer releases it - `NotNow` here, or
2302    /// `Approve` / `Feedback` when the viewer's finish files the reading.
2303    /// `path` is the
2304    /// resolved absolute path, the viewer's stable address; `reviewable` is
2305    /// what the hand-off asked for - the ask's own fact rather than the
2306    /// document's, because a readable document offers its review either way.
2307    DocumentHandOffPending {
2308        key: SessionSlot,
2309        id: Uuid,
2310        path: String,
2311        reviewable: bool,
2312    },
2313    /// A document hand-off left the core's registry: finished in some view,
2314    /// declined, or its asking session gone. `ending` is what a view that
2315    /// did not answer it says happened.
2316    DocumentHandOffResolved {
2317        key: SessionSlot,
2318        id: Uuid,
2319        ending: forge_primitives::document::DocumentHandOffEnding,
2320    },
2321    /// A configuration write waits for the reader's approval in the dock:
2322    /// the session's `settings__set` / `settings__unset` handler is blocked
2323    /// on a oneshot until `Command::RespondSettingsWrite` answers it, so
2324    /// nothing reaches `forge.toml` while this is outstanding.
2325    SettingsWritePending {
2326        key: SessionSlot,
2327        ask: forge_primitives::settings::SettingsWriteAsk,
2328    },
2329    /// A held configuration write left the core's registry: answered in some
2330    /// view, or its asking session gone. `ending` is what a view that did
2331    /// not answer it says happened.
2332    SettingsWriteResolved {
2333        key: SessionSlot,
2334        id: Uuid,
2335        ending: forge_primitives::settings::SettingsWriteEnding,
2336    },
2337    /// A vault call waits for the reader's approval in the dock: the family's
2338    /// tool handler is blocked on a oneshot until `Command::RespondVaultAsk`
2339    /// answers it, so no operation runs in the vault while this is outstanding.
2340    /// `needs_unlock` on the draft is stamped from the live session as the
2341    /// card is built, so a sibling that unlocked first re-bases this one.
2342    VaultAskPending {
2343        key: SessionSlot,
2344        draft: forge_primitives::vault::VaultDraft,
2345    },
2346    /// A held vault call left the core's registry: answered in some view, or
2347    /// its asking session gone. `ending` is what a view that did not answer
2348    /// it says happened; the tool result carries the operation's own outcome.
2349    VaultAskResolved {
2350        key: SessionSlot,
2351        id: Uuid,
2352        ending: forge_primitives::vault::VaultAskEnding,
2353    },
2354    /// The device enrolments moved, as the whole set the core holds. Pushed
2355    /// rather than read: an enrol, a release's stamp and a revoke each leave a
2356    /// set the settings page draws, and the card reads the same set - so a
2357    /// second client redraws without asking. No wrapped blob rides here; see
2358    /// [`forge_primitives::biometric::BiometricEnrolment`].
2359    BiometricEnrolmentsChanged {
2360        enrolments: Vec<forge_primitives::biometric::BiometricEnrolment>,
2361    },
2362    /// The shared vault session rotated its refresh token, so the copy an
2363    /// enrolment blob holds is spent. A client that holds the material from
2364    /// its last release rewrites its blob with this one (decision 15); one
2365    /// that cannot falls back to the password on its next start.
2366    ///
2367    /// **Slot-less**: the session is process-wide, and only the client that
2368    /// owns the enrolment acts on it. The token is a secret on the socket -
2369    /// it never reaches a transcript or the live fold, and the manual `Debug`
2370    /// redacts it.
2371    VaultRotated {
2372        refresh_token: String,
2373    },
2374    /// The shared vault session locked or unlocked outside a held card - the
2375    /// idle bound passed, a refresh or a decryption failed, or an unlock
2376    /// landed. Slot-less like the rotation: the session is process-wide, and
2377    /// the settings page's vault card is what redraws.
2378    VaultLockChanged {
2379        locked: bool,
2380    },
2381    /// One rendered clip of the seat's speech, mono 16-bit samples at
2382    /// `sample_rate`. Chunks arrive in `seq` order and every client with
2383    /// voice on for the seat plays them; a client with voice off receives
2384    /// them and stays silent.
2385    VoiceAudio {
2386        key: SessionSlot,
2387        seq: u32,
2388        sample_rate: u32,
2389        pcm: Vec<i16>,
2390    },
2391    /// The seat's voice state: how many clients are listening, which is what
2392    /// the control draws and what a listener note is injected on.
2393    VoiceModeState {
2394        key: SessionSlot,
2395        listeners: u32,
2396    },
2397}
2398
2399impl SessionUpdate {
2400    /// The [`SessionSlot`] this update routes to, or `None` for
2401    /// updates that target App-level state (`ServiceStatus`, catalog,
2402    /// plugin, fatal-error). Every identity-carrying variant names a
2403    /// slot, so nothing is synthesized here.
2404    pub fn slot(&self) -> Option<&SessionSlot> {
2405        match self {
2406            Self::Spawning { key, .. }
2407            | Self::Connected { key, .. }
2408            | Self::CallOutput { key, .. }
2409            | Self::SessionReplaced { key, .. }
2410            | Self::Handoff { key, .. }
2411            | Self::MachineTurn { key, .. }
2412            | Self::ConnectionFailed { key, .. }
2413            | Self::Releasing { key }
2414            | Self::Notice { key, .. }
2415            | Self::SetModeFailed { key, .. }
2416            | Self::SetModelFailed { key, .. }
2417            | Self::PermissionRequest { key, .. }
2418            | Self::QuestionRequest { key, .. }
2419            | Self::PendingInteractionResolved { key, .. }
2420            | Self::McpOperationError { key, .. }
2421            | Self::TurnComplete { key, .. }
2422            | Self::TurnCancelled { key }
2423            | Self::TurnError { key, .. }
2424            | Self::ForgeAccountIdentity { key, .. }
2425            | Self::DictateOverrides { key, .. }
2426            | Self::ReviewActivityNotice { key, .. }
2427            | Self::DictateStarted { key, .. }
2428            | Self::DictateLevel { key, .. }
2429            | Self::DictateTranscribing { key, .. }
2430            | Self::DictateProgress { key, .. }
2431            | Self::PromptQueued { key, .. }
2432            | Self::PromptLifecycle { key, .. }
2433            | Self::PromptCancelResolved { key, .. }
2434            | Self::DictateEnded { key, .. }
2435            | Self::SlackPostPending { key, .. }
2436            | Self::SlackDraftResolved { key, .. }
2437            | Self::BrowserHandOffPending { key, .. }
2438            | Self::BrowserHandOffResolved { key, .. }
2439            | Self::TerminalHandOffPending { key, .. }
2440            | Self::TerminalHandOffResolved { key, .. }
2441            | Self::TerminalChanged { key, .. }
2442            | Self::DocumentHandOffPending { key, .. }
2443            | Self::DocumentHandOffResolved { key, .. }
2444            | Self::SettingsWritePending { key, .. }
2445            | Self::SettingsWriteResolved { key, .. }
2446            | Self::VaultAskPending { key, .. }
2447            | Self::VaultAskResolved { key, .. }
2448            | Self::VoiceAudio { key, .. }
2449            | Self::VoiceModeState { key, .. }
2450            | Self::ChatAppended { key, .. }
2451            | Self::HookObservation { key, .. }
2452            | Self::ContextUsageSnapshot { key, .. }
2453            | Self::McpSnapshot { key, .. }
2454            | Self::WorkChanged { key, .. }
2455            | Self::TasksChanged { key, .. }
2456            | Self::CronSchedulesChanged { key, .. }
2457            | Self::ConnectorSubscriptionsChanged { key, .. }
2458            | Self::SlashCommandsChanged { key, .. }
2459            | Self::SubagentsChanged { key, .. }
2460            | Self::SubagentCardsChanged { key, .. }
2461            | Self::FileIndexChanged { key, .. }
2462            | Self::ProcessesChanged { key, .. }
2463            | Self::MonitorsChanged { key, .. }
2464            | Self::BackgroundTasksChanged { key, .. }
2465            | Self::PeerEnvelopeAppended { key, .. }
2466            | Self::GotifyNotificationAppended { key, .. }
2467            | Self::CronPromptAppended { key, .. }
2468            | Self::SlackMessageAppended { key, .. } => Some(key),
2469            Self::ServiceStatus { .. }
2470            | Self::CatalogLoaded
2471            | Self::CliVersionChanged
2472            | Self::AccountsChanged { .. }
2473            | Self::PluginsInventoryUpdated { .. }
2474            | Self::PluginsInventoryRefreshFailed { .. }
2475            | Self::PluginsCliActionSucceeded { .. }
2476            | Self::PluginsCliActionFailed { .. }
2477            | Self::PluginsUpdateRunProgress { .. }
2478            | Self::PluginsUpdateRunFinished { .. }
2479            | Self::PluginsRollbackSucceeded { .. }
2480            | Self::PluginsRollbackFailed { .. }
2481            | Self::WorkerStatusChanged { .. }
2482            | Self::DictateAvailability { .. }
2483            | Self::FatalError { .. }
2484            | Self::DictateModelsChanged { .. }
2485            | Self::BiometricEnrolmentsChanged { .. }
2486            | Self::VaultRotated { .. }
2487            | Self::VaultLockChanged { .. } => None,
2488        }
2489    }
2490}
2491
2492impl std::fmt::Debug for SessionUpdate {
2493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2494        // Custom Debug - discriminant + key/session_id only.
2495        // Payloads can be large or non-Debug-friendly; the
2496        // discriminant + routing key is sufficient for trace logs.
2497        match self {
2498            Self::Spawning { key, project_name, .. } => f
2499                .debug_struct("Spawning")
2500                .field("key", key)
2501                .field("project_name", project_name)
2502                .finish_non_exhaustive(),
2503            Self::Connected { key, .. } => {
2504                f.debug_struct("Connected").field("key", key).finish_non_exhaustive()
2505            }
2506            Self::CallOutput { key, call_id, .. } => f
2507                .debug_struct("CallOutput")
2508                .field("key", key)
2509                .field("call_id", call_id)
2510                .finish_non_exhaustive(),
2511            Self::SessionReplaced { key, .. } => {
2512                f.debug_struct("SessionReplaced").field("key", key).finish_non_exhaustive()
2513            }
2514            Self::Handoff { key, handoff } => f
2515                .debug_struct("Handoff")
2516                .field("key", key)
2517                .field("pair", &handoff.pair())
2518                .field("reason", &handoff.reason)
2519                .finish_non_exhaustive(),
2520            Self::MachineTurn { key, machine } => f
2521                .debug_struct("MachineTurn")
2522                .field("key", key)
2523                .field("machine", machine)
2524                .finish_non_exhaustive(),
2525            Self::ConnectionFailed { key, .. } => {
2526                f.debug_struct("ConnectionFailed").field("key", key).finish_non_exhaustive()
2527            }
2528            Self::Releasing { key } => {
2529                f.debug_struct("Releasing").field("key", key).finish_non_exhaustive()
2530            }
2531            Self::Notice { key, .. } => {
2532                f.debug_struct("Notice").field("key", key).finish_non_exhaustive()
2533            }
2534            Self::SetModeFailed { key, .. } => {
2535                f.debug_struct("SetModeFailed").field("key", key).finish_non_exhaustive()
2536            }
2537            Self::SetModelFailed { key, .. } => {
2538                f.debug_struct("SetModelFailed").field("key", key).finish_non_exhaustive()
2539            }
2540            Self::PermissionRequest { key, tool_id, .. } => f
2541                .debug_struct("PermissionRequest")
2542                .field("key", key)
2543                .field("tool_id", tool_id)
2544                .finish_non_exhaustive(),
2545            Self::QuestionRequest { key, tool_id, .. } => f
2546                .debug_struct("QuestionRequest")
2547                .field("key", key)
2548                .field("tool_id", tool_id)
2549                .finish_non_exhaustive(),
2550            Self::PendingInteractionResolved { key, tool_id, question_index, answered_with } => f
2551                .debug_struct("PendingInteractionResolved")
2552                .field("key", key)
2553                .field("tool_id", tool_id)
2554                .field("answered_with", answered_with)
2555                .field("question_index", question_index)
2556                .finish(),
2557            Self::McpOperationError { key, .. } => {
2558                f.debug_struct("McpOperationError").field("key", key).finish_non_exhaustive()
2559            }
2560            Self::TurnComplete { key, .. } => {
2561                f.debug_struct("TurnComplete").field("key", key).finish_non_exhaustive()
2562            }
2563            Self::TurnCancelled { key } => {
2564                f.debug_struct("TurnCancelled").field("key", key).finish()
2565            }
2566            Self::TurnError { key, .. } => {
2567                f.debug_struct("TurnError").field("key", key).finish_non_exhaustive()
2568            }
2569            Self::ChatAppended { key, .. } => {
2570                f.debug_struct("ChatAppended").field("key", key).finish_non_exhaustive()
2571            }
2572            Self::HookObservation { key, .. } => {
2573                f.debug_struct("HookObservation").field("key", key).finish_non_exhaustive()
2574            }
2575            Self::ForgeAccountIdentity { key, .. } => {
2576                f.debug_struct("ForgeAccountIdentity").field("key", key).finish_non_exhaustive()
2577            }
2578            Self::DictateOverrides { key, .. } => {
2579                f.debug_struct("DictateOverrides").field("key", key).finish_non_exhaustive()
2580            }
2581            Self::ContextUsageSnapshot { key, .. } => {
2582                f.debug_struct("ContextUsageSnapshot").field("key", key).finish_non_exhaustive()
2583            }
2584            Self::McpSnapshot { key, .. } => {
2585                f.debug_struct("McpSnapshot").field("key", key).finish_non_exhaustive()
2586            }
2587            Self::WorkChanged { key, .. } => {
2588                f.debug_struct("WorkChanged").field("key", key).finish_non_exhaustive()
2589            }
2590            Self::TasksChanged { key, tasks } => f
2591                .debug_struct("TasksChanged")
2592                .field("key", key)
2593                .field("count", &tasks.len())
2594                .finish(),
2595            Self::CronSchedulesChanged { key, crons } => f
2596                .debug_struct("CronSchedulesChanged")
2597                .field("key", key)
2598                .field("count", &crons.len())
2599                .finish(),
2600            Self::ConnectorSubscriptionsChanged { key, gotify, slack } => f
2601                .debug_struct("ConnectorSubscriptionsChanged")
2602                .field("key", key)
2603                .field("gotify", &gotify.len())
2604                .field("slack", &slack.len())
2605                .finish(),
2606            Self::SlashCommandsChanged { key, commands } => f
2607                .debug_struct("SlashCommandsChanged")
2608                .field("key", key)
2609                .field("count", &commands.len())
2610                .finish(),
2611            Self::SubagentsChanged { key, subagents } => f
2612                .debug_struct("SubagentsChanged")
2613                .field("key", key)
2614                .field("count", &subagents.len())
2615                .finish(),
2616            Self::SubagentCardsChanged { key, cards } => f
2617                .debug_struct("SubagentCardsChanged")
2618                .field("key", key)
2619                .field("count", &cards.len())
2620                .finish(),
2621            Self::FileIndexChanged { key, index } => f
2622                .debug_struct("FileIndexChanged")
2623                .field("key", key)
2624                .field("count", &index.entries.len())
2625                .finish(),
2626            Self::ProcessesChanged { key, snapshot } => f
2627                .debug_struct("ProcessesChanged")
2628                .field("key", key)
2629                .field("count", &snapshot.processes.len())
2630                .finish(),
2631            Self::MonitorsChanged { key, monitors } => f
2632                .debug_struct("MonitorsChanged")
2633                .field("key", key)
2634                .field("count", &monitors.len())
2635                .finish(),
2636            Self::BackgroundTasksChanged { key, tasks } => f
2637                .debug_struct("BackgroundTasksChanged")
2638                .field("key", key)
2639                .field("count", &tasks.len())
2640                .finish(),
2641            Self::ServiceStatus { .. } => f.debug_struct("ServiceStatus").finish_non_exhaustive(),
2642            Self::PluginsInventoryUpdated { cwd_raw, .. } => f
2643                .debug_struct("PluginsInventoryUpdated")
2644                .field("cwd_raw", cwd_raw)
2645                .finish_non_exhaustive(),
2646            Self::PluginsInventoryRefreshFailed { cwd_raw, .. } => f
2647                .debug_struct("PluginsInventoryRefreshFailed")
2648                .field("cwd_raw", cwd_raw)
2649                .finish_non_exhaustive(),
2650            Self::PluginsCliActionSucceeded { cwd_raw, .. } => f
2651                .debug_struct("PluginsCliActionSucceeded")
2652                .field("cwd_raw", cwd_raw)
2653                .finish_non_exhaustive(),
2654            Self::PluginsCliActionFailed { cwd_raw, .. } => f
2655                .debug_struct("PluginsCliActionFailed")
2656                .field("cwd_raw", cwd_raw)
2657                .finish_non_exhaustive(),
2658            Self::PluginsUpdateRunProgress { cwd_raw, run } => f
2659                .debug_struct("PluginsUpdateRunProgress")
2660                .field("cwd_raw", cwd_raw)
2661                .field("rows", &run.rows.len())
2662                .finish(),
2663            Self::PluginsUpdateRunFinished { cwd_raw, run, .. } => f
2664                .debug_struct("PluginsUpdateRunFinished")
2665                .field("cwd_raw", cwd_raw)
2666                .field("rows", &run.rows.len())
2667                .finish(),
2668            Self::PluginsRollbackSucceeded { cwd_raw, plugin_id, .. } => f
2669                .debug_struct("PluginsRollbackSucceeded")
2670                .field("cwd_raw", cwd_raw)
2671                .field("plugin_id", plugin_id)
2672                .finish_non_exhaustive(),
2673            Self::PluginsRollbackFailed { cwd_raw, plugin_id, .. } => f
2674                .debug_struct("PluginsRollbackFailed")
2675                .field("cwd_raw", cwd_raw)
2676                .field("plugin_id", plugin_id)
2677                .finish_non_exhaustive(),
2678            Self::WorkerStatusChanged { project_key, action, status, worktree } => f
2679                .debug_struct("WorkerStatusChanged")
2680                .field("project_key", project_key)
2681                .field("action", action)
2682                .field("label", &status.label)
2683                .field("worktree", worktree)
2684                .finish_non_exhaustive(),
2685            Self::PeerEnvelopeAppended { key, wrapped, .. } => f
2686                .debug_struct("PeerEnvelopeAppended")
2687                .field("key", key)
2688                .field("id", &wrapped.id)
2689                .field("kind", &wrapped.kind)
2690                .finish_non_exhaustive(),
2691            Self::GotifyNotificationAppended { key, notification, .. } => f
2692                .debug_struct("GotifyNotificationAppended")
2693                .field("key", key)
2694                .field("app", &notification.app)
2695                .field("priority", &notification.priority)
2696                .finish_non_exhaustive(),
2697            Self::CronPromptAppended { key, .. } => {
2698                f.debug_struct("CronPromptAppended").field("key", key).finish_non_exhaustive()
2699            }
2700            Self::SlackMessageAppended { key, .. } => {
2701                f.debug_struct("SlackMessageAppended").field("key", key).finish_non_exhaustive()
2702            }
2703            Self::SlackPostPending { key, draft } => f
2704                .debug_struct("SlackPostPending")
2705                .field("key", key)
2706                .field("workspace", &draft.workspace)
2707                .field("conversation", &draft.conversation)
2708                .finish_non_exhaustive(),
2709            Self::SlackDraftResolved { key, id, ending } => f
2710                .debug_struct("SlackDraftResolved")
2711                .field("key", key)
2712                .field("id", id)
2713                .field("ending", ending)
2714                .finish(),
2715            Self::BrowserHandOffPending { key, handoff } => f
2716                .debug_struct("BrowserHandOffPending")
2717                .field("key", key)
2718                .field("id", &handoff.id)
2719                .field("tab", &handoff.tab)
2720                .finish_non_exhaustive(),
2721            Self::BrowserHandOffResolved { key, id, answer } => f
2722                .debug_struct("BrowserHandOffResolved")
2723                .field("key", key)
2724                .field("id", id)
2725                .field("answer", answer)
2726                .finish(),
2727            Self::TerminalHandOffPending { key, handoff } => f
2728                .debug_struct("TerminalHandOffPending")
2729                .field("key", key)
2730                .field("id", &handoff.id)
2731                .field("terminal", &handoff.terminal)
2732                .finish_non_exhaustive(),
2733            Self::TerminalHandOffResolved { key, id, answer } => f
2734                .debug_struct("TerminalHandOffResolved")
2735                .field("key", key)
2736                .field("id", id)
2737                .field("answer", answer)
2738                .finish(),
2739            Self::TerminalChanged { key, terminal } => f
2740                .debug_struct("TerminalChanged")
2741                .field("key", key)
2742                .field("terminal", &terminal.as_ref().map(|summary| summary.id))
2743                .finish_non_exhaustive(),
2744            Self::PromptQueued { key, uuid, .. } => f
2745                .debug_struct("PromptQueued")
2746                .field("key", key)
2747                .field("uuid", uuid)
2748                .finish_non_exhaustive(),
2749            Self::PromptLifecycle { key, uuid, state } => f
2750                .debug_struct("PromptLifecycle")
2751                .field("key", key)
2752                .field("uuid", uuid)
2753                .field("state", state)
2754                .finish(),
2755            Self::PromptCancelResolved { key, uuid, cancelled } => f
2756                .debug_struct("PromptCancelResolved")
2757                .field("key", key)
2758                .field("uuid", uuid)
2759                .field("cancelled", cancelled)
2760                .finish(),
2761            Self::ReviewActivityNotice { key, branch, waiting, .. } => f
2762                .debug_struct("ReviewActivityNotice")
2763                .field("key", key)
2764                .field("branch", branch)
2765                .field("waiting", waiting)
2766                .finish_non_exhaustive(),
2767            Self::DictateAvailability => f.write_str("DictateAvailability"),
2768            Self::DictateStarted { key, .. } => {
2769                f.debug_struct("DictateStarted").field("key", key).finish_non_exhaustive()
2770            }
2771            Self::DictateLevel { key, peak_db, .. } => {
2772                f.debug_struct("DictateLevel").field("key", key).field("peak_db", peak_db).finish()
2773            }
2774            Self::DictateTranscribing { key, .. } => {
2775                f.debug_struct("DictateTranscribing").field("key", key).finish()
2776            }
2777            Self::DictateProgress { key, generation, done, total, .. } => f
2778                .debug_struct("DictateProgress")
2779                .field("key", key)
2780                .field("generation", generation)
2781                .field("done", done)
2782                .field("total", total)
2783                .finish(),
2784            Self::DictateEnded { key, outcome, .. } => f
2785                .debug_struct("DictateEnded")
2786                .field("key", key)
2787                .field("outcome", outcome)
2788                .finish_non_exhaustive(),
2789            Self::FatalError { error, .. } => {
2790                f.debug_struct("FatalError").field("error", error).finish()
2791            }
2792            Self::DictateModelsChanged { models } => f
2793                .debug_struct("DictateModelsChanged")
2794                .field("in_use", &models.in_use.len())
2795                .field("rows", &models.rows.len())
2796                .finish(),
2797            Self::DocumentHandOffPending { key, .. } => {
2798                f.debug_struct("DocumentHandOffPending").field("key", key).finish_non_exhaustive()
2799            }
2800            Self::DocumentHandOffResolved { key, .. } => {
2801                f.debug_struct("DocumentHandOffResolved").field("key", key).finish_non_exhaustive()
2802            }
2803            Self::SettingsWritePending { key, ask } => f
2804                .debug_struct("SettingsWritePending")
2805                .field("key", key)
2806                .field("id", &ask.id)
2807                .field("key_paths", &ask.edits.iter().map(|edit| &edit.key).collect::<Vec<_>>())
2808                .finish_non_exhaustive(),
2809            Self::SettingsWriteResolved { key, id, ending } => f
2810                .debug_struct("SettingsWriteResolved")
2811                .field("key", key)
2812                .field("id", id)
2813                .field("ending", ending)
2814                .finish_non_exhaustive(),
2815            Self::VaultAskPending { key, draft } => f
2816                .debug_struct("VaultAskPending")
2817                .field("key", key)
2818                .field("id", &draft.id)
2819                .field("tool", &draft.tool)
2820                .finish_non_exhaustive(),
2821            Self::VaultAskResolved { key, id, ending } => f
2822                .debug_struct("VaultAskResolved")
2823                .field("key", key)
2824                .field("id", id)
2825                .field("ending", ending)
2826                .finish_non_exhaustive(),
2827            Self::BiometricEnrolmentsChanged { enrolments } => f
2828                .debug_struct("BiometricEnrolmentsChanged")
2829                .field("enrolments", &enrolments.len())
2830                .finish(),
2831            // The token is redacted: a Debug line must never carry it.
2832            Self::VaultRotated { .. } => f.write_str("VaultRotated"),
2833            Self::VaultLockChanged { locked } => {
2834                f.debug_struct("VaultLockChanged").field("locked", locked).finish()
2835            }
2836            Self::CatalogLoaded => f.write_str("CatalogLoaded"),
2837            Self::CliVersionChanged => f.write_str("CliVersionChanged"),
2838            Self::AccountsChanged { accounts } => f
2839                .debug_struct("AccountsChanged")
2840                .field("loading", &accounts.loading.len())
2841                .finish_non_exhaustive(),
2842            Self::VoiceAudio { key, seq, sample_rate, pcm } => f
2843                .debug_struct("VoiceAudio")
2844                .field("key", key)
2845                .field("seq", seq)
2846                .field("sample_rate", sample_rate)
2847                .field("samples", &pcm.len())
2848                .finish(),
2849            Self::VoiceModeState { key, listeners } => f
2850                .debug_struct("VoiceModeState")
2851                .field("key", key)
2852                .field("listeners", listeners)
2853                .finish(),
2854        }
2855    }
2856}
2857
2858/// Errors from the dispatch boundary. `UnknownSession` and
2859/// `SessionClosed` are returned by `Workspace::dispatch`;
2860/// `NoActiveSession` is for a caller with no slot to name.
2861#[derive(Debug, thiserror::Error)]
2862pub enum DispatchError {
2863    #[error("no active session")]
2864    NoActiveSession,
2865    #[error("no session task registered for key {}", .0.display())]
2866    UnknownSession(SessionSlot),
2867    #[error("session task for key {} has closed its command channel", .0.display())]
2868    SessionClosed(SessionSlot),
2869    #[error(
2870        "no prompt of that kind is waiting on {tool_id} for key {}: it has been answered, or it asked something else",
2871        .key.display()
2872    )]
2873    NoPromptWaiting { key: SessionSlot, tool_id: String },
2874    /// A Slack answer named a draft the registry does not hold. Its own
2875    /// word rather than [`Self::NoPromptWaiting`]'s: a draft is answered by
2876    /// its own id and names no tool call, and this line is drawn where the
2877    /// dock stood. Answering and the asking session going away are the
2878    /// endings that leave nothing waiting, which is the condition this line
2879    /// names - a send-back is an answer too, and resolves the draft just the
2880    /// same.
2881    #[error(
2882        "that Slack draft is no longer waiting: it has been answered, or its asking session went away"
2883    )]
2884    NoDraftWaiting { key: SessionSlot, id: Uuid },
2885    /// A send-back carried no words. There is nothing to hand the agent,
2886    /// and resolving the draft would drop the reader's card for nothing -
2887    /// so the draft stays waiting and the field can be sent again.
2888    #[error("the feedback was empty, so the draft is still waiting")]
2889    EmptySlackFeedback { key: SessionSlot, id: Uuid },
2890    /// A browser hand-off answer named a hand-off the registry does not hold:
2891    /// answered in another view, or its asking session went away. Its own
2892    /// word for the same reason [`Self::NoDraftWaiting`] has one.
2893    #[error(
2894        "that browser hand-off is no longer waiting: it has been answered, or its asking session went away"
2895    )]
2896    NoBrowserHandOffWaiting { key: SessionSlot, id: Uuid },
2897    /// A terminal hand-off answer named a hand-off the registry does not
2898    /// hold: answered in another view, or its asking session went away.
2899    #[error(
2900        "that terminal hand-off is no longer waiting: it has been answered, or its asking session went away"
2901    )]
2902    NoTerminalHandOffWaiting { key: SessionSlot, id: Uuid },
2903    /// A document hand-off answer named a hand-off the registry does not
2904    /// hold: finished in another view, declined, or its asking session went
2905    /// away. Its own word for the same reason [`Self::NoDraftWaiting`] has
2906    /// one.
2907    #[error(
2908        "that document hand-off is no longer waiting: it has been answered, or its asking session went away"
2909    )]
2910    NoDocumentHandOffWaiting { key: SessionSlot, id: Uuid },
2911    /// A settings answer named a held write the registry does not hold:
2912    /// answered in another view, or its asking session went away. Its own
2913    /// word for the same reason [`Self::NoDraftWaiting`] has one.
2914    #[error(
2915        "that configuration write is no longer waiting: it has been answered, or its asking session went away"
2916    )]
2917    NoSettingsWriteWaiting { key: SessionSlot, id: Uuid },
2918    /// A vault answer named a held call the registry does not hold: answered
2919    /// in another view, or its asking session went away. Its own word for the
2920    /// same reason [`Self::NoDraftWaiting`] has one.
2921    #[error(
2922        "that vault call is no longer waiting: it has been answered, or its asking session went away"
2923    )]
2924    NoVaultAskWaiting { key: SessionSlot, id: Uuid },
2925    /// A biometric enrolment write was refused before anything was stored, in
2926    /// the core's own words: a blob that is not base64, an empty or oversized
2927    /// one, a nameless purpose, or a store that could not be written.
2928    #[error("the biometric enrolment was refused: {reason}")]
2929    BiometricRefused { reason: String },
2930    /// A catalogue check was asked for with `[dictate]` switched off:
2931    /// there are no pinned models for a check to be about.
2932    #[error("dictation is off, so there is no catalogue to check")]
2933    DictateOff,
2934    /// A speaking action was asked for with `[tts]` switched off or no
2935    /// model files resolving: there is nothing to speak with.
2936    #[error("speech is off: {reason}")]
2937    VoiceOff { reason: String },
2938    /// An install is already running: one at a time, like a check.
2939    #[error("a model install is already running")]
2940    Installing,
2941    /// An activation is already running; a second engine build would race
2942    /// the first one's swap.
2943    #[error("a model activation is already running")]
2944    Activating,
2945    /// A take is in flight on `holder` (a slot's display string), so the
2946    /// engine cannot be swapped under it.
2947    #[error("a dictation take is live on {holder}; wait for it to finish")]
2948    TakeLive { holder: String },
2949    /// `forge.toml` pins this role, so the runtime cannot move it; `section`
2950    /// and `key` name the table and key that do.
2951    #[error("[{section}] {key} pins this model in forge.toml; remove the key to change it here")]
2952    PinnedRole { section: &'static str, key: String },
2953    /// A bench holds the machine: it is measuring an engine, so a swap or
2954    /// a second run waits for it to finish or stops it.
2955    #[error("a bench is running; stop it before changing models")]
2956    BenchRunning,
2957    /// A stop arrived and nothing was running to stop.
2958    #[error("no bench is running")]
2959    BenchNotRunning,
2960    /// No read-aloud recording is running, or it is another connection's:
2961    /// only the connection that started one can stop it.
2962    #[error("no read-aloud recording is running here")]
2963    ReadAloudNotRecording,
2964    /// The read-aloud set would not take the change, in the core's own words.
2965    #[error("the read-aloud set could not be changed: {reason}")]
2966    ReadAloudUnavailable { reason: String },
2967    /// The model is what a role runs right now, and the engine holds it
2968    /// loaded: the role has to be moved off it first.
2969    #[error("the {role} runs this file; switch it first", role = role.label())]
2970    ModelInUse { role: crate::dictate::DictateRole },
2971    /// The model could not be removed, in the core's own words.
2972    #[error("the model could not be removed: {reason}")]
2973    UninstallRefused { reason: String },
2974    /// A check is already in flight; the one that lands is the answer,
2975    /// and a second fetch would answer the same thing twice.
2976    #[error("a catalogue check is already running")]
2977    CatalogueChecking,
2978    /// A plugin action is already running: the CLI serializes one at a
2979    /// time, so a second press waits rather than racing the first.
2980    #[error("a plugin action is already running; wait for it to finish")]
2981    PluginActionRunning,
2982    /// No home directory resolves, so a plugin call with no project
2983    /// context has nowhere to run. Refused rather than substituting a
2984    /// cwd-derived directory (hard rule 14).
2985    #[error("no home directory resolves; plugin actions with no project context cannot run")]
2986    PluginHomeUnavailable,
2987    /// A `project`/`local` install named no project; those scopes install
2988    /// into a project's own config, so the project is the caller's to say.
2989    #[error("a project-scoped plugin install needs a project")]
2990    PluginProjectRequired,
2991    /// A project-named install or update names a project `forge.toml`
2992    /// does not declare.
2993    #[error("no project named '{name}' is declared in forge.toml")]
2994    ProjectNotFound { name: String },
2995}
2996
2997#[cfg(test)]
2998mod session_update_variants {
2999    /// The client's reducer classifies a `SessionUpdate` by the name it crosses
3000    /// under, and a name it has no line for is a lookup that misses: the session
3001    /// page stops following its seat and keeps drawing the last record it read,
3002    /// with nothing to say it has fallen behind.
3003    ///
3004    /// **Both ends are parsed, and both ends are the tables themselves.** A
3005    /// hand-typed list is a copy no rename moves - the first version of this
3006    /// test read `turn_cancelled` from a literal while serde had already moved
3007    /// to `turn_cancelled_probe`, and it stayed green. The enum's names are
3008    /// derived from its own source by the rule it declares; the client's are
3009    /// read out of the three tables that classify, and not out of the file at
3010    /// large, where a payload key or a status word would answer for a variant
3011    /// nothing classifies.
3012    ///
3013    /// The client's census of variants is read back as well, rather than
3014    /// trusted because its length is asserted: a count binds it to nothing.
3015    /// **`IGNORED` has no runtime reader at all** - nothing but these tests
3016    /// consults it - so what it holds is only as right as this control.
3017    ///
3018    /// What it does not reach: `client/src/wire/fleet.ts` keeps a table of its
3019    /// own for the fleet's redraws. That is a different question and is
3020    /// deliberately partial, so this binding stops at `apply.ts`.
3021    #[test]
3022    fn every_session_update_variant_is_classified_for_the_client() {
3023        let source = include_str!("protocol.rs");
3024        let client = include_str!("../../../client/src/session/apply.ts");
3025        let declared = wire_names(source);
3026        let classified = classified_names(client);
3027
3028        // The derivation implements ONE `rename_all` rule, so it is only as good
3029        // as the enum declaring that rule: changing the attribute moves every
3030        // name at once, and nothing else here would notice.
3031        assert_eq!(
3032            rename_all(source).as_deref(),
3033            Some("snake_case"),
3034            "this control derives wire names with serde's snake_case rule and the enum no longer \
3035             declares it: extend the derivation before trusting anything below",
3036        );
3037
3038        // The denominator, and it is the enum's own: a parse that stops matching
3039        // never reaches its closing brace, and finding nothing is what a clean
3040        // run looks like - so this is what tells the two apart.
3041        assert!(
3042            declared.closed,
3043            "the parse never reached the end of `SessionUpdate`, so it is the parse that moved \
3044             and not the client",
3045        );
3046
3047        let missing: Vec<&String> =
3048            declared.names.iter().filter(|name| !classified.contains(name)).collect();
3049        assert!(
3050            missing.is_empty(),
3051            "client/src/session/apply.ts has no line for {missing:?}, and a variant the client \
3052             does not classify stops the session page following its seat: add each to HANDLERS \
3053             when the record has a field for it, and to REPLACES or IGNORED when it does not",
3054        );
3055
3056        let stale: Vec<&String> =
3057            classified.iter().filter(|name| !declared.names.contains(name)).collect();
3058        assert!(
3059            stale.is_empty(),
3060            "client/src/session/apply.ts classifies {stale:?}, which the enum does not declare: \
3061             a line for a name the wire never sends is one nobody can tell from a live one",
3062        );
3063
3064        // **And the census the client's own assertions filter over is read
3065        // too**, rather than trusted because its count is asserted. That count
3066        // binds it to nothing; this is what binds it to the enum. It lives in
3067        // the client's TEST file, beside the assertions that filter over it.
3068        let census_source = include_str!("../../../client/src/session/apply.test.ts");
3069        let mut census = array_entries(census_source, "EVERY_VARIANT");
3070        let mut declared_names = declared.names.clone();
3071        census.sort();
3072        declared_names.sort();
3073        assert_eq!(
3074            census, declared_names,
3075            "the client's `EVERY_VARIANT` and the enum's variants are not the same set: the \
3076             census is what the client's own assertions filter over, so a variant missing from \
3077             it is a variant nothing checks",
3078        );
3079    }
3080
3081    /// The draft ending crosses as the core's own externally tagged enum, and
3082    /// `draftEndingLine` narrows exactly that shape - a unit variant as its
3083    /// name, the answered one as a name around its field. Neither side
3084    /// compiles the link, so this is what holds serde's output and the
3085    /// client's narrowing together.
3086    #[test]
3087    fn a_draft_ending_serialises_as_the_client_narrows_it() {
3088        use crate::SessionSlot;
3089        use forge_primitives::slack::SlackDraftEnding;
3090        use uuid::Uuid;
3091
3092        let ending = |ending: SlackDraftEnding| {
3093            let update = super::SessionUpdate::SlackDraftResolved {
3094                key: SessionSlot::from_str_for_test("k"),
3095                id: Uuid::nil(),
3096                ending,
3097            };
3098            serde_json::to_value(update).expect("an update serialises")["slack_draft_resolved"]
3099                ["ending"]
3100                .clone()
3101        };
3102
3103        assert_eq!(ending(SlackDraftEnding::Abandoned), serde_json::json!("abandoned"));
3104        assert_eq!(ending(SlackDraftEnding::SentBack), serde_json::json!("sent_back"));
3105        assert_eq!(
3106            ending(SlackDraftEnding::Answered { approved: true }),
3107            serde_json::json!({ "answered": { "approved": true } }),
3108        );
3109    }
3110
3111    /// The voice frames are additive and rename no field, so a round trip is
3112    /// the whole contract. `sample_rate` is a plain number rather than a
3113    /// closed set, so a model rendering at a rate this build has never seen
3114    /// still reaches a client.
3115    #[test]
3116    fn the_voice_frames_round_trip() {
3117        use crate::SessionSlot;
3118
3119        let key = SessionSlot::from_str_for_test("voice");
3120        let mode = super::Command::VoiceMode {
3121            key: key.clone(),
3122            on: true,
3123            explicit: false,
3124            initiator: None,
3125        };
3126        let json = serde_json::to_string(&mode).expect("a command serialises");
3127        let super::Command::VoiceMode { key: decoded, on, .. } =
3128            serde_json::from_str(&json).expect("a command decodes")
3129        else {
3130            panic!("a voice mode came back as another command");
3131        };
3132        assert_eq!(decoded, key);
3133        assert!(on);
3134
3135        let audio = super::SessionUpdate::VoiceAudio {
3136            key: key.clone(),
3137            seq: 7,
3138            sample_rate: 24_000,
3139            pcm: vec![5, 6],
3140        };
3141        let json = serde_json::to_string(&audio).expect("an update serialises");
3142        let super::SessionUpdate::VoiceAudio { key: decoded, seq, sample_rate, pcm } =
3143            serde_json::from_str(&json).expect("an update decodes")
3144        else {
3145            panic!("a voice audio frame came back as another update");
3146        };
3147        assert_eq!(decoded, key);
3148        assert_eq!((seq, sample_rate, pcm), (7, 24_000, vec![5, 6]));
3149
3150        let state = super::SessionUpdate::VoiceModeState { key: key.clone(), listeners: 2 };
3151        let json = serde_json::to_string(&state).expect("an update serialises");
3152        let super::SessionUpdate::VoiceModeState { key: decoded, listeners } =
3153            serde_json::from_str(&json).expect("an update decodes")
3154        else {
3155            panic!("a voice mode frame came back as another update");
3156        };
3157        assert_eq!((decoded, listeners), (key.clone(), 2));
3158
3159        let unknown = serde_json::json!({
3160            "voice_audio": {
3161                "key": serde_json::to_value(&key).expect("a slot renders"),
3162                "seq": 0,
3163                "sample_rate": 12_345,
3164                "pcm": [],
3165            }
3166        });
3167        let super::SessionUpdate::VoiceAudio { sample_rate, .. } =
3168            serde_json::from_value(unknown).expect("an unknown rate decodes")
3169        else {
3170            panic!("a voice audio frame came back as another update");
3171        };
3172        assert_eq!(sample_rate, 12_345);
3173    }
3174
3175    /// What one parse of the enum found.
3176    struct Census {
3177        names: Vec<String>,
3178        /// Whether the parse reached the enum's closing brace.
3179        closed: bool,
3180    }
3181
3182    /// The names the client's classifying tables hold.
3183    ///
3184    /// The handler table's keys, and the quoted entries of the two lists. An
3185    /// export of anything else is skipped rather than read: a table of the
3186    /// record's FIELD names would answer a variant check with names the enum
3187    /// never declares.
3188    fn classified_names(client: &str) -> Vec<String> {
3189        let mut names = Vec::new();
3190        let mut table = "";
3191        // Whether a list's `[` has been seen and its `]` has not.
3192        let mut open = false;
3193        for line in client.lines() {
3194            let trimmed = line.trim();
3195            if trimmed.starts_with("export const HANDLERS") {
3196                table = "handlers";
3197                open = false;
3198                continue;
3199            }
3200            if trimmed.starts_with("export const REPLACES")
3201                || trimmed.starts_with("export const IGNORED")
3202            {
3203                table = "list";
3204                open = false;
3205            } else if trimmed.starts_with("export const") {
3206                table = "";
3207                open = false;
3208                continue;
3209            }
3210            match table {
3211                // A key of the handler table. The INDENT is the anchor: entries
3212                // sit at exactly two spaces, and an object literal inside a
3213                // handler's body is deeper. What follows the colon is not read,
3214                // so a key written against a bare function name - `turn_error:
3215                // settled,` - counts the same as an inline arrow.
3216                "handlers" => {
3217                    if let Some(rest) =
3218                        line.strip_prefix("  ").filter(|rest| !rest.starts_with(' '))
3219                        && let Some(key) = rest.split_once(':').map(|(key, _)| key)
3220                        && is_wire_name(key)
3221                    {
3222                        names.push(key.to_owned());
3223                    }
3224                }
3225                // `['spawning', 'connected']`, one line or many. **Only an
3226                // array is read, and only its entries**: the span between two
3227                // exports also carries prose, and a comment quoting a name must
3228                // not answer for a line that classifies it.
3229                "list" => {
3230                    if !open {
3231                        // The LAST bracket on the line: the declaration's own
3232                        // type carries a `[]` before the array opens.
3233                        let Some((_, after)) = trimmed.rsplit_once('[') else {
3234                            continue;
3235                        };
3236                        if let Some((entries, _)) = after.split_once(']') {
3237                            names.extend(quoted_names(entries).map(str::to_owned));
3238                            // Opened and closed on one line: the export carried
3239                            // its whole list, and what follows is not it.
3240                            table = "";
3241                        } else {
3242                            open = true;
3243                            names.extend(quoted_names(after).map(str::to_owned));
3244                        }
3245                    } else if trimmed.starts_with(']') {
3246                        open = false;
3247                        // **The array is the whole of what its export
3248                        // classifies.** The mode used to end at the next
3249                        // `export const`, which made the LAST list in the file
3250                        // read every `[` below it - a client helper's own
3251                        // literals answering as variants.
3252                        table = "";
3253                    } else if trimmed.starts_with('\'') {
3254                        names.extend(quoted_names(trimmed).map(str::to_owned));
3255                    }
3256                }
3257                _ => {}
3258            }
3259        }
3260        names
3261    }
3262
3263    /// The quoted entries of one `const NAME = [ ... ]` block, exported or not,
3264    /// which is how the client's census of variants is written.
3265    fn array_entries(client: &str, name: &str) -> Vec<String> {
3266        let heading = format!("const {name}");
3267        let mut entries = Vec::new();
3268        let mut open = false;
3269        for line in client.lines() {
3270            let trimmed = line.trim();
3271            if !open {
3272                let declared = trimmed.strip_prefix("export ").unwrap_or(trimmed);
3273                if !declared.starts_with(&heading) {
3274                    continue;
3275                }
3276                let Some((_, after)) = trimmed.rsplit_once('[') else {
3277                    continue;
3278                };
3279                if let Some((inside, _)) = after.split_once(']') {
3280                    entries.extend(quoted_names(inside).map(str::to_owned));
3281                } else {
3282                    open = true;
3283                    entries.extend(quoted_names(after).map(str::to_owned));
3284                }
3285            } else if trimmed.starts_with(']') {
3286                break;
3287            } else if trimmed.starts_with('\'') {
3288                entries.extend(quoted_names(trimmed).map(str::to_owned));
3289            }
3290        }
3291        entries
3292    }
3293
3294    /// The names quoted in a run of text: the odd segments of a split on `'`.
3295    fn quoted_names(text: &str) -> impl Iterator<Item = &str> {
3296        text.split('\'').skip(1).step_by(2).filter(|entry| is_wire_name(entry))
3297    }
3298
3299    /// Whether a token reads as a wire name, which is what keeps a bare word in
3300    /// a declaration from being taken for an entry.
3301    fn is_wire_name(token: &str) -> bool {
3302        !token.is_empty()
3303            && token.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
3304    }
3305
3306    /// The `rename_all` the enum itself declares, which is what turns a variant
3307    /// name into the name it crosses under.
3308    fn rename_all(source: &str) -> Option<String> {
3309        let mut nearest = None;
3310        for line in source.lines() {
3311            let trimmed = line.trim();
3312            if trimmed.starts_with("pub enum SessionUpdate {") {
3313                return nearest;
3314            }
3315            // Comments and blank lines do not break the run above the enum: a
3316            // doc comment between the attribute and the enum says the same
3317            // thing, and reading it as "no rename_all" would red a control on a
3318            // reformatting.
3319            if trimmed.is_empty() || trimmed.starts_with("//") {
3320                continue;
3321            }
3322            nearest = trimmed
3323                .strip_prefix("#[serde(rename_all = \"")
3324                .and_then(|rest| rest.split('"').next())
3325                .map(str::to_owned);
3326        }
3327        None
3328    }
3329
3330    /// Every wire name the enum declares, in the order it declares them.
3331    ///
3332    /// The derivation serde applies to it: `rename_all` on the enum, and an
3333    /// explicit `#[serde(rename = "...")]` on the variant above it.
3334    fn wire_names(source: &str) -> Census {
3335        let mut names = Vec::new();
3336        let mut closed = false;
3337        let mut inside = false;
3338        let mut renamed: Option<String> = None;
3339        for line in source.lines() {
3340            if !inside {
3341                inside = line.starts_with("pub enum SessionUpdate {");
3342                continue;
3343            }
3344            if line == "}" {
3345                closed = true;
3346                break;
3347            }
3348            let trimmed = line.trim();
3349            if let Some(rest) = trimmed.strip_prefix("#[serde(rename = \"") {
3350                renamed = rest.split('"').next().map(str::to_owned);
3351                continue;
3352            }
3353            if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#[") {
3354                continue;
3355            }
3356            let variant: String =
3357                trimmed.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
3358            if !variant.starts_with(|c: char| c.is_ascii_uppercase()) {
3359                // A field inside a variant's body, or the brace closing one. A
3360                // rename attribute above a field belongs to the FIELD, so it is
3361                // dropped here rather than carried onto the next variant.
3362                renamed = None;
3363                continue;
3364            }
3365            names.push(renamed.take().unwrap_or_else(|| snake_case(&variant)));
3366        }
3367        Census { names, closed }
3368    }
3369
3370    /// The book's variant counts are read from the enums, not trusted: a
3371    /// variant added without the page moving leaves a published number wrong.
3372    #[test]
3373    fn the_book_counts_match_the_enums() {
3374        let source = include_str!("protocol.rs");
3375        let book = include_str!("../../../docs/book/src/socket.md");
3376        let flat = book.split_whitespace().collect::<Vec<_>>().join(" ");
3377
3378        let (command_total, command_struct, command_unit) =
3379            variant_counts(source, "pub enum Command {");
3380        assert!(
3381            flat.contains(&format!(
3382                "`Command` has {command_total} variants, {command_struct} of them struct variants; the {} unit variants",
3383                spell(command_unit),
3384            )),
3385            "the book's Command count is not the enum's: {command_total} total, {command_struct} struct, {command_unit} unit",
3386        );
3387
3388        let (update_total, update_struct, update_unit) =
3389            variant_counts(source, "pub enum SessionUpdate {");
3390        assert!(
3391            flat.contains(&format!(
3392                "{update_struct} of `SessionUpdate`'s {update_total} variants are struct variants"
3393            )),
3394            "the book's SessionUpdate count is not the enum's: {update_total} total, {update_struct} struct, {update_unit} unit",
3395        );
3396    }
3397
3398    /// A small count as the book spells it.
3399    fn spell(n: usize) -> String {
3400        match n {
3401            0 => "zero",
3402            1 => "one",
3403            2 => "two",
3404            3 => "three",
3405            4 => "four",
3406            5 => "five",
3407            6 => "six",
3408            7 => "seven",
3409            8 => "eight",
3410            9 => "nine",
3411            10 => "ten",
3412            _ => return n.to_string(),
3413        }
3414        .to_owned()
3415    }
3416
3417    /// Count an enum's variants, and how many carry a struct body.
3418    fn variant_counts(source: &str, header: &str) -> (usize, usize, usize) {
3419        let mut total = 0;
3420        let mut structs = 0;
3421        let mut units = 0;
3422        let mut inside = false;
3423        for line in source.lines() {
3424            if !inside {
3425                inside = line.starts_with(header);
3426                continue;
3427            }
3428            if line == "}" {
3429                break;
3430            }
3431            let trimmed = line.trim();
3432            if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#[") {
3433                continue;
3434            }
3435            let variant: String =
3436                trimmed.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
3437            if !variant.starts_with(|c: char| c.is_ascii_uppercase()) {
3438                continue;
3439            }
3440            total += 1;
3441            if trimmed.contains('{') {
3442                structs += 1;
3443            } else {
3444                units += 1;
3445            }
3446        }
3447        (total, structs, units)
3448    }
3449
3450    /// serde's own rule for this enum's `rename_all`, for a variant with no
3451    /// explicit rename over it.
3452    fn snake_case(name: &str) -> String {
3453        let mut out = String::new();
3454        for (at, c) in name.chars().enumerate() {
3455            if c.is_ascii_uppercase() {
3456                if at != 0 {
3457                    out.push('_');
3458                }
3459                out.push(c.to_ascii_lowercase());
3460            } else {
3461                out.push(c);
3462            }
3463        }
3464        out
3465    }
3466}
3467
3468#[cfg(test)]
3469mod dictate_level_wire_tests {
3470    use super::*;
3471
3472    /// A level whose window heard nothing carries a non-finite dB, which
3473    /// serde writes as `null` - and must read back, or a Rust reader
3474    /// cannot decode the message at all. The wire value stays `null`: the
3475    /// tolerance is on the read side, so nothing a client narrows moves.
3476    #[test]
3477    fn a_level_with_no_signal_round_trips_as_null() {
3478        let key = SessionSlot::from_str_for_test("dictate-level".to_owned());
3479        let silent = SessionUpdate::DictateLevel {
3480            key: key.clone(),
3481            peak_db: f32::NEG_INFINITY,
3482            generation: 1,
3483            initiator: None,
3484        };
3485        let encoded = serde_json::to_value(&silent).expect("a level encodes");
3486        assert!(
3487            encoded["dictate_level"]["peak_db"].is_null(),
3488            "a no-signal reading crosses as null, got {encoded}"
3489        );
3490
3491        let back: SessionUpdate = serde_json::from_value(encoded).expect("a level decodes");
3492        let SessionUpdate::DictateLevel { peak_db, .. } = back else {
3493            panic!("the level decoded into another update")
3494        };
3495        assert_eq!(peak_db, f32::NEG_INFINITY, "null must read back as no signal");
3496
3497        // And a reading that IS a signal is untouched by the tolerance.
3498        let heard =
3499            SessionUpdate::DictateLevel { key, peak_db: -22.5, generation: 1, initiator: None };
3500        let encoded = serde_json::to_value(&heard).expect("a level encodes");
3501        let back: SessionUpdate = serde_json::from_value(encoded).expect("a level decodes");
3502        let SessionUpdate::DictateLevel { peak_db, .. } = back else {
3503            panic!("the level decoded into another update")
3504        };
3505        assert_eq!(peak_db, -22.5);
3506    }
3507
3508    /// **A reading and a transcribing name the take they belong to.** They
3509    /// are the two frames a client uses to draw a live take, and a frame
3510    /// that arrived after a newer start is the older take's: without the
3511    /// number the client cannot tell, and writes it into the live meter
3512    /// (#839).
3513    #[test]
3514    fn a_reading_and_a_transcribing_cross_with_their_generation() {
3515        let key = SessionSlot::from_str_for_test("take-generation".to_owned());
3516        let level = SessionUpdate::DictateLevel {
3517            key: key.clone(),
3518            peak_db: -20.0,
3519            generation: 7,
3520            initiator: None,
3521        };
3522        let encoded = serde_json::to_value(&level).expect("a level encodes");
3523        assert_eq!(
3524            encoded["dictate_level"]["generation"],
3525            serde_json::json!(7),
3526            "a reading carries its take's own number: {encoded}"
3527        );
3528
3529        let transcribing =
3530            SessionUpdate::DictateTranscribing { key, generation: 7, initiator: None };
3531        let encoded = serde_json::to_value(&transcribing).expect("a transcribing encodes");
3532        assert_eq!(
3533            encoded["dictate_transcribing"]["generation"],
3534            serde_json::json!(7),
3535            "and so does the frame that says it is transcribing: {encoded}"
3536        );
3537    }
3538
3539    /// A take that heard nothing crosses the peak in its END the same way a
3540    /// level's crosses, so the same tolerance has to read it: a reader that
3541    /// decodes the level and then fails on the end of the same take is just
3542    /// as broken, and the failure names the type rather than the case.
3543    #[test]
3544    fn a_no_audio_end_with_no_signal_round_trips() {
3545        let key = SessionSlot::from_str_for_test("no-audio-end".to_owned());
3546        let silent = SessionUpdate::DictateEnded {
3547            key: key.clone(),
3548            outcome: DictateOutcome::NoAudio { peak_db: f32::NEG_INFINITY, seconds: 3 },
3549            generation: 1,
3550            surface: None,
3551            initiator: None,
3552        };
3553        let encoded = serde_json::to_value(&silent).expect("an end encodes");
3554        assert!(
3555            encoded["dictate_ended"]["outcome"]["no_audio"]["peak_db"].is_null(),
3556            "a no-signal end crosses as null, got {encoded}"
3557        );
3558
3559        let back: SessionUpdate = serde_json::from_value(encoded).expect("an end decodes");
3560        let SessionUpdate::DictateEnded { outcome, .. } = back else {
3561            panic!("the end decoded into another update")
3562        };
3563        assert_eq!(
3564            outcome,
3565            DictateOutcome::NoAudio { peak_db: f32::NEG_INFINITY, seconds: 3 },
3566            "null must read back as no signal"
3567        );
3568
3569        // And a reading that IS a signal is untouched by the tolerance.
3570        let quiet = SessionUpdate::DictateEnded {
3571            key,
3572            outcome: DictateOutcome::NoAudio { peak_db: -41.5, seconds: 3 },
3573            generation: 1,
3574            surface: None,
3575            initiator: None,
3576        };
3577        let encoded = serde_json::to_value(&quiet).expect("an end encodes");
3578        let back: SessionUpdate = serde_json::from_value(encoded).expect("an end decodes");
3579        let SessionUpdate::DictateEnded { outcome, .. } = back else {
3580            panic!("the end decoded into another update")
3581        };
3582        assert_eq!(outcome, DictateOutcome::NoAudio { peak_db: -41.5, seconds: 3 });
3583    }
3584}
3585
3586#[cfg(test)]
3587mod dictate_wire_ownership_tests {
3588    use super::*;
3589
3590    /// The initiator is transport-side state and never crosses: a take's
3591    /// updates serialize exactly as v4's frames did. Catches a `serde(skip)`
3592    /// dropped from any of the five - a token on the wire is a contract
3593    /// change a client would read as an unknown field.
3594    #[test]
3595    fn no_dictate_update_carries_its_initiator_across() {
3596        let key = SessionSlot::from_str_for_test("initiator-wire".to_owned());
3597        let updates = [
3598            SessionUpdate::DictateStarted {
3599                key: key.clone(),
3600                floor_db: -50.0,
3601                generation: 1,
3602                initiator: Some(7),
3603            },
3604            SessionUpdate::DictateLevel {
3605                key: key.clone(),
3606                peak_db: -20.0,
3607                generation: 1,
3608                initiator: Some(7),
3609            },
3610            SessionUpdate::DictateTranscribing {
3611                key: key.clone(),
3612                generation: 1,
3613                initiator: Some(7),
3614            },
3615            SessionUpdate::DictateProgress {
3616                key: key.clone(),
3617                generation: 1,
3618                done: 1,
3619                total: Some(2),
3620                initiator: Some(7),
3621            },
3622            SessionUpdate::DictateEnded {
3623                key,
3624                outcome: DictateOutcome::Cancelled,
3625                generation: 1,
3626                surface: None,
3627                initiator: Some(7),
3628            },
3629        ];
3630        for update in &updates {
3631            let encoded = serde_json::to_value(update).expect("an update encodes");
3632            let body = encoded
3633                .as_object()
3634                .and_then(|wrapper| wrapper.values().next())
3635                .expect("an update encodes as its own name around its body");
3636            assert!(
3637                body.get("initiator").is_none(),
3638                "{update:?} carried its initiator across the wire: {encoded}"
3639            );
3640        }
3641    }
3642}
3643
3644#[cfg(test)]
3645mod workers_command_tests {
3646    use super::*;
3647
3648    #[test]
3649    fn worker_spawn_reply_constructs() {
3650        let r = WorkerSpawnReply {
3651            session_id: "abc".into(),
3652            tag: "forge:worker:reviewer".into(),
3653            mcp_families: None,
3654            rate_limited_account: None,
3655            durability_warning: None,
3656            worktree: None,
3657            session_choice: SessionChoice::Fresh,
3658        };
3659        assert_eq!(r.tag, "forge:worker:reviewer");
3660    }
3661
3662    /// The board's edits are the user's own: no session routes them, so a
3663    /// command with no key must reach the app-level path rather than be
3664    /// dropped as unroutable.
3665    #[test]
3666    fn the_board_commands_are_app_level() {
3667        for command in [
3668            Command::TaskVerdict {
3669                project: "p".to_owned(),
3670                id: "t".to_owned(),
3671                approve: true,
3672                words: None,
3673            },
3674            Command::TaskAnswer {
3675                project: "p".to_owned(),
3676                id: "t".to_owned(),
3677                words: "w".to_owned(),
3678            },
3679            Command::TaskRank { project: "p".to_owned(), id: "t".to_owned(), to: RankMove::Top },
3680            Command::TaskAssign { project: "p".to_owned(), id: "t".to_owned(), owner: None },
3681            Command::TaskCreate { project: "p".to_owned(), subject: "s".to_owned(), parent: None },
3682            Command::TaskMove {
3683                project: "p".to_owned(),
3684                id: "t".to_owned(),
3685                to: forge_primitives::tasks::TaskStatus::InProgress,
3686                place: None,
3687            },
3688        ] {
3689            assert!(command.key().is_none(), "app-level: {command:?}");
3690        }
3691    }
3692
3693    /// The add's dispatch id crosses, and a press that named none still
3694    /// parses: the accessor is what a refusal echoes and the outcome frames
3695    /// carry, which is the identity a page keys its own answer on.
3696    #[test]
3697    fn a_marketplace_add_names_the_dispatch_its_press_made() {
3698        let named: Command =
3699            serde_json::from_str(r#"{"marketplace_add":{"source":"owner/repo","dispatch_id":41}}"#)
3700                .expect("a press that names its dispatch");
3701        assert_eq!(named.dispatch_id(), Some(41), "the accessor reads the press's own id");
3702
3703        let bare: Command = serde_json::from_str(r#"{"marketplace_add":{"source":"owner/repo"}}"#)
3704            .expect("a press that named none still parses");
3705        assert_eq!(bare.dispatch_id(), None, "and answers none");
3706
3707        assert_eq!(
3708            Command::PluginCheckUpdates.dispatch_id(),
3709            None,
3710            "a command no press names a dispatch for carries none",
3711        );
3712    }
3713
3714    /// **A prompt's press names itself too** (#1583). The refusal of a
3715    /// prompt echoes the dispatch id it carried, so a composer with sends
3716    /// out on two seats marks the one the core refused rather than both -
3717    /// and a view that minted none, which is every view before this field,
3718    /// still parses.
3719    #[test]
3720    fn a_prompt_under_names_the_dispatch_its_press_made() {
3721        let named: Command = serde_json::from_str(
3722            r#"{"prompt_under":{"key":{"org":"Org","project":"p","label":"lead"},"text":"hi","attachments":[],"uuid":"u1","source":"you","dispatch_id":9}}"#,
3723        )
3724        .expect("a press that names its dispatch");
3725        assert_eq!(named.dispatch_id(), Some(9), "the accessor reads the press's own id");
3726
3727        let bare: Command = serde_json::from_str(
3728            r#"{"prompt_under":{"key":{"org":"Org","project":"p","label":"lead"},"text":"hi","attachments":[],"uuid":"u1","source":"you"}}"#,
3729        )
3730        .expect("a press that named none still parses");
3731        assert_eq!(bare.dispatch_id(), None, "and answers none");
3732    }
3733
3734    /// A move WITHOUT a place parses: the keyboard's lane step sends the
3735    /// state alone, and the default on the field is what keeps that frame
3736    /// from coming back as an unknown message. A place that IS there comes
3737    /// through rather than being dropped.
3738    #[test]
3739    fn a_task_move_carries_an_optional_place() {
3740        let bare: Command =
3741            serde_json::from_str(r#"{"task_move":{"project":"p","id":"t","to":"in_progress"}}"#)
3742                .expect("a place-less move");
3743        match bare {
3744            Command::TaskMove { place, to, .. } => {
3745                assert_eq!(place, None, "an absent place is None");
3746                assert_eq!(to, forge_primitives::tasks::TaskStatus::InProgress);
3747            }
3748            other => panic!("parsed as {other:?}"),
3749        }
3750        let placed: Command = serde_json::from_str(
3751            r#"{"task_move":{"project":"p","id":"t","to":"waiting","place":{"before":"a"}}}"#,
3752        )
3753        .expect("a move with a place");
3754        match placed {
3755            Command::TaskMove { place, .. } => {
3756                assert_eq!(place, Some(RankMove::Before(Some("a".to_owned()))));
3757            }
3758            other => panic!("parsed as {other:?}"),
3759        }
3760    }
3761
3762    /// A spawn frame written before `cwd` existed parses: the recorded
3763    /// frames and the in-repo client's own payloads predate the key, and
3764    /// the default on the field is what keeps one from coming back as an
3765    /// unknown message. A key that IS there comes through rather than
3766    /// being dropped.
3767    #[test]
3768    fn a_spawn_worker_carries_an_optional_cwd() {
3769        let frame = |cwd: &str| {
3770            format!(
3771                r#"{{"spawn_worker":{{"project_key":"proj","label":"w","charter":"c","spawned_by":{{"org":"o","project":"p","label":"lead"}},"resume_existing":null,"kick":null,"resume_kick":null,"interactive":false,"mcp_families":null{cwd},"from_boot_respawn":false}}}}"#
3772            )
3773        };
3774        let bare: Command =
3775            serde_json::from_str(&frame("")).expect("a frame written before the key");
3776        match bare {
3777            Command::SpawnWorker { cwd, .. } => {
3778                assert_eq!(cwd, None, "an absent tree is None, which is the project's own path");
3779            }
3780            other => panic!("parsed as {other:?}"),
3781        }
3782
3783        let stated: Command =
3784            serde_json::from_str(&frame(r#","cwd":"/tmp/nazar""#)).expect("a frame with a tree");
3785        match stated {
3786            Command::SpawnWorker { cwd, .. } => {
3787                assert_eq!(cwd.as_deref(), Some("/tmp/nazar"), "a stated tree crosses whole");
3788            }
3789            other => panic!("parsed as {other:?}"),
3790        }
3791    }
3792}