From 83b813cdb9d8992aee7ee1eaec33dac5ed5de1bf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 01:46:35 -0700 Subject: [PATCH 1/2] Move alert mechanism to the code it constrains The OSC 777/99 grammars, the speech queue's bounds and timeout, and the push device-list fencing each constrain one module, so they now live as comments there while alert.md keeps the one-line rule and its `Source of truth:` pointer. Await, Live Workspace transfer, and Alarm settings lose repeated framing and a tour of stores they do not own; transaction order defers to standalone.md, which states it. Front matter now names what the spec defers, and the retired bell no longer rings in the Await absorption rule. No behavior change. Co-Authored-By: Claude Fable 5.1 --- docs/specs/alert.md | 52 ++++++++++++++--------------- docs/specs/alert.rationale.md | 6 ++-- lib/src/lib/speech-queue.ts | 18 ++++++++++ lib/src/lib/terminal-protocol.ts | 33 ++++++++++++++++++ lib/src/remote/burrow/alert-push.ts | 9 ++--- scripts/spec-word-budgets.json | 2 +- 6 files changed, 85 insertions(+), 35 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index d471ebc20..4f01cd7b8 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -2,7 +2,9 @@ > See `docs/specs/glossary.md` for Session / Pane / Door vocabulary. > -> Owns the Session Activity layer — the three alert tracks, attention, TODO, notification text and its sanitization, the two alarm sinks, and the Workspace union projection. `docs/specs/layout.md` defers here for all alert/TODO behavior and owns placement and sizing. +> Owns the Session Activity layer — the three alert tracks, attention, TODO, notification text and its sanitization, the two alarm sinks, and the Workspace union projection. `docs/specs/layout.md` defers here for all alert/TODO behavior. +> +> Defers placement and sizing to `docs/specs/layout.md`, the webview ↔ host wire contracts to `docs/specs/transport.md`, and push sealing to `docs/specs/remote-security-model.md`. **Must preserve Activity across minimize/reattach** (glossary I3). **A browser Surface has no Activity machine** — it can never ring, and carries only a user-set TODO flag, destroyed with that Surface. @@ -34,7 +36,7 @@ Public `status` is a projection — first match wins: 4. `COMMAND_EXIT_ARMED` if command-exit alerting is armed. 5. Otherwise `WATCHING_DISABLED`. -**Must identify each uninterrupted ringing interval with an `episode` id and start time.** The first track to latch creates it; the last track clearing ends it. Additional track latches join the episode; they never start another delivery episode or replay the alarm burst. **Never persist episodes.** Older host snapshots receive a renderer-local identity on their first ringing transition. Tests: `a second track latching mid-episode keeps the episode id` and `re-latching after all tracks clear starts a new episode` in `lib/src/lib/alert-manager.test.ts`. +**Must identify each uninterrupted ringing interval with an `episode` id and start time.** The first track to latch creates it; the last track clearing ends it. Additional track latches join the episode; they never start another delivery episode. **Never persist episodes.** Older host snapshots receive a renderer-local identity on their first ringing transition. Tests: `a second track latching mid-episode keeps the episode id` and `re-latching after all tracks clear starts a new episode` in `lib/src/lib/alert-manager.test.ts`. `awaited` sits beside `status`: true while at least one `dor await` is parked on the Session (Await). It is derived from live waiters and **never persisted**. @@ -80,9 +82,9 @@ Source of truth: `registerCompletionClaimant` / `dispatchCompletion` / `deferOrD ## Await -An **await** parks on one Session until it finishes what it is doing, then reports why the wait ended — the claimant the seam above exists for, called by a program. **Where a human and a program want different things, an await serves the program and leaves the human's channels alone.** +An **await** parks on one Session until it finishes what it is doing, then reports why the wait ended — the claimant the Completion events seam exists for. **Where a human and a program want different things, an await serves the program and leaves the human's channels alone.** -`until` (`dor await`'s required `--until`) names how much evidence of completion the caller accepts — a permissiveness ladder, not orthogonal modes. +`until` (`dor await`'s required `--until`) names how much evidence of completion the caller accepts — a permissiveness ladder. | `until` | Resolves on | `cause` | For | |---|---|---|---| @@ -93,25 +95,25 @@ An **await** parks on one Session until it finishes what it is doing, then repor - **`--until` has no default and is never inferred from the WATCHING rule set** (rationale). - **Silent is not settled.** Settling comes from the always-on detector (WATCHING Track), which needs no shell integration and cannot fire until it has been BUSY (rationale). -**Is there anything to wait for?** Silence cannot separate a peer that answered long ago from one working quietly. +**Is there anything to wait for?** | At await time | Behavior | |---|---| -| A foreground command is running (`commandExitWatch`) | Park, no grace window; a silent build resolves on its exit rather than being guessed at. | -| Nothing running | Park for one grace window. A *command start* cancels it under either `until`; under `quiet` so does *output*, under `exit` output alone does not. Either way the await then waits for a real signal. Neither → resolve `cause: idle`. | +| A foreground command is running (`commandExitWatch`) | Park, no grace window. | +| Nothing running | Park for one grace window, then wait for a real signal. A *command start* cancels it under either `until`; under `quiet` so does *output*, under `exit` output alone does not. Neither → resolve `cause: idle`. | **`idle` is a resolution, not a failure** (rationale). Absent shell integration "is a command running" is unanswerable, so an `exit` await there falls back to the grace window and resolves `idle` rather than erroring. -**Resolution consumes only the ring it resolved on**, its cause named by *that ring's own source*: protocol → `bell`, command-exit → `exit`, WATCHING → `quiet`. An await arriving mid-ring resolves immediately; under `exit` only a command-exit ring counts, the others being the human's and the await keeps waiting. Two are gated, their latches outliving the fact they describe: +**Resolution consumes only the ring it resolved on**, its `cause` from *that ring's own source*: protocol → `bell`, command-exit → `exit`, WATCHING → `quiet`. An await arriving mid-ring resolves immediately; under `exit` only a command-exit ring counts and the await keeps waiting on the others. Two are gated: - **Skip a command-exit ring while a foreground command is running** (rationale). - **Skip a WATCHING ring once output has resumed since it latched** (`outputSinceWatchingRing`), and **never stand the detector in for that flag** (rationale). -- **Never skip the bell**: 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, and `attentionId` is never set. +- **Never skip the bell** — an `OSC 9` stays true until it is answered. +- **Consuming releases that one track's latch and nothing else** — no `ActivityNotification` is dropped and `attentionId` is never set. **Absorption: absorb the summons, keep the receipt.** -- **A consumed completion never latches a ring**, so it does not ring the bell, speak an alarm, or push to a paired phone; nothing quieter is substituted (rationale). +- **A consumed completion never latches a ring** — no alarm treatment, no spoken alarm, no push; nothing quieter is substituted (rationale). - **Absorption is per-signal, not per-Session** — a human's own WATCHING rule on that Session still rings on the next settle. - **A failed await absorbs nothing.** A timeout, a death, or a cancel claims no completion, so a crashed orchestration cannot silently eat the human's signal. - **An await never sets TODO, and never clears a pre-existing one** (rationale). @@ -125,20 +127,18 @@ An **await** parks on one Session until it finishes what it is doing, then repor | Settle — "has it stopped?" | 5000ms | `mightNeedAttention` + `needsAttentionConfirm` | | Ceiling | `timeoutMs` | `dor await`'s `--timeout` (seconds, default 600), the only number not derived from `cfg.alert` | -`timeoutMs` is the safety rail on a blocking call inside an agent loop, not an alert-tuning knob. - -- **Enforce it host-side**, with the grace and settle windows, so no hop can reap a parked await early and no caller can park forever by lying about its deadline. +- **Enforce all three host-side**, so no hop reaps a parked await early and no caller parks forever by lying about its deadline. - The host's `MAX_AWAIT_TIMEOUT_MS` matches the CLI's 1–86400 whole-second range (`docs/specs/dor-cli.md`); a ceiling exists at all because `setTimeout` overflows past ~24.9 days (rationale). - **Reject a non-finite, non-positive, or over-ceiling request rather than clamping it** — it settles `cancelled`, absorbing nothing; the webview handler rejects the same values with a visible error. -**Several awaits may park on one Session**, sharing one claimant: a completion goes to every await whose condition it satisfies, not only to whoever registered first, each resolving on the first qualifying signal after it registered. +**Several awaits may park on one Session**, sharing one claimant: a completion goes to every await whose condition it satisfies, each resolving on the first qualifying signal after it registered. **In VS Code an await crosses the webview -> extension-host boundary**, and the wait itself never leaves the host: -- 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 webview posts `alert:await` and, if it gives up, `alert:awaitCancel`; **the host answers exactly one `alert:awaitResult` per request**, a cancel included. - **A disposing webview must cancel everything it had parked, and must answer those requests itself *synchronously*** (rationale). - `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. -- Other hosts call `awaitCompletion` in-process. The Pocket phone adapter has no `dor` and protocol-v1 carries no await, so it settles any request `cancelled` at once. +- Other hosts call `awaitCompletion` in-process. The Pocket phone adapter has no `dor` and protocol-v1 carries no await, so it settles every request `cancelled` at once. **A PTY exit or Session removal resolves every waiter still parked as `died`**, after command-finish dispatch gets first chance to resolve normally. Manager disposal and live transfer suspension resolve every waiter as `cancelled`. @@ -193,8 +193,8 @@ Sequence syntax lives in `docs/specs/terminal-escapes.md`; what each means here: - **Standalone `BEL`** — 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 (`docs/specs/terminal-state.md`), with no alert effect. -- **`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, `p` selects the payload type (default `title`). `title`/`body` chunks append; 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`; `p=close` / `p=alive` are dropped outright, touching no pending notification. Any *other* unknown payload type still obeys the done flag: with the default `d=1` it completes a pending same-`i` notification, which may then ring on its accumulated title/body. Incomplete chunk state is capped and expired. +- **`OSC 777`** — only the `notify` subcommand is supported; unsupported subcommands and empty sanitized notifications are ignored. +- **`OSC 99`** (kitty) — chunked notifications keyed by `i`; completion rings once if the sanitized title or body is nonempty. **Management payloads (`p=?`, `p=close`, `p=alive`) contribute no content.** Incomplete chunk state is capped and expired. - **`OSC 9;4` progress** — progress only: no title, body, urgency, id, app name, or action fields. | Input | Behavior | @@ -207,7 +207,7 @@ Sequence syntax lives in `docs/specs/terminal-escapes.md`; what each means here: | invalid state, missing percent for `1`/`4`, out-of-range percent | ignored | | completion or error while attended | clears the progress, no TODO or ring | -Source of truth: parsing, sanitization limits, and OSC 99 chunk state in `lib/src/lib/terminal-protocol.ts`; `completeProtocolProgress` / `finishProtocolProgressCycle` in `lib/src/lib/alert-manager.ts`. +Source of truth: the OSC 777 and OSC 99 grammars, parsing, sanitization limits, and OSC 99 chunk state in `lib/src/lib/terminal-protocol.ts`; `completeProtocolProgress` / `finishProtocolProgressCycle` in `lib/src/lib/alert-manager.ts`. ## Command-exit Track @@ -244,7 +244,7 @@ Clearing behavior: ## Live Workspace transfer - **Must transfer live alert state separately from persisted reminders:** track latches, episode, command watch, deferred notification, and detector history/deadlines travel in the marked transfer content. **Never read this content on cold restore.** -- **Must suspend source delivery before handing ownership to the host and freeze its detector, rule walk, and controls at content capture.** The destination restores receipts after persisted seeding and before the runtime publishes, then enables delivery only after `adopt_done` succeeds. Refusal restores the source runtime and pending deadlines; source removal follows adoption. **Must await host hand-back after source content capture fails**, retaining delivery suspension until routing returns. +- **Must suspend source delivery before handing ownership to the host**, freezing its detector, rule walk, and controls at content capture, and **must await host hand-back when source content capture fails**, holding that suspension until routing returns. The destination restores receipts after persisted seeding and before the runtime publishes, and enables delivery only after `adopt_done`; refusal restores the source runtime and its pending deadlines. Transaction order is `docs/specs/standalone.md` → "Arrival queue". - **Must discard imported runtime, Activity, and pane state before releasing the delivery guards on any target failure**, mount or no mount, so a watcher never re-arms on a Workspace this window never owned. A receipt forgotten on an episode already observed is consumed, never fresh. - **Must carry per-sink receipts with the episode:** pending/queued work retains its original deadline; engine-admitted speech and dispatched push are consumed and never replayed. Suspending cuts current speech; a partially heard utterance is not restarted after a move or refusal. - **Must cancel parked await callers at suspension**, carrying no claimant closures or attention lease. Source attention loss arms a running command; destination interaction establishes its own attention. @@ -254,7 +254,7 @@ Source of truth: `pauseForTransfer` / `resumeFromTransfer` / `applyReplay` in `l ## Alarm settings -Application alarm defaults live beside the WATCHING rule set, edited in **Settings** (below), which also carries the theme picker ([theme.md](./theme.md)), the shell picker ([standalone.md](./standalone.md)), and the remote-control section ([relay.md](./relay.md)). **Each of those keeps its own store — never fold one into `AlertSettings`**, which is relayed wholesale to the host. **A host revalidates the blob before installing it** (`normalizeAlertSettings`): a webview must never be able to hand it a NaN or an absurd timer. Both stores run the same two classes in either host (`lib/src/lib/watched-command-host.ts`, `lib/src/lib/alert-settings-host.ts`), bound for standalone by `lib/src/host/alert-store-host.ts`; the shape, its defaults and its validation are the platform-free `lib/src/lib/alert-settings-model.ts`. +Application alarm defaults live beside the WATCHING rule set, edited in **Settings** (below). **Every other store reached from Settings stays its own — never fold one into `AlertSettings`**, which is relayed wholesale to the host. **A host revalidates the blob before installing it** (`normalizeAlertSettings`): a webview must never be able to hand it a NaN or an absurd timer. Both stores run the same two classes in either host (`lib/src/lib/watched-command-host.ts`, `lib/src/lib/alert-settings-host.ts`), bound for standalone by `lib/src/host/alert-store-host.ts`; the shape, its defaults and its validation are the platform-free `lib/src/lib/alert-settings-model.ts`. | Field | Meaning | |---|---| @@ -303,9 +303,8 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi - **Delivery state follows actual engine callbacks, not queue admission.** `AlertSpeechState` is a renderer-local `speaking | spoken` map keyed by Session: `start` publishes `speaking`; `end`, or `error` after a real start, publishes `spoken`; an utterance that never starts publishes neither. **Must check delivery identity before accepting `start` or completion**, including after cancellation, timeout, or teardown. Pinned by `ignores an older ring starting after a newer ring has begun speaking` and `recovers from a callback-less engine without accepting its later callbacks` in `lib/src/lib/alert-speech.test.ts`. - **Nothing in the settle path may assume the callback arrives after `speak()` returns** — an engine may dispatch `start` then `end`/`error` *synchronously* inside `speechSynthesis.speak()` (rationale). Handlers therefore close over the utterance itself and registration happens before dispatch. A dispatch the engine refuses outright settles too. - **Attending mid-sentence cuts the utterance off** — silence the engine, not merely un-render the overlay. "Mid-sentence" is the sink's own record that an utterance started — its generation token — never the rendered `speaking` state. -- **Must admit only one utterance at a time to Web Speech per renderer**, including Settings tests; pending jobs remain in Dormouse. **Must remove resolved, disabled, or suspended pending jobs before engine admission**, without cutting another pane's current utterance. Pinned by `never admits a resolved queued alarm to the speech engine` in `lib/src/lib/alert-speech.test.ts`. -- **Must bound pending jobs at 64**, rejecting later admissions while full. **Must cancel an engine attempt after 60 seconds without completion** and advance the queue; detached late callbacks cannot change the new attempt. No automatic retry follows failed, expired, or overflowed delivery. -- **Must revoke callback identity before engine cancellation**, including synchronous callbacks. Teardown cancels the current engine utterance and drops pending jobs. +- **Must admit only one utterance at a time to Web Speech per renderer**, Settings tests included, and **must remove resolved, disabled, or suspended pending jobs before engine admission** without cutting another pane's current utterance. Pinned by `never admits a resolved queued alarm to the speech engine` in `lib/src/lib/alert-speech.test.ts`. +- **No automatic retry follows failed, expired, or overflowed delivery**, and teardown cancels the current engine utterance and drops pending jobs. Queue bounds, the engine timeout, and callback-identity revocation live at `SpeechQueue`. - `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`: any action that resolves the ring (Clearing And TODO) clears it, killing the Session included, while visibility, hover, and command-mode selection do not. **Never persist it or send it to the host**, so restore/reconnect cannot recreate it. Source of truth: `toSpokenText` in `lib/src/lib/alert-speech.ts`, armed by `lib/src/components/wall/use-alert-speech.ts`; `SpeechQueue` in `lib/src/lib/speech-queue.ts`; `redactHighEntropyTokens` in `lib/src/lib/redact-high-entropy.ts`; label derivation in `lib/src/lib/session-label.ts`; `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts`. @@ -317,8 +316,7 @@ Source of truth: `toSpokenText` in `lib/src/lib/alert-speech.ts`, armed by `lib/ - **The label is sanitized by `toPushText` at send time, in the delivery half, and not by `toSpokenText`'s rule** (rationale). It keeps angle brackets 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` in `remote-lib-common/src/security/push.ts`. - **The Burrow bounds, then seals; the worker re-bounds at the render sink.** Title, body, and tag are sealed to each recipient's own Client static and the Relay forwards ciphertext, so the second pass runs in `lib/src/remote/pocket-app/sw.ts`, which imports the *same* `boundedPushText` rather than mirroring it (`docs/specs/remote-security-model.md` -> Push sealing). - **The Burrow names its targets; the Relay rejects a send that does not.** Targets are the Burrow's *active* ACL records, read at send time so a revocation during the delay takes effect, and the Relay intersects them with its own subscriptions. **One sealed envelope per recipient** — a Client static is not a group key — so a send names each `deliveryId` beside the ciphertext only that phone can open, **clamped to `MAX_PUSH_QUERY_DELIVERY_IDS`** because the route refuses the whole POST past it. Nothing propagates a revocation today (`docs/specs/remote-security-model.md` -> Future), so a Relay that chose recipients itself would keep pushing to a de-authorized phone (rationale). The Burrow does **not** ask which devices are subscribed first (rationale). -- **The settings dialog re-reads the device list when it opens** (`refreshPushDevicesNow`) — a phone can enable alerts long after this machine booted. **Must keep the transient preview on the cached list without refreshing.** The list is the Burrow's join of the Relay's subscriptions against its own ACL labels, arriving over the same bridge as a `pushDevices` command and answering `null` — rendered `no-burrow` — when no Burrow is running. -- **Writes are fenced on request order** (latest-request-wins), and the enrolled gate's disarm both invalidates in-flight refreshes and clears the list, so nothing already on the wire can repopulate the dialog with phones there is no longer anything to push to. `clearPushDevices` keeps the refresher installed — an un-enrolled machine may still ask and be told `no-burrow` — while `resetPushDevices` drops it too and is full teardown. +- **The settings dialog re-reads the device list when it opens; the transient preview never refreshes.** The list is the Burrow's join of the Relay's subscriptions against its own ACL labels. **A disarmed enrolled gate invalidates every in-flight refresh and clears the list**, so nothing already on the wire can repopulate the dialog with phones there is no longer anything to push to. Source of truth: `watchPushRings` / `invalidatePushDeviceRefreshes` in `lib/src/remote/burrow/alert-push.ts`; `sendPush` / `toPushText` in `lib/src/remote/burrow/push-delivery.ts`; `refreshPushDevicesNow` / `clearPushDevices` / `resetPushDevices` in `lib/src/lib/push-devices.ts`. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 66fc9c772..1357cfc12 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -28,7 +28,7 @@ **Why `--until` is never inferred.** The WATCHING rule set is a human notification preference — app-global, edited from a dialog. Binding a program's wake condition to it would let an unrelated human edit (removing a command from the watched set to quiet the bell) silently change what every `await` parked on that Session is waiting for. -**Why silence at a prompt is not a settle.** The BUSY-first precondition is what makes the `dor send` / `dor await` idiom safe: the await parks in the window before the peer's first byte instead of resolving on the quiet that was already there. +**Why silence at a prompt is not a settle.** The BUSY-first precondition is what makes the `dor send` / `dor await` idiom safe: the await parks in the window before the peer's first byte instead of resolving on the quiet that was already there. The grace window answers the same question from the other side — silence alone cannot separate a peer that answered long ago from one working quietly — which is also why a running foreground command skips the window outright: a silent build resolves on its exit rather than being guessed at. **Why `idle` is a `cause`, not a failure.** A caller that asked for quiet and found quiet got what it asked for. A distinct cause rather than a distinct failure lets a simple caller treat success as success, while a careful one can still tell "it settled" from "there was never anything there". @@ -42,7 +42,7 @@ **Why the claim window is left unacknowledged.** Closing the gap between a claim and the caller actually reading the outcome would need a two-phase claim on *every* completion, to cover a process that dies in the microseconds after its answer was computed. -**Why the timeout ceiling exists at all.** Like the inactivity timeout, `timeoutMs` 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 immediately, turning a long park into an instant `timeout`. +**Why the timeout ceiling exists at all.** `timeoutMs` is a safety rail on a blocking call inside an agent loop, not an alert-tuning knob. Like the inactivity timeout it 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 immediately, turning a long park into an instant `timeout`. **Why a disposing VS Code webview answers its own parked requests synchronously.** A caller that can no longer be answered would otherwise go on absorbing completions the human would have been shown. Synchronously, because the cancelled outcome would arrive a microtask after the router stopped posting and be dropped, leaving `dor` blocked on a reply that never comes. @@ -106,7 +106,7 @@ Guarding only completion leaves a stale `start` free to replace the active utter **Why `cfg.alert.ringingPaused` suppresses the pulse.** It is the Chromatic freeze that pins the alarm; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 2.6 seconds. -**Why the unlabelled treatment pulses once per episode.** An infinite per-Session animation is expensive, and the whole-Pane treatment covers far more surface than the retired bell icon did. With four focused panes wearing an infinite animation, three minutes cost 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU; pausing only those animations in the same loaded document reduced that to 0.13 MB, two recalculations, and 0.025 seconds. After bounding the burst, two consecutive three-minute windows each had zero live animations, one recalculation, under 0.40 MB of non-cumulative embedder drift, and at most 0.024 seconds of renderer CPU (measured in Chrome 150, 2026-09). A handful of cycles preserves the entry cue without leaving an animation running for the lifetime of an unattended alert. The episode — not a track latch — is the key because the episode is the summons the sinks already work from: a second track latching inside one enriches an alarm the user was already shown, and re-flashing the whole Pane for it would read as a new alarm. Running the burst off `episode.startedAt` rather than from mount makes the CSS clock a property of the episode, so minimize → reattach or a Workspace switch lands past an expired burst instead of replaying it. A per-latch counter carried this edge while the burst hung off the retired bell, and it went with the bell once the episode became the only presentation edge. A Session BEL-ing in a loop still cannot restart the burst, because a track that is already latched does not re-latch. +**Why the unlabelled treatment pulses once per episode.** An infinite per-Session animation is expensive, and the whole-Pane treatment covers far more surface than the retired bell icon did. With four focused panes wearing an infinite animation, three minutes cost 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU; pausing only those animations in the same loaded document reduced that to 0.13 MB, two recalculations, and 0.025 seconds. After bounding the burst, two consecutive three-minute windows each had zero live animations, one recalculation, under 0.40 MB of non-cumulative embedder drift, and at most 0.024 seconds of renderer CPU (measured in Chrome 150, 2026-09). A handful of cycles preserves the entry cue without leaving an animation running for the lifetime of an unattended alert. The episode — not a track latch — is the key because the episode is the summons the sinks already work from: a second track latching inside one enriches an alarm the user was already shown, and re-flashing the whole Pane for it would read as a new alarm. Running the burst off `episode.startedAt` rather than from mount makes the CSS clock a property of the episode, so minimize → reattach or a Workspace switch lands past an expired burst instead of replaying it. A Session BEL-ing in a loop still cannot restart the burst, because a track that is already latched does not re-latch. ## Text And Security diff --git a/lib/src/lib/speech-queue.ts b/lib/src/lib/speech-queue.ts index ffae0f7b9..0165c8012 100644 --- a/lib/src/lib/speech-queue.ts +++ b/lib/src/lib/speech-queue.ts @@ -9,6 +9,7 @@ export interface SpeechJob { onFinish?: (started: boolean) => void; } +/** Pending jobs are refused past this, never evicted: the refusal is an answer. */ const MAX_PENDING = 64; /** A missing engine callback must not retain every later alert indefinitely. */ export const SPEECH_ENGINE_TIMEOUT_MS = 60_000; @@ -20,6 +21,22 @@ interface Attempt { timer: ReturnType; } +/** + * The one seam between Dormouse's pending spoken alarms and the browser's Web + * Speech engine (`docs/specs/alert.md` -> "Spoken alarms" owns the behavior; + * the bounds and the timeout are here). **Only one utterance at a time is + * admitted per renderer** — Settings test sounds included, since they share + * {@link speechQueue} — so the engine never interleaves two panes and an + * ineligible job can still be dropped while it is only pending here. + * + * Bounded in both directions, because a wedged or callback-less engine must not + * retain later alerts: at most {@link MAX_PENDING} pending jobs, and at most + * {@link SPEECH_ENGINE_TIMEOUT_MS} per engine attempt, after which the attempt + * is cancelled and the queue advances. Callback identity is revoked before any + * cancel, so a detached late callback cannot settle the attempt that replaced + * it. Nothing is retried: an alarm that failed, expired, or never fit has + * missed the moment it was about. + */ export class SpeechQueue { private pending: SpeechJob[] = []; private active: Attempt | null = null; @@ -55,6 +72,7 @@ export class SpeechQueue { clearTimeout(attempt.timer); attempt.utterance.onstart = attempt.utterance.onend = attempt.utterance.onerror = null; // Revoke callback identity before cancel(), which may synchronously callback. + // Teardown (`clear`) comes through here too, cancelling the engine. if (cancel) { try { attempt.synth.cancel(); } catch { /* unavailable engine */ } } attempt.job.onFinish?.(attempt.started); this.pump(); diff --git a/lib/src/lib/terminal-protocol.ts b/lib/src/lib/terminal-protocol.ts index 94af3a45a..307b569bf 100644 --- a/lib/src/lib/terminal-protocol.ts +++ b/lib/src/lib/terminal-protocol.ts @@ -340,6 +340,13 @@ export class TerminalProtocolParser { : []; } + /** + * rxvt/WezTerm notifications. The alert behavior is `docs/specs/alert.md` -> + * "Terminal reports"; the grammar is here. Only the `notify` subcommand is + * supported: the first field after it is the title and everything past the + * next semicolon is the body, so semicolons inside a body survive — only the + * first one separates. + */ private parseOsc777(content: string): TerminalProtocolEvent[] { if (!content.startsWith('777;notify;')) return []; const rest = content.slice('777;notify;'.length); @@ -352,6 +359,32 @@ export class TerminalProtocolParser { return [{ kind: 'notification', notification: { source: 'OSC 777', title, body } }]; } + /** + * kitty desktop notifications. The alert behavior is `docs/specs/alert.md` -> + * "Terminal reports"; the grammar is here. + * + * Metadata keys are single ASCII letters separated by `:`, and an unknown key + * is ignored. `i` groups the chunks of one pending notification, `d` is the + * done flag (default `1`), `e` selects plain (`0`) or base64 (`1`) payload + * encoding, and `p` the payload type (default `title`). `title` / `body` + * chunks append into the pending entry; the done flag completes it, and a + * completion whose sanitized title or body is nonempty becomes one + * notification. Without `i` there is no pending entry to append to, so only a + * complete single-sequence notification is meaningful. + * + * Management payloads contribute no content and are consumed: `p=?` answers + * the capability query with {@link OSC99_SUPPORT_PAYLOAD}, while `p=close` + * and `p=alive` are dropped outright, touching no pending notification. Any + * *other* unknown payload type still obeys the done flag — under the default + * `d=1` it completes a pending same-`i` notification, which may then fire on + * the title and body it had already accumulated. + * + * Incomplete chunk state is bounded, so a program that opens chunked + * notifications and never finishes them cannot grow this map: + * {@link OSC99_MAX_PENDING_IDS} ids, each expiring + * {@link OSC99_PENDING_TTL_MS} after its last chunk, and each of the two + * buffers capped as it appends. + */ private parseOsc99(content: string): TerminalProtocolEvent[] { this.expireOsc99Pending(); diff --git a/lib/src/remote/burrow/alert-push.ts b/lib/src/remote/burrow/alert-push.ts index 109dfcba6..fd37044ba 100644 --- a/lib/src/remote/burrow/alert-push.ts +++ b/lib/src/remote/burrow/alert-push.ts @@ -25,10 +25,11 @@ let pushDevicesRefreshSequence = 0; /** * Run `load` and publish its result to the dialog's store, fenced as below. - * `load` goes over the service bridge (`activation.ts`), because the ACL the - * list is joined against is the Burrow's — and it answers `null` when no Burrow is - * running, which is "nowhere to push", not an empty list. Failure is reported - * as `error` rather than an empty list: "we could not ask" and "no devices are + * `load` goes over the service bridge (`activation.ts`) as a `pushDevices` + * command, because the ACL the list is joined against is the Burrow's — and it + * answers `null` when no Burrow is running, which is "nowhere to push" + * (rendered `no-burrow`), not an empty list. Failure is reported as `error` + * rather than an empty list: "we could not ask" and "no devices are * subscribed" are different things to show a user. */ export async function commitPushDevices( diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0cfbdab4b..6eefc3cff 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -2,7 +2,7 @@ "AGENTS.md": 3400, "SECURITY.md": 200, "SELF_HOST.md": 6200, - "docs/specs/alert.md": 7050, + "docs/specs/alert.md": 6750, "docs/specs/auto-update.md": 1200, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, From c829570f6e3166fcfc007f94c1096e140beedd39 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 01:52:24 -0700 Subject: [PATCH 2/2] Restore three conditions the alert spec compression dropped The await grace window resolves idle unless output or a command start cancels it; delivery on a transferred Workspace resumes only when adopt_done succeeds; and transport.md carries the alert-store relay messages, not the alert:await contract, which stays here. Co-Authored-By: Claude Fable 5.1 --- docs/specs/alert.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 145f40085..781ae92a2 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -4,7 +4,7 @@ > > Owns the Session Activity layer — the three alert tracks, attention, TODO, notification text and its sanitization, the two alarm sinks, and the Workspace union projection. `docs/specs/layout.md` defers here for all alert/TODO behavior. > -> Defers placement and sizing to `docs/specs/layout.md`, the webview ↔ host wire contracts to `docs/specs/transport.md`, and push sealing to `docs/specs/remote-security-model.md`. +> Defers placement and sizing to `docs/specs/layout.md`, the alert-store relay messages (`alert:state` / `alert:settings` / `alert:watchedCommands`) to `docs/specs/transport.md`, and push sealing to `docs/specs/remote-security-model.md`; the `alert:await*` contract is stated here. **Must preserve Activity across minimize/reattach** (glossary I3). **A browser Surface has no Activity machine** — it can never ring, and carries only a user-set TODO flag, destroyed with that Surface. @@ -100,7 +100,7 @@ An **await** parks on one Session until it finishes what it is doing, then repor | At await time | Behavior | |---|---| | A foreground command is running (`commandExitWatch`) | Park, no grace window. | -| Nothing running | Park for one grace window, then wait for a real signal. A *command start* cancels it under either `until`; under `quiet` so does *output*, under `exit` output alone does not. Neither → resolve `cause: idle`. | +| Nothing running | Park for one grace window. A *command start* cancels it under either `until`; under `quiet` so does *output*, under `exit` output alone does not. Either way the await then waits for a real signal. Neither → resolve `cause: idle`. | **`idle` is a resolution, not a failure** (rationale). Absent shell integration "is a command running" is unanswerable, so an `exit` await there falls back to the grace window and resolves `idle` rather than erroring. @@ -244,7 +244,7 @@ Clearing behavior: ## Live Workspace transfer - **Must transfer live alert state separately from persisted reminders:** track latches, episode, command watch, deferred notification, and detector history/deadlines travel in the marked transfer content. **Never read this content on cold restore.** -- **Must suspend source delivery before handing ownership to the host**, freezing its detector, rule walk, and controls at content capture, and **must await host hand-back when source content capture fails**, holding that suspension until routing returns. The destination restores receipts after persisted seeding and before the runtime publishes, and enables delivery only after `adopt_done`; refusal restores the source runtime and its pending deadlines. Transaction order is `docs/specs/standalone.md` → "Arrival queue". +- **Must suspend source delivery before handing ownership to the host**, freezing its detector, rule walk, and controls at content capture, and **must await host hand-back when source content capture fails**, holding that suspension until routing returns. The destination restores receipts after persisted seeding and before the runtime publishes, and enables delivery only after `adopt_done` succeeds; refusal restores the source runtime and its pending deadlines. Transaction order is `docs/specs/standalone.md` → "Arrival queue". - **Must discard imported runtime, Activity, and pane state before releasing the delivery guards on any target failure**, mount or no mount, so a watcher never re-arms on a Workspace this window never owned. A receipt forgotten on an episode already observed is consumed, never fresh. - **Must carry per-sink receipts with the episode:** pending/queued work retains its original deadline; engine-admitted speech and dispatched push are consumed and never replayed. Suspending cuts current speech; a partially heard utterance is not restarted after a move or refusal. - **Must cancel parked await callers at suspension**, carrying no claimant closures or attention lease. Source attention loss arms a running command; destination interaction establishes its own attention.