← back to Answer Cockpit
TK-11793: server-side menu resolution — a posted LABEL is translated to its displayed number, free text against a live menu is refused (400); stale-client auto-reload on server bootId; ticket-backlog lane renders first, orphan memos capped at 25
60b190a46d558e50bf76d7a790ba1d2e516d6c6b · 2026-09-15 20:55:35 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
A lib/menu.jsM lib/queue.jsM public/index.htmlM server.js
Diff
commit 60b190a46d558e50bf76d7a790ba1d2e516d6c6b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 15 20:55:35 2026 -0700
TK-11793: server-side menu resolution — a posted LABEL is translated to its displayed number, free text against a live menu is refused (400); stale-client auto-reload on server bootId; ticket-backlog lane renders first, orphan memos capped at 25
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/menu.js | 34 ++++++++++++++++++++++++++++++++++
lib/queue.js | 6 +++++-
public/index.html | 27 +++++++++++++--------------
server.js | 13 +++++++++++--
4 files changed, 63 insertions(+), 17 deletions(-)
diff --git a/lib/menu.js b/lib/menu.js
new file mode 100644
index 0000000..a642cbe
--- /dev/null
+++ b/lib/menu.js
@@ -0,0 +1,34 @@
+'use strict';
+// menu.js — server-side answer resolution against a LIVE AskUserQuestion menu.
+//
+// Proven live 2026-09-15 (TK-11793): a real menu selects by its DISPLAYED NUMBER. Typing the
+// option LABEL (or any free text) + Enter fires the cursor default (option 1). A stale browser
+// tab, a curl script, or a peer agent can still POST a label — so the SERVER, which parsed the
+// menu, must be the last line: translate a label to its number, and refuse free text.
+//
+// resolveMenuAnswer(text, question) → { text, translated, optionLabel, error }
+// - question == null (no live menu) → pass through unchanged
+// - text is a number matching an option n → ok
+// - text equals an option label (ci, trim) → text := String(n), translated:true
+// - anything else → error (free text would mis-answer)
+function norm(s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); }
+
+function resolveMenuAnswer(text, question) {
+ const q = question && question.questions && question.questions[0];
+ const opts = q && Array.isArray(q.options) ? q.options : [];
+ if (!q || !opts.length) return { text, translated: false, optionLabel: null, error: null };
+ const t = String(text || '').trim();
+ const nums = opts.map((o, i) => (Number.isInteger(o.n) ? o.n : i + 1));
+ if (/^\d{1,2}$/.test(t) && nums.includes(parseInt(t, 10))) {
+ const o = opts[nums.indexOf(parseInt(t, 10))];
+ return { text: t, translated: false, optionLabel: o.label || null, error: null };
+ }
+ const idx = opts.findIndex((o) => norm(o.label) === norm(t));
+ if (idx >= 0) return { text: String(nums[idx]), translated: true, optionLabel: opts[idx].label, error: null };
+ return {
+ text, translated: false, optionLabel: null,
+ error: `a live menu is on screen — send the option number (${nums.join('/')}) or an exact label; free text + Enter would fire the cursor default (option ${nums[0]})`,
+ };
+}
+
+module.exports = { resolveMenuAnswer };
diff --git a/lib/queue.js b/lib/queue.js
index f185285..3f0039c 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -37,6 +37,10 @@ const TTY_RE = /^ttys\d{3}$/;
let cached = null; // {ts, result}
let inflight = null;
+// BOOT_ID changes on every server start (= every code change). The client reloads itself when it
+// sees a new one, so a browser tab left open across a fix can never keep typing with STALE JS —
+// the likeliest way to reproduce the already-fixed "label typing selects option 1" bug.
+const BOOT_ID = `${process.pid}-${Date.now().toString(36)}`;
function run(cmd, args, timeout) {
return new Promise((resolve) => {
@@ -212,7 +216,7 @@ async function build(opts = {}) {
ticketBacklogCount = lane.length;
ticketBacklog = opts.orphans ? lane.slice(0, 80) : [];
} catch (e) { /* lane is best-effort; a read failure never blocks the live stream */ }
- return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, ticketBacklog, ticketBacklogCount, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
+ return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, ticketBacklog, ticketBacklogCount, bootId: BOOT_ID, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
}
/** one item, fresh (uncached scan) — for the post-answer confirm. */
diff --git a/public/index.html b/public/index.html
index 4b6e50d..a027a67 100644
--- a/public/index.html
+++ b/public/index.html
@@ -227,6 +227,8 @@ async function poll(){
if(e.status===401)return;
state.down=true;banner((e.status===503||e.status===502)?'iTerm unreachable / scan stale — actions disabled':(e.status?('backend error '+e.status+' — actions disabled'):'backend unreachable — actions disabled (retrying every 8s)'));render();return;}
state.down=false;state.stale=!!q.stale;state.scannedAt=q.scannedAt||new Date().toISOString();
+ // stale-client guard: a new server bootId means new code — reload rather than keep running old JS
+ if(q.bootId){if(state.bootId&&q.bootId!==state.bootId){toast('cockpit updated — reloading');setTimeout(()=>location.reload(),600);return;}state.bootId=q.bootId;}
state.orphans=q.orphanMemos||[];state.backlog=q.ticketBacklog||[];state.backlogCount=q.ticketBacklogCount||0;state.remaining=(typeof q.remaining==='number')?q.remaining:(q.items||[]).length;
$('cost').textContent=q.cost||'$0 (local)';
banner(state.stale?'iTerm unreachable / scan stale — actions disabled':'');
@@ -377,8 +379,17 @@ function render(){
}
// ?panel=1 forces the orphan/backlog panel open (bookmarkable; also lets a fresh headless browser see it)
function renderOrphans(){const o=$('orph');const on=get('orph','0')==='1'||new URLSearchParams(location.search).get('panel')==='1';o.hidden=!on;if(!on)return;
- if(!state.orphans.length){o.innerHTML='<h3 style="color:var(--muted);font-size:13px">orphan memos — none</h3>';return;}
- o.innerHTML='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px">orphan memos (no live tab) — decide only</h3>'+state.orphans.map(m=>
+ // ticket backlog renders FIRST (it was buried under 100+ orphan memos); orphans are capped at 25.
+ const bl0=state.backlog||[];const kp0=k=>k==='steve-action'?'warn':k==='external-wait'?'':'bad';
+ const laneHtml='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px">ticket backlog (no open pane) — '+esc(String(state.backlogCount||bl0.length))+' waiting on you — route only</h3>'+
+ (bl0.length?bl0.slice(0,25).map(t=>'<div class="item"><b>'+esc(t.ticket)+' · '+esc(t.title)+' <span class="pill '+kp0(t.kind)+'">'+esc(t.kind)+'</span></b>'+
+ '<div class="m mono" title="'+esc(t.created||'')+'">🕓 '+esc(fmtWhen(t.created))+' · '+esc(t.owner||'unowned')+(t.project?' · '+esc(t.project):'')+' · urgency '+esc(String(t.urgency))+'</div>'+
+ '<pre style="margin-top:8px;max-height:120px">'+esc(t.ask)+'</pre>'+
+ '<div class="actions"><a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9794" target="_blank" rel="noopener noreferrer">ticket board ↗</a></div></div>').join('')+(bl0.length>25?'<div class="m">…and '+(bl0.length-25)+' more on the ticket board</div>':'')
+ :'<div class="m">none</div>');
+ if(!state.orphans.length){o.innerHTML=laneHtml+'<h3 style="color:var(--muted);font-size:13px;margin-top:18px">orphan memos — none</h3>';return;}
+ const orphShown=state.orphans.slice(0,25);
+ o.innerHTML=laneHtml+'<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px;margin-top:18px">orphan memos (no live tab) — '+esc(String(state.orphans.length))+' — decide only'+(state.orphans.length>25?' (showing 25)':'')+'</h3>'+orphShown.map(m=>
'<div class="item" data-file="'+esc(m.file)+'"><b>'+esc(m.title||m.file)+' <span class="pill '+esc(m.recommendation||'none')+'">'+esc(m.recommendation||'no rec')+'</span></b>'+
'<div class="m mono" title="'+esc(m.createdAt||'')+'">'+esc(m.file)+' · 🕓 '+esc(fmtWhen(m.createdAt))+'</div>'+(m.excerpt?'<pre style="margin-top:8px;max-height:140px">'+esc(m.excerpt)+'</pre>':'')+
'<div class="actions"><button class="oa" data-d="approve"'+dis()+'>APPROVE</button><button class="oa danger" data-d="block"'+dis()+'>BLOCK</button><button class="oa warn" data-d="revise"'+dis()+'>REVISE</button>'+
@@ -387,18 +398,6 @@ function renderOrphans(){const o=$('orph');const on=get('orph','0')==='1'||new U
let note='';if(d!=='approve'){note=prompt(d.toUpperCase()+' note for '+file+':');if(note==null)return;}
else if(!confirm('APPROVE '+file+' → moves to _approved/ (undo available)?'))return;
decide(file,d,note,null);}));
- // ---- ticket backlog lane (TK-11793 cycle 3): tickets waiting on Steve with NO open pane.
- // Display/route only — there is nothing to type into. Sorted by urgency (steve_action >
- // past-due external_wait > blocked), then oldest first. The ticket board owns the actions.
- const bl=state.backlog||[];
- const kindPill=k=>k==='steve-action'?'warn':k==='external-wait'?'':'bad';
- o.innerHTML+='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px;margin-top:18px">ticket backlog (no open pane) — '+esc(String(state.backlogCount||bl.length))+' waiting on you — route only</h3>'+
- (bl.length?bl.map(t=>
- '<div class="item"><b>'+esc(t.ticket)+' · '+esc(t.title)+' <span class="pill '+kindPill(t.kind)+'">'+esc(t.kind)+'</span></b>'+
- '<div class="m mono" title="'+esc(t.created||'')+'">🕓 '+esc(fmtWhen(t.created))+' · '+esc(t.owner||'unowned')+(t.project?' · '+esc(t.project):'')+' · urgency '+esc(String(t.urgency))+'</div>'+
- '<pre style="margin-top:8px;max-height:120px">'+esc(t.ask)+'</pre>'+
- '<div class="actions"><a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9794" target="_blank" rel="noopener noreferrer">ticket board ↗</a></div></div>').join('')
- :'<div class="m">none</div>');
}
function bind(it){
diff --git a/server.js b/server.js
index 27f3427..1eb7b40 100644
--- a/server.js
+++ b/server.js
@@ -103,7 +103,7 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
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 } };
if (row.runtime === 'codex' && !force) return { code: 403, body: { error: 'tty hosts a codex REPL — answer disabled unless force', runtime: 'codex' } };
- return { row };
+ return { row, sess, liveMenu };
}
async function doType(row, text, body, action, extra = {}) {
@@ -167,7 +167,16 @@ const server = http.createServer(async (req, res) => {
if (bad) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: bad, refused: true }); return json(res, 400, { error: bad }); }
const g = await guardTarget(body);
if (g.code) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
- const r = await doType(g.row, text, body, 'answer', { optionLabel: typeof body.optionLabel === 'string' ? body.optionLabel.slice(0, 200) : null });
+ // Server-side menu resolution: translate a label to its displayed number, refuse free text
+ // against a live menu (a stale client or a curl script must not be able to mis-answer).
+ let typed = text, optionLabel = typeof body.optionLabel === 'string' ? body.optionLabel.slice(0, 200) : null, translated = false;
+ if (g.liveMenu && !(body.force === true && body.rawText === true)) {
+ const m = require('./lib/menu').resolveMenuAnswer(text, g.sess.detail.question);
+ if (m.error) { audit.audit({ action: 'answer', tty: g.row.tty, ticket: g.row.ticket, color: g.row.color, text, ok: false, err: m.error, refused: true }); return json(res, 400, { error: m.error, liveMenu: true }); }
+ typed = m.text; translated = m.translated; if (m.optionLabel) optionLabel = m.optionLabel;
+ }
+ const r = await doType(g.row, typed, body, 'answer', { optionLabel, translated: translated || undefined });
+ if (r.body && translated) r.body.translated = { from: text, to: typed, label: optionLabel };
return json(res, r.code, r.body);
}
if (p === '/api/continue') {
← 1e893e7 TK-11793: ?panel=1 forces the orphan/backlog panel open (boo
·
back to Answer Cockpit
·
TK-11793: pane is authoritative for the on-screen menu on ev 2f37523 →