Loading files
7d42b3ee6This 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.
*
* 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 { parseMuseQuestionPane } from '../engines/muse/askQuestion.js'
import { ampSelectionKeys, parseAmpQuestionPane } from '../engines/amp/askQuestion.js'
import { kiloSelectionKeys, parseKiloQuestionPane } from '../engines/kilo/askQuestion.js'
import { parseCursorPermissionPane } from '../engines/cursor/askQuestion.js'
import { parseDevinPermissionPane, parseDevinQuestionPane } from '../engines/devin/askQuestion.js'
import { parseGrokQuestionPane } from '../engines/grok/askQuestion.js'
import { parseAgyQuestionPane } from '../engines/agy/askQuestion.js'
import { withCopilotSubject } from '../engines/copilot/askQuestion.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
}
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
}
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,
}
})
}
/**
* The dialog on screen, read the way `engine` paints it.
*
* 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.
*/
function permissionView(view: PaneView): PaneView {
return view?.kind === 'question' ? { ...view, permission: true } : view
}
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. Only one can be on screen anyway: answering either replaces it with a summary line.
if (engine === 'devin') return parseDevinQuestionPane(capture) ?? permissionView(parseDevinPermissionPane(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 falls through rather than getting a parser of its own.
if (engine === 'muse') return parseMuseQuestionPane(capture) ?? parseQuestionPane(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))
if (engine === 'grok') {
const view = parseGrokQuestionPane(capture)
// Grok prints this footer only for tool approval, never its questionnaire.
return /always-approve|Ctrl\+o:/i.test(capture) ? permissionView(view) : view
}
// 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 falls through.
if (engine === 'agy') return parseAgyQuestionPane(capture) ?? parseQuestionPane(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') return withCopilotSubject(parseQuestionPane(unframe(capture)), capture)
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. Tried FIRST because that dialog numbers nothing: the shared parser would still match
// its `enter confirm` footer and then walk up into whatever numbered rows the scrollback holds.
const permission = parseKiloQuestionPane(capture)
if (permission) return permissionView(permission)
const plain = unframe(capture)
return opencodeReview(plain) ?? parseQuestionPane(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 opencodeReview(plain: string): ReviewView | null {
const lines = stripAnsi(plain).split('\n')
const footer = lines.findLastIndex((l) => /enter\s+submit/i.test(l))
if (footer < 0) return null
let sawReview = false
for (let i = footer - 1; i >= 0 && 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 ? { kind: 'review', submitRow: 'Enter' } : 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 key hints a permission dialog prints under its rows: claude `Esc to cancel \u00b7 Tab to amend`,
* Command Code `\u2191/\u2193 navigate \u00b7 enter select \u00b7 ctrl+e explain`. Proximity to the rows is what makes this
* a guard and not a search \u2014 it must sit within a few lines UNDER them. */
// Claude's plan review footer changed from Esc/Tab hints to
// "shift+tab to approve with this feedback" plus ctrl+g.
const PERMISSION_FOOTER_RE = /\besc\b|enter\s+select|ctrl\+e|shift\+tab\s+to\s+approve/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
/** 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 {
let top = -1
for (let i = start - 1; i >= 0 && start - i <= 25; i--) {
if (FRAME_RULE_RE.test(lines[i])) { top = i; break }
}
// 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 >= 0 && 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 },
index: start,
}
}
export function parseQuestionPane(capture: string): PaneView {
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) => /enter to (select|confirm|submit)|enter\s+(submit|confirm|toggle)/i.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)
// A permission prompt is a FOURTH anchor and gets ranked exactly like the other three: whichever sits
// lowest on screen is the live dialog. 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.
const permission = parsePermissionPane(lines)
if (permission && permission.index > footer && permission.index > review && permission.index > (submit?.index ?? -1)) {
return permission.view
}
if (review > footer) {
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 { kind: 'review', submitRow: row.number }
}
return null
}
if (submit && submit.index > footer) return { kind: 'review', submitRow: submit.row }
// 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 anchor on
// that and 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 anchor = footer >= 0 ? footer : findTabBarDialog(lines)
if (anchor < 0) return null
if (footer < 0) return parseDownward(lines, anchor)
// Rows belonging to this dialog: the numbered rows just above the footer, back to the row numbered 1.
const rows: QuestionRow[] = []
let checkbox = false // `[ ]` / `[✔]` on a row ⇒ this question is multi-select
let start = -1
for (let i = footer - 1; i >= 0 && 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 { kind: 'question', partial: true, enterSubmits, question: '', rows, multi: checkbox, typeRow: null }
}
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".
let question = ''
for (let i = start - 1; i >= 0 && 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 {
kind: 'question',
...(enterSubmits ? { enterSubmits } : {}),
question,
rows: answerable,
multi: checkbox,
typeRow: rows.find((r) => TYPE_ROW.test(r.label)) ?? null,
}
}
/** 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: by its own text, else positionally. */
export function pickAnswer(answers: Record<string, string>, question: string, used: Set<string>): { key: string; value: string } | null {
const entries = Object.entries(answers)
const q = norm(question)
const byText = entries.find(([k]) => norm(k) === q)
?? (q.length >= 6 ? entries.find(([k]) => norm(k).startsWith(q) || q.startsWith(norm(k))) : undefined)
if (byText && !used.has(byText[0])) return { key: byText[0], value: byText[1] }
const next = entries.find(([k]) => !used.has(k))
return next ? { key: next[0], value: next[1] } : null
}
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 {
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<boolean> {
const requestId = payload.requestId ?? ''
const sessionId = payload.sessionId || payload.agentId || this.pending.get(requestId) || ''
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 false
}
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 false
}
if (this.driving.has(sessionId)) {
console.warn(`[question] ${sessionId.slice(0, 8)} answer dropped · already driving this dialog`)
return false
}
// `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 false
}
this.driving.add(sessionId)
try {
const ok = await this.drive(terminalTarget, answers, this.deps.getSession(sessionId)?.engine ?? 'claude', payload.allowPermissions !== false)
this.pending.delete(requestId)
console.log(`[question] ${sessionId.slice(0, 8)} answered from device · ${ok ? 'submitted' : 'FAILED'}`)
return ok
} finally {
this.driving.delete(sessionId)
release?.()
}
}
/** Key the answers into the pane's dialog, question by question, ending on the review screen. */
private async drive(terminalTarget: string, answers: Record<string, string>, engine: AgentEngine, allowPermissions = true): Promise<boolean> {
const wait = this.deps.wait ?? sleep
const used = new Set<string>()
let lastQuestion = ''
let repeats = 0
let answered = 0
for (let step = 0; step < MAX_STEPS; step++) {
const capture = await this.deps.capture(terminalTarget, CAPTURE_LINES)
const view = parseEngineQuestionPane(engine, capture ?? '')
if (!view) {
// Nothing on screen: either the dialog was never open, or the last keystroke submitted it.
return answered > 0
}
if (!allowPermissions && view.kind === 'question' && view.permission) return false
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 false }
if (++repeats >= 2) { console.warn('[question] dialog stuck (scrolled)'); return false }
await wait(STEP_MS)
continue
}
if (view.kind === 'review') {
if (!allowPermissions && answered === 0) return false
return this.deps.sendKey(terminalTarget, view.submitRow)
}
// 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 && view.question === lastQuestion) {
if (++repeats >= 2) { console.warn(`[question] dialog stuck on "${view.question.slice(0, 60)}"`); return false }
await wait(STEP_MS)
continue
}
repeats = 0
lastQuestion = view.question
// 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 picked = pickAnswer(answers, view.question, used)
if (!picked) return answered > 0
used.add(picked.key)
answered++
if (view.multi) {
// Device joins the selected labels with ", " (q_done_tap).
const labels = picked.value.split(',').map((s) => s.trim()).filter(Boolean)
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 false
await wait(TEXT_MS)
toggled++
}
if (!toggled && view.typeRow
&& !await this.typeFreeText(terminalTarget, view.typeRow, picked.value, wait)) return false
if (!await this.deps.sendKey(terminalTarget, multiSubmitKey(engine))) return false // advance to the next question / review
await wait(STEP_MS)
continue
}
const row = 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 false
await wait(TEXT_MS)
}
await wait(STEP_MS)
continue
}
if (!view.typeRow) { console.warn(`[question] no option matched "${picked.value.slice(0, 40)}" and no free-text row`); return false }
if (!await this.typeFreeText(terminalTarget, view.typeRow, picked.value, wait)) return false
await wait(STEP_MS)
}
return false
}
/** True while a dialog is being keyed — the watcher pauses so a half-driven dialog isn't re-announced. */
isDriving(sessionId: string): boolean {
return this.driving.has(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[]) => 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.
const requestId = `q_${hash(sessionId + fingerprint)}`
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,
}])
}
}
/** What makes two captures the SAME dialog: its words, its options, its arity. */
function fingerprintOf(view: QuestionView): string {
return `${view.question}|${view.rows.map((r) => r.label).join('|')}|${view.multi}`
}
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')
}