Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 24 additions & 26 deletions docs/specs/alert.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions lib/src/lib/speech-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +21,22 @@ interface Attempt {
timer: ReturnType<typeof setTimeout>;
}

/**
* 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;
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions lib/src/lib/terminal-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();

Expand Down
9 changes: 5 additions & 4 deletions lib/src/remote/burrow/alert-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading