Skip to content

fix(triggers): exclude CPR/DECXCPR from the composer quiet clock - #170

Merged
devsuitup merged 2 commits into
mainfrom
fix-trigger-cpr-quiet-window
Sep 4, 2026
Merged

devsuitup merged 2 commits into
mainfrom
fix-trigger-cpr-quiet-window

Conversation

@devsuitup

Copy link
Copy Markdown
Owner

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:

Shape Count of 2,422 pty.input chunks
CPR / DECXCPR (CSI [?] row;col[;page] R) 1,427 59%
SGR mouse (CSI < b;x;y M/m) 495 20% — already excluded, working correctly
plain text, no control byte 473 20% — real keystrokes (JB typing before sleep)
DEL / CR 27 1% — real keystrokes
OSC, DCS 0 never observed on this machine

CPR arrives roughly every 240ms — Claude Code polling cursor position on its
own. reportLength() (composer-state.js) only recognised SGR mouse and
focus reports, so every CPR chunk fell through to the generic path and
reassigned lastInputAt. With CPR arriving continuously, the 3000ms quiet
window waitForComposerFree (trigger-watcher.js) waits for could never open,
so a trigger against a genuinely idle composer refused with not sent
whenever 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 same
way SGR_MOUSE_PARAMS_RE bounds mouse reports — a regex on the exact
parameter form, not a bare check on the final byte:

const CPR_PARAMS_RE = /^\??\d{1,4};\d{1,4}(?:;\d{1,4})?$/;

Covers bare CPR (row;col), DECXCPR (?row;col) and DECXCPR with a page
field (?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.md as an open item, each with its own
reasoning:

  • DSR / DA1 / DA2: plausible future additions if a CLI release starts
    emitting them — same treatment as CPR (measure, then add the exact shape).
  • DECRPM (CSI ? Pd $ y): can't currently be told apart from a bare
    final ymatchEscape() consumes the $ intermediate but doesn't
    return it, so distinguishing it needs a matchEscape return-shape change,
    not just a reportLength regex. Out of scope here.
  • DA3: not a CSI sequence at all (it's DCS-based), doesn't belong in
    this family.
  • OSC replies (colour query, etc.): already parsed whole by
    matchEscape, and since nothing applies to kind: 'osc', they push
    lastInputAt without touching pending — same defect shape as CPR, zero
    measured occurrences.
  • DCS: structural gap, not a report-recognition one — matchEscape has
    no DCS case, so ESC P falls into the generic 2-byte escape catch-all and
    the 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 matchEscape change,
    a different and larger piece of work than this PR, and zero measured
    occurrences.

Second-defect check (item 4)

applyCsi has no case 'R' — falls through its default — so before this
fix a CPR chunk pushed lastInputAt but never touched text/cursor/
pending. Verified by reading the switch (composer-state.js), not by a
runtime test: once reportLength claims R, applyCsi never sees it, so
no 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=1 chunks, 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
reportLength change and rerun (see conversation log / HANDOFF for the
exact node --test --test-name-pattern output); the near-miss and
safety-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 against
waitForComposerFree's free condition, reimplemented against fake time with
the measured CPR cadence, because waitForComposerFree itself isn't
exported 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.js and test/trigger-watcher.test.js are 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.

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.
@devsuitup

Copy link
Copy Markdown
Owner Author

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 pty.input writes: zero OSC, zero DCS. Not rare on this machine; absent.

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 — matchEscape does not recognise ESC P … ESC \ at all, so a DCS reply would reach the composer as literal text rather than merely pushing the clock — but nothing here emits one, so it stays a documented hole rather than a speculative widening of the filter.

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.
@devsuitup
devsuitup merged commit 38590b2 into main Sep 4, 2026
7 checks passed
@devsuitup
devsuitup deleted the fix-trigger-cpr-quiet-window branch September 4, 2026 11:35
@devsuitup devsuitup mentioned this pull request Sep 4, 2026
devsuitup added a commit that referenced this pull request Sep 4, 2026
Ships #166, #168, #169, #170, #179, #180 and #182.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant