fix(triggers): exclude CPR/DECXCPR from the composer quiet clock - #170
Conversation
A 340s trace of two idle sessions (nobody at the keyboard) put a number on the "unproven" suspects flagged in the trigger-watcher.md investigation notes: 1,427 of 2,422 pty.input chunks were CPR/DECXCPR (CSI [?] row;col R), the terminal answering a cursor-position query Claude Code issues on its own roughly every 240ms. reportLength() only recognised SGR mouse and focus reports, so every one of those chunks reached noteUserInput's generic path and reassigned lastInputAt, which meant the 3000ms quiet window used by waitForComposerFree could never open on a machine with any session left open, regardless of the pointer or the CLI's busy state. The 2026-09-02 fix (PR #160) excluded mouse SGR, which the same trace shows accounted for only ~495 of the 2,422 chunks (20%) - real, but not the dominant cause. CPR alone explains the intermittent "not sent" refusals that survived it. DSR, DA1/DA2, DECRPM and window-ops replies were not added: zero occurrences in the trace, and adding exclusions without measurement is the wrong direction to guess in - a false "free" types over the user's sentence. DECRPM specifically can't be told apart from a bare final `y` with the current matchEscape() without changing its return shape. Both are written up as open items in .ai/contexts/trigger-watcher.md, along with the OSC and DCS parsing gaps that were also investigated (OSC pushes the clock the same way CPR did, unmeasured; DCS is a separate, structural matchEscape gap - ESC P not being recognised at all - that leaks its payload into the composer as literal text). None of this is fixed here: out of scope for a change this size, and none of it is what the trace shows happening. applyCsi has no case for final R, so before this fix a CPR chunk pushed the clock but never touched text/cursor/pending - the defect was confined to the quiet window.
|
The open point in this PR — whether OSC and DCS replies also push the quiet clock, left unaddressed because nothing was observed in a 12-minute window — now has a longer measurement behind it. Across three trace segments covering roughly two hours of two live sessions, 26,458 That turns "not added because unmeasured" into "not added because measured absent", which is a materially different claim in the file that future work will read. The DCS structural gap stands as noted — The direction of risk is unchanged and is why this matters: over-excluding means injecting a command while the user types, which cannot be taken back; under-excluding means a trigger renounces and retries. |
…d gap Adversarial review of #170 found two defects in the CPR exemption: - CPR_PARAMS_RE made the leading `?` optional, so it also matched bare `CSI n;m R` — exactly what xterm.js emits for a modified F3 keypress (Shift/Alt/Ctrl, params 1;2 through 1;8). A real keystroke was silently exempted from the quiet clock, the opposite of what the guard exists to do. Re-checked the archived trace directly: 24,795/24,795 CPR chunks carried the `?`, zero did not, so requiring it excludes exactly the F3 shape and nothing measured. - `\d{1,4}` on CPR_PARAMS_RE and `\d{1,10}` on SGR_MOUSE_PARAMS_RE both had no test covering an empty numeric field, so widening either to `\d*` (a real mutant) passed the full suite. Added near-miss cases for both. Documented why the mouse and CPR exemptions rest on different properties (a keyboard-unreachable prefix vs. a measured absence) instead of treating "same pattern as mouse" as the safety argument on its own.
What was measured
A 340s activity trace of two idle sessions (nobody at the keyboard) — the
follow-up measurement the trigger-watcher.md investigation notes called for
after the 2026-09-02 mouse fix (PR #160) didn't resolve the symptom:
pty.inputchunksCSI [?] row;col[;page] R)CSI < b;x;y M/m)CPR arrives roughly every 240ms — Claude Code polling cursor position on its
own.
reportLength()(composer-state.js) only recognised SGR mouse andfocus reports, so every CPR chunk fell through to the generic path and
reassigned
lastInputAt. With CPR arriving continuously, the 3000ms quietwindow
waitForComposerFree(trigger-watcher.js) waits for could never open,so a trigger against a genuinely idle composer refused with
not sentwhenever any session was on screen.
The earlier fix (PR #160) excluding mouse SGR was correct but addressed 20%
of the traffic; CPR — the dominant 59% — was untouched, which is why the
symptom outlived that release.
The fix
composer-state.js:reportLength()gets one more shape, bounded the sameway
SGR_MOUSE_PARAMS_REbounds mouse reports — a regex on the exactparameter form, not a bare check on the final byte:
Covers bare CPR (
row;col), DECXCPR (?row;col) and DECXCPR with a pagefield (
?row;col;page). No key or chord on a keyboard produces this shape.What was NOT added, and why
The brief asked for DSR, DA1/DA2/DA3, DECRPM and window-ops (
t) as well.None of them appear anywhere in the trace (0 occurrences), so none are
added — excluding a shape that isn't measured to occur is a guess in the
dangerous direction: a false "free" types over the user's sentence. Written
up in
.ai/contexts/trigger-watcher.mdas an open item, each with its ownreasoning:
emitting them — same treatment as CPR (measure, then add the exact shape).
CSI ? Pd $ y): can't currently be told apart from a barefinal
y—matchEscape()consumes the$intermediate but doesn'treturn it, so distinguishing it needs a
matchEscapereturn-shape change,not just a
reportLengthregex. Out of scope here.this family.
matchEscape, and since nothing applies tokind: 'osc', they pushlastInputAtwithout touchingpending— same defect shape as CPR, zeromeasured occurrences.
matchEscapehasno DCS case, so
ESC Pfalls into the generic 2-byte escape catch-all andthe rest of the payload is walked as literal text. A real DCS reply would
get typed into the composer. Not fixed here — it's a
matchEscapechange,a different and larger piece of work than this PR, and zero measured
occurrences.
Second-defect check (item 4)
applyCsihas nocase 'R'— falls through its default — so before thisfix a CPR chunk pushed
lastInputAtbut never touchedtext/cursor/pending. Verified by reading the switch (composer-state.js), not by aruntime test: once
reportLengthclaimsR,applyCsinever sees it, sono post-fix assertion on this can ever go red. Documented at the call site
in
test/composer-state.test.js.The 94/473 plain-text writes (item 5)
Resolved by re-measurement, not by decoding content (the trace deliberately
never logs a control-free chunk's characters — see
docs/activity-trace.md).On the corrected 340s window they're 473
len=1chunks, all in one session,consistent with real typing (JB working before sleep) rather than synthetic
traffic. Not a third defect — this text is supposed to push the clock.
Tests
test/composer-state.test.js: one test per shape (bare CPR, DECXCPR,DECXCPR+page, split across chunks, mixed with a real keystroke, near-misses
that must still count as input), plus the safety-direction guarantee that
ordinary typing still pushes the clock through a CPR flood. Every
discriminating test was proven red individually by reverting the
reportLengthchange and rerun (see conversation log / HANDOFF for theexact
node --test --test-name-patternoutput); the near-miss andsafety-direction tests were confirmed to correctly stay green under that
same mutation, since they pin the opposite direction.
test/composer-state-quiet-window.test.js(new): end-to-end proof againstwaitForComposerFree's free condition, reimplemented against fake time withthe measured CPR cadence, because
waitForComposerFreeitself isn'texported and PR #168 edits that exact function — exporting it here would be
an avoidable conflict for a one-line predicate this file already proves via
composer-state.js's clock behaviour.
trigger-watcher.jsandtest/trigger-watcher.test.jsare untouched (PR#166 and #168 are in flight on both).
npm test: 896 tests, 889 pass, 7 pre-existing skips, 0 fail.npx eslint .: 0 errors, pre-existing warnings only.