Skip to content

Handler: submit the line it types, and ask the agent before answering for it - #71

Merged
bharathm03 merged 2 commits into
developmentfrom
antgrid/handler-submit-and-ask
Sep 1, 2026
Merged

Handler: submit the line it types, and ask the agent before answering for it#71
bharathm03 merged 2 commits into
developmentfrom
antgrid/handler-submit-and-ask

Conversation

@bharathm03

Copy link
Copy Markdown
Contributor

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.injectReply was one PTY write of text + "\r". A coding-agent
TUI 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 the
runaway guard and rests at watching, while the agent emits no turn-end and the
session goes quiet with the text stranded.

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 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 covered
by 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,
/compact reaches Claude Code's Enter handler while the suggestion list is open
and 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

buildDecidePrompt offered two moves: answer with high confidence, or escalate to
the 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
decision enum is unchanged — asking is a handle whose reply is a question — and
a test pins that a fourth enum value stays rejected.

Runaway guard and escalation gate

onUserReply reset the runaway guard on every inbound terminal:input byte,
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's
interior 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 isSubmitKeystroke gate, kept above the session lookup so
guard 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 handler
can import without a cycle.

Shape refusals (partly refuted)

The original claim was that the reply/action XOR left the judge no way to run a
command with instructions. That is wrong — the XOR never fired in the live log, and
it stays. The real defect is next to it: replyShape flattened reply but never
actionText, so a newline in a command's argument tail was refused by the
control-char rule; and because written = actionText || reply, the refusal was
reported 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 gh verbs at all. In the live run the Handler drove
gh pr merge --delete-branch into development twice with no warning and no
snapshot. 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.jsonl is unread and the push points at
a feed nothing can replay — is wrong: the activity feed is a live app screen fed by
the handler:activity push. The real gap is narrower. handler:status replays
sessions, escalations and snapshots but never activity rows, and blockedNote
rides 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 strictly
larger sync write.

Verification

bun run --filter antgrid-bridge test — 3198 pass, 0 fail (baseline 3148, so +50
tests). tsc --noEmit clean. No files under app/ change, so the Flutter gates
are 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, where
ConPTY is the coalescing layer.

… 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
bharathm03 merged commit 1d0c80c into development Sep 1, 2026
3 checks passed
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.
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