Handler: submit the line it types, and ask the agent before answering for it - #71
Merged
Merged
Conversation
… for it A PTY auto-reply was one write of text + CR. A coding-agent TUI tokenizes a read as a whole — Claude Code emits a control character as its own key event only while the read is under 64 characters — so every reply at or above that landed in the composer as literal text and was never sent. The engine banked the transition anyway, so the session rested at watching with the prompt stranded and no turn-end coming. Submitting now goes through TerminalSession.submit: the line, a gap, then the CR in a read of its own, serialized per terminal so a keystroke cannot land inside it. A bare slash verb is padded with one space, which clears the suggestion list that would otherwise route Enter to accept rather than submit. The same seam covers terminal:input, so an escalation chip — up to 400 characters — no longer types without submitting while the card latches disabled and both sides drop the row. onUserReply gated on isSubmitKeystroke. It reset the runaway guard on every inbound byte, and once an agent enables mouse tracking every pointer event is one of those, so moving the mouse bought an unbounded auto-reply budget. The CR/LF test it used also matched alt+enter and a paste's interior newlines, retiring escalations while the agent stayed blocked. The predicates move to keystrokes.ts, a leaf both agent-core and the handler can import. buildDecidePrompt gains the move it was missing: ask the agent when information is missing, rather than assuming and writing a recipe. The agent holds the live session and the working tree; the judge reads a bounded excerpt. Ordered after the escalate rule so a question cannot divert what belongs to the human. replyShape flattens the slash-command argument tail the way it already flattened reply, and names the field it actually refused — the reason is fed verbatim to the one retry, so it was teaching the judge to edit the field it got right. The destructive floor learns the outward moves: gh pr merge/close, gh release/repo delete, forced branch delete, git tag -d, npm publish. Advisory, never HARD — merging the pull request is routinely the backlog's whole point. Nothing snapshots them, so they report that no undo exists instead of passing in silence.
…tself Review of the previous commit. The floor additions had two defects that matter: `gh pr (merge|close)` and `gh (release|repo) delete` were each one regex over two operations, and §5.4 keys an authorization lift on the pattern SOURCE — so "close the stale PRs" granted every merge, and an authorized release delete authorized deleting the repository. Split one operation per pattern. PR_ANCHOR accepted a bare `#42`, but GitHub numbers issues and pull requests in one series, so "closes #42 once the fix lands" — a backlog line about an issue — granted a session-wide gh pr close. The bare arm is gone; "PR #42" still anchors. The merge alias also matched "fix the merge conflicts on PR #12", which asks for the opposite of a merge. The git branch flags had no option-token boundary, so `-perf` in a branch name read as a force and flagged the safe `git branch -d`, while the grouped spellings `-fd` and `-Dr` matched nothing. git tag scanned past quotes and separators, so `-m "fix -d flag"` read as a tag deletion. Publish flagged --dry-run and knew only npm, not the pnpm/yarn/bun this repo actually uses. The capability responder wrote straight to the PTY, the one writer that could land between an injected line and its deferred CR — the interleave the queue exists to prevent. It goes through the queue, which is a pass-through whenever no submit is in flight. The queue opened a gap before the CR but not after, so the next write joined the CR's read and robbed it of its key event one step later than the bug this fixes. padBareVerb treated an absolute path as a slash verb. hasTypedContent counted the terminal's own mouse and focus reports as typed content, and typedSessions outlives the frame that set it — so one pointer move made the next bare Enter open a turn no stop hook would close, on the same premise the previous commit acted on for onUserReply. The judge's onTimeout fired on one of the three legs that spend the budget, staying silent on an exhausted budget and a hung retry — the two that most need naming. The wrap-up push put the expiring undo offer last, behind an unbounded summary, in a string OS surfaces truncate.
bharathm03
added a commit
that referenced
this pull request
Sep 1, 2026
Six conflicts, all where #71 improved the same code HF-3 deleted and HF-6 replaced. Two of them were live risks rather than textual noise, because the auto-merge would have kept our side and silently reverted a shipped fix: The push named what a guard refused; our composer only counted it. #71's point stands and is now the composer's: a count reads the same whether the guard stopped something trivial or the one thing the session existed to do, and the push is the only channel reaching a phone whose app was not running when the activity rows went out. `blockedClause` renders the record's reasons, capped at two for the lock screen while the card keeps three. The undo clause belonged AHEAD of the reports; ours put it last. OS surfaces truncate the tail, and of the two only the undo expires — the reports keep on the wrap-up card, while the offer to undo is gone once the user stops looking for it. Restored, and pinned by a test, which nothing did before. Kept from #71 otherwise: the activity-log rotation and its three tests, and the injective `JSON.stringify([tier, matched])` Set key that replaced the NUL separator. Its rotation comment claimed the jsonl was the only durable copy of what a wrap-up describes — true when written, and no longer, so it now says what is still true: those rows are the only record of the session decision by decision. Thirteen `notifyOnly: false` arguments arrived in tests #71 added. They merged clean, so nothing flagged them against a field HF-3 removed; stripped by hand.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Handler was armed on two live sessions. Two symptoms were reported: its
auto-replies appeared in the agent's composer but were never sent, and it wrote
prescriptive how-to recipes instead of asking the agent that holds the session.
Both reproduce from the logs, and digging into them surfaced five more.
Every finding here was put to an adversarial skeptic before it was fixed. Two of
the original seven were partly refuted and are implemented in a different shape
than first proposed; that is called out below.
The reply never submitted
createPtyAdapter.injectReplywas one PTY write oftext + "\r". A coding-agentTUI tokenizes a read as a whole — Claude Code emits a control character as its own
key event only while the entire read is under 64 characters — so at or above that
the CR is absorbed into the surrounding text run and inserted as literal text. The
prompt is typed and never sent.
The live log matches exactly: of seven injected replies (18, 294, 449, 497, 552,
782, 1014 chars) only the 18-character one could have submitted. Nothing detects
the failure — the engine records
handle, banks its transitions, counts therunaway guard and rests at
watching, while the agent emits no turn-end and thesession goes quiet with the text stranded.
Submitting now goes through
TerminalSession.submit: the line, a gap, then the CRin a read of its own, serialized per terminal so a keystroke cannot land inside the
injected line. The seam sits below both the handler adapter and agent-core's
terminal:input, so the app's escalation chips (up to 400 characters) are coveredby the same mechanism rather than a second one — a chip previously typed without
submitting while the card latched disabled and both sides dropped the row.
A bare slash verb is padded with one trailing space. With the CR in its own read,
/compactreaches Claude Code's Enter handler while the suggestion list is openand selection has settled on the exact match, which routes Enter to accept rather
than submit; any slash line containing a space clears the list first. This is a
regression introduced by the split itself, found by the audit, not a pre-existing
bug.
The judge had no way to ask
buildDecidePromptoffered two moves: answer with high confidence, or escalate tothe human. Nothing let it ask the AGENT — which holds the live session, the working
tree and write access, where the judge reads a bounded excerpt. The result was
294–1014 character command recipes, including a commit message the agent then used
verbatim.
The prompt gains the ask move and an altitude rule (the Handler decides what should
happen next and why; the agent decides how), placed immediately after the
escalate rule so a question cannot divert what belongs to the human. The
decisionenum is unchanged — asking is ahandlewhose reply is a question — anda test pins that a fourth enum value stays rejected.
Runaway guard and escalation gate
onUserReplyreset the runaway guard on every inboundterminal:inputbyte,before any test. Once an agent enables mouse tracking every pointer event is one
of those frames, so moving the mouse across the terminal restored the auto-reply
budget tens of times a second; one ordinary typed character did it too. The guard's
own header says it resets "on a human reply".
The same method's
/[\r\n]/test also matched alt+enter (\x1b\r) and a paste'sinterior newlines — neither submits anything — and retired pending escalations
while the agent stayed blocked. Escalations never supersede, so a row cleared that
way is not re-raised.
Both collapse into one
isSubmitKeystrokegate, kept above the session lookup soguard state is still reclaimed for a terminal with no live session. The three input
predicates move to
keystrokes.ts, a leaf module both agent-core and the handlercan import without a cycle.
Shape refusals (partly refuted)
The original claim was that the
reply/actionXOR left the judge no way to run acommand with instructions. That is wrong — the XOR never fired in the live log, and
it stays. The real defect is next to it:
replyShapeflattenedreplybut neveractionText, so a newline in a command's argument tail was refused by thecontrol-char rule; and because
written = actionText || reply, the refusal wasreported as "reply contains control characters". That reason is fed verbatim to the
one retry, teaching the judge to edit the field it got right.
Destructive floor
The floor had no
ghverbs at all. In the live run the Handler drovegh pr merge --delete-branchintodevelopmenttwice with no warning and nosnapshot. Added at the DESTRUCTIVE tier:
gh pr merge|close,gh release|repo delete, forced branch delete,git tag -d,npm publish.Advisory, never HARD — a HARD entry is liftable by nothing, and merging the pull
request is routinely the backlog's whole point. Nothing snapshots these, so they
now report that no undo exists rather than passing in silence, and the alias table
learns their prose so an authorized merge stops recurring as a reason to escalate.
Blocked-action push (partly refuted)
The original claim — that
handler-activity.jsonlis unread and the push points ata feed nothing can replay — is wrong: the activity feed is a live app screen fed by
the
handler:activitypush. The real gap is narrower.handler:statusreplayssessions, escalations and snapshots but never activity rows, and
blockedNoterides an OS push, the one channel that reaches a phone whose app was not running
when those rows went out. So "N action(s) Handler could not take — see the activity
feed" could land on an empty feed. The push now says what was refused.
The claim that the synchronous append is a defect was dropped: the whole handler
persistence layer is sync, and every
record()call site sits beside a strictlylarger sync write.
Verification
bun run --filter antgrid-bridge test— 3198 pass, 0 fail (baseline 3148, so +50tests).
tsc --noEmitclean. No files underapp/change, so the Flutter gatesare untouched.
Not yet verified against a live Claude Code PTY. The 20ms gap and the bare-verb
padding are reasoned from the 2.1.252 binary's tokenizer and covered by unit tests,
but not measured end-to-end. The underlying bug is intermittent — a read the OS
happens to split short submits fine today — so this is worth exercising at 64,
~500 and ~4000 characters plus a bare
/compact, on Windows specifically, whereConPTY is the coalescing layer.