Repository navigation
Answer provider conditions without the composer's readiness gate - #598
Conversation
Fixes the Codex trust dialog being unanswerable on a new folder, and three routes by which a valid Claude AskUserQuestion answer failed closed. THE ROOT CAUSE, and why two previous PRs missed it. TileLeaf.send — the callback wired to every condition view's onSend — opens with a readiness gate that wakes the session when `!runtime.inputReady`. A live provider condition is precisely the state that CLEARS inputReady: Codex reports blocked whenever a modal owns the screen (#574), Claude reports not-ready whenever a permission prompt is up. So every click on the trust modal took the wake path instead of writing a keystroke, and before #597 that wake adopted the live backend, waited 30s for a readiness that could not arrive while the unanswered modal held the screen, then killed the process. That is the reported symptom exactly: accept does nothing, cancel does nothing, the modal never closes — because the keystroke that would dismiss it was never written. #574 and #597 both looked below sendInput; the write was never attempted. Condition keystrokes now use a dedicated path that does not consult readiness and does not wake — a visible condition is itself proof the backend is alive and painting — and surfaces a refused write as a toast instead of discarding the boolean. Found by experiment, not by reading. A throwaway node-pty probe drove real codex-cli 0.145.0 in a fresh temp dir at 120/80/60/50 columns. It killed two hypotheses I would otherwise have shipped as fixes: wrapped markers do NOT break detection at any width, and the keystrokes DID work against a real dialog. Both were wrong theories that looked obviously right on paper. Submodules carry the provider-side fixes: codex-headless structural anchoring for trust detection (the old substring scan fired on 14 recorded frames, every one an assistant discussing the dialog), plus accept '1' instead of '\r' and decline '2' instead of '2\r' claude-code-headless option number demoted to a hint, wrapped labels rejoined, sameQuestion given an option-set fallback Plan and full evidence trail: docs/decomposition/provider-condition-answering.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The phone client bundles this modal and has no codex-headless alias, so importing the keystroke constants broke `client:build`. That break is useful: the renderer should not have had keystroke constants at all. The condition snapshot already carries the provider's actions with their bytes, and every view already receives `actions`. Dispatching by id keeps one source of truth in the parser, keeps the phone bundle free of a headless package, and means a future upstream key change is a provider-side edit only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewer A found the fix was half-applied and one comment was wrong about its own mechanism. The keyboard route was still readiness-gated. useComposerKeybinds routes ArrowUp/Down to the PTY while a condition is live, then hard-blocked on !backendReady with blockBackendWrite() — so the arrow keys that move a Claude AskUserQuestion picker's selection did nothing, and toasted "Agent is still starting", for exactly as long as the picker was up. Same root cause, same fix, a route I missed: condition keystrokes now take sendConditionKey there too. Added a live-snapshot fence. The readiness gate happened to close one hole this path would otherwise open: a quarantined pane (ownership conflict) has its feed channels fenced INCLUDING session:exit, the sole caller of clearConditionRuntimeState, so its last condition snapshot stays frozen on screen while main's entry under that id may belong to someone else. Writing raw keystrokes there is what the quarantine exists to prevent. Requiring a live snapshot mirrors RemoteServer.applyPermissionReply. Corrected the comment's account of Codex. It claimed blockingCondition clears inputReady; it does not — that feeds only the prompt-delivery poll. Codex readiness latches true at markComposerReady and clears only on exit. The trust dialog reaches the broken state because it paints BEFORE the composer ever appears, so readiness was never true. A mid-session approval modal does not clear it and was never gated. The premise holds, the stated mechanism did not, and the doc repeated it. The toast no longer promises a retry that may never work: main returns one bit for two disjoint refusals (delivery reservation vs no backend) and the renderer cannot tell them apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A second reviewer drove a live claude v2.1.217 picker, which I never did, and all four AUQ changes failed against real output — one of them a regression that killed multi-select answering outright. The submodule PR is reverted and closed; the gitlink returns to main. The label/description distinction does not exist in plain text at all: in single-select the continuation and the description share a column, in multi-select the description sits further left. The parser holds the real xterm grid, and cell attributes (descriptions render dim) are the only signal that separates them. That is a different design, not a tweak, so this ships nothing for it rather than shipping a guess. Recorded plainly in the decomposition that the failure was the exact one the document warned about, committed by its author: the AUQ changes were built from a corpus capture plus reasoning, with no live picker, and the 51-test suite passed the entire time. What ships is what was verified against a live dialog: the renderer's condition-send path (mouse and keyboard routes), and the Codex trust-dialog anchoring and keystrokes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review round: two orchestrated reviewers. One PR reverted, five defects fixed.Reviewer A (root cause + renderer) confirmed the premise but found the fix half-applied. Reviewer B (provider parsers) drove a live The AskUserQuestion PR is reverted and closedAll four changes failed against real output. This is the important part of the round, so it is stated plainly rather than buried:
Plain text carries no discriminator between a label continuation and a description. The parser holds the real xterm grid, and cell attributes (descriptions render dim) are the only signal that separates them. That is a different design, not a tweak, so this ships nothing for it rather than shipping a guess. Worth recording: those changes were built from a corpus capture plus reasoning, with no live picker — precisely the failure mode the decomposition document warned about, committed by its author. The 51-test suite passed the entire time. Fixed in agent-code
Fixed in codex-headless
Confirmed clean by reviewer B, against the live 0.145.0 TUI
Verification after all fixes
|
The trust-dialog fix is merged upstream; the pointer moves off the feature branch so main never references a branch commit.
Fixes the Codex trust dialog being unanswerable on a new folder, and three routes by which a valid Claude AskUserQuestion answer failed closed.
Plan and full evidence trail:
docs/decomposition/provider-condition-answering.mdThe root cause — and why two previous PRs missed it
TileLeaf.send, the callback wired to every condition view'sonSend, opens with a readiness gate:A live provider condition is precisely the state that clears
inputReady. Codex reportsblockedwhenever a modal owns the screen (blockingCondition, added by #574); Claude reports not-ready whenever a permission prompt is up.So every click on the trust modal took the wake path instead of writing a keystroke — and before #597, that wake adopted the live backend, waited 30s for a readiness that could not arrive while the unanswered modal held the screen, then killed the process.
That is the reported symptom exactly: accept does nothing, cancel does nothing, the modal never closes — because the one keystroke that would dismiss it was never written.
#574 and #597 both looked below
sendInput. The write was never attempted.Condition keystrokes now use a dedicated path that does not consult readiness and does not wake — a visible condition is itself proof the backend is alive and painting — and surfaces a refused write as a toast instead of discarding the boolean.
Found by experiment, not by reading
A throwaway
node-ptyprobe drove realcodex-cli 0.145.0in a fresh temp dir at 120/80/60/50 columns. It killed two hypotheses I would otherwise have shipped as fixes:\r,1,2and ESC all did exactly what upstream documents against a real dialogBoth looked obviously right on paper. Neither was the bug.
Corpus evidence for the Codex false positive
detectCodexTrustDialogtestedscreen.includes(marker)for three phrases anywhere on screen. Scanning 52 real session recordings for all three markers found 14 full matches — and every one is a screen of an assistant discussing the trust dialog (a code review, a pasted parser, a plan document). The approval titles share the flaw: 28 frames matched three different titles simultaneously, because the frame held a list literal containing all of them. A real overlay can never do that.This is not cosmetic:
codex.trust-dialogis inactionKinds, so a phantom detection blocks keystroke routing and paints an unanswerable modal over a session asking nothing — re-firing every frame, so it never closes.Detection now requires structure: the
> You are in <path>anchor, then both numbered option rows, below it and in order.Submodule changes
codex-headless(fix/trust-dialog-anchoring) — structural anchoring, plus deterministic keystrokes:'\r'→'1'. Enter confirms whatever is highlighted (trust_directory.rsKeyboardHandler), so any stray arrow key turned "trust directory" into "quit".'2\r'→'2'. Upstream acts on the digit immediately; the trailing Enter leaked into the next screen.Also records upstream drift: 0.145 appends a sentence the vendored 0.130 source lacks, so anchoring on the full paragraph would already be broken.
claude-code-headless(fix/auq-answer-matching) — three independent routes that all surface identically as a dead row:q.options.indexOf(option) + 1— a semantic index — and the driver resolved it against the screen's numbering. On divergence it returned null outright; it now falls through to label matching, with the duplicate-label guard still failing closed.OPTION_REcaptures one physical line, so a wrapped label kept only its first line and could never match. Continuations are distinguished from descriptions by indentation.sameQuestionoption-set fallback. Both question and header can legitimately be null (multi-chip nav bars deliberately emitheader: null), which returned false unconditionally and died atwait-question:<q>— the timeout chased through several revisions of that file.Verification
visible: false. Anchor-without-options →visible: false.tsc --noEmitclean on both projects; both submodules type-check.Known gaps, stated rather than buried
🤖 Generated with Claude Code