← back to Answer Cockpit
lib/transcript.js
526 lines
'use strict';
// transcript.js — tty → transcript ranked resolver + 256KB tail parser.
//
// Resolution order (plan "Detail resolution"):
// 0. ~/.claude/answer-cockpit/map/<tty>.json {sessionId|transcriptPath} → exact
// 1. ~/.claude/sessions/<pid>.json → sessionId → ~/.claude/projects/*/<sid>.jsonl → exact
// 2. lsof -a -p <pid> -d cwd -Fn → cwd → candidate dirs whose newest file carries
// "cwd":"<cwd>" (fast path: mangled name; NEVER decode dash-names)
// 3. candidates = jsonl mtime ≥ Date.parse(owner.started) − 60s, mtime desc.
// 1 → likely. >1 → pending-state validator on each 256KB tail → 1 survivor
// → likely, else ambiguous (NO lastText — never render another session's
// question). 0 → none.
// Cache 30s per pid; re-resolve when updated_at changes.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const HOME = process.env.HOME || require('os').homedir();
const PROJECTS = path.join(HOME, '.claude/projects');
const SESSIONS = path.join(HOME, '.claude/sessions');
const MAP_DIR = path.join(HOME, '.claude/answer-cockpit/map');
const TAIL_BYTES = 256 * 1024;
const CACHE_MS = 30 * 1000;
const LAST_TEXT_MAX = 2000; // lastText char cap (parseTail + parsePane)
const cache = new Map(); // pid → {key, ts, result}
// ---- tail parser (copied from claude-control-center/server.js:337-356 tailJsonl) ----
function tailJsonl(filepath, n = TAIL_BYTES) {
let buf;
try {
const fd = fs.openSync(filepath, 'r');
const st = fs.fstatSync(fd);
const len = Math.min(n, st.size);
buf = Buffer.alloc(len);
fs.readSync(fd, buf, 0, len, st.size - len);
fs.closeSync(fd);
var full = len === st.size;
} catch { return []; }
const text = buf.toString('utf8');
// Drop a possibly-incomplete first line (unless we read the whole file)
const raw = text.split('\n');
const lines = (full ? raw : raw.slice(1)).filter(Boolean);
const out = [];
for (const l of lines) { try { out.push(JSON.parse(l)); } catch {} }
return out;
}
function blocksOf(turn) {
const c = turn && turn.message && turn.message.content;
return Array.isArray(c) ? c : [];
}
/**
* parseTail(filepath) → { question, lastText, pasteCmd, lastTs, sessionId, cwd, answeredQuestion }
* question = last AskUserQuestion tool_use with NO later tool_result for its id
* lastText = last assistant {type:"text"} (≤2000 chars)
* pasteCmd = first /^!\s+/m line found in assistant text (scanning newest→oldest)
*/
function parseTail(filepath) {
const turns = tailJsonl(filepath);
const results = new Set();
let lastAsk = null, lastText = null, lastTs = null, sessionId = null, cwd = null, pasteCmd = null;
for (const t of turns) {
if (t.sessionId && !sessionId) sessionId = t.sessionId;
if (t.cwd && !cwd) cwd = t.cwd;
if (t.timestamp) lastTs = t.timestamp;
for (const b of blocksOf(t)) {
if (!b || typeof b !== 'object') continue;
if (b.type === 'tool_result' && b.tool_use_id) results.add(b.tool_use_id);
if (t.type === 'assistant' && b.type === 'tool_use' && b.name === 'AskUserQuestion') lastAsk = { id: b.id, input: b.input, ts: t.timestamp };
if (t.type === 'assistant' && b.type === 'text' && typeof b.text === 'string' && b.text.trim()) lastText = b.text;
}
}
// pasteCmd: first `! ` line in the most recent assistant text that has one
for (let i = turns.length - 1; i >= 0 && !pasteCmd; i--) {
const t = turns[i]; if (t.type !== 'assistant') continue;
for (const b of blocksOf(t)) {
if (b && b.type === 'text' && typeof b.text === 'string') {
const m = b.text.match(/^!\s+.+$/m);
if (m) { pasteCmd = m[0].trim(); break; }
}
}
}
let question = null, answeredQuestion = false;
if (lastAsk) {
if (results.has(lastAsk.id)) answeredQuestion = true;
else {
const qs = (lastAsk.input && Array.isArray(lastAsk.input.questions)) ? lastAsk.input.questions : [];
question = {
toolUseId: lastAsk.id,
askedAt: lastAsk.ts || null,
questions: qs.map((q) => ({
question: String(q.question || ''),
header: String(q.header || ''),
multiSelect: !!q.multiSelect,
// n = the number the TUI displays: real options are listed first, synthetic ones after,
// so a transcript option's displayed number is index+1. Menus select by NUMBER.
options: Array.isArray(q.options) ? q.options.map((o, i) => ({ label: String(o.label || ''), description: String(o.description || ''), n: i + 1 })) : [],
})),
};
}
}
return {
question, answeredQuestion,
lastText: lastText ? lastText.slice(-LAST_TEXT_MAX) : null,
pasteCmd, lastTs, sessionId, cwd, turns: turns.length,
};
}
// ---- helpers ----
function cwdOf(pid) {
try {
const out = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 8000 });
for (const l of out.split('\n')) if (l.startsWith('n')) return l.slice(1);
} catch {}
return null;
}
function findSessionJsonl(sessionId) {
if (!/^[a-zA-Z0-9-]+$/.test(sessionId)) return null;
let dirs = [];
try { dirs = fs.readdirSync(PROJECTS); } catch { return null; }
for (const d of dirs) {
const fp = path.join(PROJECTS, d, sessionId + '.jsonl');
if (fs.existsSync(fp)) return fp;
}
return null;
}
function mangled(cwd) {
// fast path: cwd.replace('/', '-') and the dot-mangled variant. NEVER decode dash-names.
return [cwd.replace(/\//g, '-').replace(/\./g, '-'), cwd.replace(/\//g, '-')];
}
function jsonlsIn(dir) {
let names = [];
try { names = fs.readdirSync(dir); } catch { return []; }
const out = [];
for (const n of names) {
if (!n.endsWith('.jsonl')) continue;
const fp = path.join(dir, n);
let st; try { st = fs.statSync(fp); } catch { continue; }
if (st.isFile()) out.push({ path: fp, mtime: st.mtimeMs, size: st.size });
}
return out.sort((a, b) => b.mtime - a.mtime);
}
function headHasCwd(fp, cwd) {
// read the first 64KB and look for "cwd":"<cwd>" (exact JSON string match)
try {
const fd = fs.openSync(fp, 'r');
const buf = Buffer.alloc(65536);
const n = fs.readSync(fd, buf, 0, 65536, 0);
fs.closeSync(fd);
return buf.toString('utf8', 0, n).includes('"cwd":' + JSON.stringify(cwd));
} catch { return false; }
}
function candidateDirs(cwd) {
const dirs = new Set();
for (const m of mangled(cwd)) {
const d = path.join(PROJECTS, m);
if (fs.existsSync(d)) dirs.add(d);
}
if (dirs.size) return [...dirs];
// slow path: dirs touched in the last 24h whose newest file carries this cwd
const cutoff = Date.now() - 24 * 3600 * 1000;
let names = [];
try { names = fs.readdirSync(PROJECTS); } catch { return []; }
for (const n of names) {
const d = path.join(PROJECTS, n);
let st; try { st = fs.statSync(d); } catch { continue; }
if (!st.isDirectory() || st.mtimeMs < cutoff) continue;
const newest = jsonlsIn(d)[0];
if (newest && headHasCwd(newest.path, cwd)) dirs.add(d);
}
return [...dirs];
}
function parseStarted(s) {
if (!s) return NaN;
const t = Date.parse(s);
return Number.isNaN(t) ? NaN : t;
}
/**
* validator — does this tail look like a session that is currently WAITING on Steve?
* ends in a pending AskUserQuestion, OR has a `! ` paste line, OR its last ts is
* ≥ dot updated_at − 5min.
*/
function pendingState(parsed, updatedAt) {
if (parsed.question) return true;
if (parsed.pasteCmd) return true;
const ua = Date.parse(updatedAt || '');
const lt = Date.parse(parsed.lastTs || '');
if (!Number.isNaN(ua) && !Number.isNaN(lt) && lt >= ua - 5 * 60 * 1000) return true;
return false;
}
/**
* resolve(row) → {
* sessionId, transcriptPath, cwd, confidence: exact|likely|ambiguous|none, candidates:[...],
* detail: parseTail(...) | null (null when ambiguous/none — never render another session's question)
* }
*/
function resolve(row) {
const pid = String(row.pid || '');
const tty = String(row.tty || '');
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;
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);
// 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;
}
// ---- pane-contents source ----------------------------------------------------------
// ONE osascript pass returns every session's tty + screen contents (≈23 sessions in one call,
// instead of 23 calls). Cached 4s. Used both for per-tty detail and for the queue's
// "question on screen but dot not set" detection (pane truth beats dot truth).
let paneBatch = { ts: 0, map: new Map() };
// 12s: one batch (≈4.5s for ~30 sessions) must serve a whole /api/queue build — the scan alone
// takes ~4s, so a 4s TTL expired mid-build and the batch was read 2-3× per request (12s+).
// Writes never rely on this: guardTarget() calls invalidatePanes() before resolving.
const PANE_BATCH_MS = 12000;
function invalidatePanes() { paneBatch = { ts: 0, map: paneBatch.map }; }
// ---- background watcher (scale fix, 2026-09-16: 51 live sessions / 45 panes) ----------
// The batch read is ~5s at 45 panes. Done synchronously on the request path it blocked the
// single event loop, so /api/queue ran past 60s and even /api/health waited behind it. The
// watcher refreshes the batch ASYNCHRONOUSLY on a timer; request paths read the cache only.
// A click (guardTarget) still forces ONE fresh sync read via refreshPanesSync() — rare, and
// that is the moment freshness actually matters.
let watcher = null, refreshing = false;
// Materializing 45 sessions' scrollback is expensive FOR ITERM (it was at 131% CPU with the
// watcher on a 12s timer, and every AppleScript caller on the box — the dot scanner, the
// router — queued behind it). So: refresh only while a viewer is actually polling, and slowly.
const WATCH_INTERVAL_MS = 60000, VIEWER_IDLE_MS = 60000;
let lastViewerAt = 0;
function noteViewer() { lastViewerAt = Date.now(); }
function paneScript(BEGIN, END) {
return `tell application "iTerm2"
set out to ""
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
try
-- contents is the ENTIRE scrollback (can be MBs). Substring to the last 4000 chars
-- inside AppleScript so the concatenation stays small and fast (quadratic otherwise).
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 & "${BEGIN}" & (tty of s) & linefeed & c & linefeed & "${END}" & linefeed
end try
end repeat
end repeat
end repeat
return out
end tell`;
}
function parseBatch(raw, BEGIN, END) {
const map = new Map();
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]);
return map;
}
function refreshPanesAsync() {
if (refreshing) return;
refreshing = true;
const nonce = crypto.randomBytes(6).toString('hex');
const BEGIN = '@@TTY-' + nonce + ' ', END = '@@END-' + nonce;
const { execFile } = require('child_process');
const child = execFile('/usr/bin/osascript', [], { timeout: 20000, maxBuffer: 32 << 20 }, (err, stdout) => {
refreshing = false;
if (err || !stdout) return; // keep the previous batch; a failed read is not an empty fleet
paneBatch = { ts: Date.now(), map: parseBatch(stdout, BEGIN, END) };
});
child.stdin.on('error', () => {});
child.stdin.end(paneScript(BEGIN, END));
}
function startPaneWatcher(intervalMs = WATCH_INTERVAL_MS) {
if (watcher) return watcher;
const tick = () => { if (Date.now() - lastViewerAt < VIEWER_IDLE_MS) refreshPanesAsync(); };
watcher = setInterval(tick, intervalMs);
if (watcher.unref) watcher.unref();
return watcher;
}
/** refreshPanesSync() — ONE blocking fresh read; used only by the POST guard before typing. */
function refreshPanesSync() {
const nonce = crypto.randomBytes(6).toString('hex');
const BEGIN = '@@TTY-' + nonce + ' ', END = '@@END-' + nonce;
try {
const raw = execFileSync('/usr/bin/osascript', [], { input: paneScript(BEGIN, END), encoding: 'utf8', timeout: 20000, maxBuffer: 32 << 20 });
paneBatch = { ts: Date.now(), map: parseBatch(raw, BEGIN, END) };
} catch { /* keep previous batch */ }
return paneBatch.map;
}
function allPaneContents() {
if (Date.now() - paneBatch.ts < PANE_BATCH_MS) return paneBatch.map;
// With the watcher running, NEVER block a request path: serve the last batch and kick ONE
// async refresh (a viewer is evidently here). If we have never read at all, fall through to
// the sync read below so the very first page load is not empty.
if (watcher && paneBatch.ts > 0) { noteViewer(); refreshPanesAsync(); return paneBatch.map; }
if (watcher) noteViewer();
// 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 = 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
repeat with t in tabs of w
repeat with s in sessions of t
try
-- contents is the ENTIRE scrollback (can be MBs). Substring to the last 4000 chars
-- inside AppleScript so the concatenation stays small and fast (quadratic otherwise).
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 & "${BEGIN}" & (tty of s) & linefeed & c & linefeed & "${END}" & linefeed
end try
end repeat
end repeat
end repeat
return out
end tell`;
const map = new Map();
try {
const raw = execFileSync('/usr/bin/osascript', [], { input: script, encoding: 'utf8', timeout: 15000, maxBuffer: 32 << 20 });
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 */ }
return map;
}
function paneContents(tty) {
if (!/^ttys\d{3}$/.test(tty)) return null;
const map = allPaneContents();
return map.has(tty) ? map.get(tty) : null;
}
// Claude Code's AskUserQuestion TUI — captured live 2026-09-15 (throwaway session ttys024):
// ────────────────────────────────
// ☐ Pick ← header line (checkbox glyph)
// Which one for the cockpit proof? ← question
// ❯ 1. Alpha ← cursor on an option
// First option. ← description (indented)
// 2. Bravo
// 4. Type something. ← SYNTHETIC (free text) — never a clickable answer
// ────────────────────────────────
// 5. Chat about this ← SYNTHETIC (escape)
// Enter to select · ↑/↓ to navigate · Esc to cancel ← footer marker
// The menu REPLACES the prompt box while asking. A clickable option list is emitted ONLY
// when the footer marker is on screen AND (a ☐ header or a ❯ cursor corroborates it) —
// an ordinary numbered list in prose has none of these (Cody hole #1, TK-11793).
const OPT_RE = /^\s*(?:[❯>]\s*)?(\d{1,2})[.)]\s+(\S.*?)\s*$/;
const MENU_FOOTER_RE = /Enter to select/;
const MENU_HEADER_RE = /^\s*[☐☑✓]\s*(.*)$/;
const SYNTHETIC_OPT_RE = /^(Type something|Chat about this)\b/i;
const BORDER_RE = /^\s*─{6,}/;
/**
* 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;
// ---- 1. a REAL AskUserQuestion menu on screen? (footer marker is mandatory) ----
let question = null, menuTop = -1;
let footer = -1;
for (let i = rawLines.length - 1; i >= 0; i--) { if (MENU_FOOTER_RE.test(rawLines[i])) { footer = i; break; } }
// A footer with a prompt box (border or ❯ prompt line) BELOW it is a menu left in
// scrollback after it was answered/superseded — not a live menu (Cody hole #2).
if (footer >= 0 && rawLines.slice(footer + 1).some((l) => BORDER_RE.test(l) || /^\s*❯/.test(l))) footer = -1;
if (footer >= 0) {
let hdr = -1;
for (let i = footer - 1; i >= Math.max(0, footer - 40); i--) { if (MENU_HEADER_RE.test(rawLines[i])) { hdr = i; break; } }
const start = hdr >= 0 ? hdr : Math.max(0, footer - 40);
menuTop = start;
for (let i = start - 1; i >= Math.max(0, start - 3); i--) { if (BORDER_RE.test(rawLines[i])) { menuTop = i; break; } }
const header = hdr >= 0 ? ((rawLines[hdr].match(MENU_HEADER_RE) || [])[1] || '').trim() : '';
const region = rawLines.slice(start, footer);
const opts = [], qLines = [];
let seenOpt = false;
for (let i = 0; i < region.length; i++) {
const l = region[i];
if (hdr >= 0 && i === 0) continue; // the header line itself
if (BORDER_RE.test(l)) continue;
const m = l.match(OPT_RE);
if (m) {
seenOpt = true;
const label = m[2].replace(/\s+/g, ' ').trim();
if (!SYNTHETIC_OPT_RE.test(label)) opts.push({ n: parseInt(m[1], 10), label, description: '' });
continue;
}
// narrow panes wrap the question inside a box — strip the box-drawing glyphs
if (!seenOpt) { const t = l.replace(/[│┃║]/g, ' ').trim(); if (t) qLines.push(t); }
else if (opts.length && /^\s{3,}\S/.test(l)) opts[opts.length - 1].description = (opts[opts.length - 1].description + ' ' + l.trim()).trim();
}
const hasCursor = region.some((l) => /^\s*❯\s*\d{1,2}[.)]/.test(l));
// corroboration: footer + (header OR cursor). Footer alone is not enough (a quoted hint line).
if (opts.length >= 1 && (hasCursor || hdr >= 0)) {
// `n` = the number the TUI DISPLAYS for the option. A real menu selects by NUMBER, not by
// typed label — proven live 2026-09-15: typing "Bravo" + Enter selected the cursor default
// (Alpha). The client types String(n); synthetic options were dropped but numbering is kept.
question = { toolUseId: null, askedAt: null, source: 'pane', questions: [{ question: qLines.join(' ').replace(/\s+/g, ' ').trim() || header || '(question text not captured)', header, multiSelect: false, options: opts.map((o) => ({ label: o.label, description: o.description, n: o.n })) }] };
}
}
// ---- 2. prompt box / content cut (the menu, when present, replaces the prompt box) ----
let cut = rawLines.length, cut2 = -1;
if (menuTop < 0) {
for (let i = rawLines.length - 1; i >= 0; i--) { if (BORDER_RE.test(rawLines[i])) { cut = i; break; } }
for (let i = cut - 1; i >= 0; i--) { if (BORDER_RE.test(rawLines[i])) { cut2 = i; break; } }
}
const top = menuTop >= 0 ? menuTop : (cut2 >= 0 ? cut2 : cut);
const promptBox = menuTop >= 0 ? [] : 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);
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(-LAST_TEXT_MAX);
// answeredQuestion is UNKNOWN in pane mode (no tool_use ids) — null, never a false "not stale".
return { question, answeredQuestion: null, lastText: lastText || null, pasteCmd, lastTs: null, sessionId: null, cwd: null, turns: 0, source: 'pane', queued, degenerate, menuOnScreen: footer >= 0 };
}
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 };
}
function resolveUncached(row, pid, tty) {
// 0. explicit map file (a future hook can write this → everything becomes exact)
if (/^ttys\d{3}$/.test(tty)) {
try {
const mp = path.join(MAP_DIR, tty + '.json');
if (fs.existsSync(mp)) {
const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
let fp = m.transcriptPath && fs.existsSync(m.transcriptPath) ? m.transcriptPath : null;
if (!fp && m.sessionId) fp = findSessionJsonl(String(m.sessionId));
if (fp) return exactFrom(fp, 'map');
}
} catch {}
}
// 1. ~/.claude/sessions/<pid>.json → sessionId
if (/^\d+$/.test(pid)) {
try {
const sp = path.join(SESSIONS, pid + '.json');
if (fs.existsSync(sp)) {
const s = JSON.parse(fs.readFileSync(sp, 'utf8'));
if (s.sessionId) {
const fp = findSessionJsonl(String(s.sessionId));
if (fp) return exactFrom(fp, 'sessions-json');
}
}
} catch {}
}
// 2. lsof cwd → candidate dirs
const none = (why) => ({ sessionId: null, transcriptPath: null, cwd: null, confidence: 'none', how: why, candidates: [], detail: null });
if (!/^\d+$/.test(pid)) return none('no pid');
const cwd = cwdOf(pid);
if (!cwd) return none('lsof cwd failed');
const dirs = candidateDirs(cwd);
if (!dirs.length) return { ...none('no project dir for cwd'), cwd };
// 3. candidates by mtime ≥ started − 60s
const started = parseStarted(row.owner && row.owner.started);
const floor = Number.isNaN(started) ? 0 : started - 60 * 1000;
let cands = [];
for (const d of dirs) cands.push(...jsonlsIn(d));
cands = cands.filter((c) => c.mtime >= floor && c.size > 0).sort((a, b) => b.mtime - a.mtime);
if (!cands.length) return { ...none('no transcript newer than session start'), cwd };
const paths = cands.map((c) => c.path);
if (cands.length === 1) {
const detail = parseTail(cands[0].path);
return { sessionId: detail.sessionId, transcriptPath: cands[0].path, cwd, confidence: 'likely', how: 'cwd-single', candidates: paths, detail };
}
// >1: pending-state validator on each tail (bounded to the 8 newest)
const survivors = [];
for (const c of cands.slice(0, 8)) {
const detail = parseTail(c.path);
if (pendingState(detail, row.updated_at)) survivors.push({ c, detail });
}
if (survivors.length === 1) {
const { c, detail } = survivors[0];
return { sessionId: detail.sessionId, transcriptPath: c.path, cwd, confidence: 'likely', how: 'cwd-validated', candidates: paths, detail };
}
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, invalidatePanes, refreshPanesSync, startPaneWatcher, noteViewer, MAP_DIR, _cache: cache };