[object Object]

← 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

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 →