← back to Answer Cockpit
TK-11793: menu answers select by DISPLAYED NUMBER (label-typing fired the cursor default — proven live, now proven fixed: ANSWER_RECEIVED=Bravo); n carried on both transcript + pane paths; batched 4KB pane read promotes un-dotted on-screen questions; optionLabel audited
f7ac1713a939e85ebe5f65777e9760ff175c2788 · 2026-09-15 19:31:25 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
M lib/audit.jsM lib/queue.jsM lib/transcript.jsM public/index.htmlM server.js
Diff
commit f7ac1713a939e85ebe5f65777e9760ff175c2788
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 15 19:31:25 2026 -0700
TK-11793: menu answers select by DISPLAYED NUMBER (label-typing fired the cursor default — proven live, now proven fixed: ANSWER_RECEIVED=Bravo); n carried on both transcript + pane paths; batched 4KB pane read promotes un-dotted on-screen questions; optionLabel audited
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/audit.js | 2 ++
lib/queue.js | 16 ++++++++++++++++
lib/transcript.js | 51 ++++++++++++++++++++++++++++++++++++---------------
public/index.html | 4 +++-
server.js | 14 ++++++++++----
5 files changed, 67 insertions(+), 20 deletions(-)
diff --git a/lib/audit.js b/lib/audit.js
index d6f7a08..b369942 100644
--- a/lib/audit.js
+++ b/lib/audit.js
@@ -36,6 +36,8 @@ function audit(entry) {
};
if (entry.dry) line.dry = true;
if (entry.refused) line.refused = true;
+ // menu answers type a NUMBER; keep the human label so the audit reads "2 (Bravo)", not "2"
+ if (entry.optionLabel) line.optionLabel = String(entry.optionLabel).slice(0, 200);
try { fs.appendFileSync(AUDIT, JSON.stringify(line) + '\n'); } catch {}
// Only real osascript attempts (not guard refusals, not dry runs) move the
// FAIL needle — a 403 refusal is the cockpit working, not iTerm failing.
diff --git a/lib/queue.js b/lib/queue.js
index 2af0fab..b890a51 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -180,6 +180,22 @@ async function build(opts = {}) {
const rows = res.rows.filter((r) => r.live && NEEDS_STEVE.has(r.color) && TTY_RE.test(String(r.tty)) && r.tty !== writeback.SELF_TTY);
rows.sort((a, b) => (a.variant === 'stopped' ? 0 : 1) - (b.variant === 'stopped' ? 0 : 1) || (PRIORITY[a.color] ?? 9) - (PRIORITY[b.color] ?? 9) || String(a.tty).localeCompare(String(b.tty)));
const items = rows.map((r) => buildItem(r, byTk, opts));
+ // Pane-detected questions: a live claude session whose SCREEN shows a real AskUserQuestion
+ // menu but whose dot is not a needs-Steve color (none/green/pink — e.g. it could not dot
+ // itself because of the CLAUDE_CODE_CHILD_SESSION env leak). Pane truth beats dot truth.
+ const panes = transcript.allPaneContents();
+ for (const r of res.rows) {
+ if (!r.live || !TTY_RE.test(String(r.tty)) || r.tty === writeback.SELF_TTY || NEEDS_STEVE.has(r.color)) continue;
+ if (r.runtime === 'codex') continue;
+ const text = panes.get(r.tty); if (!text) continue;
+ const d = transcript.parsePane(text);
+ if (!d.question || d.queued) continue;
+ const synth = { ...r, color: 'yellow', dotColor: r.color, label: (r.label || `${r.tty}`) + ' · question on screen (dot not set)' };
+ const it = buildItem(synth, byTk, opts);
+ it.dotColor = r.color;
+ it.warnings.unshift(`pane-detected question — this session's dot is "${r.color || 'none'}", not yellow`);
+ items.push(it);
+ }
const linked = new Set(items.map((i) => i.ticket).filter(Boolean));
const orphanMemos = opts.orphans ? memos.filter((m) => !m.ticket || !linked.has(m.ticket)).map((m) => ({ ...m, excerpt: m.excerpt.slice(0, 600) })) : [];
return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
diff --git a/lib/transcript.js b/lib/transcript.js
index 14a3174..e144ee8 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -92,7 +92,9 @@ function parseTail(filepath) {
question: String(q.question || ''),
header: String(q.header || ''),
multiSelect: !!q.multiSelect,
- options: Array.isArray(q.options) ? q.options.map((o) => ({ label: String(o.label || ''), description: String(o.description || '') })) : [],
+ // n = the number the TUI displays: real options are listed first, synthetic ones after,
+ // so a transcript option's displayed number is index+1. Menus select by NUMBER.
+ options: Array.isArray(q.options) ? q.options.map((o, i) => ({ label: String(o.label || ''), description: String(o.description || ''), n: i + 1 })) : [],
})),
};
}
@@ -216,28 +218,44 @@ function resolve(row) {
}
// ---- pane-contents source ----------------------------------------------------------
-const paneCache = new Map(); // tty → {ts, text}
-const PANE_CACHE_MS = 5000;
-function paneContents(tty) {
- if (!/^ttys\d{3}$/.test(tty)) return null;
- const hit = paneCache.get(tty);
- if (hit && Date.now() - hit.ts < PANE_CACHE_MS) return hit.text;
+// ONE osascript pass returns every session's tty + screen contents (≈23 sessions in one call,
+// instead of 23 calls). Cached 4s. Used both for per-tty detail and for the queue's
+// "question on screen but dot not set" detection (pane truth beats dot truth).
+let paneBatch = { ts: 0, map: new Map() };
+const PANE_BATCH_MS = 4000;
+function allPaneContents() {
+ if (Date.now() - paneBatch.ts < PANE_BATCH_MS) return paneBatch.map;
const script = `tell application "iTerm2"
+set out to ""
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
try
- if (tty of s) is "/dev/${tty}" then return (contents of s)
+ -- contents is the ENTIRE scrollback (can be MBs). Substring to the last 4000 chars
+ -- inside AppleScript so the concatenation stays small and fast (quadratic otherwise).
+ set c to (contents of s)
+ set L to length of c
+ if L > 4000 then set c to text (L - 3999) thru L of c
+ set out to out & "@@TTY " & (tty of s) & linefeed & c & linefeed & "@@END" & linefeed
end try
end repeat
end repeat
end repeat
-return ""
+return out
end tell`;
- let text = null;
- try { text = execFileSync('/usr/bin/osascript', [], { input: script, encoding: 'utf8', timeout: 8000, maxBuffer: 4 << 20 }); } catch { text = null; }
- if (text != null) paneCache.set(tty, { ts: Date.now(), text });
- return text;
+ const map = new Map();
+ try {
+ const raw = execFileSync('/usr/bin/osascript', [], { input: script, encoding: 'utf8', timeout: 15000, maxBuffer: 32 << 20 });
+ const re = /@@TTY \/dev\/(ttys\d{3})\n([\s\S]*?)\n@@END\n/g;
+ let m; while ((m = re.exec(raw))) map.set(m[1], m[2]);
+ paneBatch = { ts: Date.now(), map };
+ } catch { /* leave the previous batch (possibly stale) — callers see null for unknown ttys */ }
+ return map;
+}
+function paneContents(tty) {
+ if (!/^ttys\d{3}$/.test(tty)) return null;
+ const map = allPaneContents();
+ return map.has(tty) ? map.get(tty) : null;
}
// Claude Code's AskUserQuestion TUI — captured live 2026-09-15 (throwaway session ttys024):
@@ -309,7 +327,10 @@ function parsePane(text) {
const hasCursor = region.some((l) => /^\s*❯\s*\d{1,2}[.)]/.test(l));
// corroboration: footer + (header OR cursor). Footer alone is not enough (a quoted hint line).
if (opts.length >= 1 && (hasCursor || hdr >= 0)) {
- question = { toolUseId: null, askedAt: null, source: 'pane', questions: [{ question: qLines.join(' ').replace(/\s+/g, ' ').trim() || header || '(question text not captured)', header, multiSelect: false, options: opts.map((o) => ({ label: o.label, description: o.description })) }] };
+ // `n` = the number the TUI DISPLAYS for the option. A real menu selects by NUMBER, not by
+ // typed label — proven live 2026-09-15: typing "Bravo" + Enter selected the cursor default
+ // (Alpha). The client types String(n); synthetic options were dropped but numbering is kept.
+ question = { toolUseId: null, askedAt: null, source: 'pane', questions: [{ question: qLines.join(' ').replace(/\s+/g, ' ').trim() || header || '(question text not captured)', header, multiSelect: false, options: opts.map((o) => ({ label: o.label, description: o.description, n: o.n })) }] };
}
}
@@ -402,4 +423,4 @@ function resolveUncached(row, pid, tty) {
return { sessionId: null, transcriptPath: null, cwd, confidence: 'ambiguous', how: survivors.length ? `validator kept ${survivors.length}` : 'validator kept 0', candidates: paths, detail: null };
}
-module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, MAP_DIR, _cache: cache };
+module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, allPaneContents, MAP_DIR, _cache: cache };
diff --git a/public/index.html b/public/index.html
index 1b910b4..c532a7a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -413,7 +413,9 @@ function bind(it){
}
}
function pickOpt(it,qi,oi){const q=it.question.questions[qi];const needsSend=it.question.questions.some(x=>x.multiSelect)||it.question.questions.length>1;
- if(!needsSend){return push('/api/answer',{text:q.options[oi].label},it,'question');}
+ // A real AskUserQuestion menu selects by its DISPLAYED NUMBER, not by typed label (typing the
+ // label + Enter fires the cursor default — proven live 2026-09-15). Type the number; label for audit.
+ if(!needsSend){const o=q.options[oi];return push('/api/answer',{text:String(o.n||(oi+1)),optionLabel:o.label},it,'question');}
const cur=state.sel[qi]||[];
if(q.multiSelect)state.sel[qi]=cur.includes(oi)?cur.filter(x=>x!==oi):cur.concat(oi);else state.sel[qi]=[oi];
render();}
diff --git a/server.js b/server.js
index 783da13..2a3fbbd 100644
--- a/server.js
+++ b/server.js
@@ -86,12 +86,18 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
if (body.expectKey && String(body.expectKey).startsWith(tty + '|')) return { code: 409, body: { error: 'tty vanished', gone: true } };
return { code: 400, body: { error: 'tty not in live scan', tty } };
}
+ // Resolve detail ONCE from the fresh row: the content digest for the key, and whether a real
+ // menu is live on screen (a pane-detected question is answerable even if the dot is none/green).
+ const sess = require('./lib/transcript').resolve(row);
+ const liveMenu = !!(sess.detail && sess.detail.question && !sess.detail.queued);
if (requireKey) {
- const key = queue.keyFor(row); // includes the content digest — a changed question 409s
- if (!body.expectKey || String(body.expectKey) !== key) return { code: 409, body: { error: 'stale card (expectKey mismatch)', expectKey: body.expectKey || null, currentKey: key } };
+ const key = queue.keyOf(row, sess.detail); // includes the content digest — a changed question 409s
+ const keyAsYellow = queue.keyOf({ ...row, color: 'yellow' }, sess.detail); // pane-detected items carry color yellow
+ const given = String(body.expectKey || '');
+ if (!given || (given !== key && !(liveMenu && given === keyAsYellow))) return { code: 409, body: { error: 'stale card (expectKey mismatch)', expectKey: body.expectKey || null, currentKey: key } };
}
const force = body.force === true;
- if (needsSteveOnly && !queue.NEEDS_STEVE.has(row.color) && !force) return { code: 403, body: { error: `tty is ${row.color} — not waiting on you (pass force:true to override)`, color: row.color } };
+ if (needsSteveOnly && !queue.NEEDS_STEVE.has(row.color) && !liveMenu && !force) return { code: 403, body: { error: `tty is ${row.color} — not waiting on you (pass force:true to override)`, color: row.color } };
if (row.runtime === 'codex' && !force) return { code: 403, body: { error: 'tty hosts a codex REPL — answer disabled unless force', runtime: 'codex' } };
return { row };
}
@@ -157,7 +163,7 @@ const server = http.createServer(async (req, res) => {
if (bad) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: bad, refused: true }); return json(res, 400, { error: bad }); }
const g = await guardTarget(body);
if (g.code) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
- const r = await doType(g.row, text, body, 'answer');
+ const r = await doType(g.row, text, body, 'answer', { optionLabel: typeof body.optionLabel === 'string' ? body.optionLabel.slice(0, 200) : null });
return json(res, r.code, r.body);
}
if (p === '/api/continue') {
← 3ae8d1e TK-11793: superseded-menu guard — a footer with a prompt box
·
back to Answer Cockpit
·
TK-11793: README — E2E proof recorded (ANSWER_RECEIVED=Bravo 90a2183 →