diff --git a/SECURITY.md b/SECURITY.md index 243398056..d9c468524 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ Dormouse is a terminal, so users trust it with shells, source trees, credentials Dormouse Pocket lets a phone attach to a terminal running on the user's laptop, so the pairing stack is the one part of the product that takes input from the network. An authorized Client is deliberately equivalent to a person sitting at that laptop's keyboard — `terminal.write` is raw keystroke injection into a live PTY, and protocol-v1 has no notion of a restricted session. The entire trust model therefore exists to make *authorized* hard to reach, and impossible to reach by accident. -The design lives in [`docs/specs/remote-security-model.md`](docs/specs/remote-security-model.md), the deployment in [`docs/specs/server.md`](docs/specs/server.md), and the operator runbook in [`SELF_HOST.md`](SELF_HOST.md). This section does not restate them: it names the properties that are load-bearing enough to audit, and the risks we have accepted rather than closed. Two deployment modes are defined (`docs/specs/remote-api.md` → "Server deployment modes"); everything below is **self-hosted**, the only one that ships today. Cloud-hosted is [staged](#cloud-hosted-mode-staged). +The design lives in [`docs/specs/remote-security-model.md`](docs/specs/remote-security-model.md), the deployment in [`docs/specs/server.md`](docs/specs/server.md), and the operator runbook in [`SELF_HOST.md`](SELF_HOST.md). This section does not restate them: it names the properties that are load-bearing enough to audit, and the risks we have accepted rather than closed. Two deployment modes are defined (`docs/specs/remote-api.md` → "Transport"); everything below is **self-hosted**, the only one that ships today. Cloud-hosted is [staged](#cloud-hosted-mode-staged). ### Trust boundary @@ -103,7 +103,7 @@ Web Push is the one path where the Server makes an outbound request to an addres These are the two real gaps in the shipped model, and they are gaps rather than accepted risks — we intend to close them. -**Revocation has no mechanism.** `HostAcl.revokeDevice` / `revokePasskey` exist and have no callers; no relay frame carries a revocation; there is no management UI. Revoking a lost phone means hand-editing JSON on the Host, and it takes effect at that Client's next `authorizeConnection` — an already-established session survives it, and the operator's only lever is stopping the Host. Server-pushed revocation propagation is staged in `docs/specs/remote-security-model.md` → Future. +**Revocation has no mechanism.** `HostAcl.revokeDevice` / `revokePasskey` exist and have no callers; no relay frame carries a revocation; there is no management UI. Revoking a lost phone means hand-editing JSON on the Host **and restarting it**: `RemoteHostService.#startHost` reads the store once and hands the `RemoteHost` a snapshot for its whole lifetime, so an edit alone changes nothing that is running. The restart is the whole lever — it reloads the ACL and, by dropping the relay socket, ends every established session. Server-pushed revocation propagation is staged in `docs/specs/remote-security-model.md` → Future. **There is no audit trail.** The ACL records `approvedAt` / `approvedBy` for a pairing, and nothing records connects, attaches, denials, or writes. A self-hoster cannot answer "did anyone connect to my laptop last night", which also means an ACL entry added by any of the paths above would be invisible after the fact. diff --git a/canopy/README.md b/canopy/README.md index 3abd085b9..1debc2baf 100644 --- a/canopy/README.md +++ b/canopy/README.md @@ -37,10 +37,11 @@ The `UpstreamVsFork` story renders identical content through three renderers stacked: pristine upstream `@xterm/addon-webgl`, the fork with `sdf: false` (isolates the instance-layout/shader changes), and the fork with `sdf: true` (isolates the SDF glyph path). The upstream pin must be the same commit as the -fork base — the addon's beta counter is offset from core's (addon -`0.20.0-beta.298` == core `6.1.0-beta.301` == commit `8c9b9fdb`); re-derive it -with `npm view @xterm/addon-webgl@ gitHead` when the fork rebases, or let -`node scripts/xterm-bump.mjs --canopy ` pick the matching addon. +fork base — the `@xterm/*` beta counters are independent, so the numbers never +match (addon `0.20.0-beta.298` == core `6.1.0-beta.301` == commit `8c9b9fdb`); +re-derive it with `npm view @xterm/addon-webgl@ gitHead` when the fork +rebases, or let `node scripts/xterm-bump.mjs --canopy ` pick the +matching addon. Story content writes PUA glyphs (powerline chevrons etc.) as `\uE0BX` escapes, never literal characters — the literals are invisible in editors and were once @@ -53,8 +54,10 @@ silently dropped in a file rewrite, which presented as a rendering regression. cd canopy && pnpm link ~/projects/xterm.js/addons/addon-webgl ``` -`pnpm link` writes only into `node_modules`, so nothing accidental gets -committed; a later `pnpm install` restores the release tarball. +CAUTION: pnpm 11's link also writes persistent residue — a `link:` dependency in +the ROOT `package.json` and an `overrides:` entry in `pnpm-workspace.yaml` — +which silently keeps resolving the link. Revert both and `pnpm install` before +trusting a tarball verification. ## Roadmap diff --git a/canopy/src/GlTerminal.stories.tsx b/canopy/src/GlTerminal.stories.tsx index d7e018a6d..ec9a896ea 100644 --- a/canopy/src/GlTerminal.stories.tsx +++ b/canopy/src/GlTerminal.stories.tsx @@ -4,7 +4,7 @@ import { Terminal } from '@xterm/xterm'; import { WebglAddon } from '@diffplug/xterm-addon-webgl-sdf'; // The pristine upstream addon, pinned to the exact commit the fork's sdf branch is based on // (addon 0.20.0-beta.298 and core 6.1.0-beta.301 share gitHead 8c9b9fdb) — the regression -// baseline for the RendererComparison story. +// baseline for the UpstreamVsFork story. canopy/README.md records the same triple. import { WebglAddon as UpstreamWebglAddon } from '@xterm/addon-webgl'; // Read the two versions rather than restating them, so the on-screen labels cannot drift from // the pins the way a hand-typed version does. diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3e36f1687..254e3942c 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -30,7 +30,7 @@ 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. WATCHING outranks the command-exit arm deliberately: a watched command is by definition running, so `COMMAND_EXIT_ARMED` would otherwise mask the detector's busy/quiet states for the whole run, and the detector is derived from real output. +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: a watched command is by definition running, so `COMMAND_EXIT_ARMED` would otherwise mask the detector's busy/quiet states for the whole run, and the detector is the one derived from real output. 4. `COMMAND_EXIT_ARMED` if command-exit alerting is armed. 5. Otherwise `WATCHING_DISABLED`. @@ -40,7 +40,7 @@ Persist only `todo` and the sanitized `notification` (plus `status` for diagnost ## Attention -`attentionSessionId` is set only by explicit user actions that plausibly mean "I am looking at this Session": +`attentionId` is set only by explicit user actions that plausibly mean "I am looking at this Session": - clicking a Pane body or Pane header - entering passthrough on a Pane @@ -61,7 +61,7 @@ Every completion — a detector settle, a command finish, a direct notification, 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. -Two ordering rules matter. The progress cycle is cleared *before* dispatch — a completion or error ends the cycle whether or not the event is claimed, so `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 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. Source of truth: `registerCompletionClaimant` / `dispatchCompletion` in `lib/src/lib/alert-manager.ts`. @@ -82,7 +82,7 @@ Source of truth: `awaitCompletion` in `lib/src/lib/alert-manager.ts`, reached th `--until` has no default and is never inferred from the WATCHING rule set. That rule set is a human notification preference — app-global, edited from a dialog — and binding a program's wake condition to it would mean an unrelated edit (removing a command from the watched set to quiet the bell) silently changes what every `await` on that Session is waiting for. The caller states its own intent instead. -Settling comes from the always-on detector, which needs no shell integration, runs for every Session regardless of the WATCHING rule set (WATCHING Track below), 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 — which is what stops `dor send` followed immediately by an await from racing 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,13 +91,13 @@ Settling comes from the always-on detector, which needs no shell integration, ru | 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: a caller that asked for quiet and found quiet got what it asked for. It is a distinct `cause` rather than a distinct failure so simple callers can treat success as success, while a careful one can still tell "it settled" from "there was never anything there". 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 distinguish 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 caller that asked for quiet and found quiet got what it asked for. It is a distinct `cause` rather than a distinct failure so simple callers can treat success as success, while a careful one can still tell "it settled" from "there was never anything there". 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. -**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. A command-exit ring is skipped while a foreground command is running: it latches past the run that raised it, so once another command has started it can only describe the previous one, and answering "the command exited" about the command still running is exactly the misreport `dor send` followed by `dor await --until exit` would act on. A WATCHING ring is skipped once output has resumed since it latched. It legitimately describes the command still running — a long-running watched command going quiet is what `--until quiet` exists for — but it is an inference from silence rather than a discrete event, and nothing clears it when the peer starts talking again, so the entry records whether any output has arrived since the latch and the ring is consumed only while that is still false; otherwise the await parks for the real settle rather than answering "output stopped" about a turn that is mid-flight, which is what would make the documented `await && read` idiom read a half-drawn screen. The detector cannot stand in for that record — it never latches, so it reports how output looks *now*: it stays `NOTHING_TO_SHOW` for a full `busyCandidateGap` after output resumes, which is longer than the two CLI round trips between a `dor send` and the await that follows it, and it returns there when a burst was too sparse to confirm BUSY. The bell is never skipped: an `OSC 9` is a discrete "I need input" that stays true until it is answered, so a peer ringing mid-run still means what it said whenever the await arrives. Consuming releases that one track's latch and **nothing else** — `todo` is neither set nor cleared, no `ActivityNotification` is dropped, `attentionDismissedRing` is untouched, and `attentionSessionId` is never set. +**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. A command-exit ring is skipped while a foreground command is running: it latches past the run that raised it, so once another command has started it can only describe the previous one — the misreport `dor send` followed by `dor await --until exit` would act on. A WATCHING ring is skipped once output has resumed since it latched (`outputSinceWatchingRing`); it legitimately describes a long-running watched command going quiet, which is what `--until quiet` exists for, but it is an inference from silence that nothing clears when the peer starts talking again, so consuming it mid-turn would make the documented `await && read` idiom read a half-drawn screen. The detector cannot stand in for that flag — it never latches, so it reports how output looks *now*: it stays `NOTHING_TO_SHOW` for a full `busyCandidateGap` after output resumes, longer than the two CLI round trips between a `dor send` and the await behind it. The bell is never skipped: an `OSC 9` is a discrete "I need input" that stays true until it is answered. 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. -Setting a TODO after a successful await was considered and rejected. TODO means *a human owes this pane attention*, and after an await nobody does: a program asked to be told, was told, and acted. It would also leak — the last await of an orchestration would strand a marker for an event that was fully handled — and because TODO feeds the Workspace union, an orchestration awaiting across several panes would light the whole Workspace up as needing attention. Not clearing a *pre-existing* TODO is the same rule in reverse: one left by an unrelated earlier event is still owed to the human. +An await never sets TODO. TODO means *a human owes this pane attention*, and after an await nobody does: a program asked to be told, was told, and acted. It would also leak — the last await of an orchestration would strand a marker for a fully handled event — and because TODO feeds the Workspace union, an orchestration awaiting across several panes would light the whole Workspace up. Not clearing a *pre-existing* TODO is the same rule in reverse: one left by an unrelated earlier event is still owed to the human. -**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: a receipt the human must clear by hand is the same noise in a smaller font, and forensics after a failure come from the pane's own scrollback. Absorption is **per-signal, not per-Session**: an await consumes the signal it resolved on, and 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, and summoning the human too is noise. Nothing quieter is substituted: a receipt the human must clear by hand is the same noise in a smaller font, and forensics after a failure come from the pane's own scrollback. 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: closing it would need a two-phase claim on every completion to cover a process that dies in the microseconds after its answer was computed. @@ -109,11 +109,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). Like the inactivity timeout it originates a process away and ends up in `setTimeout`, so a non-finite, non-positive, or over-ceiling host request is rejected — the request settles `cancelled`, having absorbed nothing. The host's ceiling is `MAX_AWAIT_TIMEOUT_MS` (24h), matching the CLI's cap: `setTimeout`'s delay is a signed 32-bit millisecond count, so a larger value would overflow and fire at once, turning a long park into an instant `timeout`. 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. 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. Like the inactivity timeout the value originates a process away and ends up in `setTimeout`, whose delay is a signed 32-bit millisecond count — anything past ~24.9 days overflows and fires at once, turning a long park into an instant `timeout` — so 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; 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 the same process and call `awaitCompletion` directly. The Pocket phone adapter (`RemotePtyAdapter`) 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 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. | Situation | Outcome | |---|---| @@ -133,7 +133,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 (the WATCHING rule set is meant to apply to that pane immediately). +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. Rules: @@ -143,7 +143,7 @@ Rules: - Removing a rule is the one thing that *does* silence a WATCHING ring: it is the user saying "stop alerting on this". The latched originating key makes this work after the command has exited and watching is already off. A command merely ending never clears the ring. - The rule set is app-global and persisted (`dormouse:watched-commands`). It starts empty, so WATCHING is off everywhere until the user turns it on. Source of truth: `lib/src/lib/watched-commands.ts` (renderer mirror) and `lib/src/lib/watched-command-host.ts` (multi-renderer coordinator). In VS Code the shared extension host is authoritative: the first renderer seeds it from persisted storage, edits cross the boundary as single-command mutations, and the host broadcasts its canonical snapshot to every webview. A stale webview can therefore neither replace unrelated rules nor keep reporting an obsolete rule list. -**Limitation:** WATCHING needs the shell to report command boundaries (`OSC 633` / `OSC 133`). Shells without integration — `cmd.exe`, `fish`, or any shell where injection did not take (`docs/specs/terminal-escapes.md`) — never report a command name, so WATCHING never engages there and the bell reports "nothing is running". Terminal-report and command-exit alerts are unaffected. This is accepted rather than worked around: the keystroke fallback in `docs/specs/terminal-state.md` is renderer-side and lower confidence, and routing it into the manager would buy those shells a worse version of a feature at the cost of a second command-tracking path. +**Limitation:** WATCHING needs the shell to report command boundaries (`OSC 633` / `OSC 133`). Shells without integration — `cmd.exe`, `fish`, or any shell where injection did not take (`docs/specs/terminal-escapes.md`) — never report a command name, so WATCHING never engages there and the bell reports "nothing is running". Terminal-report and command-exit alerts are unaffected. Accepted rather than worked around: the keystroke fallback in `docs/specs/terminal-state.md` is renderer-side and lower confidence, and routing it into the manager would buy those shells a worse version of the feature at the cost of a second command-tracking path. | State | Meaning | |---|---| @@ -160,8 +160,7 @@ Source of truth: `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts` implemen - 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. -- Attending or dismissing a WATCHING ring resets the detector to `NOTHING_TO_SHOW`. +- 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. ## Terminal reports @@ -173,7 +172,7 @@ Sequence syntax for every row below lives in `docs/specs/terminal-escapes.md`; p - **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: `p=?` sends `OSC99_SUPPORT_PAYLOAD`, and `p=close` / `p=alive` / `p=icon` / `p=buttons` are consumed. Like any chunk, a management chunk carrying the default `d=1` still completes a pending same-`i` notification, which may then ring on its accumulated title/body — kitty's done-flag semantics apply regardless of the final chunk's payload type. The pending-chunk TTL and max-pending-id cap live in `terminal-protocol.ts`. +- **`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`. ## Command-exit Track @@ -211,7 +210,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 on hosts that do not own the theme; the alarm sections specified here are the rest of it. Theme selection is specified in [theme.md](./theme.md) and keeps its own store — it is never 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 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. 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). @@ -224,50 +223,45 @@ 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 mirrors the WATCHING rule set exactly, and for the same reason — in VS Code the `AlertManager` lives in the shared extension host, and each webview has its own origin and therefore its own `localStorage`. The first renderer seeds the host, an edit replaces the host's copy, and the host broadcasts its canonical snapshot to every webview. The **whole** blob is relayed, not just the field the host consumes, so two webviews cannot disagree about whether alarms speak. The host revalidates everything it receives. +- 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. - 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. + ### Spoken alarms -When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelayMs` later, Dormouse says that Pane's name out loud. Source of truth: `lib/src/lib/alert-speech.ts`, armed once by `useAlertSpeech` in `Wall`. +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`. -- Any of the three tracks qualifies. "Not attended" is track-agnostic. -- **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 when that text currently 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 that name. A ringing `ActivityNotification` title/body is not itself the speech payload, but an `OSC 9` message body is also an input to normal Pane-label derivation and can be spoken on that basis. -- **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, and an empty result falls back to `terminal`. This is a robustness *and* 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 sanitization any program could permanently disable spoken alarms for the session by putting a `<` in its title. Asterisks go through the same substitution for clarity rather than safety: a label such as `eight *` must not be announced as “eight asterisk”, and replacing it with a space that then collapses away reads as `eight`. -- The trigger is a fresh transition into `ALERT_RINGING`, held to the same standard as the bell (WATCHING Track, last bullet). A Session observed for the first time *already* ringing never speaks, which is what keeps a restore or a reconnect replaying a latched ring silent. -- Attending, dismissing, or killing the Pane during the delay cancels the utterance; so does switching the setting off. Both the ring and the setting are re-read when the timer fires rather than captured when it was scheduled. -- One utterance per ring. A Session that rings, is cleared, and rings again speaks twice. Sessions ring and speak independently. +- **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". - **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()` — Chrome reports `not-allowed` that way when speech is invoked without a user gesture, which is exactly this call site. The handlers therefore close over the utterance itself and registration happens before dispatch; a handler 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.** The announcement exists to summon the user; 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 had not started is therefore re-dispatched, since attending one Pane must not silence another Pane's alarm. A queued index entry is pruned as soon as its ring resolves, so an unrelated later `cancel()` cannot re-dispatch that stale entry under a subsequent ring, bypass the new delay, and speak twice; the engine may still own the old utterance, since removing it individually would require the same global `cancel()`. A re-dispatch is a fresh decision to speak, held to the same gates as the first: a Session attended in the meantime, or the setting switched off mid-utterance, drops out instead of being replayed. 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. +- **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()` — Chrome reports `not-allowed` that way when speech is invoked without a user gesture, which is exactly this call site. 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 both the teardown set and the Session-keyed queued index evict their matching 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 an unrelated `cancel()`. After teardown the generation token makes any late callback inert. +- **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. - `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). ### Push notifications -When a Session transitions into `ALERT_RINGING` and is still ringing `pushDelayMs` later, 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, and 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; a `push` that arrives with no Host running is simply not sent, since there is no ACL to read and nothing the webview could do about it. 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. +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. -Push and speech are independent: both fire when both are on, each on its own delay. +**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 trigger is shared with spoken alarms**, not reimplemented: `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts` owns fresh-ring detection, the delay, the fire-time re-check, and every cancellation rule, with speech and push as two sinks over it. A Session observed for the first time *already* ringing never pushes, which is what keeps a restored session blob from buzzing the phone at every app launch. - **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` — the sink's cap and fallback over the shared `boundedPushText` — which is deliberately not `toSpokenText`.** The rule 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. `boundedPushText` 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 from the running Host 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: the Server applies that filter anyway, so the target set is identical and the alarm costs one round trip instead of two. +- **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: the Server applies that filter anyway, so the target set is identical and the alarm costs one round trip instead of two. - **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. -- **Attending before `pushDelayMs` cancels**, matching speech. A push already delivered is *not* recalled: reaching the phone again means sending a second push, and `userVisibleOnly` guarantees that would itself be visible — so recall would trade one stale notification for one confusing one. +- **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. That covers 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 counts report `failed > 0` or `delivered: 0` warns too — the Server answers 200 even when a push service refused every delivery, folding the outcome into the `PushSendResponse` counts (and logging the refusal server-side). 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 latest-request-wins, fenced on request order, so a slow startup refresh cannot overwrite a newer dialog refresh. The same fence carries "the Host went away": when the enrolled gate disarms it calls `invalidatePushDeviceRefreshes()` and `clearPushDevices()`, so a request already on the wire cannot resolve afterwards and repopulate the dialog with phones there is no longer anything to push to. `clearPushDevices` returns the store to `no-host` and *keeps* the refresher, which stays installed on an un-enrolled machine so the dialog can still ask 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. 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). ### Settings dialog -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 row specified in [theme.md](./theme.md); when that row is hidden (VS Code), the rule list is first and drops its section divider. +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. @@ -280,7 +274,7 @@ Reached from any of the controls at the far right of the baseboard; placement an > See `docs/specs/glossary.md` for the Workspace / Window containers and the definitions of 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. Membership includes minimized (`Doored`) Surfaces and, in standalone, 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`. 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. Where it surfaces is host-specific: @@ -311,7 +305,7 @@ The dialog carries the TODO switch, the WATCHING rule switch for the running com 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 with a wash, an animated high-contrast inset, and an explicit `SPEAKING` label. After the utterance settles, the animation stops but a static high-contrast 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 `SPEAKING` 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 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`. ### Door @@ -330,10 +324,10 @@ Click or `Enter` on a Door reattaches into passthrough, counts as attention, and Notification text is untrusted terminal output. - Treat all text as plain text: never interpret ANSI, OSC, HTML, Markdown, URLs, paths, or emoji shortcodes as markup. -- Strip C0/C1 controls after protocol parsing, collapse whitespace controls to spaces, and trim. -- Store at most the `TITLE_LIMIT` / `BODY_LIMIT` code points defined in `lib/src/lib/terminal-protocol.ts`, only the latest `ActivityNotification` rather than unbounded history, and cap/expire incomplete OSC 99 parser state. +- Sanitize at protocol-parse time (`sanitizeText` in `lib/src/lib/terminal-protocol.ts`): strip C0/C1 controls, collapse whitespace controls to spaces, trim, and keep at most `TITLE_LIMIT` / `BODY_LIMIT` code points. Every notification stored from a live PTY has been through that pass. `normalizeActivityNotification` in `lib/src/lib/alert-manager.ts` is only a *shape* check on top — known `source`, string-or-null fields, trimmed, at least one non-empty — so the cold-restore path (`seed`) re-accepts a persisted blob without re-applying the cap or the control strip. Reachable only through a corrupted or hand-edited session store, and the text is rendered as plain text everywhere, so the exposure is layout rather than markup. +- Keep only the latest `ActivityNotification` rather than unbounded history, and cap/expire incomplete OSC 99 parser state. - Never execute commands, open URLs, copy to clipboard, read files, focus outside Dormouse, or render protocol-supplied icons/buttons/actions. -- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and the resulting label may be sent to the opt-in speech channel as defined above — after a second, speech-specific pass, because a label that is safe to *render* is not automatically safe to hand a speech engine. See `toSpokenText` under Spoken alarms and `toPushText` under Push notifications — two passes with deliberately different rules, because a speech engine and an OS notification fail in different ways. +- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and that label may reach the opt-in speech and push channels — each after its own second pass, because a label safe to *render* is not automatically safe to hand a speech engine or an OS notification, and those two fail in different ways. See `toSpokenText` under Spoken alarms and `toPushText` under Push notifications. Alert-specific robustness requirements: multiple Sessions ring independently; minimize, reattach, rerender, resize, and theme changes preserve existing alert state without creating new rings; an exited Session may keep ringing until attended, dismissed, or destroyed; ringing must not rely on color alone and must respect `prefers-reduced-motion`. @@ -350,8 +344,8 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/lib/alert-ring-watch.ts` | The shared unattended-ring machine: fresh-ring detection, the delay, the re-check, cancellation | | `lib/src/lib/alert-speech.ts` | The speech sink and `toSpokenText` | | `lib/src/lib/alert-speech-state.ts` | Transient per-Session `speaking` / `spoken` delivery state | -| `lib/src/remote/host/alert-push.ts` | Webview half: `watchPushRings` ring detection, `toPushText`, and the device-list commit | -| `lib/src/remote/host/push-delivery.ts` | Service half: `sendPush` / `loadPushDevices`, the ACL-intersected recipients, and `boundedPushText` | +| `lib/src/remote/host/alert-push.ts` | Webview half: `watchPushRings` ring detection and the fenced device-list commit | +| `lib/src/remote/host/push-delivery.ts` | Service half: `sendPush` / `loadPushDevices`, the ACL-intersected recipients, and `toPushText` over `server-lib-common`'s `boundedPushText` | | `lib/src/remote/host/enrolled-gate.ts` | `armWhileEnrolled`: the edge-triggered gate that arms ring watching only while the service reports an enrollment | | `lib/src/remote/host/activation.ts` | Arms the push sink for the lifetime of the remote Host (start, stop, re-enroll) | | `lib/src/lib/push-devices.ts` | Renderer-only store of the devices a push would reach, read by the settings dialog | @@ -362,6 +356,7 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `lib/src/lib/workspace-union.ts` | `computeWorkspaceUnion` projection | | `lib/src/components/bell-icon-class.ts` | Bell tilt/animation mapping from public status | | `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview | +| `lib/src/components/TodoPillBody.tsx` | `useTodoPillContent`: the `TODO` pill body and its clear-time flourish, shared by header, Door, and mobile | | `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment | | `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list | | `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications, remote control (see [server.md](./server.md)) | diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 601bed3e3..213a2ec6d 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -1,6 +1,10 @@ # Auto-Update Spec -The standalone app checks for updates on launch and prompts in the Baseboard when one is available. It does not download or install the update until the user approves the prompt. Once approved, the app downloads the update in the background and installs it when the user quits. On next launch, a brief banner confirms the update succeeded (or notes a failure). +> See `docs/specs/glossary.md` for Baseboard / Door vocabulary. + +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. + +Source of truth: `standalone/src/updater.ts`. The release pipeline that publishes the manifest this spec's endpoint serves is `docs/specs/deploy.md`. ## How it works @@ -38,17 +42,23 @@ app launch └─ install fails → overwrite with failure marker → quit_proceed → exit ``` -The `Update` object returned by `check()` is held in memory as an available update. Clicking the approval action calls `download()` and promotes it to a pending update only after the download succeeds. +The `Update` object returned by `check()` is held in memory as the *available* update. The approval action calls `download()` and promotes it to the *pending* update only once the download succeeds — a failed download leaves the available update in place, so a second approval retries it rather than no-op'ing. + +`startUpdateCheck()` is a no-op under the browser-dev harness (`VITE_DORMOUSE_BROWSER_DEV_HOST`), which has no Tauri updater behind it. + +### 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. -Quit-time 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 an approved, downloaded update is pending, the orchestrator calls the updater's `installPendingUpdate()` (paired with `hasPendingUpdate()`). `installPendingUpdate()` writes the success marker *before* calling `install()` (§Post-install markers), 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()` skips `install()` (the orchestrator still proceeds to exit); install must be tested from a packaged app because the updater resolves its replacement target from the current executable path. +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"*. The Rust `RunEvent::Exit` sidecar kill is too late and asynchronous — NSIS starts copying files immediately after `install()` force-kills the app, racing the sidecar's shutdown. (By quit time the orchestrator's graceful teardown has already killed the sidecar's *PTYs*, but the sidecar process itself is still alive holding those native modules.) +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.) -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 now 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. +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 sends the kill, then polls `try_wait` (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 relies on and could block forever if the sidecar had already exited. macOS and Linux can replace open files in place, so they skip this and rely on the existing `RunEvent::Exit` cleanup. +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. ## Update notice in the Baseboard @@ -62,29 +72,31 @@ Update status appears as a text notice on the right side of the Baseboard (the a | `post-update-success` | "Updated to v0.5.0 — from v0.4.0" | "Changelog" | 10 seconds | | `post-update-failure` | "Update failed" | "Click here to debug" | No | -The "Install when I quit" action is the user's approval to download the update now and install it when they quit. The inline "Changelog" action calls Tauri's `getVersion()` and opens `https://dormouse.sh/changelog/after/`. -When a notice has follow-up actions, it uses ` · ` as the separator between the message and action labels. +"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. -The notice matches the Baseboard's existing text style (`text-sm font-mono text-muted` — 12px via the theme.css `text-sm` override). It's pushed right via `ml-auto` so it doesn't 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). 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. + +### Debug report on failure + +"Click here to debug" opens `UpdateDebugModal`, which snapshots the failure (version + error string) so the modal survives any later state change. It offers two steps: a GitHub issue *search* seeded with the first 80 characters of the error unquoted (so GitHub can fuzzy-match), and a copyable markdown report assembled by `buildDebugReport()` — app version, `PLATFORM_STRING`, the error, and the tail of the Dormouse log. The log tail comes from the `read_update_log` Tauri command (the last 10 KB of `dormouse.log`, sliced on a char boundary); a failure to read it is embedded as a placeholder rather than aborting the report. + +Search-before-file is the reason the modal exists at all: an update failure is environment-specific and the log tail is the only evidence that survives the force-kill. ### Threading -The Baseboard is in `lib/` but the updater is standalone-only. The notice is threaded as a `ReactNode` prop: `App` → `Wall` → `Baseboard` (via `baseboardNotice`). This keeps all updater knowledge out of `lib/` — the Baseboard just renders an opaque slot. +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. ## Platform behavior at quit -On every platform the quit orchestrator calls `quit_proceed` after the teardown + -install step returns; `quit_proceed` sets the approved flag and calls -`app.exit(0)`, so the app exit is uniform. The per-platform difference is only in -what `install()` itself does: +On every platform the quit orchestrator calls `quit_proceed` after the teardown + install step returns; `quit_proceed` sets the approved flag and calls `app.exit(0)`, so the app exit is uniform. The per-platform difference is only in what the install step does: -| Platform | What `install()` does | App exit | -|----------|----------------------|----------| -| Windows | Kills the sidecar and waits for it to exit (so NSIS can overwrite its loaded native modules), then launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | NSIS force-kills before `quit_proceed` is reached | -| macOS | Replaces the `.app` bundle in place | `quit_proceed` → `app.exit(0)` | -| Linux | Replaces the AppImage in place | `quit_proceed` → `app.exit(0)` | +| Platform | Install step | App exit | +|----------|--------------|----------| +| Windows | `installPendingUpdate()` awaits `kill_sidecar_now` (so NSIS can overwrite the sidecar's loaded native modules), then `install()` launches the NSIS installer in passive mode (progress bar, no user interaction) and force-kills the app | NSIS force-kills before `quit_proceed` is reached | +| macOS | `install()` replaces the `.app` bundle in place | `quit_proceed` → `app.exit(0)` | +| Linux | `install()` replaces the AppImage in place | `quit_proceed` → `app.exit(0)` | | No pending update | — (`installPendingUpdate` not called) | `quit_proceed` → `app.exit(0)` | | Vite dev mode | Skips `install()` to avoid replacing the dev executable directory | `quit_proceed` → `app.exit(0)` | @@ -99,16 +111,18 @@ 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. +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. ## Files | File | Role | |------|------| -| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, user-approved download, quit-time install (`hasPendingUpdate` / `installPendingUpdate`, called by the quit orchestrator), post-install markers | -| [`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/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, user-approved download, quit-time install (`hasPendingUpdate` / `installPendingUpdate`, called by the quit orchestrator), post-install markers, debug-report assembly | +| [`standalone/src/updater.test.ts`](../../standalone/src/updater.test.ts) | Marker read/clear, the 5s probe delay, approval-gated download, marker-before-install ordering, and the Windows kill-before-install ordering | | [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders inline notice content for the Baseboard | -| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Passes `` as the `baseboardNotice` prop to ``, calls `startUpdateCheck()` after platform init | +| [`standalone/src/UpdateDebugModal.tsx`](../../standalone/src/UpdateDebugModal.tsx) | Failure modal: issue search + copyable report | +| [`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. @@ -126,14 +140,14 @@ In `standalone/src-tauri/tauri.conf.json`: } ``` -The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().build()` in `lib.rs`. The updater adds no Rust commands of its own; the install step runs entirely in JS (`installPendingUpdate`) and the process exit is the quit orchestrator's `quit_proceed` (`docs/specs/standalone.md` §Quit flow). Capabilities include `core:window:allow-close` and `core:window:allow-destroy` (used by the AppBar window controls); the quit flow itself needs no added capability (`core:event:allow-listen` already exists, and the quit commands are custom, which require none). +The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().build()` in `lib.rs`. The install step itself runs entirely in JS; the process exit is the quit orchestrator's `quit_proceed` (`docs/specs/standalone.md` §Quit flow). Two custom Rust commands serve the updater — `kill_sidecar_now` (shared with the quit path) and `read_update_log` — and custom commands need no capability entry. The plugin permissions the updater does need are in `standalone/src-tauri/capabilities/default.json`: `updater:default`, plus `core:app:allow-version` (marker versions, changelog URL) and `shell:default` (opening the changelog and issue search). ## Dependencies - `@tauri-apps/plugin-updater` — update check, download, install -- `@tauri-apps/api/core` — `invoke('kill_sidecar_now')` before install on Windows -- `@tauri-apps/api/app` — `getVersion()` for the "from" version in markers -- `@tauri-apps/plugin-shell` — `open()` for the changelog link +- `@tauri-apps/api/core` — `invoke('kill_sidecar_now')` before install on Windows, `invoke('read_update_log')` for the debug report +- `@tauri-apps/api/app` — `getVersion()` for the "from" version in markers and the changelog URL +- `@tauri-apps/plugin-shell` — `open()` for the changelog and issue-search links - `tauri-plugin-updater` Rust crate — registered in `Cargo.toml` and `lib.rs` ## Design decisions @@ -142,10 +156,10 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu **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 — the update notice fits naturally alongside doors and shortcut hints. It also avoids adding a new UI element; the notice just occupies unused space in an existing one. +**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 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 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 `localStorage` instead of Tauri's store plugin?** `localStorage` persists across launches in Tauri's webview, requires no additional dependencies, and is automatically 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, and is scoped to the app. If the user resets app data, markers are cleaned up naturally. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index 2519e2b29..a8b03194f 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -2,36 +2,38 @@ ## What we ship -Every release produces three artifact groups under one version and changelog: +One version number and one changelog entry cover every artifact: | Artifact | Format | Destination | |----------|--------|-------------| | VSCode extension | `.vsix` | VS Code Marketplace + OpenVSX | -| Standalone (Windows) | `.exe` (NSIS installer) | GitHub Release + Tauri updater | +| Standalone (Windows x64) | `.exe` (NSIS installer) | GitHub Release + Tauri updater | | Standalone (macOS, Apple Silicon) | `.tar.gz` (contains signed `.app`) | GitHub Release + Tauri updater | -| Standalone (Linux) | `.AppImage` | 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. ## Release checklist Human-driven steps, in order: -1. **Update dependency snapshots** — run `node website/scripts/generate-deps.js` and review the diffs in `website/src/data/dependencies-npm.json` and `website/src/data/dependencies-cargo.json`. Commit if changed. -2. **Draft release notes and bump version** — run `/release-notes` in Claude Code at the repo root. The slash command (defined in [.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)) walks the merge commits and squash-merged PRs since the last tag, recommends a `breaking.added.bugfix` version bump, runs `./scripts/bump-version.sh X.Y.Z`, and edits `CHANGELOG.md` for the same version. Review and edit the resulting diff if needed. +1. **Update dependency snapshots** — run `node website/scripts/generate-deps.js` and review the diffs in `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, and `website/src/data/dependencies-runtime.json`. Commit if changed. +2. **Draft release notes and bump version** — run `/release-notes` in Claude Code at the repo root (defined in [.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)). It walks the merge commits and squash-merged PRs since the last tag, recommends a `breaking.added.bugfix` version bump, runs `./scripts/bump-version.sh X.Y.Z`, and edits `CHANGELOG.md` for the same version. Review and edit the resulting diff if needed. 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. **Set environment variables** — copy the relevant secrets into the terminal from your password manager (see [Environment / secrets](#environment--secrets) for the list). -6. **Run local signing** — plug in the PIV USB key, then `./scripts/sign-and-deploy.sh all X.Y.Z`. The script waits for CI, downloads unsigned artifacts, signs macOS + Windows, generates the Tauri update manifest into `website/public/standalone-latest.json`, and creates the GitHub Release. Run `./scripts/sign-and-deploy.sh --help` for resume-after-failure subcommands. -7. **Deploy website** — commit the updated `website/public/standalone-latest.json` and deploy dormouse.sh so the updater endpoint is live. -8. **Verify the release** +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. +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 - On a Mac: extract the `.tar.gz`, open the `.app`, confirm no Gatekeeper warnings - On Windows: run the `.exe` installer, confirm no SmartScreen warnings - - Confirm Tauri auto-updater picks up the new version (test from a previous version) - - Confirm VSCode extension is live on Marketplace and OpenVSX + - Confirm the Tauri auto-updater picks up the new version (test from a previous version) + - Confirm the VSCode extension is live on Marketplace and OpenVSX ## Versioning -A single version number (`X.Y.Z`) applies to all artifacts. `bump-version.sh` is the source of truth for which files carry it. +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. @@ -84,25 +86,28 @@ This runs in CI because VSCode Marketplace publishing uses PAT tokens (no hardwa ## 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. +`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 two checks: +Before any local signing step runs, downloaded CI artifacts must pass three checks: -1. `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. -2. `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. +1. Every path listed in `artifact-manifest.sha256` must be relative and free of `..` segments, so a tampered manifest cannot make hash verification read outside the artifact directory. +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. +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 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 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`. 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. 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. + ### One-time setup ```bash -brew install gh jsign +brew install gh jsign makensis gh auth login xcode-select --install pnpm install --frozen-lockfile @@ -127,71 +132,50 @@ codesign/jsign the executable → upload bundle + .sig to GitHub Release ``` -### Packaged app logging +Two macOS packaging edge cases are enforced in the script, because both ship a release that fails only on the user's machine: -Windows release builds use the GUI subsystem, so launching `dormouse.exe` from a terminal returns immediately and does not stream stdout/stderr. The Tauri backend writes sidecar diagnostics to `%LOCALAPPDATA%\Dormouse Terminal\dormouse.log` on Windows, or to `$TMPDIR/dormouse.log` on other platforms. Set `DORMOUSE_LOG_FILE` to override the path. +- 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`. -## Artifact filenames +### Packaged app logging -All release assets use **stable filenames** (no version in the name). This allows hotlinking directly from dormouse.sh via GitHub's `/latest/download/` redirect, which always resolves to the most recent release. Stable output filenames are the `FNAME_*` constants in `scripts/sign-and-deploy.sh`. +Windows release builds use the GUI subsystem, so launching `dormouse.exe` from a terminal returns immediately and does not stream stdout/stderr. The Tauri backend writes sidecar diagnostics to `%LOCALAPPDATA%\Dormouse Terminal\dormouse.log` on Windows, or to `$TMPDIR/dormouse.log` on other platforms. `DORMOUSE_LOG_FILE` overrides the path on every platform and takes precedence over both defaults. -### Download hotlinks +## Artifact filenames -The dormouse.sh download page can link directly to the latest release with no server-side logic, e.g.: +All release assets use **stable filenames** (no version in the name), so dormouse.sh can hotlink through GitHub's `/latest/download/` redirect with no server-side logic: ``` https://github.com/diffplug/dormouse/releases/latest/download/Dormouse-macos-aarch64.tar.gz ``` -These can later be migrated to `dormouse.sh/download/...` URLs backed by Cloudflare R2 (for analytics) without changing anything in the app — only the website links and the updater endpoint URL in `tauri.conf.json` would change. +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. + ### Configuration -Updater config lives in [tauri.conf.json](../../standalone/src-tauri/tauri.conf.json) (`bundle.createUpdaterArtifacts`, `plugins.updater.{pubkey,endpoints}`) and the plugin is registered in [lib.rs](../../standalone/src-tauri/src/lib.rs) via `tauri_plugin_updater`. +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`. +- `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. ### Update manifest (`standalone-latest.json`) -Generated by the local script after signing. The script writes it to `website/public/standalone-latest.json` so it's served from `dormouse.sh/standalone-latest.json` via Cloudflare Pages. This gives us request analytics on update checks. - -```json -{ - "version": "0.1.0", - "notes": "Release notes here", - "pub_date": "2026-03-25T12:00:00Z", - "platforms": { - "windows-x86_64": { - "url": "https://github.com/diffplug/dormouse/releases/download/v0.1.0/Dormouse-windows-x64-setup.exe", - "signature": "" - }, - "darwin-aarch64": { - "url": "https://github.com/diffplug/dormouse/releases/download/v0.1.0/Dormouse-macos-aarch64.tar.gz", - "signature": "" - }, - "linux-x86_64": { - "url": "https://github.com/diffplug/dormouse/releases/download/v0.1.0/Dormouse-linux-x86_64.AppImage", - "signature": "" - } - } -} -``` +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. -Note: the update manifest URLs include the version in the *path* (`/v0.1.0/`) but the *filenames* are stable. The manifest itself is served from `dormouse.sh/standalone-latest.json` — Cloudflare Pages analytics tracks 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. -## Changelog +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. -A single `CHANGELOG.md` at the repo root, following [Keep a Changelog](https://keepachangelog.com/) format. The `[Unreleased]` section is promoted to `[X.Y.Z]` at release time. The release notes include both standalone and VSCode changes in one entry. +## Changelog -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: -- `pnpm --filter dormouse-website build` runs it through the website `prebuild` script before Vite bundles the static site. -- `pnpm --filter dormouse-website dev` and `pnpm --filter dormouse-website test` also regenerate it through lifecycle scripts so clean checkouts work locally. +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. -If you edit `CHANGELOG.md` manually outside `/release-notes` and want to preview the generated data immediately, run `node website/scripts/generate-changelog.js`. Do not commit `website/src/data/changelog.json`. +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. ## Environment / secrets @@ -199,11 +183,16 @@ If you edit `CHANGELOG.md` manually outside `/release-notes` and want to preview |--------|-------|---------| | `VSCE_PAT` | `vscode-extension-publish` GitHub environment secret | VS Code Marketplace publish | | `OVSX_PAT` | `vscode-extension-publish` GitHub environment secret | OpenVSX publish | -| `GITHUB_TOKEN` | GitHub Actions (automatic) | Artifact upload | -| `APPLE_SIGNING_IDENTITY` | Local keychain | macOS codesign | -| `APPLE_ID` | Hardcoded in `sign-and-deploy.sh` | Notarization | -| `APPLE_SIGN_PASS` | Local env / prompted | Notarization password | -| `APPLE_TEAM_ID` | Local env / hardcoded | Notarization | +| `GITHUB_TOKEN` | GitHub Actions (automatic) | `tauri-action`'s build steps; `gh` calls in `security-audit` | +| `APPLE_SIGN_PASS` | Local env / prompted | Notarization (app-specific password) | | `EV_SIGN_PIN` | Local env / prompted | Windows PIV signing | | `TAURI_SIGNING_PRIVATE_KEY` | Local env / prompted | Tauri update signatures | -| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Local env / prompted | Tauri update key password | +| `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. + +`SECURITY.md` -> "Desktop Releases" owns the argv-exposure rules for the three prompted secrets (which may sit on a command line and why). + +## Future + +**Analytics-backed download URLs.** The `/latest/download/` hotlinks could move to `dormouse.sh/download/...` backed by Cloudflare R2 for download analytics. Because the release filenames are stable, this changes only the website links and the `plugins.updater.endpoints` URL in `tauri.conf.json` — nothing in the shipped app. diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index fd731ae05..49a841441 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -14,29 +14,27 @@ Entry points: `agent-browser` binary and binds that agent-browser 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 currently 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; `https://` is + accepted by the CLI but shown as an unproxyable scheme in the pane. 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. -Two independent axes define a browser pane: - -| Axis | Values | -| --- | --- | -| Target | A bare URL. (A process-backed target is owned by the **dor-tools** scope, `docs/specs/dor-tool.md`) | -| Render | `ab-screencast`, `ab-popout`, `iframe` | - -The render axis is a pane parameter, not a separate surface kind. The `dor` CLI -reports browser panes as `kind: "browser"` and includes the renderer separately -as `render_mode` derived from `renderMode` (never stored). +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 +separate `render_mode` field, computed from the persisted `renderMode` rather +than stored on the row. Source of truth: `lib/src/components/wall/BrowserPanel.tsx`, -`lib/src/components/wall/browser-surface.ts`, `lib/src/components/Wall.tsx` -(`surfaceKindFromParams`, `surfaceRenderModeFromParams`, -`componentForSurfaceType`, `createContentSurface`). +`lib/src/components/wall/browser-surface.ts` (`resolveRenderMode`, +`surfaceKindFromParams`), `lib/src/components/wall/LathHost.tsx` +(`BODY_COMPONENTS`), `lib/src/components/Wall.tsx` +(`surfaceRenderModeFromParams`, `createContentSurface`). ## Canonical Params @@ -44,7 +42,7 @@ The persisted pane params are flat: ```ts type BrowserPanelParams = { - surfaceType?: 'browser'; + surfaceType?: string; // 'browser' renderMode?: 'ab-screencast' | 'ab-popout' | 'iframe'; url?: string; session?: string; @@ -57,41 +55,43 @@ type BrowserPanelParams = { Invariants: -- `renderMode` is canonical. +- `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. -- The browser DOM is never moved *and never unmounted by a minimize*: Lath's leaf div - is never re-parented, so an embedded `