Skip to content
Merged
16 changes: 8 additions & 8 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ A Session is engaged while some present viewer focuses it.

| Gesture | Acknowledges |
|---|---|
| typing into the pane (CSI/SS3 key encodings included), a paste, a file drop, the mobile input bar or a gesture key, a remote Client's write | with input |
| typing into the pane (CSI/SS3 key encodings included), a reported wheel tick, a paste, a file drop, the mobile input bar or a gesture key, a remote Client's write | with input |
| a Pane body or header click, zoom or unzoom, the dev-server chip, a Workspace tab's TODO pill on the member it enters, entering passthrough by keyboard, a Door click or `Enter`, a mobile tap | without input |
| a terminal reply, a mouse-only report chunk, `d` reattach, a spawn, split, or promotion, a `dor` reveal, an embed focusing itself, DOM focus alone | nothing |
| a terminal reply, a hover or button report, `d` reattach, a spawn, split, or promotion, a `dor` reveal, an embed focusing itself, DOM focus alone | nothing |

- **Never treat terminal replies or mouse-only report chunks as keyboard input** (rationale).
- **Never treat terminal replies or hover or button reports as input** (rationale).
- **Must write all human-originated PTY input through `writeUserInput`**, the mobile input bar's included: it carries `userInput`, and the host acknowledges it with input before the bytes reach the PTY, in the one message (rationale).
- **A remote Client's write must acknowledge the same way unless it holds only mouse reports**, the Burrow having dropped a mirror's terminal replies (rationale).
- **A remote Client's write must acknowledge the same way unless it holds only hover or button reports**, the Burrow having dropped a mirror's terminal replies (rationale).
- **Never create an Activity entry to acknowledge**, the host's one check, so a browser Surface keeps its own TODO.
- **User input must open the echo window** (`cfg.alert.echoWindow`) even when acknowledging clears nothing, a helper's included. Output inside it is ignored entirely — detector, `outputSince`, the acknowledged state, awaits — and a completion inside it neither rings nor is held (rationale).

Expand Down Expand Up @@ -244,7 +244,7 @@ The output/silence detector is always on, one per Session for its whole lifetime

**Must use the detector as the single output-activity signal for status, inferred completion, and alert deferral**; its states are the Output detector region in Completion events. A single accepted output chunk leaves a quiet detector quiet, and only confirmed work can settle.

**A retired id must stay retired.** A host removes the alert entry in the same turn as its kill, ahead of any output still in flight. **Never let raw output or resizes revive a retired id**; a semantic or protocol event may (rationale).
**A retired id must stay retired.** A host removes the alert entry in the same turn as its kill, ahead of any output still in flight. **Never let raw output or input revive a retired id**; a semantic or protocol event may (rationale).

Rules:

Expand All @@ -258,14 +258,14 @@ Rules:

Limitation: WATCHING needs the shell to report its command line — `OSC 633 ; E`, or `cmdline_url` / `cmdline` on `OSC 133 ; C` (fish ≥ 4) — beside the boundaries. A shell reporting bare `OSC 133` boundaries, or none (`docs/specs/terminal-state.md` → Shell-integration injection), never names a command, so WATCHING never engages and the terminal context reports `no command`. Terminal reports still work; command-exit alerting also requires semantic command boundaries. **Never route the keystroke fallback in `docs/specs/terminal-state.md` into the `AlertManager`** (rationale).

**Must count only output that changes the screen**: a chunk of only queries, mode and color changes, or cursor motion is not output (rationale), nor are theme changes, remounts, DOM reparenting, selection, focus changes, or resize redraw noise during `T_RESIZE_DEBOUNCE`, a grace the host opens before it resizes the PTY, a Client's resize included. Invariants:
**Must count only output that changes the screen**: a chunk of only queries, mode and color changes, or cursor motion is not output (rationale), nor are theme changes, remounts, DOM reparenting, or selection. **A busy run must begin within `cfg.alert.workStartWindow` of input or an active `OSC 9;4` report**; input is any PTY write but a terminal reply or mouse report, a wheel tick included. Later output, such as a focus or resize repaint, is ignored entirely (rationale). Invariants:

- **Must preserve an owed `watching` source when output resumes**; Completion events owns deferral (rationale).
- **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.
- **New WATCHING summons must be caused by a fresh settle**, never rerender, theme change, remount, minimize, or reattach; Completion events owns showing a deferred summons.

Source of truth: `commandWatchKey` / `watchRuleFor` in `lib/src/lib/terminal-state.ts`; `QuiesceDetector` in `lib/src/lib/quiesce-detector.ts`; `outputPaints` in `lib/src/lib/terminal-controls.ts`; `onSettled` in `lib/src/lib/alert-manager.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`; `outputPaints` in `lib/src/lib/terminal-controls.ts`; `inputCanStartWork` in `lib/src/lib/terminal-report-filter.ts`; `onSettled` in `lib/src/lib/alert-manager.ts`; multi-renderer coordinator `lib/src/lib/watched-command-host.ts`.

## Terminal reports

Expand Down Expand Up @@ -316,7 +316,7 @@ Detail follows one richness order: a text report (`OSC 9`, `OSC 99`, `OSC 777`)
| Verb | Effect |
|---|---|
| acknowledge without input (Engagement), or dismiss (`a`, Pane Header) | clears the ring, leaving `todo` on with its detail; with nothing ringing or held, no change |
| acknowledge with input — any keystroke, paste, or drop — or clear TODO (pill click) | clears the ring; `todo` off, notification dropped, even if already off (rationale) |
| acknowledge with input — any keystroke, wheel tick, paste, or drop — or clear TODO (pill click) | clears the ring; `todo` off, notification dropped, even if already off (rationale) |
| 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 |
| a rule stops covering the `watching` key | withdraws `watching` |
Expand Down
6 changes: 5 additions & 1 deletion docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@

**Why each VS Code webview is its own viewer.** The webviews share one manager. With one attention slot, a webview's window blur sent an id-less clear that wiped the attention another webview had just set, arming its running build (audit, 2026-09-23).

**Why the reply classifier is the narrow one.** The prompt recorder filters every CSI/SS3 sequence because it cannot interpret them as command text. Reusing that filter here ignored real encoded keys, while its omission of DCS let device-query replies acknowledge background panes; regression tests reproduced both failures in September 2026. The narrower reply classifier already used for replay and untouched-session tracking separates keys from replies without changing live PTY forwarding. Mouse-only chunks need a separate guard: the narrower classifier does not include mouse encodings, and inside programs can request hover and wheel reports without a click. Those reports otherwise dismiss a ring and cancel its pending alarms. Actual clicks already acknowledge through the Pane's DOM handler. A Client's write carries no mark — protocol-v1's `terminal.write` is bytes alone — so a Pocket scroll in tmux or `vim` cleared the ring and TODO until the host classified the bytes itself (review, 2026-09-24).
**Why the reply classifier is the narrow one.** The prompt recorder filters every CSI/SS3 sequence because it cannot interpret them as command text. Reusing that filter here ignored real encoded keys, while its omission of DCS let device-query replies acknowledge background panes; regression tests reproduced both failures in September 2026. The narrower reply classifier already used for replay and untouched-session tracking separates keys from replies without changing live PTY forwarding. Mouse reports need a separate guard: the narrower classifier does not include mouse encodings, and inside programs can request hover reports without a click. Those reports otherwise dismiss a ring and cancel its pending alarms. Actual clicks already acknowledge through the Pane's DOM handler. A Client's write carries no mark — protocol-v1's `terminal.write` is bytes alone — so a Pocket tap in tmux or `vim` cleared the ring and TODO until the host classified the bytes itself (review, 2026-09-24).

**Why a wheel tick is input.** Scrolling a program that asked for wheel reports, such as Claude Code's transcript, repaints it for as long as the scroll lasts; counted as nothing, a scroll through a finished turn never answered its ring (report, 2026-10-21). As input it acknowledges as typing does, clearing the TODO, as it already did on an alternate screen without mouse reporting, where xterm sends the wheel as arrow keys. It never opens the work-start window: scrolling asks for no work, and a repaint that trails the scroll or a status-line tick after it would otherwise start a run (review, 2026-10-21). Nor does it end a selection, which a scroll moved past before. A hover or button report stays nothing, a click acknowledging through the Pane instead.

**Why human input rides its write.** The echo of a keystroke comes back from the PTY within milliseconds, so the acknowledgement that opens the echo window has to be in effect before the bytes are written. As a separate command it would race the write across the host boundary, and an echo that won would count as the program working.

Expand Down Expand Up @@ -98,6 +100,8 @@

**Why only output that paints counts.** Two diagnostic reports (2026-10-18) showed a finished Claude Code holding its ring deferred for over ten minutes: idle, it sends `CSI ? 6 n` every 200 ms for as long as the terminal answers (Claude Code 2.1.303; unanswered, it asks once), so the detector never saw 5 s of silence. A third showed Codex answering a click with frames that only reset colors and move the cursor, which looked like work and rang `codex went quiet` once the user left. Discounting only known-inert sequences keeps every misread on the side of the old count.

**Why a busy run must follow input.** Two reports (2026-10-19) showed a click ringing `claude went quiet`: the focus report made an idle Claude Code repaint text, once 1.4 s later and for 1.8 s, and the settle rang after the user left. In both reports every real turn either followed a keystroke by about a second or opened with Claude's own `OSC 9;4` cycle; neither a click nor its repaint does either. The gate replaced a 500 ms grace after each resize, which covered one cause of the same repaint. Any write but a reply or a mouse report opens it, so `dor send` and launches still start work, and a progress report opens it so a turn Claude starts unasked, such as a background task waking it, still defers an owed ring. A command whose first output comes more than the window after Enter never reads as busy, and output resuming after a mid-run settle with no input or report inside the window is ignored like any other: an owed ring stays up through it, and a later await resolves on that ring. Letting an owed ring reopen the gate would bring back a resize or status-line repaint deferring a ring that is showing; agents' spinners never leave a turn silent long enough to settle.

**Why resumed work defers 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. Deferral preserves the debt; 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
6 changes: 4 additions & 2 deletions lib/src/cfg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ export const cfg = {
mightNeedAttention: 2_000,
/** ms — additional silence before confirming NEEDS_ATTENTION. */
needsAttentionConfirm: 3_000,
/** ms — ignore resize redraw noise. */
resizeDebounce: 500,
/** ms — after input reaches the program, how long its output may still
* begin a busy run. Output that begins later — a repaint on focus or
* resize, a status-line tick — is not work. */
workStartWindow: 5_000,
/** ms — default inactivity timeout: presence lapses this long after the last typing, mouse, or scroll input. */
inactivityTimeout: 15_000,
/** ms — shared grace period for keystroke echo AND fast command completions.
Expand Down
25 changes: 10 additions & 15 deletions lib/src/host/owner-pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import type { TerminalSemanticEvent } from '../lib/terminal-state';
import { ToolLaunchLatch } from '../lib/tool-launch-latch';
import { outputPaints } from '../lib/terminal-controls';
import { stripMouseReportsFromInput } from '../lib/terminal-report-filter';
import { stripPassiveMouseReportsFromInput } from '../lib/terminal-report-filter';

/**
* What the process that owns a PTY does with it on behalf of its alerts, the
Expand Down Expand Up @@ -88,36 +88,31 @@ export function createOwnerPtyStream(id: string, options: OwnerPtyStreamOptions)
});
}

/** The slice of a host's PTY manager that input and size changes reach. */
/** The slice of a host's PTY manager that input reaches. */
export interface PtyWriteTarget {
write(id: string, data: string, options?: { paced?: boolean }): void;
resize(id: string, cols: number, rows: number, repaint?: boolean): void;
}

/**
* A host's PTY writes and resizes as its alerts must see them, whoever asked —
* a local renderer or a remote Client: **human input is acknowledged, echo
* window opened, before its bytes reach the PTY**, and a resize opens its grace
* window before the program repaints (`docs/specs/alert.md` → Engagement).
* A host's PTY writes as its alerts must see them, whoever asked — a local
* renderer, a remote Client, or a `dor` verb: **alerts hear each write
* (`AlertManager.onWrite`) before its bytes reach the PTY**
* (`docs/specs/alert.md` → Engagement).
*/
export function alertedPty(alerts: AlertManager, pty: PtyWriteTarget) {
const write = (id: string, data: string, options: { paced?: boolean; userInput?: boolean } = {}): void => {
if (options.userInput) alerts.acknowledge(id, { input: true });
alerts.onWrite(id, data, options);
pty.write(id, data, options.paced ? { paced: true } : undefined);
};
return {
write,
/**
* A remote Client's write, the Burrow having dropped a mirror's terminal
* replies: human input unless it holds only mouse reports, which a Client
* sends unmarked and the desktop never acknowledges.
* replies: human input unless it holds only passive mouse reports, which a
* Client sends unmarked and the desktop never acknowledges.
*/
writeClientInput(id: string, data: string): void {
write(id, data, { userInput: stripMouseReportsFromInput(data).length > 0 });
},
resize(id: string, cols: number, rows: number, repaint?: boolean): void {
alerts.onResize(id);
pty.resize(id, cols, rows, repaint);
write(id, data, { userInput: stripPassiveMouseReportsFromInput(data).length > 0 });
},
};
}
38 changes: 23 additions & 15 deletions lib/src/host/remote/sidecar-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,28 +391,29 @@ describe('PTYs', () => {
expect(atWrite).toEqual([{ status: 'WATCHING_DISABLED', todo: false }]);
});

// A phone's tap or scroll in tmux or vim reaches the PTY, as the desktop's does.
it.each(['\x1b[<64;10;5M', '\x1b[M`!!', '\x1b[64;10;5M'])('acknowledges nothing for a Client\'s mouse report %j', (report) => {
// A phone's tap in tmux or vim reaches the PTY, as the desktop's does.
it.each(['\x1b[<0;10;5M', '\x1b[<35;10;5M', '\x1b[64;10;5M'])('acknowledges nothing for a Client\'s tap or hover report %j', (report) => {
alerts.notifyFromProtocol('pty-1', REPORT);
bridge.provider.writePty('pty-1', report);
expect(written).toEqual([{ id: 'pty-1', data: report }]);
expect(alerts.getState('pty-1')).toMatchObject({ status: 'ALERT_RINGING', todo: false });
});

// A scroll is the user's input there too, as on the desktop.
it.each(['\x1b[<64;10;5M', '\x1b[M`!!'])('acknowledges a Client\'s wheel report %j as input', (report) => {
alerts.notifyFromProtocol('pty-1', REPORT);
bridge.provider.writePty('pty-1', report);
expect(written).toEqual([{ id: 'pty-1', data: report }]);
expect(alerts.getState('pty-1').status).not.toBe('ALERT_RINGING');
});

// The webview holds `untouched`, which decides whether closing asks
// (docs/specs/layout.md → "Kill confirmation"); mouse reports count, as local ones do.
it.each(['ls\r', '\x1b[<0;10;5M'])('tells the webview a Client wrote %j', (data) => {
bridge.provider.writePty('pty-1', data);
expect(emitted('terminal:clientInput')).toEqual([{ id: 'pty-1' }]);
});

it('gives a Client\'s repaint bounce the resize grace', () => {
const onResize = vi.spyOn(alerts, 'onResize');
bridge.provider.resizePty('pty-1', 80, 24, true);
expect(onResize).toHaveBeenCalledWith('pty-1');
expect(resized).toEqual([{ id: 'pty-1', cols: 80, rows: 24, repaint: true }]);
});

it('writes and resizes straight through to the manager', () => {
bridge.provider.writePty('pty-1', 'ls\r');
bridge.provider.resizePty('pty-1', 80, 24);
Expand Down Expand Up @@ -809,12 +810,19 @@ describe('the sidecar host', () => {
expect(opens()).toEqual(['/launched']);
});

it('opens the resize grace before the PTY resizes', () => {
const graced: string[] = [];
vi.spyOn(host.alerts, 'onResize').mockImplementation((id) => void graced.push(`grace ${id} after ${calls.length} calls`));
host.handleCommand('pty:resize', { id: 'pty-1', cols: 100, rows: 30 });
expect(graced).toEqual(['grace pty-1 after 0 calls']);
expect(calls.map((call) => [call.op, call.args])).toEqual([['resize', ['pty-1', 100, 30, undefined]]]);
it('lets a write start work: output after it defers a report', () => {
vi.useFakeTimers();
try {
host.handleCommand('pty:input', { id: 'pty-1', data: 'hi\r', paced: true });
for (let t = 0; t < 2_000; t += 100) {
vi.advanceTimersByTime(100);
host.onPtyEvent('data', { id: 'pty-1', data: 'frame' });
}
host.alerts.notifyFromProtocol('pty-1', REPORT);
expect(stateOf('pty-1').status).not.toBe('ALERT_RINGING');
} finally {
vi.useRealTimers();
}
});

it('removes a killed Session\'s alert state', () => {
Expand Down
6 changes: 3 additions & 3 deletions lib/src/host/remote/sidecar-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export function createSidecarSurfaceBridge(
return stream;
}

// A Client's keystrokes and resizes reach the PTY as a local renderer's do.
// A Client's keystrokes reach the PTY as a local renderer's do.
const pty = alertedPty(options.alerts, options.mgr);

const { provider, notifyDirectoryChanged } = createAskSurfaceProvider(ask, {
Expand All @@ -201,7 +201,7 @@ export function createSidecarSurfaceBridge(
pty.writeClientInput(id, data);
options.send('terminal:clientInput', { id });
},
resizePty: pty.resize,
resizePty: (id, cols, rows, repaint) => options.mgr.resize(id, cols, rows, repaint),

streamPty(ptyId, sink) {
const subscribed = ownerStream(ptyId);
Expand Down Expand Up @@ -555,7 +555,7 @@ export function createSidecarHost(options: SidecarHostOptions): SidecarHost {
});
return true;
case 'pty:resize':
pty.resize(id as string, detail.cols as number, detail.rows as number);
mgr.resize(id as string, detail.cols as number, detail.rows as number);
return true;
// Reached only after Rust dropped the id's owner, so the removal's
// state reaches no window.
Expand Down
Loading
Loading