[object Object]

← back to Answer Cockpit

TK-11793: pane is authoritative for the on-screen menu on every resolver path; menu digest = labels only; 409 exposes resolver path

2f375230e59744f6f75050a2376f1b6f1a5c6bb5 · 2026-09-15 23:42:45 -0700 · Steve Abrams

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

Files touched

Diff

commit 2f375230e59744f6f75050a2376f1b6f1a5c6bb5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 15 23:42:45 2026 -0700

    TK-11793: pane is authoritative for the on-screen menu on every resolver path; menu digest = labels only; 409 exposes resolver path
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
 lib/queue.js      |  7 ++++++-
 lib/transcript.js | 12 ++++++++++++
 server.js         |  2 +-
 3 files changed, 19 insertions(+), 2 deletions(-)

diff --git a/lib/queue.js b/lib/queue.js
index 3f0039c..578d12f 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -120,7 +120,12 @@ function digestOf(detail) {
   if (!detail) return '-';
   const q = detail.question && detail.question.questions && detail.question.questions[0];
   if (!q) return '-';
-  const basis = q.question + '|' + q.options.map((o) => o.label).join('|');
+  // OPTION LABELS ONLY. The question prose differs between the transcript path (the tool_use
+  // input) and the pane path (the wrapped on-screen rendering), and a session with transcript
+  // saving on can resolve either way from one read to the next — hashing the prose 409'd a
+  // correct click in the same second. Labels are identical across both sources.
+  const basis = q.options.map((o) => String(o.label || '').replace(/\s+/g, ' ').trim().toLowerCase()).join('|');
+  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)}`; }
diff --git a/lib/transcript.js b/lib/transcript.js
index f9c7028..347409b 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -213,6 +213,18 @@ function resolve(row) {
   // marker"), so none/ambiguous is the COMMON case. Fall back to the iTerm pane's own
   // screen contents — exactly what Steve sees in the tab, readable for every live session.
   if ((result.confidence === 'none' || result.confidence === 'ambiguous') && /^ttys\d{3}$/.test(tty)) result = paneFallback(result, tty);
+  // PANE TRUTH FOR THE MENU, always. Even when a transcript resolved (exact/likely), the on-screen
+  // menu is authoritative for question/options: its displayed NUMBERS are what a typed answer
+  // selects, and a narrow pane WRAPS long labels (first line = label) while the transcript holds
+  // the full label — so a resolver that flips between sources flips the digest and 409s a
+  // correct click (seen live 2026-09-15). If a menu is on screen, take it from the screen.
+  else if (result.detail && /^ttys\d{3}$/.test(tty)) {
+    const text = paneContents(tty);
+    if (text && text.trim()) {
+      const pane = parsePane(text);
+      if (pane.menuOnScreen) result = { ...result, how: (result.how || '') + ' + pane-menu', detail: { ...result.detail, question: pane.question, queued: pane.queued, menuOnScreen: true, answeredQuestion: pane.question ? null : result.detail.answeredQuestion } };
+    }
+  }
   cache.set(pid, { key, ts: Date.now(), result });
   return result;
 }
diff --git a/server.js b/server.js
index 1eb7b40..19054b2 100644
--- a/server.js
+++ b/server.js
@@ -98,7 +98,7 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
     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 } };
+    if (!given || (given !== key && !(liveMenu && given === keyAsYellow))) return { code: 409, body: { error: 'stale card (expectKey mismatch)', expectKey: body.expectKey || null, currentKey: key, liveMenu, resolvedVia: sess.confidence, how: sess.how || null } };
   }
   const force = body.force === true;
   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 } };

← 60b190a TK-11793: server-side menu resolution — a posted LABEL is tr  ·  back to Answer Cockpit  ·  TK-11793: pane batch TTL 12s + warmed once per build (/api/q c938da9 →