Loading files
56ae2e47fThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* AskUserQuestion on a REMOTE machine — mirror the question to the device, then answer it by driving the
* interactive CLI's own terminal dialog.
*
* A runtime that speaks stream-json gets this for free: a `question_request` event carries
* the questions out and a `question_response` control-frame carries the answer back. A remote machine has no
* such channel — the adapter watches a JSONL transcript and talks to a real TUI. So both halves read and
* write the pane itself:
*
* OUT QuestionWatcher polls the pane while a turn is open and pushes the open dialog as
* `commander_question`, in the SAME shape the hosted runtime sends, so the firmware's existing question
* screen renders it unchanged (commanderQuestions in websocket.ts). NOT from the transcript —
* see the QuestionWatcher docblock for why that source cannot work.
* IN the device's `question_response` → keystrokes into the pane's dialog — only once the dialog on
* screen is shown to be the one that answer was for (its requestId); a late answer is refused
* with STALE_QUESTION and types nothing.
*
* Dialog mechanics (verified against Claude Code 2.1.220, `tmux capture-pane`):
* - single-select : the option's digit selects AND submits, advancing to the next question / review.
* - free text : the digit of the "Type something." row opens it for editing (does NOT submit) →
* type the text → Enter submits.
* - multi-select : rows render as `[ ]` / `[✔]`; a digit TOGGLES; Tab advances to the next question.
* - review : after the last question, "Ready to submit your answers?" → "1. Submit answers".
*/
import type { RegisteredSession } from './registry.js'
import type { AgentEngine } from '../engines/types.js'
import { locateMuseQuestion } from '../engines/muse/askQuestion.js'
import { ampSelectionKeys, parseAmpQuestionPane } from '../engines/amp/askQuestion.js'
import { kiloSelectionKeys, locateKiloQuestion, parseKiloQuestionPane } from '../engines/kilo/askQuestion.js'
import { parseCursorPermissionPane } from '../engines/cursor/askQuestion.js'
import { locateDevinPermission, locateDevinQuestion } from '../engines/devin/askQuestion.js'
import { parseGrokQuestionPane } from '../engines/grok/askQuestion.js'
import { locateAgyQuestion } from '../engines/agy/askQuestion.js'
import { withCopilotSubject } from '../engines/copilot/askQuestion.js'
import { earlierDialogEnd, PERMISSION_FOOTER_RE, QUESTION_FOOTER_RE } from './dialogEnd.js'
/** Device-facing question shape — byte-for-byte the hosted runtime’s `commanderQuestions()` output. */
export interface ShapedQuestion {
key: string
q: string
options: string[]
multi: boolean
/** Observed free-text editor; never inferred for permission prompts. */
canText?: boolean
}
export interface QuestionRow {
/** The digit to press. */
number: string
/** Option text with any `[ ]` checkbox prefix removed. */
label: string
checked: boolean
/**
* Set only when the dialog does NOT number its rows: the row is then reached by walking to it and
* pressing Enter, and `number` carries its INDEX rather than a key.
*
* The direction is a property of the DIALOG, not of the engine. OpenCode draws BOTH a numbered ask
* dialog and — for permissions — the horizontal prompt kilo inherited from it, so keying by engine
* would send digits into a dialog that numbers nothing and select nothing at all.
*/
walk?: 'right' | 'down'
}
export interface QuestionView {
kind: 'question'
permission?: boolean
/**
* The dialog is on screen but its top — the question and its first rows — is scrolled out of the pane
* (Codex's request_user_input keeps the footer anchored and lets a short pane cut the top off; measured
* in a four-pane window: rows 3–5 and the footer visible, "1." and the question gone). Enough to know a
* dialog is STILL OPEN — the watcher must not close it — and not enough to announce as a question.
*/
partial?: boolean
/** The footer says a digit only highlights and Enter commits (`enter to submit answer` — Codex's
* request_user_input). Absent where one digit selects and submits, which is every other dialog. */
enterSubmits?: boolean
question: string
rows: QuestionRow[]
multi: boolean
/** The "Type something." row, when the dialog offers free text. */
typeRow: QuestionRow | null
/**
* A permission prompt's WHOLE dialog, every line as painted (ANSI stripped), from its frame to its last
* row. `question` is one line clipped for a device's screen; a command that wraps — `npm test &&` on one
* line, `git push` on the next — is only whole here. What the pair brain's floor reads (pair/classify.ts),
* and part of the dialog's fingerprint, so two prompts that differ below their first line are two ids.
*/
dialog?: string
}
export interface ReviewView {
kind: 'review'
submitRow: string
}
export type PaneView = QuestionView | ReviewView | null
const STEP_MS = 350 // let the TUI repaint between keystrokes
const TEXT_MS = 250
const MAX_STEPS = 14 // hard bound on the drive loop (questions × keys), never spin on a stuck pane
const CAPTURE_LINES = 60
// Rows that exist in every dialog but can never BE an answer.
const CHAT_ROW = /^chat about (this|these)$/i
// Claude writes "Type something.", Command Code "Type something..." — one trailing dot or three, and
// missing it costs the free-text row: a voice answer would have nowhere to go and the row would be
// offered on the device as if it were a real option.
// Claude "Type something.", Command Code "Type something...", OpenCode "Type your own answer", Hermes
// "Other (type your answer)". Same row in every dialog: it opens an editor instead of choosing, so it must
// never be offered to the device as a selectable label.
const TYPE_ROW = /^(type something\.{0,3}|type your own( answer)?|other \(type your (own|answer)\))$/i
function stripAnsi(value: string): string {
return value.replace(/\u001b\[[0-9;:]*[A-Za-z]/g, '')
}
function norm(value: string): string {
return value.toLowerCase().replace(/\s+/g, ' ').replace(/[.…]+$/, '').trim()
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Shape a raw AskUserQuestion input into the device form. Port of the hosted runtime’s `commanderQuestions`
* (websocket.ts): `key` MUST stay the value the CLI matches answers by — prompt/question first — because
* the device echoes it back as the answers-map key.
*/
export function shapeQuestions(questions: unknown): ShapedQuestion[] {
return (Array.isArray(questions) ? questions : []).map((raw, i) => {
const q = (raw ?? {}) as Record<string, unknown>
const opts = Array.isArray(q.options) ? q.options : []
return {
key: (q.prompt as string) || (q.question as string) || (q.id as string) || (q.header as string) || `question_${i}`,
q: (q.prompt as string) || (q.question as string) || '',
options: opts
.map((o) => String((typeof o === 'string' ? o : (o as Record<string, unknown>)?.label) ?? ''))
.filter(Boolean),
// Three spellings because three CLIs: `allow_multiple` (the hosted runtime), `multiSelect` (claude),
// `multi_select` (devin — read from a real `ask_user_question` call in its SQLite store).
multi: (q.allow_multiple as boolean) ?? (q.multiSelect as boolean) ?? (q.multi_select as boolean) ?? false,
}
})
}
/** A dialog one reader found, and the line it anchored on: what ranks two readers of the same pane. */
export interface FoundDialog {
view: QuestionView | ReviewView
at: number
}
function permissionView(view: PaneView): PaneView {
return view?.kind === 'question' ? { ...view, permission: true } : view
}
function asPermission(found: FoundDialog | null): FoundDialog | null {
return found && { ...found, view: permissionView(found.view) as QuestionView }
}
/**
* The LOWEST dialog any reader found: the live one. A pane keeps an answered dialog in its scrollback, and
* an engine with two readers (a question and an approval, say) must never let the first reader's hit on
* that one shadow the second reader's hit on the dialog under it: devin's approval under its answered
* question, muse's approval under a question, opencode's question under an approval. A tie is one dialog
* read twice, and the earlier reader, the engine's own, keeps it.
*/
function lowest(...found: Array<FoundDialog | null>): PaneView {
let best: FoundDialog | null = null
for (const f of found) if (f && (!best || f.at > best.at)) best = f
return best?.view ?? null
}
/**
* The dialog on screen, read the way `engine` paints it: the LAST one on the pane, and nothing of it from
* above an earlier dialog's end (`dialogEnd.ts`).
*
* Claude and Command Code share one shape (see parseQuestionPane); devin draws a different one and gets
* its own parser rather than more branches in here.
*/
export function parseEngineQuestionPane(engine: AgentEngine, capture: string): PaneView {
// Devin's two dialogs are told apart by one word in the footer (`↵ select` vs `↵ confirm`), so they can
// never both match the same dialog; but an answered one of either kind can sit above the live one.
if (engine === 'devin') return lowest(locateDevinQuestion(capture), asPermission(locateDevinPermission(capture)))
// Cursor has no ask-the-user tool, so its permission prompt is the ONLY dialog it ever draws — and it
// numbers nothing, stating each row's key in the row instead.
if (engine === 'cursor') return permissionView(parseCursorPermissionPane(capture))
// Muse pairs each option with a description on the same line and floats a live Preview box above the
// rows — both confuse the shared parser, so it reads its own. Its PERMISSION prompt is a different
// dialog entirely (`Would you like to allow this network access?` over `1. Yes, proceed (y)` rows under
// a `Press enter to confirm` footer, `__fixtures__/permission-muse.txt`) and that one the shared parser
// reads exactly, so it is read by both and the lower wins.
if (engine === 'muse') return lowest(locateMuseQuestion(capture), locateQuestionPane(capture))
// Amp's is a permission prompt with unnumbered rows — nothing the shared parser can anchor on.
if (engine === 'amp') return permissionView(parseAmpQuestionPane(capture))
// Kilo's is the same kind of prompt but laid out HORIZONTALLY, sharing its line with the key hints —
// it is a fork of opencode that did not keep opencode's dialog.
if (engine === 'kilo') return permissionView(parseKiloQuestionPane(capture))
// Grok tells its approval from its questionnaire itself, by the live dialog's own footer.
if (engine === 'grok') return parseGrokQuestionPane(capture)
// agy's ask-the-user dialog anchors on `Question N/M:` under an `↑/↓ Navigate` footer, which the
// shared parser cannot see. Its PERMISSION prompt is numbered rows under `Do you want to proceed?`
// and the shared parser reads that one exactly, so it is read by both and the lower wins.
if (engine === 'agy') return lowest(locateAgyQuestion(capture), locateQuestionPane(capture))
// Hermes, OpenCode and Copilot paint the dialog inside a box; peel the border and the shared parser
// fits. Measured on Copilot: framed it returns null, unframed it reads the question, the three
// options AND spots `4. Other (type your answer)` as the free-text row rather than an option.
if (engine === 'hermes') return parseQuestionPane(unframe(capture))
// Copilot boxes its dialog the same way, but names the SUBJECT of a permission prompt above the
// question — "attempting to access the following URL:" over a boxed value. Without it the device
// shows "Do you want to allow this access?" and a bare "Yes", with nothing to judge.
if (engine === 'copilot') {
const found = locateQuestionPane(unframe(capture))
return found ? withCopilotSubject(found.view, capture, found.at) : null
}
if (engine === 'opencode') {
// OpenCode's PERMISSION prompt is the horizontal one kilo inherited from it — same `△ Permission
// required` title, same `⇆ select · enter confirm` footer, same unnumbered rows. Measured: the live
// capture in `permission-opencode.txt` parses through kilo's parser unchanged, so it is shared rather
// than copied. Listed FIRST because that dialog numbers nothing: on its own dialog the shared parser
// still matches the `enter confirm` footer and walks up into whatever numbered rows are above, and
// the tie goes to kilo's reader.
const plain = unframe(capture)
return lowest(asPermission(locateKiloQuestion(capture)), locateOpencodeReview(plain), locateQuestionPane(plain))
}
if (engine === 'codex') return withCodexLabels(parseQuestionPane(capture))
return parseQuestionPane(capture)
}
/**
* Codex's request_user_input rows carry the option's description on the same line as its label, in
* an aligned column — `1. Red Creates a bold, high-contrast` — and wrap the rest of the description
* onto plain lines below. The label is the part before the column gap; the dial has an 80-byte option
* buffer and a 466px face, and "Red" is what the person is choosing. The approval prompt's rows
* (`Yes, proceed (y)`) have no such gap and pass through unchanged.
*/
function withCodexLabels(view: PaneView): PaneView {
if (!view || view.kind !== 'question') return view
return {
...view,
rows: view.rows.map((r) => ({ ...r, label: r.label.split(/\s{2,}/)[0].trim() || r.label })),
}
}
/**
* Strip the box-drawing frame Hermes draws around its `clarify` dialog.
*
* Measured on a live pane: Hermes paints the SAME dialog Claude does — `❯ 1. Xanh` rows, a footer reading
* `↑/↓ to select, Enter to confirm` — only wrapped in `│ … │`. Peeling the border makes the existing
* parser fit exactly, which is far better than a second parser that would drift from it over time.
*/
/**
* OpenCode's final step of a multi-question dialog.
*
* It is a REVIEW, not a question: a "Review" heading over `label: answer` lines, with no numbered rows at
* all, submitted by pressing Enter. The shared parser needs rows to recognise a dialog, so without this
* the driver saw "nothing on screen", stopped, and left the agent sitting on an unsubmitted form after
* every answer had been given (measured on a live pane).
*
* `submitRow` carries the KEY to press, which for every other CLI happens to be a digit — 'Enter' rides
* the same field rather than widening the type for one engine.
*/
function locateOpencodeReview(plain: string): FoundDialog | null {
const lines = stripAnsi(plain).split('\n')
const footer = lines.findLastIndex((l) => /enter\s+submit/i.test(l))
if (footer < 0) return null
const floor = earlierDialogEnd(lines, footer, 14)
let sawReview = false
for (let i = footer - 1; i > floor && footer - i <= 14; i--) {
if (parseRow(lines[i])) return null // rows above ⇒ still a question, not the review
if (/^\s*review\s*$/i.test(lines[i])) { sawReview = true; break }
}
return sawReview ? { view: { kind: 'review', submitRow: 'Enter' }, at: footer } : null
}
function unframe(capture: string): string {
// stripAnsi FIRST: on a real capture the border is preceded by SGR codes, so matching `^│` against the
// raw text silently never fired — hermes read as "no dialog open" with the dialog plainly on screen.
return stripAnsi(capture)
.split('\n')
.map((line) => line.replace(/^(\s*)[│┃|]\s?/, '$1').replace(/\s*[│┃|]\s*$/, ''))
.join('\n')
}
/** How this engine's multi-select dialog is submitted once the boxes are ticked. */
function multiSubmitKey(engine: AgentEngine): string {
// Claude advances with Tab; devin submits with Enter (measured on a live pane — Tab does nothing there).
return engine === 'devin' ? 'Enter' : 'Tab'
}
function parseRow(line: string): QuestionRow | null {
const m = /^\s*[❯›>]?\s*(\d+)\.\s+(.+?)\s*$/.exec(line)
if (!m) return null
const raw = m[2]
const box = /^\[([^\]])\]\s*(.*)$/.exec(raw)
return {
number: m[1],
label: (box ? box[2] : raw).trim(),
checked: !!box && box[1].trim() !== '',
}
}
/**
* Read the CURRENT dialog off a pane capture. The capture includes scrollback (old dialogs, old plan
* text), so everything is anchored to the LAST footer line — the only marker the live dialog always
* paints at the bottom of the screen.
*/
/** "❯ 1. Submit" immediately followed by "2. Cancel" — Command Code's review screen, which carries no
* other marker. Returns the Submit row's number and where it sits, or null. */
function findSubmitPair(lines: string[]): { row: string; index: number } | null {
for (let i = lines.length - 1; i >= 1; i--) {
const row = parseRow(lines[i])
if (!row || !/^submit$/i.test(row.label)) continue
const next = parseRow(lines[i + 1] ?? '')
if (next && /^cancel$/i.test(next.label)) return { row: row.number, index: i }
}
return null
}
/** The tab bar of a footer-less dialog: "● Loại game | ◯ Review". Returns its LAST occurrence, or -1. */
function findTabBarDialog(lines: string[]): number {
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (!/^[●◯✔✓]/.test(line) || !line.includes('|')) continue // answered tabs turn into ✔
// Only a tab bar with a numbered list under it is a question; the same glyphs appear in prose.
for (let j = i + 1; j < lines.length && j - i <= 8; j++) {
const row = parseRow(lines[j])
if (row?.number === '1') return i
}
}
return -1
}
/** Read a dialog that has no footer: question first, then the numbered rows, top-down from the tab bar. */
function parseDownward(lines: string[], tabBar: number): PaneView {
let question = ''
const rows: QuestionRow[] = []
let checkbox = false
for (let i = tabBar + 1; i < lines.length; i++) {
const row = parseRow(lines[i])
if (row) {
if (/^\s*[❯›>]?\s*\d+\.\s+\[/.test(lines[i])) checkbox = true
rows.push(row)
continue
}
// Between the tab bar and row 1 sits the question; after the rows start, plain lines are the option
// descriptions and must not overwrite it.
const line = lines[i].trim()
if (!question && rows.length === 0 && line && !/^[─━-]{6,}$/.test(line)) question = line
}
if (!rows.length) return null
const answerable = rows.filter((r) => !CHAT_ROW.test(r.label) && !TYPE_ROW.test(r.label))
return {
kind: 'question',
question,
rows: answerable,
multi: checkbox,
typeRow: rows.find((r) => TYPE_ROW.test(r.label)) ?? null,
}
}
// \u2500\u2500 permission prompts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
/**
* A permission prompt is a QUESTION whose options are the approval choices \u2014 the shape amp and kilo
* already ship (`engines/amp/askQuestion.ts`, `engines/kilo/askQuestion.ts`). It matters far more on a
* remote machine than a question does: the CLI attaches to an agent the USER started, under the user's
* own config and with no permission flag of ours (`engines/README.md`), so a blocking approval is the
* normal state of a pane, not an edge case. Unparsed, the turn simply sits at `Processing` until someone
* walks to the computer.
*
* Claude and Command Code draw it as a framed block ending in numbered rows, and it carries NONE of the
* three anchors `parseQuestionPane` knows. Captured live (`__fixtures__/permission-claude.txt`):
*
* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
* Bash command \u2190 the header: what kind of approval this is
*
* curl -s https://api.coingecko.com/\u2026 \u2190 the argument, then a one-line description
* Fetch Bitcoin price from CoinGecko API
*
* This command requires approval
*
* Do you want to proceed?
* \u276f 1. Yes
* 2. Yes, and don\u2019t ask again for: curl *
* 3. No
*
* Esc to cancel \u00b7 Tab to amend \u00b7 ctrl+e to explain
*
* Command Code differs only in wording (`permission-commandcode.txt`): header `Execute Shell Command`,
* a sentence for the body, footer `\u2191/\u2193 navigate \u00b7 enter select \u00b7 ctrl+e explain`. Same frame, same rows \u2014
* which is why one parser covers both instead of two near-copies that would drift apart.
*
* **The prose is not the anchor.** Every engine words it differently and Claude alone writes at least
* three ("Do you want to proceed?", "Do you want to make this edit to README.md?", "Do you want to create
* \u2026?"). The ROWS are: an approval always offers a way to say yes and a way to say no. That pair is the
* signal, guarded by the dialog's own key hints so an ordinary numbered list in assistant output can never
* be read as a prompt.
*/
/** The first row of an approval, measured across claude, commandcode, codex, devin, grok, amp and kilo. */
const APPROVE_RE = /^(yes|allow|approve|accept|proceed|run|continue)\b/i
/** \u2026and the row that declines it. Never dropped \u2014 see the rule amp's parser states: a device user given
* three ways to say yes and none to say no cannot answer the prompt at all. */
// `skip` is here because cursor's decline row is "Skip & tell the agent what to do instead" — an engine
// whose only way to refuse says neither "no" nor "reject" would otherwise fail the yes/no guard and its
// whole prompt would go unread.
// Claude's plan review does not use a negative verb at all: its only refusal
// row is "Tell Claude what to change". Keep `tell ... what to change` narrow so
// an ordinary numbered list beginning with "Tell" cannot become an approval.
const REJECT_RE = /^(no|reject|deny|decline|cancel|skip|don'?t|stop)\b|^tell\b.*\bwhat to change\b/i
/** The solid rule that opens the frame. Deliberately NOT the dashed one (`\u254c`) that brackets an edit diff,
* which sits BELOW the header and would cost the title. */
const FRAME_RULE_RE = /^\s*[\u2500\u2501\u2550]{6,}\s*$/
/** Any rule, solid or dashed \u2014 used to skip them while reading the frame's contents. */
const ANY_RULE_RE = /^\s*[\u2500\u2501\u2550\u254c\u2504\u2508-]{6,}\s*$/
/** The dialog's own question line, and Command Code's `Press [ctrl+e] \u2026` hint: both sit between the
* header and the rows, and neither says what is being approved. */
const PERMISSION_PROSE_RE = /^((do|would) you\b|press \[)/i
/** The opening rule of the frame the rows at `start` sit in, or -1 when they have none of their own. */
function frameTop(lines: string[], start: number, floor: number): number {
for (let i = start - 1; i > floor && start - i <= 25; i--) {
if (FRAME_RULE_RE.test(lines[i])) return i
}
return -1
}
/** Keep a synthesised title inside the device's `text[200]` buffer, with the tail marked as cut. */
function clipTitle(value: string): string {
return value.length <= 160 ? value : `${value.slice(0, 159)}\u2026`
}
/**
* What is being approved, as one line: `Approve <header>: <argument>`, the form amp's parser already
* produces so the two read alike on the device.
*
* The frame's opening rule is the only reliable top \u2014 the prose under it varies per engine AND per tool.
* Below the rule sit the header (`Bash command`, `Edit file`, `Execute Shell Command`) and then the
* argument: the command, the file, or the sentence naming it.
*/
function permissionTitle(lines: string[], start: number): string {
// The header must be THIS dialog's: never read past the end of an earlier one still in scrollback.
const floor = earlierDialogEnd(lines, start, 25)
const top = frameTop(lines, start, floor)
// No frame above the rows (codex draws none): the nearest text is the dialog's own question, which
// names the command outright. Better than inventing a header that is not on screen.
if (top < 0) {
for (let i = start - 1; i > floor && start - i <= 4; i--) {
const line = lines[i].trim()
if (line) return clipTitle(line)
}
return 'Approval required'
}
let header = ''
let arg = ''
for (let i = top + 1; i < start; i++) {
const line = lines[i].trim()
if (!line || ANY_RULE_RE.test(line)) continue
if (!header) { header = line; continue }
if (PERMISSION_PROSE_RE.test(line)) continue
arg = line
break
}
if (!header) return 'Approval required'
return clipTitle(arg ? `Approve ${header}: ${arg}` : `Approve ${header}`)
}
/**
* The permission prompt on screen, with the index of its first row so the caller can rank it against the
* other anchors.
*
* Only the LOWEST numbered block on the pane is considered, and it is rejected outright if it is not an
* approval. Digging further up would find an answered prompt in the scrollback and re-announce it \u2014 the
* live dialog is always the bottom-most thing on screen, so there is nothing below it to miss.
*/
export function parsePermissionPane(lines: string[]): { view: QuestionView; index: number } | null {
let end = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (parseRow(lines[i])) { end = i; break }
}
if (end < 0) return null
// Walk up through CONTIGUOUS rows to the one numbered 1. Contiguity is the point: a permission dialog
// never interleaves prose with its options, while assistant output that happens to be numbered does.
const rows: QuestionRow[] = []
let start = -1
for (let i = end; i >= 0 && end - i < 12; i--) {
const row = parseRow(lines[i])
if (!row) break
rows.unshift(row)
if (row.number === '1') { start = i; break }
}
if (start < 0 || rows.length < 2) return null
if (!APPROVE_RE.test(rows[0].label) || !rows.some((row) => REJECT_RE.test(row.label))) return null
let hinted = false
for (let i = end + 1; i < lines.length && i - end <= 4; i++) {
if (PERMISSION_FOOTER_RE.test(lines[i])) { hinted = true; break }
}
if (!hinted) return null
// Single-select, and no free-text row: every option here is a choice to be TAPPED. Verified on live
// panes for claude, codex, devin and grok \u2014 one digit selects and submits, exactly as `rowKeys` assumes.
return {
view: { kind: 'question', permission: true, question: permissionTitle(lines, start), rows, multi: false, typeRow: null, dialog: permissionDialog(lines, start, end) },
index: start,
}
}
/** Every line of the dialog, untruncated: from its opening rule (or, with no frame, up to 12 lines above
* the rows) to its last row — never reaching back past the end of an earlier dialog. */
function permissionDialog(lines: string[], start: number, end: number): string {
const floor = earlierDialogEnd(lines, start, 25)
const rule = frameTop(lines, start, floor)
const top = rule >= 0 ? rule + 1 : Math.max(0, start - 12, floor + 1)
return lines.slice(top, end + 1).map((line) => line.trimEnd()).filter((line) => !ANY_RULE_RE.test(line)).join('\n').trim()
}
export function parseQuestionPane(capture: string): PaneView {
return locateQuestionPane(capture)?.view ?? null
}
/** `parseQuestionPane`, with the line its dialog was anchored on. */
function locateQuestionPane(capture: string): FoundDialog | null {
const lines = stripAnsi(capture).replace(/\u00a0/g, ' ').split('\n')
// Each CLI words its own footer, and OpenCode rewords it PER SCREEN — `enter submit` on a single
// question, `enter toggle` on a multi-select, `enter confirm` on a step of a multi-question. They all
// mark the same thing: the bottom of a live dialog.
// `enter to submit answer` is Codex's request_user_input dialog (plan mode; captured live in
// __fixtures__/question-codex.txt). Without it that dialog was never a question at all here: the dial
// showed nothing while the pane waited, and a question it DID show could never be closed, because
// the watcher had no fingerprint to notice leaving.
const footer = lines.findLastIndex((l) => QUESTION_FOOTER_RE.test(l))
// The review screen paints no footer and puts its rows BELOW the prompt, so it needs its own anchor.
// Whichever anchor is LOWER on screen is the live one (the other is scrollback from an earlier step).
const review = lines.findLastIndex((l) => /Ready to submit your answers/i.test(l))
// Command Code's review screen has neither Claude's "Ready to submit your answers" line nor a footer:
// it is a "Submit"/"Cancel" pair under a summary that is itself numbered. Anchor on that PAIR — the two
// rows adjacent, in that order — because the summary lines above are numbered too and reading them as
// options is how the device answered everything and then sat there, never submitting.
const submit = findSubmitPair(lines)
// Command Code paints the SAME dialog with no footer at all — the pane simply ends at the last option.
// Its tab bar is the only thing above the rows that is unmistakably part of the dialog, so it anchors
// that dialog, read DOWNWARD. Nothing else on either CLI's screen looks like "● X | ◯ Y" followed by a
// numbered list, which is what keeps ordinary numbered output from being read as a question.
const tabBar = findTabBarDialog(lines)
// A permission prompt is one more anchor, and every anchor is ranked the same way: whichever sits
// lowest on screen is the live dialog, the others are scrollback. That ordering is what keeps the two
// apart in both directions — codex and hermes draw an approval whose footer the question anchor also
// matches, and there the footer is BELOW the rows, so the question path (which reads a better title off
// the same block) still wins. And a footer-less dialog under an answered one is the live one: anchored
// on the answered one's footer instead, Command Code's question was announced as the OLD question.
const permission = parsePermissionPane(lines)
if (permission && permission.index > Math.max(footer, review, submit?.index ?? -1, tabBar)) {
return { view: permission.view, at: permission.index }
}
if (tabBar > Math.max(footer, review, submit?.index ?? -1)) {
const view = parseDownward(lines, tabBar)
return view && { view, at: tabBar }
}
if (review > footer && review > (submit?.index ?? -1)) {
for (let i = review + 1; i < lines.length && i - review <= 10; i++) {
const row = parseRow(lines[i])
if (row && /^submit answers$/i.test(row.label)) return { view: { kind: 'review', submitRow: row.number }, at: review }
}
return null
}
if (submit && submit.index > footer) return { view: { kind: 'review', submitRow: submit.row }, at: submit.index }
if (footer < 0) return null
// Rows belonging to this dialog: the numbered rows just above the footer, back to the row numbered 1 —
// and never from above an earlier dialog's end, which is where a dialog whose top is scrolled out of
// the pane would otherwise borrow its first rows.
const rows: QuestionRow[] = []
let checkbox = false // `[ ]` / `[✔]` on a row ⇒ this question is multi-select
let start = -1
const floor = earlierDialogEnd(lines, footer, 40)
for (let i = footer - 1; i > floor && footer - i <= 40; i--) {
const row = parseRow(lines[i])
if (!row) continue
rows.unshift(row)
if (/^\s*[❯›>]?\s*\d+\.\s+\[/.test(lines[i])) checkbox = true
if (row.number === '1') { start = i; break }
}
const enterSubmits = /enter to submit answer/i.test(lines[footer])
if (rows.length && start < 0 && enterSubmits) {
return { view: { kind: 'question', partial: true, enterSubmits, question: '', rows, multi: checkbox, typeRow: null }, at: footer }
}
if (start < 0 || rows.length === 0) return null
// The question is the nearest real text line above the rows. The dialog paints it between its header
// and its rows — `[tab bar | header chip] · blank · question · blank · rows` — so those, and a rule,
// are the TOP of this frame. Stop there, never skip past: mid-repaint the question line can be blank
// for one capture, and walking on would pick up the PREVIOUS question still sitting in scrollback and
// pair a stale title with the live options. An empty result just means "look again next tick". The
// end of an earlier dialog is a top too: its footer is not this dialog's question.
let question = ''
const top = earlierDialogEnd(lines, start, 12)
for (let i = start - 1; i > top && start - i <= 12; i--) {
const line = lines[i].trim()
if (!line) continue
if (/[←→]/.test(line) || /^[☐☒✔✓]/.test(line) || /^[─━-]{6,}$/.test(line)) break
question = line
break
}
const answerable = rows.filter((r) => !CHAT_ROW.test(r.label) && !TYPE_ROW.test(r.label))
return {
view: {
kind: 'question',
...(enterSubmits ? { enterSubmits } : {}),
question,
rows: answerable,
multi: checkbox,
typeRow: rows.find((r) => TYPE_ROW.test(r.label)) ?? null,
dialog: dialogAbove(lines, start, footer),
},
at: footer,
}
}
/**
* A footer-anchored dialog, whole: up to 12 lines above its rows — stopping at a rule, a tab bar, the
* agent's own output (a `•`/`⏺` bullet) or the end of an earlier dialog — down to its last row. Codex puts
* the command it asks about here (`$ …`, wrapped over as many lines as it takes), with its reason and
* environment.
*/
function dialogAbove(lines: string[], start: number, footer: number): string {
let top = start
const floor = earlierDialogEnd(lines, start, 12)
for (let i = start - 1; i > floor && start - i <= 12; i--) {
const line = lines[i].trim()
if (/^[•⏺●]/.test(line) || /[←→]/.test(line) || /^[─━═-]{6,}$/.test(line)) break
top = i
}
return lines.slice(top, footer).map((line) => line.trimEnd()).join('\n').trim()
}
/**
* A dialog that asks to RUN or CHANGE something (an approval), whichever parser read it: a framed
* permission prompt, or a footer dialog whose first row approves, whose rows include a rejection, and
* which names a command (`$ …`) or asks "would you like to run / make …". What the pair's floor treats as
* a permission prompt (pair/classify.ts); nothing else reads it.
*/
export function isApprovalDialog(view: QuestionView): boolean {
if (view.permission) return true
if (!view.rows.length || !APPROVE_RE.test(view.rows[0].label) || !view.rows.some((row) => REJECT_RE.test(row.label))) return false
const text = view.dialog ?? view.question
return /(^|\n)\s*\$ /.test(text) || /would you like to (run|make|apply|execute|edit|write)/i.test(text)
}
/** Match an answer to a row. The device stores option labels in an 80-byte buffer, so a long label comes
* back truncated — prefix matches count, in both directions. */
/**
* The keystrokes that commit one row.
*
* Every engine but Amp numbers its options, so the digit both selects and submits in one press. Amp
* draws an unnumbered list navigated with the arrow keys, so its rows carry an index and are reached by
* walking down to them.
*/
function rowKeys(engine: AgentEngine, row: QuestionRow, view?: QuestionView): string[] {
if (engine === 'amp') return ampSelectionKeys(row)
if (engine === 'codex') return codexRowKeys(row, view)
// Kilo's rows sit side by side, so its walk is horizontal — see engines/kilo/askQuestion.ts.
if (engine === 'kilo') return kiloSelectionKeys(row)
// Same dialog, different engine: opencode numbers its ask dialog but not its permission prompt, so the
// ROW says how it is reached and a per-engine rule would break one of the two.
if (row.walk === 'right') return kiloSelectionKeys(row)
if (row.walk === 'down') return ampSelectionKeys(row)
return [row.number]
}
/**
* Codex, measured 2026-09-15 on 0.149: in its request_user_input dialog a digit only MOVES the highlight
* and Enter submits (`enter to submit answer`) — a digit alone left the dialog up and the device's answer
* reported as stuck. Its approval prompt is the other way round and was verified earlier: one digit
* selects and commits, so that one keeps the single key.
*/
export function codexRowKeys(row: QuestionRow, view?: QuestionView): string[] {
return view?.enterSubmits ? [row.number, 'Enter'] : [row.number]
}
export function matchRow(rows: QuestionRow[], answer: string): QuestionRow | null {
const a = norm(answer)
if (!a) return null
return rows.find((r) => norm(r.label) === a)
?? (a.length >= 3 ? rows.find((r) => norm(r.label).startsWith(a)) ?? rows.find((r) => a.startsWith(norm(r.label)) && norm(r.label).length >= 3) : undefined)
?? null
}
/**
* Pick the answer for the question the dialog is currently showing: the entry keyed by its own text.
*
* `positional` also takes the next unused entry when none names it. Only for a dialog the answer's
* requestId proves it was written for: without that proof, an answer that names no question on screen
* belongs to one that is gone, and typing it here would answer — or approve — something nobody saw.
*
* The text must be the question's OWN (case, spacing and a trailing `…` aside), never a prefix either way.
* An approval is titled `Approve <header>: <argument>` and the header is shared by every prompt of its
* kind: a key left from an earlier prompt — `Approve Bash command` (its argument unread) — was a prefix of
* `Approve Bash command: rm -rf ~/projects` and pressed Yes on it, and an old full title named a
* header-only prompt the other way round. A client echoes back the key it was announced, so the whole
* text is always there to match; one it has cut is answered through its requestId (`positional`).
*/
export function pickAnswer(
answers: Record<string, string>,
question: string,
used: Set<string>,
opts: { positional?: boolean } = {},
): { key: string; value: string } | null {
const entries = Object.entries(answers)
const q = norm(question)
const byText = q ? entries.find(([k]) => norm(k) === q) : undefined
if (byText && !used.has(byText[0])) return { key: byText[0], value: byText[1] }
if (!opts.positional) return null
const next = entries.find(([k]) => !used.has(k))
return next ? { key: next[0], value: next[1] } : null
}
/**
* The id a dialog is announced under — the SAME function the watcher names it with, so the answer's
* requestId can be checked against the dialog on screen at the moment of typing rather than against
* whatever the watcher last saw (it polls every 1.5s, and forgets on a reset).
*/
export function questionRequestId(sessionId: string, view: QuestionView): string {
return `q_${hash(sessionId + fingerprintOf(view))}`
}
/** Why an answer was not keyed. Sent back to the client as `question_response_result.error`. */
export type QuestionAnswerError = 'STALE_QUESTION' | 'AGENT_NOT_FOUND' | 'ANSWER_BUSY' | 'ANSWER_FAILED'
export type QuestionAnswerResult = { ok: true } | { ok: false; error: QuestionAnswerError; detail: string }
const STALE_CHANGED: QuestionAnswerResult = { ok: false, error: 'STALE_QUESTION', detail: 'That question changed before your answer arrived.' }
const STALE_GONE: QuestionAnswerResult = { ok: false, error: 'STALE_QUESTION', detail: 'That question is no longer open.' }
const failed = (detail: string): QuestionAnswerResult => ({ ok: false, error: 'ANSWER_FAILED', detail })
const KEYS_FAILED = failed('The answer could not be typed into the agent\'s terminal.')
const STUCK = failed('The question did not take the answer.')
export interface AskQuestionDeps {
getSession: (sessionId: string) => RegisteredSession | undefined
capture: (terminalTarget: string, historyLines?: number) => Promise<string | null>
sendText: (terminalTarget: string, text: string) => Promise<boolean>
sendKey: (terminalTarget: string, key: string) => Promise<boolean>
/** Pins one backend locator for the whole multi-step dialog drive. */
acquireControl?: (sessionId: string, opts?: { forAnswer?: boolean }) => (() => void) | null
/** Injected for tests. */
wait?: (ms: number) => Promise<void>
}
export interface QuestionAnswerPayload {
/** Exact contents reviewed on the device; guarded submissions never use positional fallback. */
expectedQuestions?: ShapedQuestion[]
selectedLabels?: Record<string, string[]>
freeTextKeys?: string[]
allowPermissions?: boolean
requestId?: string
sessionId?: string
agentId?: string
answers?: Record<string, string>
}
/**
* Owns the OUT side's pending map (requestId → session) and the IN side's pane driving. One answer at a
* time per agent: a second `question_response` for a dialog already being driven is dropped, not queued.
*/
export class AskQuestionController {
private pending = new Map<string, string>() // requestId → sessionId
private driving = new Set<string>() // sessionIds currently keying a dialog
constructor(private readonly deps: AskQuestionDeps) {}
/** Remember which session a mirrored question belongs to (the device may answer minutes later). */
remember(requestId: string, sessionId: string): void {
if (!requestId) return
if (this.pending.size > 64) this.pending.delete(this.pending.keys().next().value as string)
this.pending.set(requestId, sessionId)
}
async answer(payload: QuestionAnswerPayload): Promise<QuestionAnswerResult> {
const requestId = payload.requestId ?? ''
if (payload.expectedQuestions !== undefined && (!Array.isArray(payload.expectedQuestions) ||
payload.expectedQuestions.length < 1 || payload.expectedQuestions.length > 4 ||
payload.expectedQuestions.some(q => !q || typeof q.key !== 'string' || !q.key ||
typeof q.q !== 'string' || !q.q || !Array.isArray(q.options) || !q.options.length ||
q.options.some(option => typeof option !== 'string' || !option) ||
typeof q.multi !== 'boolean' || (q.multi && !Array.isArray(payload.selectedLabels?.[q.key]))))) {
return failed('The reviewed question metadata is invalid.')
}
if (payload.freeTextKeys !== undefined && (!payload.expectedQuestions || !Array.isArray(payload.freeTextKeys) ||
payload.freeTextKeys.some(key => typeof key !== 'string' || !payload.expectedQuestions!.some(q =>
q.key === key && q.canText === true && !q.multi)))) {
return failed('The reviewed text answer metadata is invalid.')
}
const remembered = this.pending.get(requestId)
const sessionId = payload.sessionId || payload.agentId || remembered || ''
const answers = payload.answers && typeof payload.answers === 'object' ? payload.answers : null
if (!sessionId || !answers || Object.keys(answers).length === 0) {
console.warn(`[question] ignoring answer with no session/answers (req=${requestId})`)
return failed('The answer named no agent or carried no choice.')
}
const session = this.deps.getSession(sessionId)
const terminalTarget = session?.agentId || session?.sessionId
if (!terminalTarget) {
console.warn(`[question] no terminal target for ${sessionId.slice(0, 8)} — answer dropped`)
return { ok: false, error: 'AGENT_NOT_FOUND', detail: 'That agent is no longer running.' }
}
if (remembered) {
const owner = this.deps.getSession(remembered)
if ((owner?.agentId || owner?.sessionId) !== terminalTarget) return STALE_CHANGED
}
if (this.driving.has(terminalTarget)) {
console.warn(`[question] ${sessionId.slice(0, 8)} answer dropped · already driving this dialog`)
return { ok: false, error: 'ANSWER_BUSY', detail: 'Another answer is already being entered for this agent.' }
}
// `forAnswer`: a dialog is the engine waiting for input mid-turn, so the open turn must not block it.
const release = this.deps.acquireControl?.(terminalTarget, { forAnswer: true })
// Silence here is the failure mode this whole file exists to prevent: the device sends an answer,
// nothing keys it in, and the pane sits on the dialog looking like a hung agent.
if (this.deps.acquireControl && !release) {
console.warn(`[question] ${sessionId.slice(0, 8)} answer dropped · terminal control unavailable`)
return { ok: false, error: 'ANSWER_BUSY', detail: 'The agent\'s terminal is busy. Try again.' }
}
// The ids the watcher could have announced this dialog under: the session it was remembered for, and
// the session as the registry knows it now.
const owners = [...new Set([remembered, session?.sessionId].filter((id): id is string => !!id))]
this.driving.add(terminalTarget)
try {
const result = await this.drive(terminalTarget, answers, this.deps.getSession(sessionId)?.engine ?? 'claude', payload.allowPermissions !== false, { requestId, owners }, payload.expectedQuestions ? payload : undefined)
this.pending.delete(requestId)
const outcome = result.ok ? 'submitted' : result.error === 'STALE_QUESTION' ? 'refused · STALE_QUESTION, nothing typed' : 'FAILED'
console.log(`[question] ${sessionId.slice(0, 8)} answered from device · ${outcome} (req=${requestId || 'none'})`)
return result
} finally {
this.driving.delete(terminalTarget)
release?.()
}
}
/**
* Key the answers into the pane's dialog, question by question, ending on the review screen.
*
* Nothing is typed until the dialog on screen is shown to be the question the answer was written for:
* its requestId when the answer carries one, else its own text. An answer can arrive late — the agent
* moved on, another client answered, the next question of the form is up — and positionally matching
* it to whatever is showing now is how a person's "Yes" lands on a permission prompt they never saw.
*/
private async drive(
terminalTarget: string,
answers: Record<string, string>,
engine: AgentEngine,
allowPermissions: boolean,
asked: { requestId: string; owners: string[] },
reviewed?: QuestionAnswerPayload,
): Promise<QuestionAnswerResult> {
const wait = this.deps.wait ?? sleep
const used = new Set<string>()
const reviewedComplete = () => !reviewed || used.size === reviewed.expectedQuestions!.length
let lastQuestion = ''
let repeats = 0
let blanks = 0
let answered = 0
for (let step = 0; step < MAX_STEPS; step++) {
const capture = await this.deps.capture(terminalTarget, CAPTURE_LINES)
if (reviewed && capture === null) return failed('The question could not be read.')
const view = parseEngineQuestionPane(engine, capture ?? '')
if (!view) {
// Nothing on screen: either the dialog was already gone, or the last keystroke submitted it.
return answered > 0 && reviewedComplete() ? { ok: true } : STALE_GONE
}
if (!allowPermissions && view.kind === 'question' && view.permission) return failed('Permission prompts cannot be answered from here.')
if (view.kind === 'question' && view.partial) {
// The dialog's top is out of the pane. If we have keyed an answer it has not been taken yet;
// give the TUI a beat. If we have not, there is nothing to match an answer against.
if (answered === 0) { console.warn('[question] dialog scrolled out of view — cannot key an answer'); return failed('The question is scrolled out of view.') }
if (++repeats >= 2) { console.warn('[question] dialog stuck (scrolled)'); return STUCK }
await wait(STEP_MS)
continue
}
if (view.kind === 'review') {
// Reached by our own keys, this submits the form. Reached first, it means every question was
// answered somewhere else — submitting would send answers this person never gave.
if (answered === 0) return STALE_GONE
if (!reviewedComplete()) return STALE_CHANGED
return await this.deps.sendKey(terminalTarget, view.submitRow) ? { ok: true } : failed('The answers could not be submitted.')
}
// Mid-repaint the question line can read blank for a capture (see parseQuestionPane). Neither its id
// nor its text can be checked against a blank, so look again rather than judge the dialog by it.
if (!view.question) {
if (++blanks > 2) return answered > 0 && reviewedComplete() ? { ok: true } : failed('The question could not be read.')
await wait(STEP_MS)
continue
}
blanks = 0
// The same question still showing after we acted on it: give the TUI one more beat to repaint,
// then treat it as stuck rather than hammering the pane with more keystrokes. Never consume a
// second answer for it.
if (view.question === lastQuestion) {
if (++repeats >= 2) { console.warn(`[question] dialog stuck on "${view.question.slice(0, 60)}"`); return STUCK }
await wait(STEP_MS)
continue
}
repeats = 0
lastQuestion = view.question
// Is this the question the answer was for? Only the FIRST one needs the id: every later screen is
// one our own keys advanced to, and must be named by its text (below).
let positional = false
if (answered === 0 && asked.requestId) {
if (!asked.owners.some((owner) => questionRequestId(owner, view) === asked.requestId)) {
console.warn(`[question] answer for req=${asked.requestId} arrived after the dialog changed to "${view.question.slice(0, 60)}" — nothing typed`)
return STALE_CHANGED
}
positional = true
}
// Out of answers with the dialog still up = a multi-QUESTION dialog whose next question the device
// hasn't been shown yet. Leave it open: the watcher pushes that one and the device answers it next.
const expected = reviewed?.expectedQuestions?.find(q => q.q === view.question)
if (reviewed && (!expected || expected.multi !== view.multi ||
JSON.stringify(expected.options) !== JSON.stringify(view.rows.map(row => row.label)))) {
// A request id proves the initial dialog; every reviewed screen must also match its full choices.
return answered > 0 && reviewedComplete() ? { ok: true } : STALE_CHANGED
}
const picked = expected
? (!used.has(expected.key) && typeof answers[expected.key] === 'string'
? { key: expected.key, value: answers[expected.key] } : null)
: pickAnswer(answers, view.question, used, { positional })
if (!picked) {
if (answered > 0 && reviewedComplete()) return { ok: true }
console.warn(`[question] no answer names "${view.question.slice(0, 60)}" — nothing typed`)
return STALE_CHANGED
}
used.add(picked.key)
answered++
if (reviewed?.freeTextKeys?.includes(picked.key)) {
// Spoken words are explicitly text, even when they happen to equal an option label.
// A permission prompt can never acquire consent through this path.
if (!expected?.canText || view.permission || view.multi || !view.typeRow ||
!picked.value.trim() || Buffer.byteLength(picked.value, 'utf8') > 1200 ||
/[\x00-\x09\x0b-\x1f\x7f]/.test(picked.value)) return failed('The text answer cannot be entered into this question.')
if (!await this.typeFreeText(terminalTarget, view.typeRow, picked.value, wait)) return KEYS_FAILED
await wait(STEP_MS)
continue
}
if (view.multi) {
// Device joins the selected labels with ", " (q_done_tap).
const labels = reviewed ? reviewed.selectedLabels?.[picked.key] ?? []
: picked.value.split(',').map((s) => s.trim()).filter(Boolean)
if (reviewed && (!labels.length || labels.some(label => !view.rows.some(row => row.label === label)))) return failed('That answer matches no option.')
if (reviewed) {
// Set the exact reviewed set, including clearing choices selected in another client.
for (const row of view.rows) if (row.checked !== labels.includes(row.label)) {
if (!await this.deps.sendKey(terminalTarget, row.number)) return KEYS_FAILED
await wait(TEXT_MS)
}
if (!await this.deps.sendKey(terminalTarget, multiSubmitKey(engine))) return KEYS_FAILED
await wait(STEP_MS)
continue
}
let toggled = 0
for (const label of labels) {
const row = matchRow(view.rows, label)
if (!row || row.checked) continue
if (!await this.deps.sendKey(terminalTarget, row.number)) return KEYS_FAILED
await wait(TEXT_MS)
toggled++
}
if (!toggled && view.typeRow
&& !await this.typeFreeText(terminalTarget, view.typeRow, picked.value, wait)) return KEYS_FAILED
if (!await this.deps.sendKey(terminalTarget, multiSubmitKey(engine))) return KEYS_FAILED // advance to the next question / review
await wait(STEP_MS)
continue
}
const row = reviewed ? view.rows.find(row => row.label === picked.value) ?? null : matchRow(view.rows, picked.value)
if (row) {
// One digit selects AND submits — except on Amp, whose rows are unnumbered and reached by
// walking the list, so this is a short sequence rather than a single key.
for (const key of rowKeys(engine, row, view)) {
if (!await this.deps.sendKey(terminalTarget, key)) return KEYS_FAILED
await wait(TEXT_MS)
}
await wait(STEP_MS)
continue
}
if (reviewed) return failed('That answer matches no option.')
if (!view.typeRow) { console.warn(`[question] no option matched "${picked.value.slice(0, 40)}" and no free-text row`); return failed('That answer matches no option.') }
if (!await this.typeFreeText(terminalTarget, view.typeRow, picked.value, wait)) return KEYS_FAILED
await wait(STEP_MS)
}
return STUCK
}
/** True while a dialog is being keyed — the watcher pauses so a half-driven dialog isn't re-announced. */
isDriving(sessionId: string): boolean {
const session = this.deps.getSession(sessionId)
return this.driving.has(session?.agentId || session?.sessionId || sessionId)
}
/** Free-text answer (a voice answer is always free text): open the "Type something." row, type, Enter. */
private async typeFreeText(terminalTarget: string, typeRow: QuestionRow, text: string, wait: (ms: number) => Promise<void>): Promise<boolean> {
if (!await this.deps.sendKey(terminalTarget, typeRow.number)) return false
await wait(TEXT_MS)
if (!await this.deps.sendText(terminalTarget, text)) return false
await wait(TEXT_MS)
return this.deps.sendKey(terminalTarget, 'Enter')
}
}
// ── watching a terminal for an open question ─────────────────────────────────────────────────────
export interface QuestionWatcherDeps {
getSession: (sessionId: string) => RegisteredSession | undefined
capture: (terminalTarget: string, historyLines?: number) => Promise<string | null>
/** Skip the capture entirely when no device is listening — nothing would consume the question. */
hasDevice: () => boolean
/** A dialog is open on screen. Fires ONCE per distinct question (until it changes or closes). */
onQuestion: (sessionId: string, requestId: string, questions: ShapedQuestion[], detail?: { permission: boolean; dialog: string }) => void
/**
* An announced dialog LEFT the screen — answered somewhere else, or abandoned.
*
* This is the other half of `onQuestion`, and it was missing: a question answered in the app (or in the
* pane by hand) simply stopped being on screen, the watcher forgot it, and every OTHER client went on
* showing it. On the dial that is a screen you cannot leave without dismissing a question that no longer
* exists. The pane is the only source of truth here — there is no server-side question object — so
* "gone from the pane" is the only signal there is.
*/
onQuestionGone?: (sessionId: string, requestId: string) => void
/** True while that session's dialog is being keyed by an answer already in flight. */
isDriving?: (sessionId: string) => boolean
}
const POLL_MS = 1500
// Consecutive empty polls before a question is declared gone.
//
// NOT 1. A capture taken mid-repaint parses as no-dialog, and announcing a close on that would yank a
// live question off the dial's screen — the exact failure this feature exists to prevent, inverted.
// Two ticks costs 1.5s of delay on a real close and makes a flicker unable to cause one.
const GONE_TICKS = 2
// Amp and codex are here for their PERMISSION prompt, not a question tool — neither has one. That prompt
// is drawn only in the pane and recorded nowhere, so polling the pane is the only way it is ever seen.
// Codex needs no parser of its own: it draws numbered rows under a `Press enter to confirm or esc to
// cancel` footer, which is the shared parser's anchor exactly (`__fixtures__/permission-codex.txt`), and
// the question it lands on is the command itself. Membership in this set is what starts the poll, so an
// engine belongs here only once something can actually read its pane.
const QUESTION_ENGINES = new Set<AgentEngine>(['claude', 'commandcode', 'codex', 'cursor', 'devin', 'hermes', 'opencode', 'muse', 'amp', 'kilo', 'grok', 'agy', 'copilot'])
/** Does this engine ever paint a question dialog? Callers use it to decide whether to watch its pane. */
export function pollsQuestions(engine: AgentEngine): boolean {
return QUESTION_ENGINES.has(engine)
}
/**
* The OUT half. The obvious source — the transcript's AskUserQuestion `tool_use` line — is useless here:
* the CLI does not flush that line until the question has been ANSWERED (its JSONL `timestamp` is the
* message's creation time, not its write time), so a device would only ever learn about a question after
* it no longer exists. The dialog itself, on the other hand, is on screen the whole time it is waiting.
*
* So while a turn is open we read the pane. This also means the question survives an adapter restart and
* re-announces to a device that attaches mid-question — neither of which a one-shot event could do.
*/
export class QuestionWatcher {
private timers = new Map<string, NodeJS.Timeout>()
private last = new Map<string, string>() // sessionId → fingerprint of the announced question
private lastId = new Map<string, string>() // sessionId → requestId of the announced question
private misses = new Map<string, number>() // sessionId → consecutive polls with no dialog
private readonly blocked = new Map<string, string>()
/** sessionId → the dialog that was ALREADY on the pane when this turn began. */
private readonly preTurn = new Map<string, string>()
constructor(private readonly deps: QuestionWatcherDeps) {}
/**
* A turn just began — remember any dialog already on the pane, so it is not
* announced as this turn's question.
*
* A dialog belonging to a turn cannot have been on screen before that turn's
* first byte. That invariant is the only way to tell "waiting for an answer"
* from "answered a moment ago and still drawn", because a pane looks
* identical either way.
*
* MEASURED: the daemon attached to a Codex that had just been asked about an
* update and answered in the app. Its first capture, 0.8s into the NEXT turn,
* still held that prompt — so the dial was shown a question nobody was being
* asked, and it stood there for 25s until the engine's output scrolled it out
* of the captured window.
*
* Deliberately NOT applied on attach: re-announcing a genuinely open dialog to
* a client that arrives mid-question is a feature, and there is no turn
* boundary there to reason from.
*/
noteTurnStart(sessionId: string): void {
this.preTurn.delete(sessionId)
void (async () => {
const session = this.deps.getSession(sessionId)
const target = session?.agentId || session?.sessionId
if (!target) return
const view = parseEngineQuestionPane(session?.engine ?? 'claude', await this.deps.capture(target, CAPTURE_LINES) ?? '')
if (!view || view.kind !== 'question' || !view.question || view.rows.length === 0) return
// Only if nothing has been announced for this turn yet: the capture takes
// a moment, and a dialog that opened inside that window is this turn's.
if (this.lastId.has(sessionId)) return
this.preTurn.set(sessionId, fingerprintOf(view))
})()
}
/** Poll this session's pane while its turn is open (called on turn_started). */
start(sessionId: string): void {
if (this.timers.has(sessionId)) return
const session = this.deps.getSession(sessionId)
// Only the engines that actually paint a question dialog: Claude and Command Code share one shape,
// devin has its own (parseDevinQuestionPane). Polling any other pane would be pure waste.
if (!session || session.active === false || !(session.agentId || session.sessionId) || !QUESTION_ENGINES.has(session.engine)) return
this.timers.set(sessionId, setInterval(() => { void this.tick(sessionId) }, POLL_MS))
}
/**
* Stop polling — and CLOSE any question still outstanding on this session.
*
* ⚠️ THIS IS THE COMMON CASE, NOT THE EDGE ONE, and leaving it out made the whole close mechanism look
* like it did not work. `stop()` is called on turn_ended, and answering the question is precisely what
* lets the turn end — so the dialog leaving the pane and the watcher being torn down happen within a
* second or two of each other, far inside the two-tick confirmation. Measured on hardware: the answer
* landed at ~16:50:19 and the turn ended at 16:50:29 with no close ever announced.
*
* No confirmation is needed here and none is wanted: the turn is over, so whatever we announced is
* definitively not waiting for anybody any more. The same holds for the other callers — an agent that
* was removed cannot answer either.
*/
stop(sessionId: string): void {
this.preTurn.delete(sessionId)
const timer = this.timers.get(sessionId)
if (timer) { clearInterval(timer); this.timers.delete(sessionId) }
const requestId = this.lastId.get(sessionId)
this.forget(sessionId)
if (requestId) this.deps.onQuestionGone?.(sessionId, requestId)
}
stopAll(): void {
for (const timer of this.timers.values()) clearInterval(timer)
this.timers.clear()
this.last.clear()
this.lastId.clear()
this.misses.clear()
}
/** A device (re)joined: forget what we announced so an open question is pushed again. */
reset(): void {
this.last.clear()
this.lastId.clear()
this.misses.clear()
}
private forget(sessionId: string): void {
this.last.delete(sessionId)
this.lastId.delete(sessionId)
this.misses.delete(sessionId)
}
/**
* A poll found no dialog. Announce the close once the miss is CONFIRMED, and only if we announced the
* question in the first place — a session nobody was told about has nothing to take back.
*/
private noteGone(sessionId: string): void {
this.last.delete(sessionId) // unchanged: a dialog that comes back announces fresh
const requestId = this.lastId.get(sessionId)
if (!requestId) return
const misses = (this.misses.get(sessionId) ?? 0) + 1
this.misses.set(sessionId, misses)
if (misses < GONE_TICKS) return
this.lastId.delete(sessionId)
this.misses.delete(sessionId)
this.deps.onQuestionGone?.(sessionId, requestId)
}
private async tick(sessionId: string): Promise<void> {
const session = this.deps.getSession(sessionId)
const terminalTarget = session?.agentId || session?.sessionId
if (!terminalTarget) { this.stop(sessionId); return }
// Both of these silently do nothing, which is how a live dialog can sit on the terminal with no trace
// in the log. Say it once per transition rather than every 1.5s tick.
const blocked = !this.deps.hasDevice() ? 'no device' : this.deps.isDriving?.(sessionId) ? 'driving an answer' : ''
if (blocked !== (this.blocked.get(sessionId) ?? '')) {
this.blocked.set(sessionId, blocked)
console.log(`[question] ${sessionId.slice(0, 8)} watcher ${blocked ? `paused · ${blocked}` : 'polling'}`)
}
if (blocked) return
const engine = this.deps.getSession(sessionId)?.engine ?? 'claude'
const view = parseEngineQuestionPane(engine, await this.deps.capture(terminalTarget, CAPTURE_LINES) ?? '')
if (view?.kind === 'question' && view.partial) {
// Scrolled so its top is out of the pane: still open, so the client showing it keeps showing it —
// but there is no question text to announce, and the rows in view are whichever the scroll left.
this.misses.delete(sessionId)
return
}
if (!view || view.kind !== 'question' || !view.question || view.rows.length === 0) {
// Dialog closed, or moved to review. Either way it is no longer waiting on anybody, so the clients
// showing it are told to stop — see noteGone for why this is not announced on the first miss.
this.noteGone(sessionId)
return
}
this.misses.delete(sessionId) // a dialog on screen ends any run of misses
const fingerprint = fingerprintOf(view)
// Already on the pane before this turn started → it belongs to whatever came
// before, and has been answered. Say nothing until it changes or leaves.
if (this.preTurn.get(sessionId) === fingerprint) return
this.preTurn.delete(sessionId)
if (this.last.get(sessionId) === fingerprint) return
this.last.set(sessionId, fingerprint)
// A pane-derived question has no tool_use id. The key only has to round-trip through the device and
// back (the answer is keyed into the pane, not matched to a tool call), so the question's own text
// serves as both — and the device dedups a repeated push by this id. The answer brings the id back,
// and AskQuestionController recomputes it off the live pane before typing: a different id there
// means a different dialog, and the answer is refused (STALE_QUESTION) instead of keyed into it.
const requestId = questionRequestId(sessionId, view)
this.lastId.set(sessionId, requestId)
this.deps.onQuestion(sessionId, requestId, [{
key: view.question,
q: view.question,
options: view.rows.map((r) => r.label),
multi: view.multi,
...(view.typeRow && !view.multi && !view.permission ? { canText: true } : {}),
}], { permission: isApprovalDialog(view), dialog: view.dialog ?? view.question })
}
}
/**
* What makes two captures the SAME dialog: its words, its options, its arity — and, when the parser kept
* the whole dialog, what it says below its first line (a command that differs only on its second line is
* another prompt).
*
* ⚠️ NOT the raw `dialog`. The id is recomputed every 1.5s poll and again at the moment an answer is typed,
* so it may only change when the QUESTION does. The raw dialog changes on its own: Hermes and Muse paint a
* live timer inside it (`(01m30s · ↓ 82 tok)`, `(21s · esc to interrupt)`), and every engine moves its
* `❯`/`›`/`>` cursor and ticks its `[✔]` boxes in place. Hashed raw, a question was re-announced as new on
* every poll — the needs-you alert, the sound, the dial push, again and again — and every answer from a
* dial, a device or the cable was refused as STALE_QUESTION. `dialogSignature` is the dialog with all of
* that taken out; the raw text still goes, unchanged, to the pair's floor (isApprovalDialog, pair/sensor).
*/
function fingerprintOf(view: QuestionView): string {
const base = `${view.question}|${view.rows.map((r) => r.label).join('|')}|${view.multi}`
return view.dialog === undefined ? base : `${base}|${dialogSignature(view.dialog)}`
}
// A live status group: an elapsed time (`21s`, `30.5s`, `01m30s`, `1h02m`), a token counter (`↓ 82 tok`,
// `1.2k tokens`) or `esc to interrupt` inside one pair of parentheses. Units hug their digits, as every
// engine paints them, so `(see 2 files)` or `(tokens.json)` is never mistaken for one.
const TIMER_GROUP = String.raw`\([^()\n]*?(?:\b\d+(?:\.\d+)?(?:ms|s|m|h)\b|\b\d+m\d+s\b|\b\d+h\d+m\b|\d+(?:\.\d+)?k?\s*tok(?:en)?s?\b|esc to interrupt)[^()\n]*\)`
const TIMER_GROUP_RE = new RegExp(TIMER_GROUP, 'i')
const TIMER_GROUPS_RE = new RegExp(TIMER_GROUP, 'gi')
// The same, outside parentheses: `↓ 82 tokens · esc to interrupt`.
const STATUS_BITS_RE = /[↑↓]\s*\d+(?:\.\d+)?k?\s*tok(?:en)?s?\b|\besc to interrupt\b/i
// A bare elapsed time, with no parentheses and no ` · ` to mark it: a status line that is only a word or
// three and a duration — a verb in -ing/-ed (`waiting 3s`, `thinking 4s`, `Churned for 4s`) or anything
// trailing off in an ellipsis (`Waiting… 12s`, `Fetch Bitcoin price… 1m33s`). A command's own number stays:
// `sleep 30s` and `retry after 30s` are neither.
const DURATION = String.raw`(?:\d+h\d+m(?:\d+s)?|\d+m\d+s|\d+(?:\.\d+)?m?s)`
const WORDS = String.raw`(?:\p{L}[\p{L}'’-]*\s+){0,2}\p{L}[\p{L}'’-]*`
const BARE_TIMER_LINE_RE = new RegExp(String.raw`^${WORDS}(?:(?<=ing|ed)(?:\s+for)?\s+|\s*(?:…|\.{3})\s*)${DURATION}$`, 'iu')
// …and one hung off the end of a longer line, after an ellipsis or a column gap: grok's right-aligned
// `Waiting on answers for Which color should I report? 4.2s`. Only the time goes.
const TRAILING_TIMER_RE = new RegExp(String.raw`(?:(?<=…|\.{3})\s*|\s{2,})${DURATION}$`, 'u')
// Codex's cursor readout under its request_user_input rows: `option 2/4 | tab to add notes`.
const CURSOR_READOUT_RE = /^option\s+\d+\s*\/\s*\d+\b/i
// Whatever leads a line and moves on its own: a cursor (`❯ › > ▶`), a spinner frame (braille, `✻`, `◐`,
// Muse's `◇`/`◆`) or a box/tab state (`☐ ☒ ✔ ○ ● ◉`).
const LEAD_MARKS_RE = /^(?:[❯›>▶►▸➤\u2800-\u28ff✻✽✶✳✢✺✹✸✷◐◓◑◒◴◵◶◷◇◆☐☑☒✔✓✗✘○◯●◉◎]\s*)+/u
// A row's own state right after its number, or at the start of an unnumbered row: `[ ]`, `[✔]`, `(•)`, `◉`.
const ROW_STATE_RE = /^(\d+[.)]\s+|)(?:\[[^\]\n]?\]|\([^)\n]?\)|[☐☑☒✔✓○◯●◉◎])\s*/u
// Box drawing: frames and rules redraw to the pane's width.
const BOX_RE = /[\u2500-\u257f]+/g
/**
* The dialog as a person reads it, with nothing that changes while it waits: status lines (a live timer,
* bare or in parentheses, a token counter, `esc to interrupt`) and Codex's cursor readout dropped; cursor marks, spinner frames
* and checkbox/radio state stripped; frames and whitespace collapsed. Every word of the prompt stays —
* two commands that differ anywhere are still two signatures.
*/
function dialogSignature(dialog: string): string {
const out: string[] = []
for (const raw of dialog.replace(/\u00a0/g, ' ').split('\n')) {
let line = raw.replace(BOX_RE, ' ').trim()
if (CURSOR_READOUT_RE.test(line)) continue
line = line.replace(LEAD_MARKS_RE, '')
const row = /^\d+[.)]\s/.test(line)
// A line that carries a live timer is the engine's status line (Hermes' `💻 curl … (01m30s · ↓ 82 tok)`
// under its frame, Muse's `◇ Calling tools (21s · esc to interrupt)` above its rule, a bare `waiting 3s`),
// not the prompt: the prompt is always painted on lines of its own. A ROW keeps its words; only the
// group goes.
if (!row && (TIMER_GROUP_RE.test(line) || STATUS_BITS_RE.test(line) || BARE_TIMER_LINE_RE.test(line))) continue
if (!row) line = line.replace(TRAILING_TIMER_RE, '')
line = line.replace(TIMER_GROUPS_RE, ' ').replace(ROW_STATE_RE, '$1').replace(/\s+/g, ' ').trim()
if (line) out.push(line)
}
return out.join('\n')
}
function hash(value: string): string {
let h = 0x811c9dc5
for (let i = 0; i < value.length; i++) h = Math.imul(h ^ value.charCodeAt(i), 0x01000193) >>> 0
return h.toString(16).padStart(8, '0')
}