Skip to content
Merged
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
46 changes: 23 additions & 23 deletions docs/specs/alert.md

Large diffs are not rendered by default.

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

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

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

Expand All @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
4 changes: 2 additions & 2 deletions lib/src/components/Door.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/src/components/MobileTerminalUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions lib/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,9 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
onChange={(deferAlertsUntilQuiet) => updateAlertSettings({ deferAlertsUntilQuiet })}
/>
<div className={`${UNDER_SWITCH_INDENT} mt-1 text-sm leading-relaxed text-muted`}>
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.
</div>
</div>
</section>
Expand Down
1 change: 1 addition & 0 deletions lib/src/lib/alert-delivery-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
75 changes: 47 additions & 28 deletions lib/src/lib/alert-delivery-scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AlertManager, AlertState } from './alert-manager';
import { isAlertPaused } from './alert-episode';
import {
normalizeAlertDeliveryOverrides,
resolveAlertDeliveryPolicy,
Expand Down Expand Up @@ -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<typeof setTimeout> | 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<string, { episodeId: string; timers: Map<AlertSink, ReturnType<typeof setTimeout>> }>();
/** Deadlines survive pauses; a sink missing from `pending` is consumed. */
const episodes = new Map<string, { episodeId: string; pending: Map<AlertSink, Delivery> }>();
/** Each Session's last publication, and the realm that made it. */
const published = new Map<string, Published>();

Expand Down Expand Up @@ -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<AlertSink, ReturnType<typeof setTimeout>>();
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()));
}
}

Expand Down Expand Up @@ -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();
},
Expand Down
22 changes: 14 additions & 8 deletions lib/src/lib/alert-engagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

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

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

Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion lib/src/lib/alert-episode.ts
Original file line number Diff line number Diff line change
@@ -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() };
}
Loading
Loading