← back to Answer Cockpit
TK-11793: Cody FIX-FIRST — multi-select menus refuse typed answers (focus the tab instead; no label path remains), single-question clicks answer by number immediately; nonce delimiters for the batched pane capture; guard drops pane+resolve caches for a genuinely fresh digest
490a39928b723165250a99407aeb220c848b1419 · 2026-09-15 19:38:09 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
M lib/transcript.jsM public/index.htmlM server.js
Diff
commit 490a39928b723165250a99407aeb220c848b1419
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 15 19:38:09 2026 -0700
TK-11793: Cody FIX-FIRST — multi-select menus refuse typed answers (focus the tab instead; no label path remains), single-question clicks answer by number immediately; nonce delimiters for the batched pane capture; guard drops pane+resolve caches for a genuinely fresh digest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/transcript.js | 11 ++++++++---
public/index.html | 15 ++++++++++-----
server.js | 6 +++++-
3 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/lib/transcript.js b/lib/transcript.js
index 2ad94f9..f9c7028 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -223,8 +223,13 @@ function resolve(row) {
// "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 invalidatePanes() { paneBatch = { ts: 0, map: paneBatch.map }; }
function allPaneContents() {
if (Date.now() - paneBatch.ts < PANE_BATCH_MS) return paneBatch.map;
+ // Per-call NONCE delimiters: a pane that happens to print the literal delimiter (e.g. a session
+ // reviewing this file) can no longer truncate its own capture (Cody FIX-FIRST #4).
+ const nonce = require('crypto').randomBytes(6).toString('hex');
+ const BEGIN = '@@TTY-' + nonce + ' ', END = '@@END-' + nonce;
const script = `tell application "iTerm2"
set out to ""
repeat with w in windows
@@ -236,7 +241,7 @@ repeat with w in windows
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
+ set out to out & "${BEGIN}" & (tty of s) & linefeed & c & linefeed & "${END}" & linefeed
end try
end repeat
end repeat
@@ -246,7 +251,7 @@ end tell`;
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;
+ const re = new RegExp(BEGIN.replace(/[-\s]/g, (ch) => ch === ' ' ? ' ' : '\\-') + '\\/dev\\/(ttys\\d{3})\\n([\\s\\S]*?)\\n' + END.replace(/-/g, '\\-') + '\\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 */ }
@@ -424,4 +429,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, allPaneContents, MAP_DIR, _cache: cache };
+module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, allPaneContents, invalidatePanes, MAP_DIR, _cache: cache };
diff --git a/public/index.html b/public/index.html
index c532a7a..dc93638 100644
--- a/public/index.html
+++ b/public/index.html
@@ -412,17 +412,22 @@ function bind(it){
$('primary').addEventListener('click',()=>sendFree(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;
+// The TUI shows ONE question at a time, so a click answers the displayed question by NUMBER
+// immediately; the next question (if any) shows up as a fresh card on the next poll.
+// Only a multi-select menu needs staging — and it cannot be typed at all (see sendSelected).
+function pickOpt(it,qi,oi){const q=it.question.questions[qi];const needsSend=!!q.multiSelect;
// 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();}
-function sendSelected(it){const qs=it.question.questions;const parts=[];
- for(let i=0;i<qs.length;i++){const s=state.sel[i]||[];if(!s.length){toast('pick an option for: '+(qs[i].header||('question '+(i+1))));return;}
- parts.push(s.map(o=>qs[i].options[o].label).join(', '));}
- push('/api/answer',{text:parts.join(' | ')},it,'question');}
+function sendSelected(it){
+ // A multi-select menu is toggled with Space/Enter INSIDE the TUI; there is no typed form that
+ // selects several options — typing labels fires the cursor default (proven live 2026-09-15).
+ // Refuse and hand the user to the terminal instead of mis-answering (Cody FIX-FIRST #1).
+ toast('multi-select menus must be picked in the terminal — focusing the tab');
+ push('/api/focus',{},it,'focus');}
function sendFree(it){const f=$('free');const t=f?f.value.replace(/\r/g,'').replace(/\n+/g,' ').trim():'';if(!t){toast('type something first');if(f)f.focus();return;}
if(t.length>2000){toast('too long (max 2000 chars)');return;}push('/api/answer',{text:t},it,it.kind==='question'?'question':'text');}
diff --git a/server.js b/server.js
index 2a3fbbd..27f3427 100644
--- a/server.js
+++ b/server.js
@@ -88,7 +88,11 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
}
// 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);
+ // GENUINELY fresh: drop the 4s pane batch + this pid's 30s resolve cache before resolving, so a
+ // menu that closed/advanced since the last poll cannot digest-match a stale card (Cody #3).
+ const tr = require('./lib/transcript');
+ tr.invalidatePanes(); tr._cache.delete(String(row.pid));
+ const sess = tr.resolve(row);
const liveMenu = !!(sess.detail && sess.detail.question && !sess.detail.queued);
if (requireKey) {
const key = queue.keyOf(row, sess.detail); // includes the content digest — a changed question 409s
← 393a954 TK-11793: strip box-drawing glyphs from narrow-pane question
·
back to Answer Cockpit
·
TK-11793: expectKey digest covers only the menu (question + 2502297 →