[object Object]

← 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

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 →