diff --git a/docs/specs/alert.md b/docs/specs/alert.md
index 5d4586f4d..e329bc946 100644
--- a/docs/specs/alert.md
+++ b/docs/specs/alert.md
@@ -30,17 +30,17 @@ Only `watching` requires WATCHING. **Every source obeys one engagement rule —
Public `status` is a projection — first match wins:
-1. `ALERT_RINGING` if the ring is active.
+1. `ALERT_RINGING` if the ring is active and not paused; consumers use `isAlertPaused` in `lib/src/lib/alert-episode.ts`.
2. `OSC_NOTIF_BUSY` if a progress cycle is active.
3. The output/silence detector's own state if WATCHING is on. The detector runs regardless; the rule only makes its state public. **Never reorder 3 and 4** (rationale).
4. `COMMAND_EXIT_ARMED` if command-exit alerting is armed.
5. Otherwise `WATCHING_DISABLED`.
-**Must identify each uninterrupted ringing interval with an `episode` id and start time.** Opening the ring creates it; the ring clearing ends it. A source joining an active ring joins its episode and never starts another delivery episode. **Never persist episodes.** Tests: `a second source joining mid-episode keeps the episode id` and `re-latching after the ring clears starts a new episode` in `lib/src/lib/alert-manager.test.ts`.
+**Must identify each unresolved summons with an `episode` id and start time**, retained through animation pauses (Completion events). Opening the ring creates it; clearing ends it. A source joining an active ring joins its episode and never starts another delivery episode. **Never persist episodes.** Tests: `a second source joining mid-episode keeps the episode id` and `re-latching after the ring clears 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**.
-**Persist only** `todo` and the sanitized `notification` (plus `status` for diagnostics); **an unacknowledged ring persists as the TODO a look would leave**, with its detail (pinned by `persists an unacknowledged %s ring as the TODO a look would leave` in `lib/src/lib/alert-manager.test.ts`). Every spawn starts the id's alert state over; **a cold restore carries those two on the pane's spawn** (`SpawnPtyOptions.alert`), seeded **before the PTY spawns**; a live resume keeps the host's. Restore **must not** recreate a ring, a progress cycle, or a command-exit arm. Pinned by `restores a pane's persisted TODO through its spawn` in `lib/src/lib/terminal-registry.alert.test.ts`. **Must read a notification this build cannot validate as none, keeping the pane** (pinned by `keeps a pane whose notification this build cannot read, seeding its TODO without the detail` in `lib/src/lib/session-restore.test.ts`), and **never write a source outside `STRICT_READER_NOTIFICATION_SOURCES`** until no strict pre-tolerant build reads the file (rationale). **WATCHING is never persisted per Session** — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` keeps old terminal output from firing notification side effects again.
+**Persist only** `todo` and the sanitized `notification` (plus `status` for diagnostics); **an unacknowledged ring, paused or visible, persists as the TODO a look would leave**, with its detail (pinned by `persists an unacknowledged %s ring as the TODO a look would leave` in `lib/src/lib/alert-manager.test.ts`). Every spawn starts the id's alert state over; **a cold restore carries those two on the pane's spawn** (`SpawnPtyOptions.alert`), seeded **before the PTY spawns**; a live resume keeps the host's. Restore **must not** recreate a ring, a progress cycle, or a command-exit arm. Pinned by `restores a pane's persisted TODO through its spawn` in `lib/src/lib/terminal-registry.alert.test.ts`. **Must read a notification this build cannot validate as none, keeping the pane** (pinned by `keeps a pane whose notification this build cannot read, seeding its TODO without the detail` in `lib/src/lib/session-restore.test.ts`), and **never write a source outside `STRICT_READER_NOTIFICATION_SOURCES`** until no strict pre-tolerant build reads the file (rationale). **WATCHING is never persisted per Session** — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` keeps old terminal output from firing notification side effects again.
**Must retain host Activity before xterm initialization and clear it on Session disposal.** Test: `preserves pre-registration activity through terminal creation and orphaning` in `lib/src/lib/terminal-registry.alert.test.ts`.
@@ -81,24 +81,24 @@ Every completion — a detector settle, a command finish, a direct notification,
Claimants get first refusal per Session 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, where the echo window, holding, and the command-exit seen check live.
-**Must hold a completion that would ring an engaged Session**: the sources it would raise and the richest detail (Clearing And TODO), repeated holds merging, never public. A deferred report that comes due while engaged is held too, and **never deferred again on escalation** (rationale). **Presence lapsing `idle` with focus unchanged rings what was held**, each source by its unengaged path; any other end drops it — a user verb (Clearing And TODO), focus moving away, the viewer leaving, seeding, removal, or teardown (rationale). **Dropping on an explicit disengage may be a mistake** (rationale). Resumed watched work and rule removal withdraw a held `watching` source as they withdraw a ringing one (WATCHING Track).
+**Must hold a completion that would ring an engaged Session**: the sources it would raise and the richest detail (Clearing And TODO), repeated holds merging, never public. A deferred report that comes due while engaged is held too; escalation rechecks recent output. **Presence lapsing `idle` with focus unchanged rings what was held**, each source by its unengaged path; any other end drops it — a user verb (Clearing And TODO), focus moving away, the viewer leaving, seeding, removal, or teardown (rationale). **Dropping on an explicit disengage may be a mistake** (rationale). Rule removal withdraws a held `watching` source as it withdraws a ringing one (WATCHING Track).
With `deferAlertsUntilQuiet` enabled:
-- **Must defer an eligible unengaged terminal-notification ring while the private detector is fully armed** — `BUSY` or `MIGHT_NEED_ATTENTION`, including when WATCHING is off or a progress cycle masks that projection (rationale).
-- **Never defer `MIGHT_BE_BUSY`, a detector settle, or a command-finish ring** — unconfirmed, already quiet, and authoritative respectively (rationale).
-- **Must fold a pending terminal notification into an eligible command-finish ring immediately**, so protocol detail enriches that ring instead of publishing stale later.
-- **Must defer after claimants and ring eligibility, never redispatch the historical `CompletionEvent`** (rationale).
-- **Keep pending intent live-only and bounded** to one protocol notification, chosen by richness (Clearing And TODO). Meaningful output moves its quiet deadline; command-boundary detector resets do not drop it.
-- **Cancel pending delivery on an acknowledgement, a dismissal that clears a ring, TODO changes, removal, seeding, or teardown.** A dismiss on a quiet Session keeps it: a cancelled deferral was never visible to dismiss. Disabling the setting releases it immediately; otherwise confirmed quiet raises one fresh ring, after which speech/push begin their own delays.
-- **Must ring a deferral by `cfg.alert.deferCeiling` (30 s) after the first notification deferred**, quiet or not; a replacement keeps that start (rationale).
+- **Must defer an eligible terminal notification until five seconds after the last accepted output**, including unconfirmed activity and WATCHING off. Idle panes ring immediately (rationale).
+- **Must derive pauses from recent output, never store them**, keeping the unresolved ring's sources, detail, episode, TODO, and pending alarm deadlines. Quiet restores it without confirmed BUSY; publishing a pause arms its wake.
+- **Never defer a command-finish ring or pause one carrying an `exit` source**; a pending terminal notification joins it immediately. A held exit does not release an existing pause.
+- **Must defer after claimants and ring eligibility, never redispatch the historical `CompletionEvent`** (rationale). A claimed new settle answers a retained `watching` source, never an earlier report.
+- **Keep initial pending intent live-only and bounded** to one notification, chosen by richness (Clearing And TODO). Accepted output moves the quiet deadline; command-boundary detector resets preserve it.
+- **Never cap a deferral or pause** (rationale).
+- **Cancel pending delivery on acknowledgement, a dismissal that clears a ring, TODO changes, removal, seeding, or teardown.** A dismiss without a ring leaves initial deferral alone. Disabling the setting releases pending notifications immediately.
Two ordering rules:
- **Clear the progress cycle *before* dispatch**, so a completion or error ends the cycle whether or not the event is claimed and `OSC_NOTIF_BUSY` falls back either way.
- **Dispatch a command finish for every watch that existed**, including the unseen and engaged ones the ring rule then discards or holds.
-Source of truth: `registerCompletionClaimant` / `dispatchCompletion` / `holdOrDeliver` / `deferOrDeliverNotification` / `scheduleDeferredNotification` / `flushDeferredNotification` / `escalateHeld` in `lib/src/lib/alert-manager.ts`; `quietAt` in `lib/src/lib/quiesce-detector.ts`. Pinned by `held completions` in `lib/src/lib/alert-engagement.test.ts`.
+Source of truth: `registerCompletionClaimant` / `dispatchCompletion` / `holdOrDeliver` / `isPaused` / `notify` / `deferOrDeliverNotification` / `scheduleDeferredNotification` / `flushDeferredNotification` / `escalateHeld` in `lib/src/lib/alert-manager.ts`; `hasRecentOutput` / `quietAt` in `lib/src/lib/quiesce-detector.ts`. Pinned by `held completions` in `lib/src/lib/alert-engagement.test.ts`.
## Await
@@ -200,12 +200,12 @@ Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`, **a g
- First output starts candidate tracking without changing status; unconfirmed `MIGHT_BE_BUSY` returns to `NOTHING_TO_SHOW`.
- **Must discard unconfirmed candidate history after an output gap beyond `busyCandidateGap + busyConfirmGap`, including when a timer runs late** (rationale). Pinned by `forgets stale candidate history` and `expires candidate history even when the confirmation timer has not run` in `lib/src/lib/quiesce-detector.test.ts`.
- **The detector never holds `ALERT_RINGING`.** It reports each settle once and returns to `NOTHING_TO_SHOW`; the ring latches instead.
-- **With `deferAlertsUntilQuiet`, withdraw the `watching` source, ringing or held, when watched work resumes confirmed BUSY.** Preserve the detector and other sources; a ring left empty is withdrawn (Clearing And TODO). The next unengaged settle restarts speech/push delays. Short redraws and post-exit output retain the ring (rationale).
+- **Must preserve an owed `watching` source when output resumes**; Completion events owns animation pauses (rationale).
- **A settle rings only if** a rule matches the foreground command; an engaged Session holds it (Completion events).
- **Never reset the detector for engagement alone**; settles must reach awaits. **Must reset to `NOTHING_TO_SHOW` when a user verb or an await clears a ring with a `watching` source**, preventing its output tail from settling again; resumed work and rule removal leave it running.
-- **Rings must be caused by a fresh transition** — a settle the detector just reported — never by rerender, theme change, remount, minimize, or reattach.
+- **New WATCHING summons must be caused by a fresh settle**, never rerender, theme change, remount, minimize, or reattach; Completion events owns resuming a paused summons.
-Source of truth: `commandWatchKey` / `watchRuleFor` in `lib/src/lib/terminal-state.ts`; `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts`; `onSettled` / `withdrawResumedWatchingRing` in `lib/src/lib/alert-manager.ts` (pinned by `lib/src/lib/alert-resumed-output.test.ts`); renderer mirror `lib/src/lib/watched-commands.ts`, multi-renderer coordinator `lib/src/lib/watched-command-host.ts`.
+Source of truth: `commandWatchKey` / `watchRuleFor` in `lib/src/lib/terminal-state.ts`; `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts`; `onSettled` / `isPaused` in `lib/src/lib/alert-manager.ts` (pinned by `lib/src/lib/alert-resumed-output.test.ts`); renderer mirror `lib/src/lib/watched-commands.ts`, multi-renderer coordinator `lib/src/lib/watched-command-host.ts`.
## Terminal reports
@@ -254,7 +254,7 @@ Source of truth: `dispatchCompletion` / `setViewer` / `formatCommandExitBody` in
## Clearing And TODO
-`todo` is a boolean reminder. **A ring never sets it**: a look at a ring without typing turns it into a TODO carrying the ring's detail, and dealing with it — typing, the pill — clears it (rationale). **`notification` is the ring's detail while ringing, else the TODO's.** Pinned by `a %s ring shows its own detail and never sets TODO` and `acknowledging without input, dismissing, or toggling TODO on a %s ring leaves a TODO with its detail` in `lib/src/lib/alert-manager.test.ts`.
+`todo` is a boolean reminder. **A ring never sets it**: a look at a ring without typing turns it into a TODO carrying the ring's detail, and dealing with it — typing, the pill — clears it (rationale). **`notification` is the unresolved ring's detail, paused or visible, else the TODO's.** Pinned by `a %s ring shows its own detail and never sets TODO` and `acknowledging without input, dismissing, or toggling TODO on a %s ring leaves a TODO with its detail` in `lib/src/lib/alert-manager.test.ts`.
**Detail follows one richness order:** a text report (`OSC 9`, `OSC 99`, `OSC 777`) > `COMMAND_EXIT` > `OSC 9;4` > `WATCHING` > `BEL`. A new ring shows its own detail; a source joining an active ring, a deferred notification, and an acknowledged receipt each replace the detail only at an equal or higher rank (rationale). Pinned by `detail joining a ring` in `lib/src/lib/alert-manager.test.ts`.
@@ -265,7 +265,6 @@ Source of truth: `dispatchCompletion` / `setViewer` / `formatCommandExitBody` in
| dismiss (`a`, Pane Header) | clears the ring, leaving `todo` on with its detail. **With nothing ringing: no change, no notify, a deferred notification kept** |
| toggle TODO (`t`) | clears the ring; `todo` flips, on keeping the ring's detail, off dropping the notification |
| an await consumes a source (Await) | withdraws that source |
-| watched work resumes (WATCHING Track) | withdraws `watching` |
| a rule stops covering the `watching` key | withdraws `watching` |
- **The user verbs acknowledge; a withdrawal never does.** Each also drops whatever was held or deferred, except a dismiss with nothing ringing, and **a dropped hold or deferral leaves no TODO**. A ring a withdrawal empties goes with its detail, leaving `todo` and its notification as they stood.
@@ -295,7 +294,7 @@ Application alarm defaults live beside the WATCHING rule set, edited in **Settin
| Field | Meaning |
|---|---|
| `inactivityTimeoutMs` | The presence window (Engagement), and nothing else; only the renderer's presence tracker reads it. |
-| `deferAlertsUntilQuiet` | Gates animation deferral (Completion events) and resumed-ring withdrawal (WATCHING Track). Default on. (rationale) |
+| `deferAlertsUntilQuiet` | Gates animation deferral and pauses (Completion events). Default on. (rationale) |
| `speakEnabled` / `speakDelayMs` | Spoken alarms, below. |
| `pushEnabled` / `pushDelayMs` | Push notifications, below. |
@@ -314,8 +313,8 @@ Rules:
**The host decides when to deliver, in `createAlertHost` beside the manager** (rationale):
-- **Must deliver at most once per sink per episode**, due at the episode's start plus the Session's delay; a source joining the episode delivers nothing, and the ring clearing consumes what it had pending.
-- **Must recheck at the deadline, and consume a deadline that fails, never retrying it**: **speech only while the Session is not engaged** (Engagement); **push only while no viewer is present**, VS Code's focused, active window counting as one (`docs/specs/vscode.md` → Workspaces; rationale).
+- **Must deliver at most once per sink per episode**, due at the episode's start plus the Session's delay. Pauses retain unsent deadlines and consumed sinks; resuming sends overdue alarms without restarting the delay. A source joining delivers nothing; clearing consumes pending work.
+- **Must defer paused alarms until quiet; otherwise recheck at the deadline and consume a deadline that fails, never retrying it**: **speech only while the Session is not engaged** (Engagement); **push only while no viewer is present**, VS Code's focused, active window counting as one (`docs/specs/vscode.md` → Workspaces; rationale).
- **Disabling consumes pending work immediately; enabling never replays an episode**, one that began disabled included. Delay edits never move a deadline; speech reads the current voice at engine admission.
- **Each realm publishes every Session it shows** — Pane label and Workspace overrides — as one `sessions` op: membership and override changes at the end of their task, label changes on a `LABEL_PUBLISH_THROTTLE_MS` trailing throttle (rationale), nothing unchanged resent. **A publication overwrites each Session it names, whichever realm published it last, and never drops one the manager holds state for**; one its realm omits with none is forgotten (rationale). **The host keeps each Session's entry until the Session is removed**, through its realm's end and a respawn under its id; an unpublished Session uses the defaults.
- **A due push goes from the host's own Burrow** (Push notifications), titled by the published label, whether or not a realm still shows the Session; **a due spoken alarm goes to the realm showing it** as `alert:speak`, and with none is not spoken.
@@ -341,10 +340,11 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi
- **The label must be sanitized before it reaches the engine** (`toSpokenText`): all Unicode punctuation, symbols, and `Other` characters (including controls, bidi controls, and zero-width formats) become spaces, except apostrophes, which are elided so contractions survive; letters, numbers, and their combining marks from every script remain. Whitespace collapses, the result is capped in code points, and an empty result falls back to `terminal`. **Security, not tidiness** (rationale).
- **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.
-- **Clearing the ring 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.
+- **Clearing or pausing the ring 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 retain paused, unstarted speech, preparation included, without blocking other Sessions.** Never replay speech that started.
- **Must admit only one utterance at a time to a speech engine per renderer**, Settings tests included, and **must remove resolved or disabled pending jobs before engine admission**, a delivery that crossed either included, 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 and cancel a stalled engine attempt**, advancing the queue and revoking callback identity before every cancel, and **never retry a failed, expired, or overflowed delivery**. Teardown cancels the current engine utterance and drops pending jobs. The bound, the timeout, and the revocation mechanism 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.
+- `speaking` / `spoken` remains only while the originating episode is unresolved, a pause retaining `spoken`: 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` / `startAlertSpeech` in `lib/src/lib/alert-speech.ts`, armed by `lib/src/components/wall/use-alert-delivery.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`.
@@ -385,7 +385,7 @@ Reached from the baseboard sliders; `docs/specs/layout.md` owns placement.
- **Must toggle only the clicked baseboard alarm setting**, as an override for that Workspace, showing the effective value. Components without a Workspace scope edit application defaults. **Must show its shared settings section for 2 seconds, then fade for 250ms**, anchored to the button and bounded by the viewport. The preview is inert, announces the resulting state, preserves keyboard focus and command dispatch, and omits test actions. Each click replaces the preview and restarts its lifetime; opening Settings or unmounting clears it. Reduced motion skips the fade. Pinned by `Baseboard.test.tsx`.
- Lists every watched command with a remove control, and **cannot add one** — WATCHING is keyed on a running command's watch key, so creating a rule stays the terminal context of a Pane running it, and the empty state says so. **It is the only place a rule set on a since-closed Pane can be removed**, the terminal context reaching only the command its own Pane is running.
-- The watcher group carries the **Defer alerts until animation stops** switch and explains that a fully armed watcher delays terminal notifications and withdraws a ring once watched work resumes.
+- The watcher group carries the **Defer alerts until animation stops** switch and explains recent-output deferral and pauses under Completion events.
- **Delays are committed on blur or `Enter`, never per keystroke** — typing `3` on the way to `30` must not briefly install a 3-second timer. They are shown in seconds; an out-of-range or empty entry snaps back to whatever the store clamped it to.
- **The push group's device line names every device a push would reach**, and otherwise says why there is none — Network set to Nothing, no Burrow enrolled, nothing subscribed yet, or the server could not be asked (rationale).
- **Must show only application-wide settings**, excluding Workspace overrides.
diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md
index 38f12c756..faff5d137 100644
--- a/docs/specs/alert.rationale.md
+++ b/docs/specs/alert.rationale.md
@@ -44,7 +44,7 @@
**Why command finishes bypass animation deferral.** A shell-reported exit is a lifecycle event; animation detection is only a recent-output heuristic. Letting the heuristic overrule the event would add latency and let unrelated background output defer a certain completion indefinitely.
-**Why deferral has a ceiling.** Unbounded, it held an `OSC 9` behind any output that never went quiet for five seconds: a `watch -n1` pane redrawing once a second, or a dev server's heartbeat line every three seconds after its startup burst, deferred it indefinitely (audit probes, 2026-09-23). Thirty seconds is far past an agent's final spinner frames, which deferral exists to wait out, and short enough that a report behind a ticking clock still reaches its user. An escalated hold that deferred again restarted the ceiling: an `OSC 9` deferred at 0 s, held when it came due at 30 s, and escalated at 45 s while the pane still animated rang near 75 s (review, 2026-09-24).
+**Why deferral waits for recent output without a ceiling.** The 30-second cap addressed notifications starved by `watch -n1` redraws or dev-server heartbeat output (audit, 2026-09-23). It also rang during ongoing animation. The chosen preference is now to wait indefinitely for quiet rather than summon during output; even a brief unconfirmed redraw can delay an owed notification without cancelling it (product decision, 2026-10-01). Idle-pane notifications remain immediate.
**Why a deferred event is not dispatched again.** Claimants already had first refusal when the completion happened; re-offering it at quiet time would let a later-registered await consume history, and would report one completion twice.
@@ -96,7 +96,7 @@
**Why the keystroke fallback is not routed into the manager.** The fallback in `docs/specs/terminal-state.md` is renderer-side and lower confidence than a shell-reported command boundary. Wiring it in would buy integration-less shells a worse version of WATCHING at the price of a second command-tracking path to keep in sync.
-**Why resumed work withdraws an inferred WATCHING ring.** The marked `ttr.pgstencil-adopt` speech (2026-09-09 18:17:03) followed a WATCHING settle, resumed output, and confirmed BUSY before the speech deadline; the latched ring masked that activity, so the renderer spoke while the terminal was still animating. Withdrawing the ring lets the existing sink cancellation and fresh-ring delays follow the new busy/quiet cycle. Only the inference goes: explicit reports and command exits stay authoritative, and a redraw too brief to confirm BUSY never invalidates completion.
+**Why resumed work pauses an owed ring.** The marked `ttr.pgstencil-adopt` speech (2026-09-09 18:17:03) followed a WATCHING settle and resumed output. Withdrawing only confirmed WATCHING left program-sent reports ringing through animation. Pausing on the first accepted output preserves the debt even when a redraw never confirms BUSY; the same episode and original deadlines prevent alarm replay and avoid adding another full delay after quiet (product decision, 2026-10-01).
## Terminal reports
@@ -124,7 +124,7 @@
## Alarm settings
-**Why animation deferral defaults on.** Coding agents (`claude`, `codex`) send their notification OSC while their TUI is still redrawing its spinner, so an undeferred ring summons the user to a pane that is still animating (2026-09). The gate engages only while the private detector is fully armed, so a BEL from an otherwise quiet shell still rings at once. The deferral ceiling bounds the wait (Completion events); turning the switch off restores the protocols' literal timing. Installs that saved any settings blob keep the old value: the blob has no version field, and a persisted `false` cannot be told from a deliberate opt-out, so dropping it on read would leave the off position unpersistable.
+**Why animation deferral defaults on.** Coding agents (`claude`, `codex`) send their notification OSC while their TUI is still redrawing its spinner, so an undeferred ring summons the user to a pane that is still animating (2026-09). The gate engages only on recent output, so a BEL from an otherwise quiet shell still rings at once; turning the switch off restores the protocols' literal timing. Installs that saved any settings blob keep the old value: the blob has no version field, and a persisted `false` cannot be told from a deliberate opt-out, so dropping it on read would leave the off position unpersistable.
**Why the settings ride the WATCHING rule set's seed/broadcast shape.** Each VS Code webview has its own origin and therefore its own `localStorage`, while the `AlertManager` is shared; without a host-authoritative copy, two webviews would each believe their own blob. The one difference is the whole-blob relay: an alarm setting is not a set of independent keys the way a rule list is.
@@ -140,6 +140,8 @@
## Spoken alarms
+**Why paused preparation is retained.** Host admission is not playback: Web Speech can queue an utterance and managed voice can prepare audio before the start callback. Treating a pause as cancellation lost an unsent alarm. An unstarted attempt returns to the pending queue, while revoking its callbacks prevents an old answer from starting or finishing the resumed job; a job that already started is not replayed (product decision, 2026-10-01).
+
**Why an entropy heuristic, and what it costs.** A bare token can reach a terminal-supplied title without credential-related wording. Finite samples often fall below their alphabet's maximum entropy, so the cutoffs sit below those maxima and still miss some random tokens. Conversely, `/`, `-`, and `_` are token characters: 135 of this repo's 1102 tracked paths redact (12.3%, measured 2026-09), and `vim lib/src/lib/redact-high-entropy.ts` speaks as `vim REDACTED.ts`. Speech accepts this loss of detail to reduce accidental disclosure. Redacting before punctuation cleanup and truncation prevents those transforms from hiding a token's recognizable shape while leaving its contents speakable.
**Why only hex grouping is normalized.** Grouped hex otherwise falls into the base64 tier and almost always misses its higher cutoff. In review samples of 20,000 random UUIDs, removing hex separators reduced misses from 100% to 0.01%, with no additional matches among the 1102 tracked paths (measured 2026-09). Applying separator removal to other alphabets would also redact `PostgreSQL_Connection_Manager` and `implementation_details_v2`; limiting normalization to hex keeps those identifiers unchanged.
diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts
index 843563792..6db51901c 100644
--- a/lib/src/cfg.ts
+++ b/lib/src/cfg.ts
@@ -31,8 +31,6 @@ export const cfg = {
* wait to ring on inactivity. Keep these effects coupled; there is no separate
* minimum command runtime (product decision, 2026-09-24). */
echoWindow: 750,
- /** ms — longest a terminal notification may wait behind animation before it rings anyway. */
- deferCeiling: 30_000,
/** When true, the ALERT_RINGING alarm pulse animations are frozen at T=0 (for deterministic Chromatic snapshots). */
ringingPaused: false,
},
diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx
index 8467a4e9b..1bc2eb5b3 100644
--- a/lib/src/components/Door.tsx
+++ b/lib/src/components/Door.tsx
@@ -30,8 +30,8 @@ export interface DoorProps {
status?: SessionStatus;
todo?: TodoState;
speechState?: AlertSpeechState;
- /** `ActivityState.episode` — the Session's current ringing interval. A new one
- * replays the alarm ring's arrival burst; `null` while the Session is quiet. */
+ /** `ActivityState.episode` — the Session's unresolved summons, retained during
+ * pauses. A new one replays the alarm ring's arrival burst. */
episode: AlertEpisode | null;
onClick?: () => void;
/** When provided, a primary-button press reports its start point and the Wall begins
diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx
index f546afb45..ddb166e11 100644
--- a/lib/src/components/MobileTerminalUi.tsx
+++ b/lib/src/components/MobileTerminalUi.tsx
@@ -59,8 +59,8 @@ export interface MobileTerminalSessionItem {
secondary?: string | null;
active?: boolean;
status?: SessionStatus;
- /** `ActivityState.episode` — the Session's current ringing interval, which
- * anchors the row's arrival burst; `null` while it is quiet. */
+ /** `ActivityState.episode` — the Session's unresolved summons, retained during
+ * pauses, which anchors the row's arrival burst. */
episode: AlertEpisode | null;
todo?: boolean;
}
diff --git a/lib/src/components/SettingsDialog.tsx b/lib/src/components/SettingsDialog.tsx
index 87c444727..5a1e8d530 100644
--- a/lib/src/components/SettingsDialog.tsx
+++ b/lib/src/components/SettingsDialog.tsx
@@ -419,9 +419,9 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
onChange={(deferAlertsUntilQuiet) => updateAlertSettings({ deferAlertsUntilQuiet })}
/>
- When the animation watcher is fully armed, terminal notifications wait
- for the pane to become quiet, and a ring raised by silence goes away if
- the watched command starts working again.
+ Terminal notifications wait until five seconds after the last output.
+ If output resumes, the alert pauses until quiet without losing it or
+ repeating alarms already sent. Command exits alert immediately.
diff --git a/lib/src/lib/alert-delivery-scheduler.test.ts b/lib/src/lib/alert-delivery-scheduler.test.ts
index c1f8217e6..1fe0921f9 100644
--- a/lib/src/lib/alert-delivery-scheduler.test.ts
+++ b/lib/src/lib/alert-delivery-scheduler.test.ts
@@ -31,6 +31,7 @@ const ringAgain = () => {
manager.acknowledge(PANE, { input: false });
manager.onData(PANE);
ring();
+ vi.advanceTimersByTime(5_000);
};
const session = (overrides: object, label = 'pnpm build') => ({ label, overrides });
diff --git a/lib/src/lib/alert-delivery-scheduler.ts b/lib/src/lib/alert-delivery-scheduler.ts
index 2fd073d4d..590c3dfc9 100644
--- a/lib/src/lib/alert-delivery-scheduler.ts
+++ b/lib/src/lib/alert-delivery-scheduler.ts
@@ -1,4 +1,5 @@
import type { AlertManager, AlertState } from './alert-manager';
+import { isAlertPaused } from './alert-episode';
import {
normalizeAlertDeliveryOverrides,
resolveAlertDeliveryPolicy,
@@ -49,12 +50,22 @@ interface Published {
overrides: AlertDeliveryOverrides;
}
+/** A sink's fixed deadline, armed only while the Session is visibly ringing. */
+interface Delivery {
+ dueAt: number;
+ timer: ReturnType | undefined;
+}
+
+function disarm(delivery: Delivery): void {
+ clearTimeout(delivery.timer);
+ delivery.timer = undefined;
+}
+
export function createAlertDeliveryScheduler(options: AlertDeliverySchedulerOptions): AlertDeliveryScheduler {
const { manager } = options;
let defaults = DEFAULT_ALERT_SETTINGS;
- /** Each ringing Session's episode, and the sinks whose deadline is still to
- * come; a sink missing from `timers` is consumed for that episode. */
- const episodes = new Map> }>();
+ /** Deadlines survive pauses; a sink missing from `pending` is consumed. */
+ const episodes = new Map }>();
/** Each Session's last publication, and the realm that made it. */
const published = new Map();
@@ -86,40 +97,50 @@ export function createAlertDeliveryScheduler(options: AlertDeliverySchedulerOpti
/** A sink turned off consumes its pending deadline; one turned on never
* replays it. */
function recheck(id: string): void {
- const timers = episodes.get(id)?.timers;
- if (!timers) return;
+ const pending = episodes.get(id)?.pending;
+ if (!pending) return;
const effective = policy(id);
- for (const [sink, timer] of timers) {
+ for (const [sink, delivery] of pending) {
if (sinks[sink].enabled(effective)) continue;
- clearTimeout(timer);
- timers.delete(sink);
+ disarm(delivery);
+ pending.delete(sink);
}
}
function onState(id: string, state: AlertState): void {
const episode = state.episode ?? null;
- const current = episodes.get(id);
- // At most once per sink per episode: a source joining it delivers nothing.
- if (current && current.episodeId === episode?.id) return;
- if (current) {
- for (const timer of current.timers.values()) clearTimeout(timer);
+ let current = episodes.get(id);
+ if (current && current.episodeId !== episode?.id) {
+ current.pending.forEach(disarm);
episodes.delete(id);
+ current = undefined;
}
if (!episode) return;
- const effective = policy(id);
- const timers = new Map>();
- episodes.set(id, { episodeId: episode.id, timers });
- for (const sink of Object.keys(sinks) as AlertSink[]) {
+ if (!current) {
+ const effective = policy(id);
+ current = { episodeId: episode.id, pending: new Map() };
+ episodes.set(id, current);
+ for (const sink of Object.keys(sinks) as AlertSink[]) {
+ const rule = sinks[sink];
+ // Fixed at episode start; disabled sinks and delay edits never replay it.
+ if (rule.enabled(effective)) current.pending.set(sink, {
+ dueAt: episode.startedAt + rule.delayMs(effective), timer: undefined,
+ });
+ }
+ }
+ const { pending } = current;
+ // Disarm without consuming, so quiet re-arms the original deadline.
+ if (isAlertPaused(state)) {
+ pending.forEach(disarm);
+ return;
+ }
+ for (const [sink, delivery] of pending) {
+ if (delivery.timer !== undefined) continue;
const rule = sinks[sink];
- // Off at the start consumes it: turning the sink on never replays it.
- if (!rule.enabled(effective)) continue;
- // Fixed here, so a later delay edit never moves it. A deadline that
- // fails is consumed, never retried.
- const dueAt = episode.startedAt + rule.delayMs(effective);
- timers.set(sink, setTimeout(() => {
- timers.delete(sink);
+ delivery.timer = setTimeout(() => {
+ pending.delete(sink);
if (!rule.blocked(id)) rule.deliver(id, episode.id);
- }, Math.max(0, dueAt - Date.now())));
+ }, Math.max(0, delivery.dueAt - Date.now()));
}
}
@@ -160,9 +181,7 @@ export function createAlertDeliveryScheduler(options: AlertDeliverySchedulerOpti
dispose() {
for (const stop of stops) stop();
- for (const { timers } of episodes.values()) {
- for (const timer of timers.values()) clearTimeout(timer);
- }
+ for (const { pending } of episodes.values()) pending.forEach(disarm);
episodes.clear();
published.clear();
},
diff --git a/lib/src/lib/alert-engagement.test.ts b/lib/src/lib/alert-engagement.test.ts
index 825fc467b..3073f30e3 100644
--- a/lib/src/lib/alert-engagement.test.ts
+++ b/lib/src/lib/alert-engagement.test.ts
@@ -105,6 +105,7 @@ describe('held completions', () => {
expect(manager.getState(PANE).todo).toBe(false);
goIdle(manager, PANE);
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(PANE)).toMatchObject({ status: 'ALERT_RINGING', todo: false });
});
@@ -141,6 +142,7 @@ describe('held completions', () => {
manager.notifyFromProtocol(PANE, PERMISSION);
manager.notifyFromProtocol(PANE, BELL);
goIdle(manager, PANE);
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(PANE).notification).toEqual(PERMISSION);
});
@@ -168,17 +170,17 @@ describe('held completions', () => {
expect(manager.getState(PANE)).toMatchObject({ status: 'ALERT_RINGING', notification: PERMISSION });
});
- // Its deferral already ran from the first report; escalating it must not start another.
- it('rings a held report whose deferral came due while engaged, still animating', () => {
+ it('rechecks recent output when a quiet report held for engagement escalates', () => {
output(PANE, 3_000);
manager.notifyFromProtocol(PANE, PERMISSION);
- output(PANE, 10_000);
engage(manager, PANE);
- output(PANE, cfg.alert.deferCeiling);
+ vi.advanceTimersByTime(5_000);
expect(ringing(PANE)).toBe(false);
output(PANE, 5_000);
goIdle(manager, PANE);
+ expect(ringing(PANE)).toBe(false);
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(PANE)).toMatchObject({ status: 'ALERT_RINGING', notification: PERMISSION });
});
@@ -195,12 +197,14 @@ describe('held completions', () => {
expect(manager.getState(PANE)).toMatchObject({ status: 'NOTHING_TO_SHOW', todo: true });
});
- it('withdraws a held settle once watched work resumes', () => {
+ it('defers a held settle once watched work resumes, without losing it', () => {
engage(manager, PANE);
watchedTurn(PANE);
output(PANE, 3_000);
goIdle(manager, PANE);
expect(ringing(PANE)).toBe(false);
+ vi.advanceTimersByTime(5_000);
+ expect(ringing(PANE)).toBe(true);
});
});
@@ -345,8 +349,10 @@ describe('walking away from a permission prompt', () => {
expect(manager.getState(PANE)).toMatchObject({ todo: false, notification: null });
});
- it('rings it once the user has gone quiet, through the redraw that never stops', () => {
+ it('keeps it pending through endless redraws, then rings when they stop', () => {
replay(120_000);
+ expect(ringing(PANE)).toBe(false);
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(PANE)).toMatchObject({
status: 'ALERT_RINGING',
todo: false,
@@ -433,7 +439,7 @@ describe('a Claude Code turn', () => {
});
it.each([
- ['at the end of the recorded turn, too short to look busy', RECORDED_TURN_MS, 0],
+ ['after a short turn and its trailing frame go quiet', RECORDED_TURN_MS, 3 + QUIET_MS],
['once a longer turn has gone quiet', LONG_TURN_MS, 3 + QUIET_MS],
] as const)('rings "claude finished" once when the user moved away mid-turn: %s', (_when, turnMs, afterEndMs) => {
const ringAt = ENTER_AT + turnMs + afterEndMs;
@@ -449,7 +455,7 @@ describe('a Claude Code turn', () => {
it('records the idle notice after a click on the ring as TODO, without a second summons', () => {
const endAt = ENTER_AT + RECORDED_TURN_MS;
- const clickAt = endAt + 5_000;
+ const clickAt = endAt + 3 + QUIET_MS;
const run = player([
...turn(RECORDED_TURN_MS),
MOVED_AWAY,
diff --git a/lib/src/lib/alert-episode.ts b/lib/src/lib/alert-episode.ts
index c585b08cb..a4bc9e11c 100644
--- a/lib/src/lib/alert-episode.ts
+++ b/lib/src/lib/alert-episode.ts
@@ -1,9 +1,16 @@
-/** One uninterrupted interval with at least one ringing track. Never cold-persisted. */
+import type { SessionStatus } from './alert-manager';
+
+/** One unresolved summons, retained through animation pauses. Never cold-persisted. */
export interface AlertEpisode {
id: string;
startedAt: number;
}
+/** An owed ring held back by recent output (`docs/specs/alert.md` -> Completion events). */
+export function isAlertPaused(state: { episode?: AlertEpisode | null; status: SessionStatus }): boolean {
+ return state.episode != null && state.status !== 'ALERT_RINGING';
+}
+
export function createAlertEpisode(): AlertEpisode {
return { id: crypto.randomUUID(), startedAt: Date.now() };
}
diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts
index 0a9eec4b5..b6901aaee 100644
--- a/lib/src/lib/alert-manager.test.ts
+++ b/lib/src/lib/alert-manager.test.ts
@@ -202,8 +202,7 @@ describe('AlertManager in isolation', () => {
it('ALERT_RINGING latches through output until acknowledged', () => {
const id = 'latch-test';
- // Deferral ships on and withdraws a WATCHING ring once output resumes
- // confirmed BUSY; latching through output is the switched-off timing.
+ // Deferral ships on and pauses a WATCHING ring once output resumes; latching through output is the switched-off timing.
manager.setDeferAlertsUntilQuiet(false);
runWatchedCommand(id);
@@ -426,6 +425,7 @@ describe('AlertManager in isolation', () => {
manager.dismissAlert(id);
manager.onData(id);
manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'second' });
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(id).status).toBe('ALERT_RINGING');
});
@@ -550,6 +550,7 @@ describe('AlertManager in isolation', () => {
// The run goes on after the answer, then its cycle ends.
manager.onData(id);
manager.updateProtocolProgress(id, { state: 'clear', percent: null });
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(id)).toMatchObject({
status: 'ALERT_RINGING',
@@ -732,6 +733,7 @@ describe('AlertManager in isolation', () => {
// Output since the clear: the next bell is news, not the acknowledged state.
manager.onData(id);
applyTerminalEvents(manager, id, [{ kind: 'notification', notification: bell }]);
+ vi.advanceTimersByTime(5_000);
const second = manager.getState(id).episode;
expect(second?.id).toBeTruthy();
expect(second?.id).not.toBe(first!.id);
@@ -1130,6 +1132,17 @@ describe('AlertManager in isolation', () => {
manager.setDeferAlertsUntilQuiet(true);
});
+ it('rings immediately in an idle pane, but waits after a single output chunk', () => {
+ manager.notifyFromProtocol('idle', REPORT);
+ expect(manager.getState('idle').status).toBe('ALERT_RINGING');
+ manager.onData('one-chunk');
+ manager.notifyFromProtocol('one-chunk', REPORT);
+ vi.advanceTimersByTime(4_999);
+ expect(manager.getState('one-chunk').episode).toBeNull();
+ vi.advanceTimersByTime(1);
+ expect(manager.getState('one-chunk')).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT });
+ });
+
it('defers a protocol alert behind an unwatched confirmed-busy detector', () => {
const id = 'defer-unwatched-protocol';
driveToBusy(manager, id);
@@ -1183,13 +1196,15 @@ describe('AlertManager in isolation', () => {
expect(manager.getState(id).status).toBe('ALERT_RINGING');
});
- it('does not defer from MIGHT_BE_BUSY, which has not confirmed activity', () => {
+ it('defers from MIGHT_BE_BUSY without requiring confirmed activity', () => {
const id = 'do-not-defer-candidate';
manager.onData(id);
vi.advanceTimersByTime(1_600);
manager.onData(id);
manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Done' });
+ expect(manager.getState(id).status).toBe('WATCHING_DISABLED');
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(id).status).toBe('ALERT_RINGING');
});
@@ -1281,13 +1296,13 @@ describe('AlertManager in isolation', () => {
expect(manager.getState(id).status).toBe('ALERT_RINGING');
});
- it('rings a deferred notification at the ceiling when output never goes quiet', () => {
- const id = 'defer-ceiling';
+ it('keeps a deferred notification past thirty seconds until output goes quiet', () => {
+ const id = 'defer-without-ceiling';
driveToBusy(manager, id);
manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'build failed' });
- heartbeat(manager, id, cfg.alert.deferCeiling - 1_000);
- vi.advanceTimersByTime(999);
+ heartbeat(manager, id, 120_000);
+ vi.advanceTimersByTime(4_999);
expect(manager.getState(id).status).toBe('WATCHING_DISABLED');
vi.advanceTimersByTime(1);
expect(manager.getState(id)).toMatchObject({
@@ -1296,14 +1311,15 @@ describe('AlertManager in isolation', () => {
});
});
- it('keeps the first deferral time when a later notification replaces the detail', () => {
- const id = 'defer-ceiling-latest';
+ it('keeps the latest equal-richness detail until output goes quiet', () => {
+ const id = 'defer-latest-after-long-output';
driveToBusy(manager, id);
manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'First' });
heartbeat(manager, id, 20_000);
manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Second' });
- heartbeat(manager, id, cfg.alert.deferCeiling - 21_000);
- vi.advanceTimersByTime(1_000);
+ heartbeat(manager, id, 60_000);
+ expect(manager.getState(id).notification).toBeNull();
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(id)).toMatchObject({
status: 'ALERT_RINGING',
notification: { source: 'OSC 9', title: null, body: 'Second' },
@@ -1370,7 +1386,7 @@ describe('AlertManager in isolation', () => {
// --- Ordered ingestion ---
- it('judges a notification written after a command finish against the reset detector', () => {
+ it('keeps a notification after an unarmed command finish behind recent output', () => {
const id = 'ordered-ingestion';
const parser = new TerminalProtocolParser();
const feed = (chunk: string): void => {
@@ -1386,6 +1402,8 @@ describe('AlertManager in isolation', () => {
// A precmd hook reports after the shell's D and A, in the same read.
feed('done\r\n\x1b]633;D;0\x07\x1b]633;A\x07\x1b]777;notify;Command completed;./build.sh\x1b\\$ ');
+ expect(manager.getState(id).status).not.toBe('ALERT_RINGING');
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(id)).toMatchObject({
status: 'ALERT_RINGING',
notification: { source: 'OSC 777', title: 'Command completed', body: './build.sh' },
@@ -1624,6 +1642,7 @@ describe('AlertManager in isolation', () => {
manager.dismissAlert(id);
manager.onData(id);
manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'later' });
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(id).notification?.body).toBe('later');
const handle = manager.awaitCompletion(id, { until: 'quiet', timeoutMs: NEVER });
diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts
index dd034a02d..92c39a3f8 100644
--- a/lib/src/lib/alert-manager.ts
+++ b/lib/src/lib/alert-manager.ts
@@ -133,9 +133,6 @@ interface Ring extends SourceSet {
*/
interface HeldCompletion extends SourceSet {
detail: ActivityNotification;
- /** Its report came due from an animation deferral: escalating rings it,
- * never defers it again. */
- reportDeferred: boolean;
}
interface CommandExitWatch {
@@ -252,7 +249,7 @@ interface AwaitGroup {
}
export interface AlertState {
- /** The ringing interval's delivery identity; null while not ringing. */
+ /** The unresolved summons' delivery identity, retained while paused. */
episode: AlertEpisode | null;
status: SessionStatus;
watchingEnabled: boolean;
@@ -294,10 +291,9 @@ interface AlertEntry {
notification: ActivityNotification | null;
/**
* The terminal notification held behind animation, never public or
- * persisted, and when the hold began: a replacement keeps that start, so the
- * ceiling bounds the whole hold.
+ * persisted. Replacements keep the richest detail until quiet.
*/
- deferred: { notification: ActivityNotification; since: number } | null;
+ deferred: ActivityNotification | null;
deferredTimer: ReturnType | null;
/** Completions withheld while engaged: escalated or dropped as engagement ends (`setViewer`), dropped by a user verb. */
held: HeldCompletion | null;
@@ -343,15 +339,14 @@ export class AlertManager {
this.setDeferAlertsUntilQuiet(settings.deferAlertsUntilQuiet);
}
- /** Let confirmed terminal activity finish before terminal-notification rings. */
+ /** Let recent terminal output finish before presenting an owed alert. */
setDeferAlertsUntilQuiet(enabled: boolean): void {
if (enabled === this.deferAlertsUntilQuiet) return;
this.deferAlertsUntilQuiet = enabled;
- if (enabled) return;
-
- // Turning the gate off releases news it was holding; dropping it would turn
- // a timing preference into alert loss.
- for (const [id, entry] of this.entries) this.flushDeferredNotification(id, entry);
+ for (const [id, entry] of this.entries) {
+ if (enabled) this.notify(id);
+ else this.flushDeferredNotification(id, entry);
+ }
}
/** Mark (or, on promotion, unmark) a helper Session. */
@@ -389,10 +384,13 @@ export class AlertManager {
if (!entry) return;
// The echo of the user's own keystroke is not the program working.
if (this.inEchoWindow(entry)) return;
- entry.detector.onData();
+ const wasRinging = entry.ring !== null && !this.isPaused(entry);
+ if (!entry.detector.onData()) return;
for (const set of [entry.ring, entry.held]) noteOutput(set);
entry.ackedQuiet = false;
this.eachWaiter(id, (waiter) => waiter.onOutput());
+ // Publish only the pause itself, not every chunk of a paused ring.
+ if (wasRinging && this.isPaused(entry)) this.notify(id);
}
onExit(id: string, exitCode?: number): void {
@@ -459,7 +457,6 @@ export class AlertManager {
onChange: () => {
const entry = this.entries.get(id);
if (!entry || !this.isWatching(entry)) return;
- this.withdrawResumedWatchingRing(entry);
this.notify(id);
},
onSettled: () => this.onSettled(id),
@@ -467,14 +464,14 @@ export class AlertManager {
}
/**
- * Watched work that resumed invalidates a ring inferred from silence, so the
- * next settle raises a fresh one on fresh delivery delays. The detector is
- * left alone: resetting it would stop this run from settling again. Callers
- * are the WATCHING-only paths; this adds no rule-set check of its own.
+ * A single accepted redraw is enough to delay, never discard, an owed ring.
+ * Exit sources remain authoritative, including a report joined to an exit.
*/
- private withdrawResumedWatchingRing(entry: AlertEntry): void {
- if (!this.deferAlertsUntilQuiet || !entry.detector.isConfirmedBusy()) return;
- this.invalidateWatching(entry, () => true);
+ private isPaused(entry: AlertEntry): boolean {
+ return entry.ring !== null
+ && this.deferAlertsUntilQuiet
+ && entry.detector.hasRecentOutput()
+ && !entry.ring.sources.includes('exit');
}
/**
@@ -502,12 +499,17 @@ export class AlertManager {
private onSettled(id: string): void {
const entry = this.entries.get(id);
if (!entry) return;
- this.dispatchCompletion(id, entry, { kind: 'settled' });
+ if (this.dispatchCompletion(id, entry, { kind: 'settled' })) {
+ // An await answering this settle also answers the inference retained
+ // through its output. An earlier explicit report remains separate news.
+ this.invalidateWatching(entry, () => true);
+ }
// The settle completion gets first refusal before delivery held from an
// earlier event. Never re-offer that historical event to current claimants.
- // Unconditional: a claimant taking *this* settle says nothing about the
- // earlier completion it never saw, which is now quiet and due.
- this.flushDeferredNotification(id, entry);
+ // Regardless of a claimant: taking *this* settle says nothing about the
+ // earlier completion it never saw, which is now quiet and due — unless
+ // output accepted during dispatch renewed the quiet deadline.
+ if (!this.deferAlertsUntilQuiet || !entry.detector.hasRecentOutput()) this.flushDeferredNotification(id, entry);
}
// --- Completion events ---
@@ -582,9 +584,8 @@ export class AlertManager {
}
/**
- * The one path from a completion that may ring to the ring: a live one, a
- * deferred report coming due (`deferrable` false — its deferral is over),
- * and an escalated hold alike. Engaged, it is held for engagement to end;
+ * The path from a completion that may ring to the ring: a live one and an
+ * escalated hold alike. Engaged, it is held for engagement to end;
* otherwise a report goes through the acknowledged-state check and animation
* deferral, and anything else rings. Publishes, including a progress cycle
* the caller cleared before dispatch.
@@ -594,19 +595,18 @@ export class AlertManager {
entry: AlertEntry,
source: ListedRingSource | WatchingSource,
detail: ActivityNotification,
- deferrable = true,
): void {
if (this.isEngaged(id)) {
- this.hold(entry, source, detail, !deferrable);
- } else if (source === 'report' && deferrable) {
+ this.hold(entry, source, detail);
+ } else if (source === 'report') {
this.deliverReport(id, entry, detail);
return;
} else {
this.raiseRing(entry, source, detail);
}
- // A terminal notification already waiting on animation joins the exit's
- // ring (or hold) now; keeping its timer would publish stale detail later.
- if (source === 'exit' && entry.deferred !== null) this.flushDeferredNotification(id, entry);
+ // A terminal notification waiting on animation joins the exit's ring (or
+ // hold) now; keeping its timer would publish stale detail later.
+ if (source === 'exit') this.flushDeferredNotification(id, entry);
else this.notify(id);
}
@@ -959,26 +959,17 @@ export class AlertManager {
entry: AlertEntry,
notification: ActivityNotification,
): void {
- // Once a ring is active, another source only enriches the same summons.
- // There is no fresh transition left for animation deferral to suppress. An
- // already pending deferral keeps deferring: a command boundary resets the
- // detector, so `isConfirmedBusy` alone could release a notification before
- // quiet.
if (
this.deferAlertsUntilQuiet
&& entry.ring === null
- && (entry.deferred !== null || entry.detector.isConfirmedBusy())
+ && entry.detector.hasRecentOutput()
) {
- // The richer detail waits, as it would show on a ring; the ceiling still
- // counts from the first.
- if (entry.deferred === null) entry.deferred = { notification, since: Date.now() };
- else entry.deferred.notification = richer(entry.deferred.notification, notification);
+ entry.deferred = richer(entry.deferred, notification);
this.scheduleDeferredNotification(id, entry);
} else {
- // An existing ring means this is enrichment, not a fresh summons. Cancel
- // any older pending detail so it cannot overwrite this notification later.
+ const detail = richer(entry.deferred, notification);
this.clearDeferredNotification(entry);
- this.raiseRing(entry, 'report', notification);
+ this.raiseRing(entry, 'report', detail);
}
// The caller may have cleared a publicly visible cycle and delegated the
// publish to the ring rules; deferring the ring must not swallow it.
@@ -986,8 +977,8 @@ export class AlertManager {
}
/**
- * Wake at the earlier of the detector's quiet deadline and the deferral
- * ceiling, re-arming for the remainder if output moved the former — so
+ * Wake at the detector's quiet deadline to ring a deferred notification or
+ * publish a paused ring's end, re-arming if output moved it — so
* continuing output costs one timer per quiet window rather than one per PTY
* chunk. A timer already waiting stays: the due time only moves later, and
* the wake re-checks it. Mostly the detector's own settle gets there first;
@@ -999,22 +990,20 @@ export class AlertManager {
if (entry.deferredTimer !== null) return;
entry.deferredTimer = setTimeout(() => {
entry.deferredTimer = null;
- if (this.deferredDueAt(entry) > Date.now()) this.scheduleDeferredNotification(id, entry);
+ if (entry.detector.hasRecentOutput()) this.scheduleDeferredNotification(id, entry);
else this.flushDeferredNotification(id, entry);
- }, Math.max(0, this.deferredDueAt(entry) - Date.now()));
- }
-
- /** Quiet, or the deferral ceiling, whichever comes first. */
- private deferredDueAt(entry: AlertEntry): number {
- return Math.min(entry.detector.quietAt(), (entry.deferred?.since ?? Date.now()) + cfg.alert.deferCeiling);
+ }, Math.max(0, entry.detector.quietAt() - Date.now()));
}
+ /** Callers decide quiet: a settle, the wake timer, a finish, or disabling the gate. */
private flushDeferredNotification(id: string, entry: AlertEntry): void {
const deferred = entry.deferred;
- if (deferred === null) return;
this.clearDeferredNotification(entry);
- // Never deferred again; due while engaged, it waits on engagement instead.
- this.holdOrDeliver(id, entry, 'report', deferred.notification, false);
+ if (deferred !== null) {
+ if (this.isEngaged(id)) this.hold(entry, 'report', deferred);
+ else this.raiseRing(entry, 'report', deferred);
+ }
+ this.notify(id);
}
private clearDeferredNotification(entry: AlertEntry): void {
@@ -1189,17 +1178,15 @@ export class AlertManager {
return Date.now() < entry.echoUntil;
}
- private hold(entry: AlertEntry, source: ListedRingSource | WatchingSource, detail: ActivityNotification, deferred: boolean): void {
- const held = entry.held ??= { sources: [], watching: null, detail, reportDeferred: false };
+ private hold(entry: AlertEntry, source: ListedRingSource | WatchingSource, detail: ActivityNotification): void {
+ const held = entry.held ??= { sources: [], watching: null, detail };
held.detail = richer(held.detail, detail);
- held.reportDeferred ||= deferred;
addSource(held, source);
}
/**
* Presence lapsed from inactivity with focus unchanged: each held source takes
- * the path it would have taken unengaged, with the richest held detail — a
- * report whose deferral already came due skipping deferral.
+ * the path it would have taken unengaged, with the richest held detail.
*/
private escalateHeld(id: string, entry: AlertEntry): void {
const held = entry.held;
@@ -1207,7 +1194,7 @@ export class AlertManager {
entry.held = null;
if (held.watching !== null) this.holdOrDeliver(id, entry, held.watching, held.detail);
for (const source of LISTED_RING_SOURCES) {
- if (held.sources.includes(source)) this.holdOrDeliver(id, entry, source, held.detail, !held.reportDeferred);
+ if (held.sources.includes(source)) this.holdOrDeliver(id, entry, source, held.detail);
}
}
@@ -1386,7 +1373,7 @@ export class AlertManager {
}
private getProjectedStatus(id: string, entry: AlertEntry): SessionStatus {
- if (entry.ring !== null) return 'ALERT_RINGING';
+ if (entry.ring !== null && !this.isPaused(entry)) return 'ALERT_RINGING';
if (entry.progress !== null) return 'OSC_NOTIF_BUSY';
// WATCHING outranks the command-exit arm: a watched command is by
// definition running, so COMMAND_EXIT_ARMED would otherwise mask the
@@ -1421,6 +1408,9 @@ export class AlertManager {
}
private notify(id: string): void {
+ // Whatever publishes a pause arms the wake that publishes its end.
+ const entry = this.entries.get(id);
+ if (entry && this.isPaused(entry)) this.scheduleDeferredNotification(id, entry);
const state = this.getState(id);
const last = this.lastEmitted.get(id);
// A helper publishes nothing, but takes back what it published before a demotion.
diff --git a/lib/src/lib/alert-resumed-output.test.ts b/lib/src/lib/alert-resumed-output.test.ts
index 490d4d1d5..971272738 100644
--- a/lib/src/lib/alert-resumed-output.test.ts
+++ b/lib/src/lib/alert-resumed-output.test.ts
@@ -2,14 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AlertManager } from './alert-manager';
import { createAlertDeliveryScheduler, type AlertDeliveryScheduler } from './alert-delivery-scheduler';
import { DEFAULT_ALERT_SETTINGS } from './alert-settings-model';
-import { armCommandExit, driveToBusy, finishCommand, runCommand, settle } from './alert-manager-test-utils';
+import { armCommandExit, driveToBusy, engage, finishCommand, goIdle, heartbeat, REPORT, runCommand, settle } from './alert-manager-test-utils';
+import { toPersistedAlertState } from './session-types';
+import { createOwnerPtyStream } from '../host/owner-pty';
-const ID = 'resumed-watched-work';
+const ID = 'resumed-work';
const WATCHED = 'longtask';
const DELAY = 10_000;
let manager: AlertManager;
let scheduler: AlertDeliveryScheduler;
-/** The host's deliveries, one call per sink. */
let spoken: ReturnType;
let pushed: ReturnType;
@@ -18,118 +19,204 @@ beforeEach(() => {
spoken = vi.fn();
pushed = vi.fn();
manager = new AlertManager();
- manager.setDeferAlertsUntilQuiet(true);
manager.setWatchedCommands([WATCHED]);
runCommand(manager, ID, WATCHED);
scheduler = createAlertDeliveryScheduler({ manager, speak: spoken, push: pushed });
scheduler.setDefaults({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: true, speakDelayMs: DELAY, pushEnabled: true, pushDelayMs: DELAY });
});
-
afterEach(() => {
scheduler.dispose();
manager.dispose();
vi.useRealTimers();
});
+function ring(source: 'watching' | 'report'): void {
+ if (source === 'report') manager.notifyFromProtocol(ID, REPORT);
+ else { driveToBusy(manager, ID); settle(); }
+}
-describe('WATCHING output resuming before alarm delivery', () => {
- it('cancels pending speech and push during animation, then gives the next settle a fresh delay', () => {
+describe('owed alerts during resumed output', () => {
+ it('keeps a deferred report when output arrives while a claimant takes the settle', () => {
driveToBusy(manager, ID);
- settle();
- const firstEpisode = manager.getState(ID).episode;
- expect(firstEpisode?.id).toBeTruthy();
- expect(manager.getState(ID).status).toBe('ALERT_RINGING');
+ manager.notifyFromProtocol(ID, REPORT);
vi.advanceTimersByTime(1_000);
- driveToBusy(manager, ID);
- expect(manager.getState(ID)).toMatchObject({ status: 'BUSY', todo: false, episode: null });
-
- // Keep animating across the old speech deadline, as in the marked incident.
- for (let i = 0; i < 50; i++) {
- vi.advanceTimersByTime(200);
+ manager.onData(ID);
+ manager.registerCompletionClaimant(ID, (event) => {
+ if (event.kind !== 'settled') return false;
manager.onData(ID);
- }
- expect(spoken).not.toHaveBeenCalled();
- expect(pushed).not.toHaveBeenCalled();
-
+ return true;
+ });
settle();
- const relatched = manager.getState(ID);
- expect(relatched.status).toBe('ALERT_RINGING');
- expect(relatched.episode?.id).toBeTruthy();
- expect(relatched.episode?.id).not.toBe(firstEpisode?.id);
- vi.advanceTimersByTime(DELAY - 1);
+ expect(manager.getState(ID).status).not.toBe('ALERT_RINGING');
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT });
+ });
+ it.each(['watching', 'report'] as const)('pauses a %s on the first redraw and keeps its original alarm deadlines', (source) => {
+ ring(source);
+ const episode = manager.getState(ID).episode;
+ vi.advanceTimersByTime(1_000);
+ manager.onData(ID);
+ expect(manager.getState(ID)).toMatchObject({ status: 'NOTHING_TO_SHOW', episode, todo: false });
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode });
+ vi.advanceTimersByTime(3_999);
expect(spoken).not.toHaveBeenCalled();
- expect(pushed).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
- expect(spoken).toHaveBeenCalledExactlyOnceWith(ID, manager.getState(ID).episode!.id);
+ expect(spoken).toHaveBeenCalledExactlyOnceWith(ID, episode!.id);
expect(pushed).toHaveBeenCalledOnce();
});
-
- it('keeps an inferred ring through a short redraw that never confirms BUSY', () => {
- driveToBusy(manager, ID);
- settle();
- manager.onData(ID);
- vi.advanceTimersByTime(62);
- manager.onData(ID);
+ it.each(['watching', 'report'] as const)('keeps a %s past its deadlines during output, then delivers once on quiet', (source) => {
+ ring(source);
+ const episode = manager.getState(ID).episode;
+ heartbeat(manager, ID, 120_000);
+ expect(manager.getState(ID).status).not.toBe('ALERT_RINGING');
+ expect(manager.getState(ID).episode).toEqual(episode);
+ expect(spoken).not.toHaveBeenCalled();
+ expect(pushed).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(4_999);
+ expect(spoken).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(2);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode });
+ expect(spoken).toHaveBeenCalledExactlyOnceWith(ID, episode!.id);
+ expect(pushed).toHaveBeenCalledOnce();
+ });
+ it('does not repeat delivered sinks through repeated pauses', () => {
+ ring('report');
vi.advanceTimersByTime(DELAY);
- expect(manager.getState(ID).status).toBe('ALERT_RINGING');
+ for (let i = 0; i < 3; i++) {
+ manager.onData(ID);
+ vi.advanceTimersByTime(5_000 + DELAY);
+ }
expect(spoken).toHaveBeenCalledOnce();
+ expect(pushed).toHaveBeenCalledOnce();
});
-
- it('retains the latched ring with animation deferral disabled', () => {
- manager.setDeferAlertsUntilQuiet(false);
- driveToBusy(manager, ID);
- settle();
- driveToBusy(manager, ID);
- expect(manager.getState(ID).status).toBe('ALERT_RINGING');
- vi.advanceTimersByTime(DELAY);
+ it('preserves an unsent push when speech already sent', () => {
+ scheduler.setDefaults({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: true, speakDelayMs: 1_000, pushEnabled: true, pushDelayMs: DELAY });
+ ring('report');
+ vi.advanceTimersByTime(1_000);
+ heartbeat(manager, ID, DELAY);
+ vi.advanceTimersByTime(5_001);
expect(spoken).toHaveBeenCalledOnce();
+ expect(pushed).toHaveBeenCalledOnce();
});
-
- it('preserves TODO and notification detail when withdrawing inferred completion', () => {
- manager.notifyFromProtocol(ID, { source: 'OSC 9', title: 'earlier notice', body: null });
- manager.dismissAlert(ID);
- const receipt = manager.getState(ID);
- expect(receipt.todo).toBe(true);
- driveToBusy(manager, ID);
- settle();
- driveToBusy(manager, ID);
- expect(manager.getState(ID)).toMatchObject({ status: 'BUSY', todo: true, notification: receipt.notification });
+ it('keeps the richest detail across pauses and new reports', () => {
+ ring('report');
+ manager.onData(ID);
+ manager.notifyFromProtocol(ID, { source: 'BEL', title: 'Terminal bell', body: null });
+ expect(manager.getState(ID).notification).toEqual(REPORT);
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT });
});
-
- it.each(['report', 'exit'] as const)('preserves an authoritative %s source behind WATCHING', (source) => {
+ it('exempts a mixed command-exit ring from pauses', () => {
armCommandExit(manager, ID, WATCHED);
- driveToBusy(manager, ID);
- settle();
- if (source === 'report') {
- manager.notifyFromProtocol(ID, { source: 'OSC 9', title: 'input needed', body: null });
- } else {
- finishCommand(manager, ID);
- expect(manager.getState(ID).notification?.source).toBe('COMMAND_EXIT');
- runCommand(manager, ID, WATCHED);
- }
+ ring('report');
+ manager.onData(ID);
+ finishCommand(manager, ID);
+ heartbeat(manager, ID, DELAY);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT });
+ expect(spoken).toHaveBeenCalledOnce();
+ });
+ it('leaves a paused ring paused behind an engaged exit until the exit escalates', () => {
+ armCommandExit(manager, ID, WATCHED);
+ ring('report');
+ manager.onData(ID);
+ engage(manager, ID);
+ finishCommand(manager, ID);
+ const paused = manager.getState(ID);
+ expect(paused.episode).not.toBeNull();
+ expect(paused.status).not.toBe('ALERT_RINGING');
+ goIdle(manager, ID);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode: paused.episode });
+ });
+ it('pauses a report once an await consumes the exit that exempted it', async () => {
+ armCommandExit(manager, ID, WATCHED);
+ ring('report');
+ finishCommand(manager, ID);
+ manager.onData(ID);
+ expect(await manager.awaitCompletion(ID, { until: 'exit', timeoutMs: 60_000 }).promise).toMatchObject({ cause: 'exit' });
+ expect(manager.getState(ID).status).not.toBe('ALERT_RINGING');
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT });
+ });
+ it('works without a matching WATCHING rule', () => {
+ manager.setWatchedCommands([]);
+ ring('report');
+ manager.onData(ID);
+ expect(manager.getState(ID).status).toBe('WATCHING_DISABLED');
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID).status).toBe('ALERT_RINGING');
+ });
+ it('pauses a notification followed by output in the same PTY read', () => {
+ const stream = createOwnerPtyStream(ID, {
+ alerts: manager, colorProvider: () => null,
+ onToolEvents() {}, onSemanticEvents() {}, writeResponse() {}, onClipboardOffer() {}, onChunk() {},
+ });
+ stream.write('\x1b]9;needs input\x07final spinner frame');
const episode = manager.getState(ID).episode;
- driveToBusy(manager, ID);
+ expect(episode).not.toBeNull();
+ expect(manager.getState(ID).status).not.toBe('ALERT_RINGING');
+ vi.advanceTimersByTime(5_000);
expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode });
+ });
+ it('ignores resize output when deciding whether to pause', () => {
+ ring('report');
+ manager.onResize(ID);
+ manager.onData(ID);
+ expect(manager.getState(ID).status).toBe('ALERT_RINGING');
vi.advanceTimersByTime(DELAY);
expect(spoken).toHaveBeenCalledOnce();
});
-
- it('keeps a WATCHING ring through output after the watched command exits', () => {
- driveToBusy(manager, ID);
- settle();
- finishCommand(manager, ID);
- driveToBusy(manager, ID);
- expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', watchingEnabled: false });
+ it('releases a pause when disabled and pauses immediately when re-enabled', () => {
+ ring('report');
+ const episode = manager.getState(ID).episode;
+ manager.onData(ID);
+ manager.setDeferAlertsUntilQuiet(false);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode });
+ manager.setDeferAlertsUntilQuiet(true);
+ expect(manager.getState(ID).status).not.toBe('ALERT_RINGING');
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', episode });
});
-
- it('keeps the resumed detector alive so an await can claim its next settle', async () => {
- driveToBusy(manager, ID);
- settle();
+ it('persists a previously visible pause as TODO but keeps initial deferral live-only', () => {
+ ring('report');
+ manager.onData(ID);
+ expect(toPersistedAlertState(manager.getState(ID))).toMatchObject({ todo: true, notification: REPORT });
+ manager.onData('never-visible');
+ manager.notifyFromProtocol('never-visible', REPORT);
+ expect(toPersistedAlertState(manager.getState('never-visible'))).toMatchObject({ todo: false, notification: null });
+ });
+ it.each(['acknowledge', 'dismiss', 'clearTodo', 'remove', 'seed'] as const)('clears a paused summons on %s without stale delivery', (action) => {
+ ring('report');
+ manager.onData(ID);
+ if (action === 'acknowledge') manager.acknowledge(ID, { input: false });
+ else if (action === 'dismiss') manager.dismissAlert(ID);
+ else if (action === 'seed') manager.seed(ID, { todo: false });
+ else manager[action](ID);
+ vi.advanceTimersByTime(60_000);
+ expect(manager.getState(ID).episode).toBeNull();
+ expect(spoken).not.toHaveBeenCalled();
+ expect(pushed).not.toHaveBeenCalled();
+ });
+ it('removes a paused WATCHING source with its rule while retaining a report', () => {
+ ring('watching');
+ manager.onData(ID);
+ manager.setWatchedCommands([]);
+ expect(manager.getState(ID).episode).toBeNull();
+ vi.advanceTimersByTime(5_000);
+ ring('report');
+ manager.onData(ID);
+ manager.setWatchedCommands([WATCHED]);
+ manager.setWatchedCommands([]);
+ vi.advanceTimersByTime(5_000);
+ expect(manager.getState(ID)).toMatchObject({ status: 'ALERT_RINGING', notification: REPORT });
+ });
+ it('lets an await claim a new settle without replaying the old inference', async () => {
+ ring('watching');
driveToBusy(manager, ID);
const handle = manager.awaitCompletion(ID, { until: 'quiet', timeoutMs: 60_000 });
settle();
expect(await handle.promise).toMatchObject({ kind: 'resolved', cause: 'quiet' });
vi.advanceTimersByTime(DELAY);
- expect(manager.getState(ID)).toMatchObject({ status: 'NOTHING_TO_SHOW', todo: false });
+ expect(manager.getState(ID)).toMatchObject({ status: 'NOTHING_TO_SHOW', episode: null });
expect(spoken).not.toHaveBeenCalled();
});
});
diff --git a/lib/src/lib/alert-settings-model.ts b/lib/src/lib/alert-settings-model.ts
index 4f38c75b0..e5cacffc3 100644
--- a/lib/src/lib/alert-settings-model.ts
+++ b/lib/src/lib/alert-settings-model.ts
@@ -13,7 +13,7 @@ import { cfg } from '../cfg';
export interface AlertSettings {
/** ms — how long without typing, pointer, or wheel input before the user counts as away (the renderer's presence window). */
inactivityTimeoutMs: number;
- /** Delay terminal-notification rings behind confirmed animation. */
+ /** Pause non-exit alerts until five seconds after the last accepted output. */
deferAlertsUntilQuiet: boolean;
/** Speak a ring out loud after `speakDelayMs`, unless its pane is the one being looked at. */
speakEnabled: boolean;
diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts
index 1517396ac..e5df30eb3 100644
--- a/lib/src/lib/alert-speech.test.ts
+++ b/lib/src/lib/alert-speech.test.ts
@@ -137,6 +137,70 @@ describe('toSpokenText', () => {
* (`alert-delivery-scheduler.test.ts`).
*/
describe('spoken alarms', () => {
+ it('keeps a paused queued alarm while allowing another pane to speak', () => {
+ ringTwoWithFirstSpeaking();
+ const second = getActivity('pty-2');
+ setTerminalActivity('pty-2', { ...second, status: 'BUSY' });
+ ring('pty-3');
+ due('pty-3');
+ engine.utterances[0].onend?.();
+ expect(engine.spoken).toHaveLength(2);
+ engine.utterances[1].onstart?.();
+ expect(getAlertSpeechState('pty-3')).toBe('speaking');
+ engine.utterances[1].onend?.();
+ setTerminalActivity('pty-2', second);
+ expect(engine.spoken).toHaveLength(3);
+ engine.utterances[2].onstart?.();
+ expect(getAlertSpeechState('pty-2')).toBe('speaking');
+ });
+
+ it('retains preparation that never started and rejects its cancelled callbacks', () => {
+ start();
+ ring('pty-1');
+ const state = getActivity('pty-1');
+ due('pty-1');
+ const oldStart = engine.utterances[0].onstart;
+ const oldEnd = engine.utterances[0].onend;
+ setTerminalActivity('pty-1', { ...state, status: 'BUSY' });
+ expect(engine.cancels).toBe(1);
+ oldStart?.();
+ oldEnd?.();
+ expect(getAlertSpeechState('pty-1')).toBeNull();
+ vi.advanceTimersByTime(120_000);
+ expect(engine.spoken).toHaveLength(1);
+ setTerminalActivity('pty-1', state);
+ expect(engine.spoken).toHaveLength(2);
+ engine.utterances[1].onstart?.();
+ oldStart?.();
+ oldEnd?.();
+ expect(getAlertSpeechState('pty-1')).toBe('speaking');
+ });
+
+ it('cuts started speech on a pause without replaying it after quiet', () => {
+ start();
+ ring('pty-1');
+ const state = getActivity('pty-1');
+ due('pty-1');
+ engine.utterances[0].onstart?.();
+ setTerminalActivity('pty-1', { ...state, status: 'BUSY' });
+ expect(engine.cancels).toBe(1);
+ expect(getAlertSpeechState('pty-1')).toBe('spoken');
+ setTerminalActivity('pty-1', state);
+ expect(engine.spoken).toHaveLength(1);
+ expect(getAlertSpeechState('pty-1')).toBe('spoken');
+ });
+
+ it('drops paused preparation on acknowledgement instead of restoring it later', () => {
+ start();
+ ring('pty-1');
+ const state = getActivity('pty-1');
+ due('pty-1');
+ setTerminalActivity('pty-1', { ...state, status: 'BUSY' });
+ setStatus('pty-1', 'NOTHING_TO_SHOW');
+ setTerminalActivity('pty-1', state);
+ expect(engine.spoken).toHaveLength(1);
+ });
+
it('speaks terminal-supplied OSC 0/2/9 titles when they are the pane label', () => {
const sources: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9'];
for (const [index, source] of sources.entries()) {
@@ -408,4 +472,26 @@ describe('spoken alarms', () => {
expect(engine.spoken).toHaveLength(65);
expect(engine.utterances.every(utterance => utterance.onend === null)).toBe(true);
});
+
+ it('returns paused preparation to a full queue and speaks it after quiet', () => {
+ start();
+ for (let i = 0; i < 66; i++) ring(`pty-${i}`);
+ const first = getActivity('pty-0');
+ for (let i = 0; i < 66; i++) due(`pty-${i}`);
+ setTerminalActivity('pty-0', { ...first, status: 'BUSY' });
+ expect(engine.cancels).toBe(1);
+ engine.utterances[1].onstart?.();
+ expect(getAlertSpeechState('pty-1')).toBe('speaking');
+ setTerminalActivity('pty-0', first);
+ engine.utterances[1].onend?.();
+ engine.utterances[2].onstart?.();
+ expect(getAlertSpeechState('pty-0')).toBe('speaking');
+ for (let i = 2; i < 66; i++) {
+ engine.utterances[i].onstart?.();
+ engine.utterances[i].onend?.();
+ }
+ expect(engine.spoken).toHaveLength(66);
+ expect(getAlertSpeechState('pty-64')).toBe('spoken');
+ expect(getAlertSpeechState('pty-65')).toBeNull();
+ });
});
diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts
index 303021612..2fb021df7 100644
--- a/lib/src/lib/alert-speech.ts
+++ b/lib/src/lib/alert-speech.ts
@@ -1,4 +1,5 @@
import { getSessionAlertPolicy, subscribeToAlertDeliveryPolicy } from './alert-delivery-policy';
+import { isAlertPaused } from './alert-episode';
import { createManagedVoiceEngine } from './managed-voice-engine';
import { getPlatformOrNull } from './platform';
import { SpeechQueue } from './speech-queue';
@@ -88,6 +89,7 @@ export function startAlertSpeech(): AlertSpeaker {
text: () => toSpokenText(deriveSessionLabel(id)),
voice: () => getSessionAlertPolicy(id).speakVoice,
eligible: () => eligible(id, episodeId),
+ paused: () => isAlertPaused(getActivity(id)),
onStart: () => { renderedEpisodes.set(id, episodeId); setAlertSpeechState(id, 'speaking'); },
onFinish: (started) => {
if (started && eligible(id, episodeId)) { setAlertSpeechState(id, 'spoken'); return; }
diff --git a/lib/src/lib/quiesce-detector.test.ts b/lib/src/lib/quiesce-detector.test.ts
index 8bb67f2e2..fafdd00e0 100644
--- a/lib/src/lib/quiesce-detector.test.ts
+++ b/lib/src/lib/quiesce-detector.test.ts
@@ -42,6 +42,33 @@ describe('QuiesceDetector', () => {
expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW');
});
+ it('uses a single accepted chunk to delay an alert, independently of BUSY and resets', () => {
+ const { monitor } = createMonitor();
+ expect(monitor.hasRecentOutput()).toBe(false);
+ expect(monitor.onData()).toBe(true);
+ expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW');
+ monitor.reset();
+ vi.advanceTimersByTime(4_999);
+ expect(monitor.hasRecentOutput()).toBe(true);
+ vi.advanceTimersByTime(1);
+ expect(monitor.hasRecentOutput()).toBe(false);
+ });
+
+ it('rejects resize redraws and disposed output without moving the quiet deadline', () => {
+ const { monitor } = createMonitor();
+ monitor.onData();
+ const quietAt = monitor.quietAt();
+ vi.advanceTimersByTime(1_000);
+ monitor.onResize();
+ expect(monitor.onData()).toBe(false);
+ expect(monitor.quietAt()).toBe(quietAt);
+ vi.advanceTimersByTime(500);
+ expect(monitor.onData()).toBe(true);
+ expect(monitor.quietAt()).toBe(quietAt + 1_500);
+ monitor.dispose();
+ expect(monitor.onData()).toBe(false);
+ });
+
it('keeps the first meaningful output after a reset in NOTHING_TO_SHOW', () => {
const { monitor, changes } = createMonitor();
monitor.reset();
diff --git a/lib/src/lib/quiesce-detector.ts b/lib/src/lib/quiesce-detector.ts
index c1dee9f17..a6fb0eb16 100644
--- a/lib/src/lib/quiesce-detector.ts
+++ b/lib/src/lib/quiesce-detector.ts
@@ -74,6 +74,11 @@ export class QuiesceDetector {
return this.status === 'BUSY' || this.status === 'MIGHT_NEED_ATTENTION';
}
+ /** Deferring an owed alert needs only recent output, not confirmed work. */
+ hasRecentOutput(): boolean {
+ return this.lastAcceptedOutputAt !== null && Date.now() < this.quietAt();
+ }
+
/**
* When the pane counts as quiet if nothing more arrives — the instant a
* settle would confirm. The one place that composition is written down, so an
@@ -92,8 +97,8 @@ export class QuiesceDetector {
this.setStatus('NOTHING_TO_SHOW');
}
- onData(): void {
- if (this.disposed || this.resizeGrace) return;
+ onData(): boolean {
+ if (this.disposed || this.resizeGrace) return false;
const now = Date.now();
// Candidate history only describes one run of output. A timer callback can
@@ -123,6 +128,7 @@ export class QuiesceDetector {
this.enterBusy();
break;
}
+ return true;
}
onResize(): void {
diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts
index 6ed83e0e5..cba649ebd 100644
--- a/lib/src/lib/session-types.ts
+++ b/lib/src/lib/session-types.ts
@@ -3,6 +3,7 @@ import { isRecord } from './is-record';
import { isToolKeyScope, isToolRender, type ToolKeyScope, type ToolRender } from './platform/tool-types';
import { isBrowserViewportSetting, type BrowserViewportSetting } from 'dor-lib-common/browser-viewports';
import type { SessionStatus } from './alert-manager';
+import { isAlertPaused, type AlertEpisode } from './alert-episode';
import { hasShellInputControls } from 'dor/commands/shell-quote';
import {
ACTIVITY_NOTIFICATION_SOURCES,
@@ -72,11 +73,11 @@ const STRICT_READER_NOTIFICATION_SOURCES: readonly ActivityNotificationSource[]
* stale fields (`docs/specs/alert.md` -> Public State, "Persist only"). A ring
* no one has looked at is written as the TODO a look would have left.
*/
-export function toPersistedAlertState(state: PersistedAlertState): PersistedAlertState {
+export function toPersistedAlertState(state: PersistedAlertState & { episode?: AlertEpisode | null }): PersistedAlertState {
const notification = state.notification ?? null;
return {
status: state.status,
- todo: state.todo || state.status === 'ALERT_RINGING',
+ todo: state.todo || state.status === 'ALERT_RINGING' || isAlertPaused(state),
notification: notification !== null && STRICT_READER_NOTIFICATION_SOURCES.includes(notification.source)
? notification
: null,
diff --git a/lib/src/lib/speech-queue.ts b/lib/src/lib/speech-queue.ts
index 71b96f4f2..7c36bb946 100644
--- a/lib/src/lib/speech-queue.ts
+++ b/lib/src/lib/speech-queue.ts
@@ -6,6 +6,8 @@ export interface SpeechJob {
text: () => string;
voice?: () => string | null;
eligible: () => boolean;
+ /** A still-owed job waits without blocking other Sessions. */
+ paused?: () => boolean;
onStart?: () => void;
onFinish?: (started: boolean) => void;
}
@@ -30,7 +32,8 @@ interface Attempt {
* 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
+ * retain later alerts: at most {@link MAX_PENDING} pending jobs admitted (one
+ * more while a paused, unstarted attempt returns to the queue), 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
@@ -60,6 +63,7 @@ export class SpeechQueue {
if (!this.active && this.pending.length === 0) return;
if (this.pending.length) this.pending = this.pending.filter(job => job.eligible());
if (this.active && !this.active.job.eligible()) this.finish(this.active, true);
+ if (this.active?.job.paused?.()) this.pause(this.active);
this.pump();
}
@@ -79,13 +83,33 @@ export class SpeechQueue {
this.pump();
}
+ private pause(attempt: Attempt): void {
+ // Preparation is not delivery. Retain a job that never actually started,
+ // past the bound if the queue filled meanwhile, and revoke the old engine
+ // attempt before cancellation can call back.
+ if (!attempt.started) this.pending.unshift(attempt.job);
+ this.finish(attempt, true);
+ }
+
+ /** Drop resolved jobs on the way to the first that is not paused. */
+ private takeNext(): SpeechJob | undefined {
+ for (let i = 0; i < this.pending.length;) {
+ const job = this.pending[i];
+ if (!job.eligible()) { this.pending.splice(i, 1); continue; }
+ if (job.paused?.()) { i++; continue; }
+ this.pending.splice(i, 1);
+ return job;
+ }
+ return undefined;
+ }
+
private pump(): void {
if (this.pumping) return;
this.pumping = true;
try {
- while (!this.active && this.pending.length) {
- const job = this.pending.shift()!;
- if (!job.eligible()) continue;
+ while (!this.active) {
+ const job = this.takeNext();
+ if (!job) break;
const attempt: Attempt = {
job, handle: null, started: false,
timer: setTimeout(() => this.finish(attempt, true), SPEECH_ENGINE_TIMEOUT_MS),
@@ -96,6 +120,7 @@ export class SpeechQueue {
onStart: () => {
if (this.active !== attempt || attempt.started) return;
if (!job.eligible()) { this.finish(attempt, true); return; }
+ if (job.paused?.()) { this.pause(attempt); return; }
attempt.started = true;
job.onStart?.();
},
diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts
index d0e0da424..132e7eda0 100644
--- a/lib/src/lib/terminal-registry.alert.test.ts
+++ b/lib/src/lib/terminal-registry.alert.test.ts
@@ -712,14 +712,17 @@ describe('terminal-registry alert behavior', () => {
});
});
- it('Story 9: new output while ringing latches until the user acknowledges', () => {
+ it('Story 9: new output pauses an owed ring until quiet or acknowledgement', () => {
const id = 'story-9';
createSession(id);
enableAlert(id);
driveToRingingNeedsAttention(id);
+ const episode = getActivity(id).episode;
emitOutput(id, 'shell prompt');
- expect(getActivity(id).status).toBe('ALERT_RINGING');
+ expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', episode });
+ advance(5_000);
+ expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING', episode });
clickSession(id);
expect(getActivity(id).status).toBe('NOTHING_TO_SHOW');
@@ -887,11 +890,16 @@ describe('terminal-registry alert behavior', () => {
enableAlert(id);
driveToRingingNeedsAttention(id);
const write = vi.spyOn(fakePlatform, 'writePty');
+ const episode = getActivity(id).episode;
try {
entry.terminal.emitInput(input);
// Written as it came, and not as user input.
expect(write.mock.calls).toEqual([[id, input]]);
- expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING' });
+ // The fake PTY echoes the reply as output. It may pause presentation,
+ // but must never acknowledge or discard the owed episode.
+ expect(getActivity(id)).toMatchObject({ episode, todo: false });
+ advance(5_000);
+ expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING', episode });
} finally {
write.mockRestore();
}
diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts
index 226160794..8b09f0c68 100644
--- a/vscode-ext/test/message-router.test.ts
+++ b/vscode-ext/test/message-router.test.ts
@@ -564,6 +564,9 @@ describe('alarm delivery', () => {
moveWindow({ focused: true });
ptys.callbacks!.onData('pty-1', REPORT);
vi.advanceTimersByTime(1_000);
+ expect(wiring.pushes).toEqual([]);
+ // Recent output defers the ring; its alarm delay starts after quiet.
+ vi.advanceTimersByTime(5_000);
expect(wiring.pushes).toEqual([['pty-1', 'terminal']]);
} finally {
disposable.dispose();