[object Object]

← back to Answer Cockpit

TK-11793: Cody HOLD fixes — real-TUI menu corroboration (footer+header/cursor, synthetic opts dropped, menu-replaces-prompt layout), queued input downgrades kind to text, content-digest expectKey, answeredQuestion=null in pane mode; negative test on prose

82ab1f48e65176b2d22e791504b7f2cca38e2f3a · 2026-09-15 19:05:36 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Files touched

Diff

commit 82ab1f48e65176b2d22e791504b7f2cca38e2f3a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 15 19:05:36 2026 -0700

    TK-11793: Cody HOLD fixes — real-TUI menu corroboration (footer+header/cursor, synthetic opts dropped, menu-replaces-prompt layout), queued input downgrades kind to text, content-digest expectKey, answeredQuestion=null in pane mode; negative test on prose
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
 lib/queue.js      | 24 ++++++++++++---
 lib/transcript.js | 92 ++++++++++++++++++++++++++++++++++++++-----------------
 server.js         |  2 +-
 3 files changed, 85 insertions(+), 33 deletions(-)

diff --git a/lib/queue.js b/lib/queue.js
index 333a21a..2af0fab 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -104,7 +104,20 @@ async function freshLive() {
   return { scan: res, live };
 }
 
-function keyOf(row) { return `${row.tty}|${row.ticket || '-'}|${row.color}`; }
+/**
+ * keyOf(row, detail) — `tty|ticket|color|<content digest>`. The digest covers the parsed
+ * option labels (or the last text) so a question that CHANGES while tty/ticket/color stay
+ * the same still 409s a stale card (Cody hole #3, TK-11793). keyFor(row) resolves detail.
+ */
+function digestOf(detail) {
+  if (!detail) return '-';
+  const q = detail.question && detail.question.questions && detail.question.questions[0];
+  const basis = q ? (q.question + '|' + q.options.map((o) => o.label).join('|')) : (detail.lastText ? String(detail.lastText).slice(-300) : '');
+  if (!basis) return '-';
+  return require('crypto').createHash('sha1').update(basis).digest('hex').slice(0, 8);
+}
+function keyOf(row, detail) { return `${row.tty}|${row.ticket || '-'}|${row.color}|${digestOf(detail)}`; }
+function keyFor(row) { const s = transcript.resolve(row); return keyOf(row, s.detail); }
 
 function toIso(started) {
   if (!started) return null;
@@ -116,8 +129,11 @@ function kindOf(row, detail, session) {
   const c = row.color;
   if (c === 'purple') return 'gated';
   if (c === 'orange') return 'paste';
+  // A queued input in the prompt box means the on-screen menu (if any) is SUPERSEDED —
+  // downgrade to text so the option buttons never render (Cody hole #2, TK-11793).
+  const liveMenu = !!(detail && detail.question && !detail.queued);
+  if (liveMenu) return 'question';
   if (c === 'lightblue') return 'needs-steve';
-  if (c === 'yellow') return detail && detail.question ? 'question' : 'text';
   return 'text';
 }
 
@@ -136,7 +152,7 @@ function buildItem(row, memosByTk, opts) {
   const memos = row.ticket ? (memosByTk.get(row.ticket) || []) : [];
   const kind = kindOf(row, detail, session);
   return {
-    key: keyOf(row),
+    key: keyOf(row, detail),
     tty: row.tty, pid: row.pid, runtime: row.runtime || 'unknown',
     color: row.color, rgb: color.rgb, emoji: color.emoji, meaning: color.meaning,
     ticket: row.ticket || null, label: row.label || '', variant: row.variant || '',
@@ -179,4 +195,4 @@ async function item(tty) {
   return { item: buildItem(row, memo.byTicket(memos), {}), gone: false, scannedAt: res.scannedAt, stale: !!res.stale };
 }
 
-module.exports = { scan, freshLive, build, item, keyOf, COLORS, PRIORITY, NEEDS_STEVE, TTY_RE };
+module.exports = { scan, freshLive, build, item, keyOf, keyFor, COLORS, PRIORITY, NEEDS_STEVE, TTY_RE };
diff --git a/lib/transcript.js b/lib/transcript.js
index c13b5b4..33bbe0d 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -240,8 +240,25 @@ end tell`;
   return text;
 }
 
-// Claude Code's AskUserQuestion TUI renders options as "❯ 1. Label" / "  2. Label".
+// Claude Code's AskUserQuestion TUI — captured live 2026-09-15 (throwaway session ttys024):
+//   ────────────────────────────────
+//    ☐ Pick                          ← header line (checkbox glyph)
+//   Which one for the cockpit proof? ← question
+//   ❯ 1. Alpha                       ← cursor on an option
+//        First option.               ← description (indented)
+//     2. Bravo
+//     4. Type something.             ← SYNTHETIC (free text) — never a clickable answer
+//   ────────────────────────────────
+//     5. Chat about this             ← SYNTHETIC (escape)
+//   Enter to select · ↑/↓ to navigate · Esc to cancel   ← footer marker
+// The menu REPLACES the prompt box while asking. A clickable option list is emitted ONLY
+// when the footer marker is on screen AND (a ☐ header or a ❯ cursor corroborates it) —
+// an ordinary numbered list in prose has none of these (Cody hole #1, TK-11793).
 const OPT_RE = /^\s*(?:[❯>]\s*)?(\d{1,2})[.)]\s+(\S.*?)\s*$/;
+const MENU_FOOTER_RE = /Enter to select/;
+const MENU_HEADER_RE = /^\s*[☐☑✓]\s*(.*)$/;
+const SYNTHETIC_OPT_RE = /^(Type something|Chat about this)\b/i;
+const BORDER_RE = /^\s*─{6,}/;
 
 /**
  * parsePane(text) → same shape as parseTail(), plus {source:'pane', queued, degenerate}
@@ -257,40 +274,59 @@ function parsePane(text) {
   const nonEmpty = rawLines.filter((l) => l.trim());
   const short = nonEmpty.filter((l) => l.trim().length <= 2).length;
   const degenerate = nonEmpty.length > 20 && short / nonEmpty.length > 0.6;
-  let cut = rawLines.length;
-  for (let i = rawLines.length - 1; i >= 0; i--) { if (/^\s*─{6,}/.test(rawLines[i])) { cut = i; break; } }
-  let cut2 = -1;
-  for (let i = cut - 1; i >= 0; i--) { if (/^\s*─{6,}/.test(rawLines[i])) { cut2 = i; break; } }
-  const top = cut2 >= 0 ? cut2 : cut;
-  const promptBox = rawLines.slice(top, cut + 1);
+
+  // ---- 1. a REAL AskUserQuestion menu on screen? (footer marker is mandatory) ----
+  let question = null, menuTop = -1;
+  let footer = -1;
+  for (let i = rawLines.length - 1; i >= 0; i--) { if (MENU_FOOTER_RE.test(rawLines[i])) { footer = i; break; } }
+  if (footer >= 0) {
+    let hdr = -1;
+    for (let i = footer - 1; i >= Math.max(0, footer - 40); i--) { if (MENU_HEADER_RE.test(rawLines[i])) { hdr = i; break; } }
+    const start = hdr >= 0 ? hdr : Math.max(0, footer - 40);
+    menuTop = start;
+    for (let i = start - 1; i >= Math.max(0, start - 3); i--) { if (BORDER_RE.test(rawLines[i])) { menuTop = i; break; } }
+    const header = hdr >= 0 ? ((rawLines[hdr].match(MENU_HEADER_RE) || [])[1] || '').trim() : '';
+    const region = rawLines.slice(start, footer);
+    const opts = [], qLines = [];
+    let seenOpt = false;
+    for (let i = 0; i < region.length; i++) {
+      const l = region[i];
+      if (hdr >= 0 && i === 0) continue; // the header line itself
+      if (BORDER_RE.test(l)) continue;
+      const m = l.match(OPT_RE);
+      if (m) {
+        seenOpt = true;
+        const label = m[2].replace(/\s+/g, ' ').trim();
+        if (!SYNTHETIC_OPT_RE.test(label)) opts.push({ n: parseInt(m[1], 10), label, description: '' });
+        continue;
+      }
+      if (!seenOpt) { const t = l.trim(); if (t) qLines.push(t); }
+      else if (opts.length && /^\s{3,}\S/.test(l)) opts[opts.length - 1].description = (opts[opts.length - 1].description + ' ' + l.trim()).trim();
+    }
+    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 })) }] };
+    }
+  }
+
+  // ---- 2. prompt box / content cut (the menu, when present, replaces the prompt box) ----
+  let cut = rawLines.length, cut2 = -1;
+  if (menuTop < 0) {
+    for (let i = rawLines.length - 1; i >= 0; i--) { if (BORDER_RE.test(rawLines[i])) { cut = i; break; } }
+    for (let i = cut - 1; i >= 0; i--) { if (BORDER_RE.test(rawLines[i])) { cut2 = i; break; } }
+  }
+  const top = menuTop >= 0 ? menuTop : (cut2 >= 0 ? cut2 : cut);
+  const promptBox = menuTop >= 0 ? [] : rawLines.slice(top, cut + 1);
   let queued = null;
   for (const l of promptBox) { const m = l.match(/❯\s+(.+)$/); if (m && !/Press up to edit/.test(m[1])) queued = m[1].trim(); }
   const content = rawLines.slice(0, top).filter((l) => l.trim());
   const tail = content.slice(-45);
-  const opts = [];
-  for (let i = 0; i < tail.length; i++) {
-    const m = tail[i].match(OPT_RE);
-    if (m) opts.push({ n: parseInt(m[1], 10), label: m[2].replace(/\s+/g, ' ').trim(), at: i });
-  }
-  let question = null;
-  if (opts.length >= 2) {
-    const run = [opts[opts.length - 1]];
-    for (let i = opts.length - 2; i >= 0; i--) { if (opts[i].n === run[0].n - 1) run.unshift(opts[i]); else break; }
-    if (run.length >= 2 && run[0].n === 1) {
-      const qLines = [];
-      for (let i = run[0].at - 1; i >= 0 && qLines.length < 6; i--) {
-        const l = tail[i].trim();
-        if (!l) { if (qLines.length) break; else continue; }
-        if (/^[●⏺⎿✻]/.test(l)) break;
-        qLines.unshift(l);
-      }
-      question = { toolUseId: null, askedAt: null, source: 'pane', questions: [{ question: qLines.join(' ').replace(/\s+/g, ' ').trim() || '(question text not captured — see last message)', header: '', multiSelect: false, options: run.map((o) => ({ label: o.label, description: '' })) }] };
-    }
-  }
   let pasteCmd = null;
   for (let i = tail.length - 1; i >= 0 && !pasteCmd; i--) { const m = tail[i].match(/^\s*!\s+(.+)$/); if (m) pasteCmd = '! ' + m[1].trim(); }
   const lastText = (degenerate ? nonEmpty.map((l) => l.trim()).join('') : tail.join('\n')).slice(-2000);
-  return { question, answeredQuestion: false, lastText: lastText || null, pasteCmd, lastTs: null, sessionId: null, cwd: null, turns: 0, source: 'pane', queued, degenerate };
+  // answeredQuestion is UNKNOWN in pane mode (no tool_use ids) — null, never a false "not stale".
+  return { question, answeredQuestion: null, lastText: lastText || null, pasteCmd, lastTs: null, sessionId: null, cwd: null, turns: 0, source: 'pane', queued, degenerate, menuOnScreen: footer >= 0 };
 }
 
 function paneFallback(base, tty) {
diff --git a/server.js b/server.js
index 6da5754..783da13 100644
--- a/server.js
+++ b/server.js
@@ -87,7 +87,7 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
     return { code: 400, body: { error: 'tty not in live scan', tty } };
   }
   if (requireKey) {
-    const key = queue.keyOf(row);
+    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 force = body.force === true;

← 0cdcea1 TK-11793: README — run, endpoints, rails, detail sources (pa  ·  back to Answer Cockpit  ·  TK-11793: superseded-menu guard — a footer with a prompt box 3ae8d1e →