[object Object]

← 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

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 →