← 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 };