← 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
M lib/queue.jsM lib/transcript.jsM server.js
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 →