← back to Answer Cockpit
TK-11793: pane-contents source — iTerm screen as detail when transcript saving is off (fleet default); AskUserQuestion option parser; degenerate-scrollback flag; drop .deploy.conf
19f8c3b12b4fa77e2d7113b7a0c09904c0a59fc3 · 2026-09-15 18:52:12 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
M lib/queue.jsM lib/transcript.jsM public/index.html
Diff
commit 19f8c3b12b4fa77e2d7113b7a0c09904c0a59fc3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 15 18:52:12 2026 -0700
TK-11793: pane-contents source — iTerm screen as detail when transcript saving is off (fleet default); AskUserQuestion option parser; degenerate-scrollback flag; drop .deploy.conf
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/queue.js | 3 ++
lib/transcript.js | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
public/index.html | 2 +-
3 files changed, 95 insertions(+), 3 deletions(-)
diff --git a/lib/queue.js b/lib/queue.js
index fb8b5eb..333a21a 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -129,6 +129,9 @@ function buildItem(row, memosByTk, opts) {
if (row.runtime === 'codex') warnings.push('runtime:codex');
if (session.confidence === 'ambiguous') warnings.push(`transcript ambiguous (${session.candidates.length} candidates) — label only`);
if (session.confidence === 'none') warnings.push('transcript not found — label only');
+ if (session.confidence === 'pane') warnings.push('source: pane contents (transcript saving is off for this session)');
+ if (detail && detail.degenerate) warnings.push('pane scrollback is 1-char-per-line (narrow-width collapse) — Focus the tab to redraw');
+ if (detail && detail.queued) warnings.push('queued input already in the prompt: ' + String(detail.queued).slice(0, 80));
if (row.color === 'yellow' && detail && !detail.question && detail.answeredQuestion) warnings.push('stale dot? (question already answered)');
const memos = row.ticket ? (memosByTk.get(row.ticket) || []) : [];
const kind = kindOf(row, detail, session);
diff --git a/lib/transcript.js b/lib/transcript.js
index de1e8d9..c13b5b4 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -206,11 +206,100 @@ function resolve(row) {
const key = pid + '|' + (row.updated_at || '');
const hit = cache.get(pid);
if (hit && hit.key === key && Date.now() - hit.ts < CACHE_MS) return hit.result;
- const result = resolveUncached(row, pid, tty);
+ let result = resolveUncached(row, pid, tty);
+ // Transcript saving is OFF for ~half the fleet ("inherited CLAUDE_CODE_CHILD_SESSION
+ // 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);
cache.set(pid, { key, ts: Date.now(), result });
return result;
}
+// ---- pane-contents source ----------------------------------------------------------
+const paneCache = new Map(); // tty → {ts, text}
+const PANE_CACHE_MS = 5000;
+function paneContents(tty) {
+ if (!/^ttys\d{3}$/.test(tty)) return null;
+ const hit = paneCache.get(tty);
+ if (hit && Date.now() - hit.ts < PANE_CACHE_MS) return hit.text;
+ const script = `tell application "iTerm2"
+repeat with w in windows
+ repeat with t in tabs of w
+ repeat with s in sessions of t
+ try
+ if (tty of s) is "/dev/${tty}" then return (contents of s)
+ end try
+ end repeat
+ end repeat
+end repeat
+return ""
+end tell`;
+ let text = null;
+ try { text = execFileSync('/usr/bin/osascript', [], { input: script, encoding: 'utf8', timeout: 8000, maxBuffer: 4 << 20 }); } catch { text = null; }
+ if (text != null) paneCache.set(tty, { ts: Date.now(), text });
+ return text;
+}
+
+// Claude Code's AskUserQuestion TUI renders options as "❯ 1. Label" / " 2. Label".
+const OPT_RE = /^\s*(?:[❯>]\s*)?(\d{1,2})[.)]\s+(\S.*?)\s*$/;
+
+/**
+ * parsePane(text) → same shape as parseTail(), plus {source:'pane', queued, degenerate}
+ * - cuts off the prompt box (the last ───── borders + status chrome) so lastText is the
+ * conversation content, not the tokens/auto-mode/subagent rows
+ * - question = the last consecutive 1..N numbered run near the bottom, with the text
+ * block just above it as the question
+ * - degenerate = a narrow-width collapse left 1-char-per-line scrollback (iTerm does not
+ * re-wrap old scrollback after a resize); collapsed into one string + flagged
+ */
+function parsePane(text) {
+ const rawLines = String(text || '').replace(/\r/g, '').split('\n').map((l) => l.replace(/\s+$/, ''));
+ 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);
+ 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 };
+}
+
+function paneFallback(base, tty) {
+ const text = paneContents(tty);
+ if (text == null || !text.trim()) return base;
+ const detail = parsePane(text);
+ return { ...base, confidence: 'pane', how: (base.how || '') + ' → pane-contents', detail };
+}
+
function exactFrom(fp, how) {
const detail = parseTail(fp);
return { sessionId: detail.sessionId || path.basename(fp, '.jsonl'), transcriptPath: fp, cwd: detail.cwd || null, confidence: 'exact', how, candidates: [fp], detail };
@@ -274,4 +363,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, MAP_DIR, _cache: cache };
+module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, MAP_DIR, _cache: cache };
diff --git a/public/index.html b/public/index.html
index 273248d..1b910b4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -294,7 +294,7 @@ async function focusTab(it){if(!it||state.down)return;try{await api('/api/focus'
// ---------- render ----------
function chips(it){
const conf=(it.session&&it.session.confidence)||'none';
- const cc=conf==='exact'?'ok':conf==='likely'?'':conf==='ambiguous'?'warn':'bad';
+ const cc=conf==='exact'?'ok':(conf==='likely'||conf==='pane')?'':conf==='ambiguous'?'warn':'bad';
const h=[];
h.push('<span class="chip" title="'+esc(it.createdAt||'')+'">🕓 '+esc(fmtWhen(it.createdAt)||'unknown')+'</span>');
h.push('<span class="chip mono">'+esc(it.tty)+(it.pid?' · pid '+esc(it.pid):'')+'</span>');
← a0798f0 TK-11793: backend (server.js + lib/queue,transcript,memo,wri
·
back to Answer Cockpit
·
TK-11793: README — run, endpoints, rails, detail sources (pa 0cdcea1 →