diff --git a/AGENTS.md b/AGENTS.md index d54d2de0f..678f2f5db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,9 +72,11 @@ When updating code covered by a spec, update the spec to match. When the two spe Rationale entries are keyed by the spec's own headings: each `## X` in `.rationale.md` is a heading that exists in `.md`. In the spec, a rule whose evidence moved gets a trailing `(rationale)` marker — a hint, not a link; the pairing is by heading. Rationale files are informative, not normative: no `## Future`, no `Reserved:`, no `Source of truth:` obligations, and measurements should be dated (`measured in Safari 26.5, 2026-08`) so later pruning is safe. Within spec paragraphs, the normative statement leads and any remaining inline why follows — rule first, rationale after (applied opportunistically, not as a corpus rewrite). +**House form for rules.** A rule leads with a bolded imperative — **Never …**, **Must …**, **May …** — and at most one clause of why; the bold carries the emphasis, so scaffolding words ("deliberately", "note that", "it is worth stating") are deleted rather than kept. A section that is really a rule list, precedence ladder, or flow renders as invariant bullets or a table, not narrative prose; number rules only where another spec cites them. Mechanism that constrains a single module lives as a comment at that code, with the spec keeping the one-line rule and a `Source of truth:` pointer; mechanism that constrains editors of *other* files stays in the spec. Consolidate `Source of truth:` pointers at the end of a section rather than per paragraph. + When editing specs, keep them concise but do not replace invariants or edge cases with only a code pointer. Use `Source of truth:` for implementation references, and include direction/scope for protocols, command orchestration, and cross-package boundaries. For docs-only compression, spot-check referenced symbols, message directions, and root-vs-package script ownership against code before committing. -Every spec that uses Session / Pane / Door / baseboard / passthrough vocabulary leads with a `> See \`docs/specs/glossary.md\` for ...` blockquote (see `layout.md`, `alert.md`, `terminal-state.md`). When introducing glossary vocabulary into a spec that lacks the callout, add it in the same edit. +Every spec that uses Session / Pane / Door / baseboard / passthrough vocabulary leads with a `> See \`docs/specs/glossary.md\` for ...` blockquote (see `layout.md`, `alert.md`, `terminal-state.md`). When introducing glossary vocabulary into a spec that lacks the callout, add it in the same edit. The callout licenses using glossary terms bare — do not re-explain them locally. A spec's opening blockquotes are its front-matter: the callout plus, where useful, one line each for what the spec owns, what it defers and to whom, and what to read first; ownership is stated there once, not re-disclaimed per section. ### Spec lifecycle @@ -86,7 +88,7 @@ Specs are written ahead of the code on purpose: a new component's spec starts as - **Reservations.** When unbuilt design constrains present code — a reserved wire field, a reserved ref grammar, an additive-evolution guarantee — state that constraint in the body, marked `Reserved:`, pointing at the `## Future` item it serves. Test: if deleting the sentence would let someone break future compatibility today, it belongs in the body. - **Promotion is part of done.** Implementing a staged item is not finished until its text moves above the fold — rewritten from "will" to "is", with `Source of truth:` added — and the built portion is deleted from `## Future`. Never leave completed plan text (build orders, phase lists) below the fold; delete it — git history keeps the record. -The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, backticked repo paths existing on disk, the leading glossary callout wherever its vocabulary is used, scopes defined exactly once with references resolving, `Reserved:` paragraphs naming `## Future` or a scope, and every `*.rationale.md` pairing with its spec, keyed by that spec's headings, with no `## Future`. `SELF_HOST.md` — the one spec living outside `docs/specs/` — rides the same checks. +The mechanically checkable parts of these conventions are enforced by `scripts/spec-lint.mjs` (`pnpm lint:specs`, also the first step of the root `pnpm test`): every spec indexed here, `## Future` last, relative links/anchors resolving, backticked repo paths existing on disk, the leading glossary callout wherever its vocabulary is used, scopes defined exactly once with references resolving, `Reserved:` paragraphs naming `## Future` or a scope, and every `*.rationale.md` pairing with its spec, keyed by that spec's headings, with no `## Future`. It also ratchets file size: every spec, rationale file, and this file carries a word budget in `scripts/spec-word-budgets.json`, and growth past it fails the lint — cut, or raise the budget deliberately in the same PR. `SELF_HOST.md` — the one spec living outside `docs/specs/` — rides the same checks. Three sibling lints run alongside it in `pnpm test`, each enforcing one invariant a spec states in prose: `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) for the `@xterm/*` version lockstep in `docs/specs/webgl-text.md`; `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) for the rule in `SECURITY.md` -> "Loopback Listeners" that a loopback bind is not an access control — a new listener must reference a guard module or be allowlisted with a reason; and `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) for the installer controls in `SECURITY.md` -> "Credentials at rest" and "Network posture (self-hosted)", which bind all three of `deploy/local/install-{macos,windows,linux}` and were previously enforced by nothing. Its companion `scripts/deploy-lint-selftest.mjs` proves each rule is load-bearing by deleting the control — and, for exact-count rules, adding a copy — and requiring the lint to fail. diff --git a/docs/specs/alert.md b/docs/specs/alert.md index c9ba1a640..1ab0c3e5e 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -1,8 +1,10 @@ # Alert Spec > See `docs/specs/glossary.md` for Session / Pane / Door vocabulary. This spec uses it throughout. +> +> Owns the Session Activity layer — the three alert tracks, attention, TODO, notification text and its sanitization, the two alarm sinks, and the Workspace union projection. `docs/specs/layout.md` defers here for all alert/TODO behavior and owns placement and sizing. -Terminal alert state belongs to the **Session** Activity layer. It survives Pane <-> Door movement and is destroyed with the Session. This spec also defines how the Workspace union counts browser-surface TODO flags: a browser Surface has no Session Activity machine, can never ring, and carries only a user-set TODO flag as Surface state that is destroyed with that browser Surface. +Alert state belongs to the Session Activity layer: it survives Pane <-> Door movement and is destroyed with the Session. A browser Surface has no Activity machine, can never ring, and carries only a user-set TODO flag as Surface state, destroyed with that Surface. Dormouse can owe the user attention in three ways: @@ -10,14 +12,14 @@ Dormouse can owe the user attention in three ways: - **Terminal report**: the PTY emitted a supported notification or progress protocol (`BEL`, `OSC 9`, `OSC 9;4`, `OSC 99`, or `OSC 777`). - **Command exit**: Dormouse saw a foreground command running while the user attended the Session, attention was lost while that same command was still running, and the command exited after at least `T_USER_ATTENTION`. -Terminal-report and command-exit alerts do not require WATCHING. All three share the same attention suppression rule — do not ring if the user is actively attending that Session at the completion moment — applied at the one seam every completion passes through (Completion events below). +Terminal-report and command-exit alerts do not require WATCHING. All three obey one suppression rule — **never ring while the user is actively attending that Session** at the completion moment — applied at the single seam every completion passes through (Completion events below). Internally these are three independent tracks — `watchingRingingCommand` (+ `outputSinceWatchingRing`), `protocolStatus` + `progress`, and `commandExitStatus` + `commandExitWatch`. Each runs IDLE -> busy/armed -> ringing without entangling the others, and each latches its own ring in the entry until it is cleared. The output/silence detector (`QuiesceDetector`) is not a track: it is an always-on observer the WATCHING track reads. ## Non-goals - No process heuristics. Dormouse never decides on its own that `vim`, `npm dev`, agents, or test runners deserve alerts. WATCHING applies only to command names the user explicitly asked for. -- No native OS notifications on the machine Dormouse runs on, and no separate progress-bar widget. The one audible local channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects. Push notifications are the deliberate exception and go only to a *remote* paired phone, never to this machine — the point is reaching a user who walked away. +- No native OS notifications on the machine Dormouse runs on, and no separate progress-bar widget. The one audible local channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects. Push notifications are the exception and go only to a *remote* paired phone, never to this machine — the point is reaching a user who walked away. - No process-tree introspection for command-exit alerts; normalized terminal semantic events are the reliable input. - No HTML, Markdown, ANSI styling, clickable actions, custom icons, or remote-controlled buttons in notification previews. - No Door-specific alert menu that changes the Door actions defined in `docs/specs/layout.md`. @@ -30,13 +32,13 @@ Public `status` is a projection — first match wins: 1. `ALERT_RINGING` if any of the three tracks is ringing. 2. `OSC_NOTIF_BUSY` if protocol progress is active. -3. The output/silence detector's own state if WATCHING is on — that is, if the rule set matches the running command. The detector runs regardless; the rule is what makes its state public. It outranks the command-exit arm deliberately — do not reorder 3 and 4 (rationale). +3. The output/silence detector's own state if WATCHING is on — if the rule set matches the running command. The detector runs regardless; the rule only makes its state public. **Never reorder 3 and 4** (rationale). 4. `COMMAND_EXIT_ARMED` if command-exit alerting is armed. 5. Otherwise `WATCHING_DISABLED`. -`awaited` sits beside `status`: true while at least one `dor await` is parked on the Session (Await below). It is derived from live waiters and is **never persisted** — a wait cannot survive the process that was blocking on it. +`awaited` sits beside `status`: true while at least one `dor await` is parked on the Session (Await below). It is derived from live waiters and **must never be persisted** — a wait cannot survive the process that was blocking on it. -Persist only `todo` and the sanitized `notification` (plus `status` for diagnostics). Restore replays those two and nothing else: it must not recreate a ring, protocol progress, or a command-exit arm. WATCHING is not per-Session state and is never persisted per Session — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` prevents old terminal output from firing notification side effects again. +**Persist only** `todo` and the sanitized `notification` (plus `status` for diagnostics); restore replays those two and **must not** recreate a ring, protocol progress, or a command-exit arm. WATCHING is not per-Session state and is never persisted per Session — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` prevents old terminal output from firing notification side effects again. ## Attention @@ -57,11 +59,14 @@ Source of truth: `cfg.alert` in `lib/src/cfg.ts` defines the shipped default for ## Completion events -Every completion — a detector settle, a command finish, a direct notification, and the end of a protocol progress cycle (completion or error) — is dispatched as a `CompletionEvent` **before** any suppression runs. Nothing is decided at the point of detection, so an observer sees the three-second `npm test` that finishes while the user is watching it and would never have rung anyone. +Every completion — a detector settle, a command finish, a direct notification, and the end of a protocol progress cycle (completion or error) — is dispatched as a `CompletionEvent` **before any suppression runs**: nothing is decided at the point of detection, so an observer sees even the three-second `npm test` that finishes attended and would never have rung anyone. -Claimants are registered per Session and get first refusal, in registration order; the first to return `true` claims the event and the rest are not offered it. A claimed event never rings, never sets TODO, and never stores an `ActivityNotification` — it stops before the ring rules. An unclaimed event falls through to its track's ring rule, which is where the attention suppression above and the command-exit armed and minimum-runtime checks live. With no claimant registered, the three tracks below behave exactly as they always have. +Claimants are registered per Session and get first refusal, in registration order; the first to return `true` claims the event and the rest are not offered it. **A claimed event never rings, never sets TODO, and never stores an `ActivityNotification`** — it stops before the ring rules. An unclaimed event falls through to its track's ring rule, which is where the attention suppression above and the command-exit armed and minimum-runtime checks live. -Two ordering rules matter. The progress cycle is cleared *before* dispatch, so a completion or error ends the cycle whether or not the event is claimed and `OSC_NOTIF_BUSY` falls back either way. And a command finish is dispatched for every watch that existed, including the short, unarmed, and attended ones the ring rule then discards. +Two ordering rules: + +- **Clear the progress cycle *before* dispatch**, so a completion or error ends the cycle whether or not the event is claimed and `OSC_NOTIF_BUSY` falls back either way. +- **Dispatch a command finish for every watch that existed**, including the short, unarmed, and attended ones the ring rule then discards. Source of truth: `registerCompletionClaimant` / `dispatchCompletion` in `lib/src/lib/alert-manager.ts`. @@ -78,11 +83,11 @@ Source of truth: `awaitCompletion` in `lib/src/lib/alert-manager.ts`, reached th | `quiet` | The Session settled, **or** the foreground command exited, **or** the Session emitted a notification | `quiet` / `exit` / `bell` | Agents that never exit — `claude`, `codex` | | `exit` | The foreground command exited. Nothing else | `exit` | Builds, test runs, migrations | -The ladder is deliberate: never narrow `quiet` to silence alone, and never let `exit` resolve on a bell (rationale). +**Never narrow `quiet` to silence alone, and never let `exit` resolve on a bell** (rationale). -`--until` has no default and is never inferred from the WATCHING rule set — that set is a human notification preference, and binding a program's wake condition to it would let an unrelated edit silently change what every `await` on the Session is waiting for (rationale). The caller states its own intent instead. +**`--until` has no default and is never inferred from the WATCHING rule set** — a human notification preference must not decide a program's wake condition (rationale). The caller states its own intent. -Settling comes from the always-on detector (WATCHING Track below), which needs no shell integration and cannot fire until it has been BUSY. A Session at a prompt is silent, but silent is not settled — which is what stops `dor send` followed immediately by an await from racing on the silence before the peer's first byte. +Settling comes from the always-on detector (WATCHING Track below), which needs no shell integration and cannot fire until it has been BUSY. A Session at a prompt is silent, but **silent is not settled**, so `dor send` followed immediately by an await cannot race on the silence before the peer's first byte. **Is there anything to wait for?** The one thing silence cannot distinguish is a peer that delivered its final answer long ago from one working quietly. @@ -91,7 +96,7 @@ Settling comes from the always-on detector (WATCHING Track below), which needs n | A foreground command is running (`commandExitWatch`) | There is something to wait for. Park, with no grace window. A silent build therefore resolves on its exit rather than being guessed at. | | Nothing running | Park for one grace window. A *command start* cancels it under either condition — it is the same "there is something to wait for" the row above tests, arriving a moment late. Under `quiet` *output* cancels it too; under `exit` output alone does not. Whichever arrives, the await goes on waiting for a real signal. Neither → resolve `cause: idle`. | -`idle` is a resolution, not a failure, and it is a distinct `cause` rather than a distinct failure (rationale). Absent shell integration, "is a command running" is unanswerable, so an `exit` await on such a shell falls back to the grace window and resolves `idle`: the host cannot tell a shell with no integration from one sitting at a prompt, so it degrades rather than erroring. +`idle` is a resolution, not a failure — a distinct `cause` (rationale). Absent shell integration, "is a command running" is unanswerable, so an `exit` await on such a shell falls back to the grace window and resolves `idle` rather than erroring. **Resolution consumes only the ring it resolved on.** An await that arrives while the Session is already ringing resolves immediately, with the cause named by *that ring's own source*: a protocol ring is `bell`, a command-exit ring is `exit`, a WATCHING ring is `quiet`. Under `exit` only a command-exit ring counts; the others are the human's and the await keeps waiting. Two of the three are gated, because their latches outlive the fact they describe: @@ -101,11 +106,11 @@ Settling comes from the always-on detector (WATCHING Track below), which needs n Consuming releases that one track's latch and **nothing else** — `todo` is neither set nor cleared, no `ActivityNotification` is dropped, `attentionDismissedRing` is untouched, and `attentionId` is never set. -**An await never sets TODO, and never clears a pre-existing one.** TODO means *a human owes this pane attention*, and after an await nobody does: a program asked to be told, was told, and acted. A TODO left by an unrelated earlier event is still owed to the human (rationale). +**An await never sets TODO, and never clears a pre-existing one.** TODO means *a human owes this pane attention*, and after an await nobody does — a program asked to be told, was told, and acted. A TODO left by an unrelated earlier event is still owed to the human (rationale). -**Absorption: absorb the summons, keep the receipt.** A completion an await consumes never latches a ring, so it does not ring the bell, speak an alarm, or push to a paired phone — the program is already handling it, and summoning the human too is noise. Nothing quieter is substituted (rationale). Absorption is **per-signal, not per-Session**: if the human independently holds a WATCHING rule on that Session, the next settle rings for them as usual. A failed await absorbs nothing — a timeout, a death, or a cancel claims no completion, so a crashed orchestration cannot silently eat the one signal that would have told the human the build finished. +**Absorption: absorb the summons, keep the receipt.** A completion an await consumes never latches a ring, so it does not ring the bell, speak an alarm, or push to a paired phone — the program is already handling it. Nothing quieter is substituted (rationale). Absorption is **per-signal, not per-Session**: if the human independently holds a WATCHING rule on that Session, the next settle rings for them as usual. **A failed await absorbs nothing** — a timeout, a death, or a cancel claims no completion, so a crashed orchestration cannot silently eat the one signal that would have told the human the build finished. -Claiming is delivery. Once a completion has been handed to an await the wait is settled and a later `cancel()` is a no-op; there is no release-after-claim. The window between claiming and the caller actually reading the outcome is therefore unacknowledged, and that is accepted (rationale). +**Claiming is delivery.** Once a completion has been handed to an await the wait is settled and a later `cancel()` is a no-op; there is no release-after-claim. The window between claiming and the caller reading the outcome is therefore unacknowledged (rationale). **Timing.** Every window derives from `cfg.alert`, so an await inherits the tuning the bell has had in the field: @@ -115,11 +120,11 @@ Claiming is delivery. Once a completion has been handed to an await the wait is | Settle — "has it stopped?" | 5000ms | `mightNeedAttention` + `needsAttentionConfirm` | | Ceiling | `timeoutMs` | `dor await`'s `--timeout` (seconds, default 600), and the only number not derived from `cfg.alert` | -`timeoutMs` is not an alert-tuning knob: it is the safety rail on a blocking call inside an agent loop, so a wedged peer cannot hang its caller forever. It is enforced **host-side**, alongside the grace and settle windows, so no intermediate hop can reap a parked await early and no caller can park forever by lying about its own deadline. The CLI accepts whole-second ceilings from 1 through 86400 (24h), and the host's `MAX_AWAIT_TIMEOUT_MS` matches; a ceiling is needed at all because `setTimeout`'s delay is a signed 32-bit millisecond count, so anything past ~24.9 days overflows and fires at once (rationale). A non-finite, non-positive, or over-ceiling host request is **rejected rather than clamped**: it settles `cancelled`, having absorbed nothing. The webview handler rejects the same values with a visible error rather than letting them settle silently. +`timeoutMs` is not an alert-tuning knob: it is the safety rail on a blocking call inside an agent loop, so a wedged peer cannot hang its caller forever. **Enforce it host-side**, alongside the grace and settle windows, so no intermediate hop can reap a parked await early and no caller can park forever by lying about its own deadline. The CLI accepts whole-second ceilings from 1 through 86400 (24h) and the host's `MAX_AWAIT_TIMEOUT_MS` matches; a ceiling exists at all because `setTimeout` overflows past ~24.9 days (rationale). A non-finite, non-positive, or over-ceiling host request is **rejected rather than clamped**: it settles `cancelled`, having absorbed nothing. The webview handler rejects the same values with a visible error rather than letting them settle silently. Several awaits may park on one Session. They share a single claimant, so one completion is delivered to every await whose condition it satisfies rather than only to whoever registered first, and each resolves on the first qualifying signal after it registered. -In VS Code the `AlertManager` lives in the extension host while `dor` control requests land in a webview, so an await crosses that boundary: the webview posts `alert:await` and, if it gives up, `alert:awaitCancel`; the host answers exactly one `alert:awaitResult` per request — a cancel included, so a claim is never released twice. The wait itself never leaves the host. A webview that disposes cancels everything it had parked, because a caller that cannot be answered must not go on absorbing, and it answers those requests itself *synchronously*, since the cancelled outcome would otherwise arrive a microtask after the router stopped posting. `cancelled` has no wire outcome of its own — the webview reports it to `dor` as an error, which is also what forgets the in-flight control request. Source of truth: `vscode-ext/src/message-router.ts` and `VSCodeAdapter.alertAwait`; the other hosts run the `AlertManager` in-process and call `awaitCompletion` directly. The Pocket phone adapter has no `dor` and protocol-v1 carries no await, so it settles any request `cancelled` at once rather than parking a promise that can never resolve. +In VS Code the `AlertManager` lives in the extension host while `dor` control requests land in a webview, so an await crosses that boundary: the webview posts `alert:await` and, if it gives up, `alert:awaitCancel`; the host answers **exactly one** `alert:awaitResult` per request — a cancel included, so a claim is never released twice. The wait itself never leaves the host. A disposing webview **must cancel everything it had parked**, because a caller that cannot be answered must not go on absorbing, and **must answer those requests itself *synchronously*** — the cancelled outcome would otherwise arrive a microtask after the router stopped posting. `cancelled` has no wire outcome of its own — the webview reports it to `dor` as an error, which is also what forgets the in-flight control request. Source of truth: `vscode-ext/src/message-router.ts` and `VSCodeAdapter.alertAwait`; the other hosts run the `AlertManager` in-process and call `awaitCompletion` directly. The Pocket phone adapter has no `dor` and protocol-v1 carries no await, so it settles any request `cancelled` at once rather than parking a promise that can never resolve. | Situation | Outcome | |---|---| @@ -139,7 +144,7 @@ In VS Code the `AlertManager` lives in the extension host while `dor` control re **The output/silence detector is always on.** Every Session runs one `QuiesceDetector` for its whole lifetime, fed by every output chunk and reset at every command boundary. It is a plain observer: it never latches and knows nothing about attention or rules. The rule set decides only whether the detector's state is publicly visible and whether a settle — a busy Session that stayed quiet — is allowed to *ring*. -Because a chunk creates the Session's entry, `remove(id)` also has to keep it removed: `disposeSession` retires the alert state and only *then* kills the PTY, so output already in flight would otherwise rebuild an entry and a detector that nothing ever disposes. Raw output and resizes therefore cannot revive a retired id — they are exactly what a dying PTY emits. A semantic or protocol event can, because an id may be handed to a replacement pane, and its first reported command start is the evidence that somebody is home. +**A retired id must stay retired.** Because a chunk creates the Session's entry, `disposeSession` retires the alert state and only *then* kills the PTY — otherwise output already in flight rebuilds an entry and a detector nothing ever disposes. **Raw output and resizes never revive a retired id**; they are exactly what a dying PTY emits. A semantic or protocol event may, because an id can be handed to a replacement pane and its first reported command start is the evidence that somebody is home. Rules: @@ -160,30 +165,34 @@ Rules: | `MIGHT_NEED_ATTENTION` | A busy Session went quiet. Debounce state. | | `ALERT_RINGING` | WATCHING observed likely completion while the Session lacked attention. | -Source of truth: `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts` implements the detector's `QuiesceStatus` transitions; `AlertManager.onSettled` in `lib/src/lib/alert-manager.ts` reports every settle into the completion-event seam above, whose `settled` ring rule decides whether it rings. Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`; theme changes, remounts, DOM reparenting, selection, and focus changes are not output. The invariants the implementation must honor: +Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`; theme changes, remounts, DOM reparenting, selection, and focus changes are not output. The invariants the implementation must honor: - Output drives the detector up the chain `NOTHING_TO_SHOW` -> `MIGHT_BE_BUSY` -> `BUSY`; silence drives it down `BUSY` -> `MIGHT_NEED_ATTENTION` -> settled. The `MIGHT_*` states are debounce windows in both directions. - First output starts candidate tracking without changing status; unconfirmed `MIGHT_BE_BUSY` returns to `NOTHING_TO_SHOW`. -- The detector never holds `ALERT_RINGING`. A settle is reported once and the detector immediately returns to `NOTHING_TO_SHOW`; the ring it may raise latches in the Session entry (`watchingRingingCommand`), which is what makes the public status `ALERT_RINGING` and what keeps it there through further output. -- A settle rings only if a rule matches the foreground command *and* the Session lacks attention at the confirmation moment. Attention at confirmation time suppresses the ring. -- Attention alone never resets the detector: an in-flight `BUSY` -> `MIGHT_NEED_ATTENTION` -> settled transition continues, so a parked quiet await still receives its completion. Only attending or dismissing an actual WATCHING ring resets it, to `NOTHING_TO_SHOW`, so the tail of the run that just rang cannot immediately settle again. -- Rings must be caused by a fresh transition — a settle the detector just reported — never by rerender, theme change, remount, minimize, or reattach. +- **The detector never holds `ALERT_RINGING`.** A settle is reported once and the detector immediately returns to `NOTHING_TO_SHOW`; the ring it may raise latches in the Session entry (`watchingRingingCommand`), which makes the public status `ALERT_RINGING` and keeps it there through further output. +- **A settle rings only if** a rule matches the foreground command *and* the Session lacks attention at the confirmation moment. +- **Attention alone never resets the detector**: an in-flight `BUSY` -> `MIGHT_NEED_ATTENTION` -> settled transition continues, so a parked quiet await still receives its completion. Only attending or dismissing an actual WATCHING ring resets it, to `NOTHING_TO_SHOW`, so the tail of the run that just rang cannot immediately settle again. +- **Rings must be caused by a fresh transition** — a settle the detector just reported — never by rerender, theme change, remount, minimize, or reattach. + +Source of truth: `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts` implements the `QuiesceStatus` transitions; `AlertManager.onSettled` in `lib/src/lib/alert-manager.ts` reports every settle into the completion-event seam above, whose `settled` ring rule decides whether it rings. ## Terminal reports Terminal notifications are explicit requests for attention and are independent of WATCHING. A direct notification rings immediately only when the Session lacks attention; if the user has attention it is suppressed and unrelated protocol progress is left alone. A ring sets `todo = true`, stores the latest sanitized `ActivityNotification`, and sets `protocolStatus = ALERT_RINGING`; clearing it returns `protocolStatus` to `IDLE` and public status falls back to the other tracks. -Sequence syntax for every row below lives in `docs/specs/terminal-escapes.md`; parsing is `lib/src/lib/terminal-protocol.ts`. What each one means here: +Sequence syntax for every row below lives in `docs/specs/terminal-escapes.md`. What each one means here: -- **Standalone `BEL`** — a `BEL` outside an OSC is stripped from visible output and creates `TERMINAL_BELL_NOTIFICATION`. If the same parse batch also holds a richer OSC notification or progress event, drop the generic bells so they cannot overwrite useful preview text; multiple bells in one batch collapse to one notification. +- **Standalone `BEL`** — a `BEL` outside an OSC is stripped from visible output and creates `TERMINAL_BELL_NOTIFICATION`. If the same parse batch also holds a richer OSC notification or progress event, **drop the generic bells** so they cannot overwrite useful preview text; multiple bells in one batch collapse to one notification. - **`OSC 9`** — the message becomes the body, title null. Empty sanitized messages are ignored. It also feeds title-candidate derivation in `docs/specs/terminal-state.md`, which does not change alert behavior. - **`OSC 777`** — only the `notify` subcommand is supported. The first field after `notify` is the title; everything after the next semicolon is body, preserving semicolons there. Unsupported subcommands and empty sanitized notifications are ignored. - **`OSC 99`** (kitty) — metadata keys are single ASCII letters separated by `:`; unknown keys are ignored. `i` groups chunks of one pending notification, `d` is the done flag (default `1`), `e` selects plain or base64 payload encoding, and `p` selects the payload type (default `title`). `title`/`body` chunks append to the pending notification; completion rings once if the sanitized title or body is nonempty. Without `i`, only a complete single-sequence notification is meaningful. Management payloads contribute no content and are consumed: `p=?` sends `OSC99_SUPPORT_PAYLOAD` and `p=close` / `p=alive` are dropped outright, touching no pending notification. Any *other* unknown payload type still obeys kitty's done-flag semantics — carrying the default `d=1` it completes a pending same-`i` notification, which may then ring on its accumulated title/body. The pending-chunk TTL and max-pending-id cap live in `terminal-protocol.ts`. -- **`OSC 9;4` progress** — progress only: no title, body, urgency, id, app name, or action fields. Active normal, warning, or indeterminate progress sets `protocolStatus = OSC_NOTIF_BUSY` and creates no TODO; it never rings because of silence. `state=1, progress=100` rings as completion and `state=2` rings as error, both only when unattended. A clear rings as completion only if there was an active cycle, otherwise it is ignored. Warning progress does not ring by itself, but completing a warning cycle rings with a generated warning title. Invalid states, missing required percents for states `1` and `4`, and out-of-range percents are ignored. Completion or error while attended clears the progress without TODO or ring. Source of truth for the generated titles/bodies: `completeProtocolProgress` / `finishProtocolProgressCycle` in `lib/src/lib/alert-manager.ts`. +- **`OSC 9;4` progress** — progress only: no title, body, urgency, id, app name, or action fields. Active normal, warning, or indeterminate progress sets `protocolStatus = OSC_NOTIF_BUSY` and creates no TODO; it never rings because of silence. `state=1, progress=100` rings as completion and `state=2` rings as error, both only when unattended. A clear rings as completion only if there was an active cycle, otherwise it is ignored. Warning progress does not ring by itself, but completing a warning cycle rings with a generated warning title. Invalid states, missing required percents for states `1` and `4`, and out-of-range percents are ignored. Completion or error while attended clears the progress without TODO or ring. + +Source of truth: parsing, sanitization limits, and OSC 99 chunk state in `lib/src/lib/terminal-protocol.ts`; the generated progress titles/bodies in `completeProtocolProgress` / `finishProtocolProgressCycle` in `lib/src/lib/alert-manager.ts`. ## Command-exit Track -The command-exit track consumes normalized semantic command events from `docs/specs/terminal-state.md` (`OSC 133`, `OSC 633`, or equivalent). It must not parse raw OSC itself. +The command-exit track consumes normalized semantic command events from `docs/specs/terminal-state.md` (`OSC 133`, `OSC 633`, or equivalent) and **must not parse raw OSC itself**. Rules: @@ -216,7 +225,7 @@ Clearing behavior: ## Alarm settings -A second app-global store sits beside the WATCHING rule set: the alarm settings, edited in the app-global **Settings** dialog reached from the far right of the baseboard (`lib/src/components/SettingsDialog.tsx`). That dialog also carries the theme picker ([theme.md](./theme.md)), the shell picker ([standalone.md](./standalone.md)), and the remote-control section ([server.md](./server.md)); the alarm sections specified here are the rest of it. Each of those keeps its own store — none is folded into `AlertSettings`, which is relayed wholesale to the VS Code extension host. +A second app-global store sits beside the WATCHING rule set: the alarm settings, edited in the app-global **Settings** dialog (below). That dialog also carries the theme picker ([theme.md](./theme.md)), the shell picker ([standalone.md](./standalone.md)), and the remote-control section ([server.md](./server.md)); the alarm sections specified here are the rest of it. **Each of those keeps its own store — never fold one into `AlertSettings`**, which is relayed wholesale to the VS Code extension host. Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mirror, persisted at `dormouse:alert-settings`) and `lib/src/lib/alert-settings-host.ts` (multi-renderer coordinator). @@ -228,23 +237,29 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi Rules: -- Every field is validated and clamped on read *and* on write (`normalizeAlertSettings`), so a hand-edited `localStorage` blob or a hostile message can never install a `NaN` or absurd timer. Unknown keys are dropped and missing keys defaulted, so the blob evolves additively with no version field. The shipped defaults come from `cfg.alert`, keeping `lib/src/cfg.ts` the one place a default is written down. -- Distribution follows the WATCHING rule set's seed/broadcast shape, and for the same reason: each VS Code webview has its own origin and therefore its own `localStorage`, while the `AlertManager` is shared. The one difference is that an edit relays the **whole** blob rather than a per-command delta — not just the field the host consumes — so two webviews cannot disagree about whether alarms speak. The host revalidates everything it receives. +- **Validate and clamp every field on read *and* on write** (`normalizeAlertSettings`), so a hand-edited `localStorage` blob or a hostile message can never install a `NaN` or absurd timer. Unknown keys are dropped and missing keys defaulted, so the blob evolves additively with no version field. The shipped defaults come from `cfg.alert`, keeping `lib/src/cfg.ts` the one place a default is written down. +- Distribution follows the WATCHING rule set's seed/broadcast shape, and for the same reason: each VS Code webview has its own origin and therefore its own `localStorage`, while the `AlertManager` is shared. The one difference is that an edit **relays the whole blob** rather than a per-command delta, so two webviews cannot disagree about whether alarms speak. The host revalidates everything it receives. - Single-webview hosts (standalone, browser sidecar, Storybook) own the `AlertManager` in the renderer, so they apply the settings inline and broadcast nothing back. -**Both sinks run over one machine**, `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts`, rather than each carrying a copy of rules subtle enough to drift. It detects a *fresh* transition into `ALERT_RINGING` — any of the three tracks; "not attended" is track-agnostic — waits that sink's delay, then re-reads both the ring and the setting before firing, so attending, dismissing, killing the Pane, or switching the sink off during the delay cancels. A Session observed for the first time *already* ringing never fires: that is what keeps a restore or reconnect replaying a latched ring silent, and a restored session blob from buzzing the phone at every launch. One fire per ring — a Session that rings, is cleared, and rings again fires twice — and Sessions are independent, as are the two sinks: both fire when both are on, each on its own delay. +**Both sinks run over one machine**, `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts`, rather than each carrying a copy of rules subtle enough to drift: + +- It fires on a *fresh* transition into `ALERT_RINGING` — any of the three tracks; "not attended" is track-agnostic. +- **Re-read both the ring and the setting after the delay**, so attending, dismissing, killing the Pane, or switching the sink off during the delay cancels. +- **A Session observed for the first time *already* ringing never fires** — what keeps a restore or reconnect replaying a latched ring silent, and a restored session blob from buzzing the phone at every launch. +- One fire per ring: a Session that rings, is cleared, and rings again fires twice. +- Sessions are independent, as are the two sinks — both fire when both are on, each on its own delay. ### Spoken alarms When a Session rings and stays unattended for `speakDelayMs`, Dormouse says that Pane's name out loud. Source of truth: `lib/src/lib/alert-speech.ts`, armed once by `useAlertSpeech` in `Wall`. -- **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation, shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken whenever that text wins the normal Pane-label derivation. This is deliberate: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied it. The ringing `ActivityNotification` is never itself the speech payload. -- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, asterisks, and control characters become spaces, whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. This is a security requirement, not tidiness — WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like `` and terminal-supplied titles reach speech, so without this any program could permanently disable spoken alarms for the session by putting a `<` in its title. Asterisks are substituted for clarity rather than safety: `eight *` must not be announced as "eight asterisk". +- **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation, shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken whenever that text wins the normal Pane-label derivation: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied it. **The ringing `ActivityNotification` is never itself the speech payload.** +- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, asterisks, and control characters become spaces, whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness:** WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like `` and terminal-supplied titles reach speech, so without this any program could permanently disable spoken alarms for the session by putting a `<` in its title. Asterisks are substituted for clarity rather than safety: `eight *` must not be announced as "eight asterisk". - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts` is a renderer-local `speaking | spoken` map keyed by Session. The engine's `start` event publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`. An utterance that never starts publishes neither. Each utterance carries an opaque generation token, so a late callback from a resolved or older ring cannot overwrite a newer ring or resurrect a cleared marker. - **Nothing in the settle path may assume the callback arrives after `speak()` returns.** An engine may dispatch `start` and then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). So the handlers close over the utterance itself and registration happens before dispatch; reading a variable the caller assigns afterward would drop the settle and pin the Session at `speaking` for the rest of the ring. A dispatch the engine refuses outright settles too. -- **Attending mid-sentence cuts the utterance off.** Once a deliberate action resolves the ring, finishing the sentence is noise, so the engine is silenced rather than the overlay merely un-rendered. What counts as mid-sentence is the sink's own record that an utterance started — its generation token — not the rendered `speaking` state. Web Speech has no per-utterance stop, so `cancel()` empties the whole queue: every still-ringing Session whose current-ring utterance had been accepted but not started is re-dispatched, because attending one Pane must not silence another Pane's alarm. A re-dispatch is a fresh decision to speak, held to the same gates as the first (attended meanwhile, or the setting switched off, drops out). A queued entry is pruned as soon as its ring resolves, so a later unrelated `cancel()` cannot re-dispatch a stale one, bypass the new ring's delay, and speak twice. A Session that is only queued is never cut — it has nothing audible to stop, and cutting it would take the Pane that *is* talking with it. -- **Teardown silences the engine, not just the callbacks.** Detaching handlers only protects the renderer's own state; `speechSynthesis` still owns its queue, so the disposer calls `cancel()`. Otherwise a webview that unmounts mid-alarm — closing a VS Code webview, switching workspaces — keeps reading Pane names aloud with no visible source and no UI left to stop it. -- **In-flight tracking is bounded.** A dropped utterance (the WebKit wedge above) never fires a callback to retire itself, so the tracking set and the Session-keyed queued index evict their oldest entry past a small shared cap rather than pinning an utterance and handler closure per ring for the life of the app. An evicted utterance that does still fire settles normally; it is merely no longer eligible for collateral re-dispatch. After teardown the generation token makes any late callback inert. +- **Attending mid-sentence cuts the utterance off** — silence the engine, not merely un-render the overlay. Mid-sentence is the sink's own record that an utterance started — its generation token — never the rendered `speaking` state. Web Speech has no per-utterance stop, so `cancel()` empties the whole queue: **re-dispatch every still-ringing Session whose current-ring utterance was accepted but never started**, because attending one Pane must not silence another Pane's alarm, and hold each re-dispatch to the same gates as the first (attended meanwhile, or the setting switched off, drops out). **Prune a queued entry as soon as its ring resolves**, so a later unrelated `cancel()` cannot re-dispatch a stale one, bypass the new ring's delay, and speak twice. **Never cut a Session that is only queued** — it has nothing audible to stop, and cutting it would take the Pane that *is* talking with it. +- **Teardown must `cancel()` the engine, not just detach the callbacks** — detaching protects only the renderer's own state, and a webview that unmounts mid-alarm would keep reading Pane names aloud with no visible source and no UI left to stop it. +- **In-flight tracking is bounded.** A dropped utterance (the WebKit wedge above) never fires a callback to retire itself, so the tracking set and the Session-keyed queued index evict their oldest entry past a small shared cap. An evicted utterance that does still fire settles normally; it is only no longer eligible for collateral re-dispatch. - `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`. Any deliberate action that resolves the ring clears it: clicking or entering the Pane, typing in passthrough, clicking/pressing `Enter` on its Door, dismissing the bell, or marking/clearing TODO. Mere visibility, hover, or command-mode selection does not. Killing the Session also clears it. The state is not persisted or sent to the host, so restore/reconnect never recreates it. - Renderer-side, via `window.speechSynthesis`. Where that is absent — Tauri on Linux (WebKitGTK ships no speech backend), or a test environment — speaking is a silent no-op rather than an error. `speak()` is the single seam a native host path would replace. - Desktop shell only: `MobileWall` / Pocket does not arm it and has no settings UI (no baseboard, so no Settings dialog). @@ -253,16 +268,16 @@ When a Session rings and stays unattended for `speakDelayMs`, Dormouse says that When a Session rings and stays unattended for `pushDelayMs`, Dormouse sends that Pane's name to every paired phone that has enabled alerts. Desktop shell only, and only where a Host runs — a build with no enrollment has nowhere to push. -**The two halves run in different processes.** Ring *detection* is webview state — the activity store, the alarm settings, the Pane's derived label — so `watchPushRings` (`lib/src/remote/host/alert-push.ts`) stays in the webview and fires one `push { sessionId, title }` command at the Host service. *Delivery* needs the enrollment and the ACL, which only the Host holds, so `sendPush` (`lib/src/remote/host/push-delivery.ts`) runs in the service's process and touches no DOM or store. **A webview cannot choose recipients:** it names the Session and what to call it; the service reads its own active ACL at send time. Watching is armed only while the service reports an enrollment (`enrolled-gate.ts`), so a machine that never enrolls pays no activity-store subscription, and a `push` arriving with no Host running is simply not sent. Both halves live under `remote/host/` to keep the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere. +**The two halves run in different processes.** Ring *detection* is webview state — the activity store, the alarm settings, the Pane's derived label — so `watchPushRings` (`lib/src/remote/host/alert-push.ts`) stays in the webview and fires one `push { sessionId, title }` command at the Host service. *Delivery* needs the enrollment and the ACL, which only the Host holds, so `sendPush` (`lib/src/remote/host/push-delivery.ts`) runs in the service's process and touches no DOM or store. **A webview cannot choose recipients:** it names the Session and what to call it; the service reads its own active ACL at send time. Watching is armed only while the service reports an enrollment (`enrolled-gate.ts`), so a machine that never enrolls pays no activity-store subscription, and a `push` arriving with no Host running is not sent. Both halves live under `remote/host/` to keep the sink inside the lazily-imported `RemotePairingModalHost` chunk, so hosts that never set `enableRemoteHost` never fetch it; the shared ring machine and the device store stay in the common bundle, since speech and the settings dialog need them everywhere. - **The derived Pane label is the payload**, on the same rule as speech: the ringing `ActivityNotification`'s title/body is not selected as the payload, but terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text can appear when it is the winning Pane label. The body is a fixed string; the Pane name carries the information. -- **The label is sanitized by `toPushText` at send time, in the delivery half, and deliberately not by `toSpokenText`'s rule.** It keeps angle brackets — the speech restriction exists only because WebKit's synthesizer wedges on them — and instead strips control characters and the Unicode bidi and zero-width format characters (including the Arabic letter mark), which can visually reorder or hide text in an OS notification; the cap counts code points, so a cut never ships half a surrogate pair. `toPushText` is only this sink's limit and fallback over `boundedPushText`, which lives in `server-lib-common/src/security/push.ts` so the Host and the Server run the *same* rule rather than a strong copy and a weak one; `lib/pocket/public/sw.js` mirrors it a third time at the render sink, being a verbatim-copied file that can import nothing. -- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host deliberately does **not** ask which devices are subscribed first (rationale). +- **The label is sanitized by `toPushText` at send time, in the delivery half, and not by `toSpokenText`'s rule.** It keeps angle brackets — the speech restriction exists only because WebKit's synthesizer wedges on them — and instead strips control characters and the Unicode bidi and zero-width format characters (including the Arabic letter mark), which can visually reorder or hide text in an OS notification; the cap counts code points, so a cut never ships half a surrogate pair. `toPushText` is only this sink's limit and fallback over `boundedPushText`, which lives in `server-lib-common/src/security/push.ts` so the Host and the Server run the *same* rule rather than a strong copy and a weak one; `lib/pocket/public/sw.js` mirrors it a third time at the render sink, being a verbatim-copied file that can import nothing — the mirror is pinned by `lib/src/remote/pocket-app/service-worker.test.ts`. +- **The Host names its targets; the Server rejects a send that does not.** Targets are the Host's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Server intersects them with its own subscriptions. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a revoked Client keeps its subscription row — a Server that chose recipients itself would keep pushing Pane labels to a de-authorized phone. The Host does **not** ask which devices are subscribed first (rationale). - **One notification per Session at a time.** Each push carries the Session id as a collapse tag, so a Pane that rings, is cleared, and rings again replaces its own notification rather than stacking copies on the lock screen. - **A push already delivered is never recalled.** Attending during the delay cancels it like any sink, but once it is out, reaching the phone again means sending a second push, and `userVisibleOnly` guarantees that would itself be visible — recall would trade one stale notification for one confusing one. - Delivery is an HTTP POST to the Server, not a relay frame ([server.md](./server.md) -> Web Push). The relay routes between two live sockets; a push exists to reach a phone whose app is closed. -- A failed send warns and is dropped, in both failure classes: a non-2xx response is checked rather than ignored, so a revoked host token cannot leave push permanently broken and silent; and a 2xx whose `PushSendResponse` counts report `failed > 0` or `delivered: 0` warns too, because the Server answers 200 even when every push service refused delivery. There is nothing useful to retry against — by the next ring the alarm is already stale. -- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`). A phone can enable alerts long after this machine booted, so a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. The list is the Host's join of the Server's subscriptions against its own ACL labels, so it comes back over the same bridge as a `pushDevices` command and answers `null` — rendered `no-host` — when no Host is running. Writes are fenced on request order (latest-request-wins), so a slow startup refresh cannot overwrite a newer dialog refresh. The same fence carries "the Host went away": the enrolled gate's disarm calls `invalidatePushDeviceRefreshes()` and `clearPushDevices()`, so a request already on the wire cannot land afterwards and repopulate the dialog with phones there is nothing left to push to. `clearPushDevices` returns the store to `no-host` and *keeps* the refresher installed, so the dialog can still ask on an un-enrolled machine and be told `no-host`; `resetPushDevices` drops the refresher too and is full teardown (a Storybook story, a test). +- **A failed send warns and is dropped**, in both failure classes: a non-2xx response is checked rather than ignored, so a revoked host token cannot leave push permanently broken and silent; and a 2xx whose `PushSendResponse` counts report `failed > 0` or `delivered: 0` warns too, because the Server answers 200 even when every push service refused delivery. **Never retry** — by the next ring the alarm is already stale. +- The settings dialog re-reads the device list when it opens (`refreshPushDevicesNow`), because a phone can enable alerts long after this machine booted and a list fetched only at Host start would name the wrong devices — or none — for the rest of the session. The list is the Host's join of the Server's subscriptions against its own ACL labels, so it comes back over the same bridge as a `pushDevices` command and answers `null` — rendered `no-host` — when no Host is running. **Writes are fenced on request order** (latest-request-wins), so a slow startup refresh cannot overwrite a newer dialog refresh. The same fence carries "the Host went away": the enrolled gate's disarm calls `invalidatePushDeviceRefreshes()` and `clearPushDevices()`, so a request already on the wire cannot land afterwards and repopulate the dialog with phones there is nothing left to push to. `clearPushDevices` returns the store to `no-host` and *keeps* the refresher installed, so the dialog can still ask on an un-enrolled machine and be told `no-host`; `resetPushDevices` drops the refresher too and is full teardown (a Storybook story, a test). ### Settings dialog @@ -270,17 +285,17 @@ When a Session rings and stays unattended for `pushDelayMs`, Dormouse sends that Reached from any of the controls at the far right of the baseboard; placement and the baseboard's right cluster belong to `docs/specs/layout.md`. Source of truth: `lib/src/components/SettingsDialog.tsx`. The alarm sections below sit under the theme and shell rows; when both are hidden (VS Code owns the theme and the shells), the rule list is first and drops its section divider. - Lists every watched command with a remove control, and **cannot add one**. WATCHING is keyed on a running command's name, so creating a rule stays a bell click / `a` press in the tab running it; the empty state says so. This dialog and the bell dialog are the two places a rule set on a since-closed Pane can be found and removed — they render the same `WatchedCommandList`, so the list has one implementation. -- Delays are shown in seconds and committed on blur or `Enter`, never per keystroke — typing `3` on the way to `30` must not briefly install a 3-second timer. An out-of-range or empty entry snaps back to whatever the store clamped it to. +- **Delays are committed on blur or `Enter`, never per keystroke** — typing `3` on the way to `30` must not briefly install a 3-second timer. They are shown in seconds; an out-of-range or empty entry snaps back to whatever the store clamped it to. - The push group's device line names every device a push would reach, and otherwise states why there is none — no Host enrolled, nothing subscribed yet, or the server could not be asked. A push that silently goes nowhere is indistinguishable from a broken one. - Each alarm sink carries a **try it now** control — **Play test sound** and **Send test push** — because an alarm is otherwise unobservable until it fires unattended, which is the moment its being wrong costs the most. Source of truth: `lib/src/components/AlarmTestButtons.tsx`. Both sit outside the switch's dimming and stay enabled while the sink is off: checking that the speakers work, or that the phone buzzes, is most useful *before* committing to the alarm. Each reports its own outcome inline and clears it after a few seconds, because for both sinks a working path and a broken one produce the same observation — silence. - - **Play test sound** speaks a fixed phrase through the same sanitizer as a real alarm, but deliberately not through `speak()`: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. It reports a webview with no speech backend rather than degrading silently the way the alarm path correctly does. + - **Play test sound** speaks a fixed phrase through the same sanitizer as a real alarm, but **not through `speak()`**: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. It reports a webview with no speech backend rather than degrading silently the way the alarm path correctly does. - **Send test push** goes through the real Host, ACL and server, so what it proves is what the alarm will do. It is the one caller of the push path that must **not** swallow failures — the ring path's rule that a failed push never breaks the alert path would make a test button report success over a fan-out that reached nobody. It distinguishes four outcomes: no devices targeted (the ordinary answer on a freshly enrolled machine, and not a failure), nothing delivered, a partial fan-out, and success. The button is hidden entirely where no Host service exists, matching the Remote control section ([server.md](./server.md)). ## Workspace union -> See `docs/specs/glossary.md` for the Workspace / Window containers and the definitions of the three union fields (`ringing`, `todo`, `count`). +> `docs/specs/glossary.md` defines the Workspace / Window containers and the three union fields (`ringing`, `todo`, `count`). -The projection is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts`. It is display-only: it never enters the Activity state machine and never fires a ring of its own, so it simply mirrors whichever per-Session rings survive attention suppression. A Surface with no activity entry contributes nothing, and one that is both ringing and TODO counts once. Callers must include minimized (`Doored`) Surfaces — and, once Workspaces are more than one, the Surfaces of inactive (unmounted) Workspaces — because a Session's Activity survives minimize and unmount (glossary I2/I3) and a browser Surface's `todo` survives in its persisted `alert` blob. +The projection is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts`. **Display-only:** it never enters the Activity state machine and never fires a ring of its own, mirroring whichever per-Session rings survive attention suppression. A Surface with no activity entry contributes nothing, and one that is both ringing and TODO counts once. Callers **must include** minimized (`Doored`) Surfaces — and, once Workspaces are more than one, the Surfaces of inactive (unmounted) Workspaces — because a Session's Activity survives minimize and unmount (glossary I2/I3) and a browser Surface's `todo` survives in its persisted `alert` blob. Where it surfaces is host-specific: @@ -307,11 +322,11 @@ Bell interactions — one transition table, in `dismissOrToggleAlert` in `lib/sr - Pressing `a` on the selected Pane in command mode uses the same action. Right-click always opens the dialog. - Pressing `t` toggles TODO. -The dialog carries the TODO switch, the WATCHING rule switch for the running command, notification detail, and the list of every watched command with a remove control. The list is load-bearing, not decoration: it is the only place a rule set on a since-closed Pane can be found and removed. +The dialog carries the TODO switch, the WATCHING rule switch for the running command, notification detail, and the same `WatchedCommandList` the Settings dialog renders — load-bearing, not decoration, for the reason under Settings dialog above. The TODO pill always displays `TODO`; remote notification text belongs in preview/detail surfaces, not inside the pill. Clicking the pill clears TODO. On clear, the pill briefly shows the success flourish before unmounting. -Spoken-alarm delivery is deliberately much louder than the bell. While the engine is actually speaking, a pointer-transparent treatment spans the whole terminal Pane: a wash, an animated high-contrast inset, and an explicit `SPEAKING` label. After the utterance settles the animation stops, but a static inset, a `SPOKEN` label, and a half-strength wash remain until the ring is resolved — `SPOKEN` is an unbounded window, so the haze stays light enough to read terminal text through. `prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse, as does `cfg.alert.ringingPaused` (the Chromatic freeze that pins the bell — an infinite opacity cycle would otherwise snapshot at an arbitrary phase). Layering, placement, and sizing belong to `docs/specs/layout.md`; source of truth: `lib/src/components/wall/AlertSpeechIndicator.tsx`. +Spoken-alarm delivery is much louder than the bell. While the engine is actually speaking, a pointer-transparent treatment spans the whole terminal Pane: a wash, an animated high-contrast inset, and an explicit `SPEAKING` label. After the utterance settles the animation stops, but a static inset, a `SPOKEN` label, and a half-strength wash remain until the ring is resolved — `SPOKEN` is an unbounded window, so the haze stays light enough to read terminal text through. `prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse, as does `cfg.alert.ringingPaused` (the Chromatic freeze that pins the bell — an infinite opacity cycle would otherwise snapshot at an arbitrary phase). Layering, placement, and sizing belong to `docs/specs/layout.md`; source of truth: `lib/src/components/wall/AlertSpeechIndicator.tsx`. ### Door diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 213a2ec6d..18157c35b 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -1,10 +1,10 @@ # Auto-Update Spec -> See `docs/specs/glossary.md` for Baseboard / Door vocabulary. +> See `docs/specs/glossary.md` for Baseboard / Door vocabulary. Owns the standalone updater's lifecycle; the release pipeline that publishes the update manifest it fetches is `docs/specs/deploy.md`, and the quit orchestrator that drives the install is `docs/specs/standalone.md` §Quit flow. -The standalone app checks for updates on launch and prompts in the Baseboard when one is available. Nothing is downloaded or installed until the user approves that prompt. Once approved, the download runs in the background and the install runs when the user quits. On the next launch a brief banner confirms the update succeeded, or offers a debug report if it failed. +The standalone app checks for updates on launch and prompts in the Baseboard when one is available. **Nothing is downloaded or installed until the user approves that prompt**; after approval the download runs in the background and the install runs when the user quits. On the next launch a brief banner confirms the update succeeded, or offers a debug report if it failed. -Source of truth: `standalone/src/updater.ts`. The release pipeline that publishes the manifest this spec's endpoint serves is `docs/specs/deploy.md`. +Source of truth: `standalone/src/updater.ts`. ## How it works @@ -48,21 +48,21 @@ The `Update` object returned by `check()` is held in memory as the *available* u ### Quit-time install -The install is driven by the quit orchestrator (`docs/specs/standalone.md` §Quit flow): after the graceful terminal teardown and the durable final session save land, and only when `hasPendingUpdate()` is true, the orchestrator calls `installPendingUpdate()`. That function writes the success marker *before* calling `install()` (§localStorage), and on Windows first kills the sidecar and waits for it to fully exit (§Sidecar teardown on Windows). It never closes the window itself: exiting the process is the orchestrator's `quit_proceed` job, which runs after this returns. +**The updater owns no quit interception of its own** — the install is the last step of the quit orchestrator (`docs/specs/standalone.md` §Quit flow), because it must run *after* the graceful terminal teardown and the durable final session save land or a Windows NSIS force-kill mid-teardown would lose the freshest scrollback. Only when `hasPendingUpdate()` is true does the orchestrator call `installPendingUpdate()`. That function writes the success marker *before* calling `install()` (§localStorage), and on Windows first kills the sidecar and waits for it to fully exit (§Sidecar teardown on Windows). It **never closes the window itself**: exiting the process is the orchestrator's `quit_proceed` job, which runs after this returns. In Vite dev mode (`pnpm dev:standalone`), `installPendingUpdate()` drops the pending update and skips `install()` — the updater resolves its replacement target from the current executable path, so install must be tested from a packaged app. The skip is lifted under `MODE === 'test'` so `standalone/src/updater.test.ts` can exercise the real path. ## Sidecar teardown on Windows -The NSIS installer overwrites files inside the bundled sidecar — including node-pty's native `conpty.node`. Windows refuses to overwrite a native module that a live process still has loaded, so if the Node sidecar is running when NSIS reaches `node_modules`, the install fails with *"Error opening file for writing: …\_up_\sidecar\node_modules\node-pty\prebuilds\win32-x64\conpty.node"*. Rust's `RunEvent::Exit` sidecar shutdown cannot cover this: `install()` force-kills the app and NSIS starts copying immediately, so that handler either never runs or is still polling for the sidecar's exit while NSIS is already writing. (By quit time the orchestrator's graceful teardown has killed the sidecar's *PTYs*, but the sidecar process itself is still alive holding those native modules.) +**On Windows `installPendingUpdate()` must `invoke` `kill_sidecar_now` and await it before `install()`** — the NSIS installer overwrites files inside the bundled sidecar, and Windows refuses to overwrite a native module (node-pty's `conpty.node`) that a live process still has loaded, so if the Node sidecar is running when NSIS reaches `node_modules`, the install fails with *"Error opening file for writing: …\_up_\sidecar\node_modules\node-pty\prebuilds\win32-x64\conpty.node"*. Rust's `RunEvent::Exit` sidecar shutdown cannot cover this: `install()` force-kills the app and NSIS starts copying immediately, so that handler either never runs or is still polling for the sidecar's exit while NSIS is already writing. (By quit time the orchestrator's graceful teardown has killed the sidecar's *PTYs*, but the sidecar process itself is still alive holding those native modules.) Because `pty-core` spawns with `useConptyDll: true` on Windows (see [terminal-escapes.md](terminal-escapes.md#osc-color-queries-on-windows-require-the-bundled-conpty)), the same hazard covers two more bundled files: the sidecar additionally `LoadLibrary`s node-pty's `conpty/conpty.dll`, and each pseudoconsole runs an `OpenConsole.exe` child process. `conpty.dll` is released when the sidecar exits (same as `conpty.node`); the `OpenConsole.exe` children run inside the sidecar's job object (`process_wrap`'s `JobObject`), so terminating the sidecar tears them down too. -So on Windows `installPendingUpdate()` `invoke`s `kill_sidecar_now` and awaits it before `install()`. That command is synchronous on the Rust side: it calls `start_kill()`, then polls `try_wait` every 20 ms (capped at ~5s) until the process has actually exited and released its file handles. `try_wait` is used instead of the job-object `wait()` because `wait()` consumes a completion-port message the reaper thread may already have drained — if the sidecar had crashed earlier it would block forever. The ~5s cap means a wedged sidecar cannot stall quit indefinitely. macOS and Linux can replace open files in place, so they skip the kill and rely on the existing `RunEvent::Exit` cleanup. +`kill_sidecar_now` is synchronous on the Rust side: it calls `start_kill()`, then polls `try_wait` every 20 ms (capped at ~5s) until the process has actually exited and released its file handles. **Poll `try_wait`, never the job-object `wait()`** — `wait()` consumes a completion-port message the reaper thread may already have drained, so a sidecar that had crashed earlier would block forever. The ~5s cap means a wedged sidecar cannot stall quit indefinitely. macOS and Linux can replace open files in place, so they skip the kill and rely on the existing `RunEvent::Exit` cleanup. ## Update notice in the Baseboard -Update status appears as a text notice on the right side of the Baseboard (the always-visible bottom strip — see `layout.md`). It coexists with doors and shortcut hints. +Update status appears as a text notice in the Baseboard (the always-visible bottom strip — see `layout.md`). | State | Message | Actions | Auto-dismiss | |-------|---------|---------|--------------| @@ -74,9 +74,9 @@ Update status appears as a text notice on the right side of the Baseboard (the a "Install when I quit" is the user's approval to download now and install at quit. "Changelog" calls Tauri's `getVersion()` and opens `https://dormouse.sh/changelog/after/`. When a notice has follow-up actions, ` · ` separates the message from the action labels. -All states are dismissible via [×]. Dismissing an unapproved `available` notice means no update is downloaded or installed in that session. Dismissing a `downloading` or `downloaded` notice hides it for the session only — it does not cancel an already-approved download/install. +**All states are dismissible via [×].** Dismissing an unapproved `available` notice means no update is downloaded or installed in that session. Dismissing a `downloading` or `downloaded` notice hides it for the session only — it **does not cancel** an already-approved download/install. -The notice matches the Baseboard's existing text style (`text-sm font-mono text-muted` — 12px via the theme.css `text-sm` override). The Baseboard places it inside its single right-hand `ml-auto` cluster, so it does not compete with doors or the shortcut hint on the left. +The notice matches the Baseboard's existing text style (`text-sm font-mono text-muted` — 12px via the theme.css `text-sm` override), and sits inside the Baseboard's single right-hand `ml-auto` cluster, so it does not compete with doors or the shortcut hint on the left. ### Debug report on failure @@ -86,7 +86,7 @@ Search-before-file is the reason the modal exists at all: an update failure is e ### Threading -The Baseboard is in `lib/` but the updater is standalone-only. The notice is threaded as a `ReactNode` prop: `App` → `Wall` (`baseboardNotice`) → `Baseboard` (`notice`). This keeps all updater knowledge out of `lib/` — the Baseboard just renders an opaque slot. +**No updater knowledge in `lib/`** — the Baseboard lives there but all updater code is standalone-only, so the notice is threaded through as an opaque `ReactNode` slot: `App` → `Wall` (`baseboardNotice`) → `Baseboard` (`notice`). ## Platform behavior at quit @@ -111,7 +111,7 @@ Single key: `dormouse:update-result` | Successful install | `{ "from": "0.4.0", "to": "0.5.0" }` | On next launch, after reading | | Failed install | `{ "failed": true, "version": "0.5.0", "error": "..." }` | On next launch, after reading | -The success marker is written *before* `install()` because Windows NSIS force-kills the process — if we wrote it after, it would never persist. If `install()` then throws, the marker is overwritten with a failure entry. No marker is written for an update that was found but never approved. A corrupt marker is swallowed and treated as no marker. +**The success marker is written *before* `install()`** because Windows NSIS force-kills the process — written after, it would never persist; if `install()` then throws, the marker is overwritten with a failure entry. No marker is written for an update that was found but never approved, and a corrupt marker is swallowed and treated as no marker. ## Files @@ -124,8 +124,6 @@ The success marker is written *before* `install()` because Windows NSIS force-ki | [`standalone/src/quit.ts`](../../standalone/src/quit.ts) | Quit orchestrator (owned by `docs/specs/standalone.md` §Quit flow); calls `installPendingUpdate()` as the last teardown step | | [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Owns `` (banner + modal wiring), passes it as the `baseboardNotice` prop to ``, calls `startUpdateCheck()` after restore | -All updater code is standalone-only. The Baseboard accepts a generic `notice` prop (`ReactNode`) — it has no knowledge of the updater. - ## Configuration In `standalone/src-tauri/tauri.conf.json`: @@ -152,14 +150,10 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu ## Design decisions -**Why install on quit after approval, not immediately?** Dormouse is a terminal app with running processes. A mid-session relaunch would kill all sessions. By installing at quit time, the user has already decided to close their terminals. - -**Why no silent download?** Update bundles can be large, can fail for environment-specific reasons, and may surprise users who did not opt into changing the app. The launch probe is silent, but download/install only begins after explicit approval. - -**Why the Baseboard, not a top banner?** A top banner pushes terminal content down, which is disruptive in a terminal app. The Baseboard is already a status strip, so the notice occupies unused space in an existing element instead of adding a new one. +**Why install on quit after approval, not immediately?** Dormouse is a terminal app with running processes; a mid-session relaunch would kill every session, while at quit time the user has already decided to close their terminals. -**Why write the success marker before `install()`?** On Windows, the NSIS installer force-kills the process — code after `install()` may never run. Writing optimistically and overwriting on failure handles both platforms correctly. +**Why no silent download?** Update bundles are large, fail for environment-specific reasons, and would surprise a user who did not opt into changing the app — so the launch probe is silent, but download and install wait for explicit approval. -**Why install as the last step of the quit orchestrator, not a standalone hook?** The install must run *after* the graceful terminal teardown and the durable final session save (`docs/specs/standalone.md` §Quit flow) — otherwise a Windows NSIS force-kill mid-teardown would lose the freshest scrollback. Folding install into the orchestrator makes that ordering explicit and gives it the same bounded-exit backstops. The updater therefore owns no quit interception of its own. +**Why the Baseboard, not a top banner?** A top banner pushes terminal content down; the Baseboard is already a status strip, so the notice occupies unused space in an existing element instead of adding a new one. -**Why `localStorage` instead of Tauri's store plugin?** It persists across launches in Tauri's webview, needs no extra dependency, and is scoped to the app. If the user resets app data, markers are cleaned up naturally. +**Why `localStorage` instead of Tauri's store plugin?** It persists across launches in Tauri's webview, needs no extra dependency, is scoped to the app, and resetting app data cleans the markers up naturally. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index a8b03194f..d5f6714a8 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -1,5 +1,7 @@ # Deploy Spec +> Owns the release process: the artifact matrix, the human release checklist, the two-stage sign-and-release pipeline, the update manifest it publishes, and the changelog flow. The client side of the updater — when the app checks, the approval prompt, install-on-quit — is `docs/specs/auto-update.md`. + ## What we ship One version number and one changelog entry cover every artifact: @@ -11,7 +13,7 @@ One version number and one changelog entry cover every artifact: | Standalone (macOS, Apple Silicon) | `.tar.gz` (contains signed `.app`) | GitHub Release + Tauri updater | | Standalone (Linux x86_64) | `.AppImage` | GitHub Release + Tauri updater | -The GitHub Release carries exactly those three standalone bundles and nothing else; the `.vsix` ships only through the two marketplaces. +The GitHub Release **must carry** exactly those three standalone bundles and nothing else; the `.vsix` ships only through the two marketplaces. ## Release checklist @@ -22,7 +24,7 @@ Human-driven steps, in order: 3. **Commit and tag** — `git commit -am "Release vX.Y.Z"` then `git tag vX.Y.Z`. 4. **Push** — `git push && git push origin vX.Y.Z`. This triggers CI (Stage 1). 5. **Run local signing** — plug in the PIV USB key, then `./scripts/sign-and-deploy.sh all X.Y.Z`. It waits for CI, downloads and verifies the unsigned artifacts, signs macOS + Windows, generates the Tauri update manifest into `website/public/standalone-latest.json`, and creates the GitHub Release. Each secret is read from the environment if set and prompted for otherwise (see [Environment / secrets](#environment--secrets)); `--help` lists the resume-after-failure subcommands. - It refuses to start unless the working tree is clean, has no untracked files, and has no unpushed commits — CI builds the tag, so anything local is not in what gets signed. + It **refuses to start** unless the working tree is clean, has no untracked files, and has no unpushed commits — CI builds the tag, so anything local is not in what gets signed. 6. **Deploy website** — commit the updated `website/public/standalone-latest.json` and deploy dormouse.sh so the updater endpoint is live. 7. **Verify the release** - Check GitHub Release assets are correct @@ -35,11 +37,11 @@ Human-driven steps, in order: A single version number (`X.Y.Z`) applies to all artifacts. `scripts/bump-version.sh` is the source of truth for which files carry it; it also re-syncs `Cargo.lock` (via `cargo check --offline`) so the lockfile's `dormouse` entry does not ship out of step with the binary. -A release is triggered by pushing a tag: `v0.1.0`. This is intentionally a single tag (not separate `vscode-ext/v*` and `standalone/v*` tags) because we want one changelog entry for both. +A release is triggered by pushing one tag (`v0.1.0`) — **never separate `vscode-ext/v*` and `standalone/v*` tags**, because one changelog entry covers both. ## Two-stage pipeline -Code signing for Windows requires a physical USB hardware key (EV cert via PIV). macOS signing uses a local Developer ID cert. Both must happen locally. So: +Windows code signing requires a physical USB hardware key (EV cert via PIV) and macOS uses a local Developer ID cert, so **both signing steps must run locally**: ``` Stage 1: CI (GitHub Actions) @@ -68,25 +70,25 @@ The workflow defaults `GITHUB_TOKEN` to read-only repository access (`contents: Both build jobs run in the `release-attest` environment, whose deployment policy admits `v*` tags and nothing else. That bounds the ref a provenance OIDC token can be minted from: the `Tag operations` ruleset restricts tag `creation` and `update` to admins across `~ALL` tag refs, so an environment scoped to `v*` is a ref no non-admin can produce. The environment carries **no secrets and no required reviewer** — a reviewer would stall every release on manual approval at its first jobs, and the build jobs have no business seeing credentials. This is also why neither existing `v*` environment is reused: `vscode-extension-publish` requires reviewers, and `security-audit` holds `AUDIT_PAT` and `CLAUDE_CODE_OAUTH_TOKEN`. -The environment must exist **before** the `environment:` keys reference it. A workflow naming an environment that does not exist auto-creates an unprotected one on the next `v*` push, which would leave the OIDC token ungated and add a no-policy environment to clean up. +The environment **must exist before** the `environment:` keys reference it — a workflow naming an environment that does not exist auto-creates an unprotected one on the next `v*` push, leaving the OIDC token ungated. -**Note:** We do NOT use `tauri-action`'s built-in GitHub Release creation. We create the release locally after signing. +**Never use `tauri-action`'s built-in GitHub Release creation** — the release is created locally, after signing. -The `build-standalone` artifact upload sets `include-hidden-files: true` — `actions/upload-artifact` v4.4+ silently drops dotfiles by default, but the zsh shell integration ships as ZDOTDIR dotfiles (`standalone/sidecar/shell-integration/zsh/.zshenv` etc.). Without the flag, the artifact is missing files that `artifact-manifest.sha256` hashed (the manifest is generated from the runner's disk, before upload), and Stage 2 hash verification fails. The `vscode-extension` upload keeps the safer default since it only contains `*.vsix` and the manifest. +The `build-standalone` artifact upload **must set** `include-hidden-files: true`: `actions/upload-artifact` v4.4+ silently drops dotfiles by default, and the zsh shell integration ships as ZDOTDIR dotfiles (`standalone/sidecar/shell-integration/zsh/.zshenv` etc.), so without the flag the artifact is missing files that `artifact-manifest.sha256` hashed (the manifest is generated from the runner's disk, before upload) and Stage 2 hash verification fails. The `vscode-extension` upload keeps the safer default since it only contains `*.vsix` and the manifest. -The CI updater key exists only so Tauri emits updater-shaped artifacts during unsigned builds. It is generated inside the runner, is not stored in source control or GitHub Secrets, and its public key is not the public key trusted by shipped apps. The final release bundles are re-signed locally by `scripts/sign-and-deploy.sh` with the production Tauri updater key before upload. +The CI updater key exists only so Tauri emits updater-shaped artifacts during unsigned builds: generated inside the runner, never stored in source control or GitHub Secrets, and its public key is not the one shipped apps trust. The final release bundles are re-signed locally by `scripts/sign-and-deploy.sh` with the production Tauri updater key before upload. ### Job: `security-audit` -Dispatches the `security-audit.yaml` workflow on the release tag (via `gh workflow run`), polls for the resulting run, and waits for its conclusion with `gh run watch --exit-status`, so a failing audit fails this job. `publish-vscode` is gated on it, so a failing security audit blocks the VS Code Marketplace publish. It dispatches rather than calling the reusable workflow with `uses:` because `anthropics/claude-code-action` rejects the `push` event that a tag-triggered `workflow_call` would inherit (and `GITHUB_EVENT_NAME` is a default variable that cannot be overridden); a dispatched run sees a supported `workflow_dispatch` event — the same path the nightly audit uses. `workflow_dispatch` is the documented exception that still creates a run when triggered by the default `GITHUB_TOKEN`, so no extra PAT is needed. +Dispatches the `security-audit.yaml` workflow on the release tag (via `gh workflow run`), polls for the resulting run, and waits for its conclusion with `gh run watch --exit-status`, so a failing audit fails this job. `publish-vscode` is gated on it, so a failing security audit blocks the VS Code Marketplace publish. **Dispatch, never `uses:` the reusable workflow**: `anthropics/claude-code-action` rejects the `push` event that a tag-triggered `workflow_call` would inherit (and `GITHUB_EVENT_NAME` is a default variable that cannot be overridden), while a dispatched run sees a supported `workflow_dispatch` event — the same path the nightly audit uses, and the documented exception that still creates a run when triggered by the default `GITHUB_TOKEN`, so no extra PAT is needed. ### Job: `publish-vscode` -This runs in CI because VSCode Marketplace publishing uses PAT tokens (no hardware key needed). The `vscode-extension-publish` environment must require reviewer approval and allow deployments only from `v*` tags. Store `VSCE_PAT` and `OVSX_PAT` as environment secrets there, not broad repository secrets. +Runs in CI because VSCode Marketplace publishing uses PAT tokens, no hardware key. The `vscode-extension-publish` environment **must require** reviewer approval and admit deployments only from `v*` tags, and `VSCE_PAT` / `OVSX_PAT` **must be** environment secrets there, never broad repository secrets. ## Stage 2: Local script -`scripts/sign-and-deploy.sh` is the source of truth for the local pipeline (download, sign, notarize, package, release). Run with no args or `--help` to see subcommands. Downloads in `release-signed/downloads/` are never mutated — every signing step operates on a fresh copy in `release-signed/work/` — so any step can be re-run without re-downloading. +`scripts/sign-and-deploy.sh` is the source of truth for the local pipeline (download, sign, notarize, package, release). Run with no args or `--help` to see subcommands. **Downloads in `release-signed/downloads/` are never mutated** — every signing step operates on a fresh copy in `release-signed/work/`, so any step can be re-run without re-downloading. Before any local signing step runs, downloaded CI artifacts must pass three checks: @@ -94,15 +96,13 @@ Before any local signing step runs, downloaded CI artifacts must pass three chec 2. `gh attestation verify` must prove the artifact manifest was attested by `.github/workflows/release.yml` in `diffplug/dormouse`, for `refs/tags/vX.Y.Z`, at the exact commit SHA resolved by the local tag. 3. `sha256sum -c` or `shasum -a 256 -c` must prove every downloaded file listed in `artifact-manifest.sha256` still has the hash CI recorded before upload. -The manifest itself is the attested subject, not the final signed app. This closes the gap between CI artifact production and the local machine that holds signing credentials: stale cached artifacts, wrong-tag artifacts, and tampered downloads are rejected before codesign, jsign, notarization, Tauri signing, or release upload can run. Cached artifacts are re-verified on every run, not trusted because the download marker exists. - -The local script must also select release artifacts by strict expected paths (or a find that must match exactly one file) instead of broad `find | head` matches. Release signing fails closed unless the expected files exist at the expected locations. The exact expected paths are enforced in `scripts/sign-and-deploy.sh`. +The attested subject is the manifest, not the final signed app. That closes the gap between CI artifact production and the local machine that holds signing credentials: stale cached artifacts, wrong-tag artifacts, and tampered downloads are rejected before codesign, jsign, notarization, Tauri signing, or release upload can run. **Cached artifacts are re-verified on every run**, never trusted because the download marker exists. -Release upload likewise uses only the three stable output filenames (the `FNAME_*` constants in `scripts/sign-and-deploy.sh`) and fails if `release-signed/release-assets` contains any other files. +**Never select release artifacts with a broad `find | head`** — the script uses strict expected paths (or a find that must match exactly one file) and fails closed unless the expected file is at the expected location. Release upload likewise uses only the three stable output filenames (the `FNAME_*` constants) and fails if `release-signed/release-assets` contains any other files. When rebuilding the Windows installer locally, the script rewrites the absolute CI-runner paths baked into the Tauri-generated NSIS `.nsi` script (via `scripts/patch-nsis-paths.pl`) and patches the `ADDITIONALPLUGINSPATH` and `OUTFILE` defines to the expected local plugin directory and installer path before running `makensis`. -The script runs on macOS only: it uses `codesign` / `xcrun notarytool` / `ditto`, and its in-place `sed -i ''` edits are BSD-sed form. +**Runs on macOS only** — it uses `codesign` / `xcrun notarytool` / `ditto`, and its in-place `sed -i ''` edits are BSD-sed form. ### One-time setup @@ -116,14 +116,14 @@ pnpm --dir standalone exec tauri signer generate # creates the Tauri update sig ### Two signing layers -OS signing proves the executable is from DiffPlug; Tauri signing proves the update bundle hasn't been tampered with in transit. Both are required — they protect different things at different points in time. +OS signing proves the executable is from DiffPlug; Tauri signing proves the update bundle hasn't been tampered with in transit. **Both are required** — they protect different things at different points in time. | Layer | What it signs | Who verifies | What happens without it | |-------|--------------|--------------|------------------------| | OS (codesign / jsign) | The executable (`.app` / `.exe`) | The OS, on launch | Gatekeeper / SmartScreen warnings | | Tauri updater (ed25519) | The update bundle (`.tar.gz` / `.exe` / `.AppImage`) | The running app, on update | Updater rejects the download | -**Order matters:** OS-sign the inner executable first, then package it into the update bundle, then Tauri-sign the bundle. The `.sig` file is generated from the final bundle that already contains the OS-signed binary. +**Order matters:** OS-sign the inner executable first, package it into the update bundle, then Tauri-sign the bundle — so the `.sig` is generated from a final bundle that already contains the OS-signed binary. ``` codesign/jsign the executable @@ -132,10 +132,10 @@ codesign/jsign the executable → upload bundle + .sig to GitHub Release ``` -Two macOS packaging edge cases are enforced in the script, because both ship a release that fails only on the user's machine: +Two macOS packaging edge cases are enforced in the script; both would ship a release that fails only on the user's machine: -- Nested binaries (the Node sidecar, node-pty prebuilds, `spawn-helper`) are signed individually before the outer `.app`, and the outer sign is **not** `--deep` — `--deep` would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs to run. After signing, the script actually launches the signed sidecar and `require('node-pty')` from it. -- The `.tar.gz` is built with `COPYFILE_DISABLE=1`, and the result is re-scanned for `._*` entries. AppleDouble resource-fork sidecars make the Tauri updater's extraction fail with `failed to unpack ._Dormouse.app`. +- **Never `--deep`-sign the outer `.app`** — `--deep` would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs to run. Nested binaries (the Node sidecar, node-pty prebuilds, `spawn-helper`) are signed individually first, and the script then actually launches the signed sidecar and `require('node-pty')` from it. +- **Build the `.tar.gz` with `COPYFILE_DISABLE=1`** and re-scan the result for `._*` entries — AppleDouble resource-fork files make the Tauri updater's extraction fail with `failed to unpack ._Dormouse.app`. ### Packaged app logging @@ -153,21 +153,21 @@ The stable names are the `FNAME_*` constants in `scripts/sign-and-deploy.sh`. ## Tauri auto-updater -`docs/specs/auto-update.md` owns the client side (when the app checks, the approval prompt, install-on-quit). This section owns what the release pipeline produces. +What the release pipeline produces for the updater. ### Configuration Updater config lives in [tauri.conf.json](../../standalone/src-tauri/tauri.conf.json) (`bundle.createUpdaterArtifacts`, `plugins.updater.{pubkey,endpoints,windows}`) and the plugin is registered in [lib.rs](../../standalone/src-tauri/src/lib.rs) via `tauri_plugin_updater`. -Design notes that aren't obvious from the files: -- `createUpdaterArtifacts: true` is the Tauri v2 artifact mode: Windows updates use the NSIS installer `.exe` directly, Linux updates use the `.AppImage` directly, and macOS uses `.app.tar.gz`. There is no `.nsis.zip` or `.AppImage.tar.gz` to collect. -- Do **not** set `"v1Compatible"` unless you're intentionally producing legacy `.nsis.zip` / `.AppImage.tar.gz` bundles for old Tauri v1 clients. +Not obvious from the files: +- `createUpdaterArtifacts: true` is the Tauri v2 artifact mode: Windows updates use the NSIS installer `.exe` directly, Linux the `.AppImage` directly, and macOS `.app.tar.gz`. There is no `.nsis.zip` or `.AppImage.tar.gz` to collect. +- **Never set `"v1Compatible"`** unless you intend legacy `.nsis.zip` / `.AppImage.tar.gz` bundles for old Tauri v1 clients. ### Update manifest (`standalone-latest.json`) Written by `sign_updates` after signing, to `website/public/standalone-latest.json`, so it is served from `dormouse.sh/standalone-latest.json` (the `plugins.updater.endpoints` entry) via Cloudflare Pages. That gives us request analytics on every update check. -Shape: `version`, `notes` (a link to the GitHub release tag, not the changelog body), `pub_date`, and a `platforms` map keyed `darwin-aarch64` / `windows-x86_64` / `linux-x86_64`, each with `url` and `signature` (the verbatim contents of that bundle's `.sig`). The script fails rather than emitting a platform with an empty signature. +Shape: `version`, `notes` (a link to the GitHub release tag, not the changelog body), `pub_date`, and a `platforms` map keyed `darwin-aarch64` / `windows-x86_64` / `linux-x86_64`, each with `url` and `signature` (the verbatim contents of that bundle's `.sig`). **The script fails rather than emit a platform with an empty signature.** The manifest URLs put the version in the *path* (`/v0.1.0/`) while the *filenames* stay stable, which is why the website download links and the updater manifest can use different URL schemes for the same asset. @@ -175,7 +175,7 @@ The manifest URLs put the version in the *path* (`/v0.1.0/`) while the *filename A single `CHANGELOG.md` at the repo root, following [Keep a Changelog](https://keepachangelog.com/) format, with one entry covering both standalone and VSCode changes. Entries are tagged with the artifact emoji defined in the file's own header (🖥️ standalone-only, 🔌 VS Code-only, no emoji for both). `create_release` extracts the `## [X.Y.Z]` section as the GitHub Release body, so the heading shape is load-bearing. -The website changelog page imports generated data from `website/src/data/changelog.json`, but `CHANGELOG.md` is the source of truth and the JSON is gitignored. You do not normally run `website/scripts/generate-changelog.js` by hand — the website's `prebuild`, `predev`, and `pretest` lifecycle scripts regenerate it, so clean checkouts work locally. Run it by hand only to preview a manual `CHANGELOG.md` edit, and never commit the result. +The website changelog page imports generated data from `website/src/data/changelog.json`, but `CHANGELOG.md` is the source of truth and the JSON is gitignored — **never commit it**. The website's `prebuild`, `predev`, and `pretest` lifecycle scripts regenerate it, so clean checkouts work locally; run `website/scripts/generate-changelog.js` by hand only to preview a manual `CHANGELOG.md` edit. ## Environment / secrets @@ -189,7 +189,7 @@ The website changelog page imports generated data from `website/src/data/changel | `TAURI_SIGNING_PRIVATE_KEY` | Local env / prompted | Tauri update signatures | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Local env / prompted | Tauri update key password (optional) | -Non-secret signing identity — the Developer ID string, team ID, Apple ID, `jsign` alias, and TSA URL — is hardcoded at the top of `scripts/sign-and-deploy.sh`, not passed through the environment. The Developer ID cert itself lives in the local keychain and the EV cert on the YubiKey; neither is a value the script reads. +Non-secret signing identity — the Developer ID string, team ID, Apple ID, `jsign` alias, and TSA URL — is hardcoded at the top of `scripts/sign-and-deploy.sh`, never passed through the environment. The Developer ID cert itself lives in the local keychain and the EV cert on the YubiKey; neither is a value the script reads. `SECURITY.md` -> "Desktop Releases" owns the argv-exposure rules for the three prompted secrets (which may sit on a command line and why). diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 49a841441..3b7e5a724 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -4,6 +4,9 @@ > (a browser pane is a **browser Surface**), and `docs/specs/dor-cli.md` for the > shared `dor` CLI, surface handle model, and host control plumbing this surface > builds on. +> Owns the browser Surface end to end — params, chrome, both renderers, the +> iframe proxy boundary. Evidence behind the rules: +> [dor-browser.rationale.md](dor-browser.rationale.md). Dormouse has one body component for web content: `BrowserPanel`, persisted as `surfaceType: 'browser'` with a swappable `renderMode`. @@ -11,22 +14,22 @@ Dormouse has one body component for web content: `BrowserPanel`, persisted as Entry points: - `dor ab ...` / `dor agent-browser ...` forwards to the user's own - `agent-browser` binary and binds that agent-browser session to a browser pane. - Typical navigation is `dor ab open `. + `agent-browser` binary and binds that session to a browser pane; typical + navigation is `dor ab open `. - `dor iframe ` opens an absolute `http://` or `https://` URL in the iframe - renderer. The proxy instruments only `http://` upstreams; `https://` is - accepted by the CLI but shown as an unproxyable scheme in the pane. + renderer. The proxy instruments only `http://` upstreams; the CLI accepts + `https://` but the pane shows it as an unproxyable scheme. -Both `dor ab open` and `dor iframe` also accept, wherever they take a URL, a -schemeless `host:port` (defaulted to `http://`, including the `:port` localhost -shorthand) or a terminal Surface handle resolved to the dev server it owns — see -`docs/specs/dor-cli.md` → Browser Open Target Resolution. +Both accept, wherever they take a URL, a schemeless `host:port` (defaulted to +`http://`, including the `:port` localhost shorthand) or a terminal Surface +handle resolved to the dev server it owns (`docs/specs/dor-cli.md` → Browser Open +Target Resolution). Two independent axes define a browser pane: its **target** (today always a bare URL — process-backed targets belong to the **dor-tools** scope, `docs/specs/dor-tool.md`) and its **render** mode (`ab-screencast`, `ab-popout`, -`iframe`). Render is a pane parameter, not a separate surface kind: `dor list` -reports every browser pane as `kind: "browser"` and puts the renderer in a +`iframe`). **Render is a pane parameter, never a separate surface kind**: `dor +list` reports every browser pane as `kind: "browser"` and puts the renderer in a separate `render_mode` field, computed from the persisted `renderMode` rather than stored on the row. @@ -55,19 +58,19 @@ type BrowserPanelParams = { Invariants: -- `renderMode` is canonical, and an absent one resolves to `iframe` (the +- **`renderMode` is canonical**, and an absent one resolves to `iframe` (the engine-less embed), never to a live agent-browser. -- `url` is the canonical target across render swaps and relaunches. Agent-browser - mirrors the newest non-blank active tab URL into params; iframe persists only - navigations initiated by Dormouse chrome. -- Agent-browser session state is flat (`session`, `wsPort`, `binaryPath`, - `syncEngaged`, `key`), not nested. Pop-out is *not* a param: it is derived from +- **`url` is the canonical target** across render swaps and relaunches. + Agent-browser mirrors the newest non-blank active tab URL into params; iframe + persists only navigations initiated by Dormouse chrome. +- **Agent-browser session state is flat** (`session`, `wsPort`, `binaryPath`, + `syncEngaged`, `key`), never nested. Pop-out is not a param: it derives from `renderMode` once, at controller construction. -- The browser DOM is never moved *and never unmounted by a minimize*: Lath's leaf - div is never re-parented, so an embedded `