← back to Desktop Dotbar

live-filter.js

113 lines

'use strict';
// TK-12236: the PARKED panel must show only LIVE things. Pure functions (no I/O) so the
// filter is unit-testable; server.js feeds them one batched `ps` snapshot + the board's
// ticket-status map per refresh.
//
// Three states per row, never two: live/open (shown), dead/closed (hidden), and
// NOT-MEASURED (probe failed -> shown with liveness:'unknown', never silently live or dead).

const CLOSED = new Set(['done', 'closed', 'cancelled', 'canceled']);
const ttyBase = (t) => String(t || '').split('/').pop();
const tkKey = (id) => { const m = /^(TK-\d+)/i.exec(String(id || '')); return m ? m[1].toUpperCase() : ''; };

// `comm` can carry a renamed title ("claude bg-spare") or a full path. A "claude bg-spare"
// ON A REAL TTY is a CLAIMED daemon spare, i.e. the interactive session itself (cc-daemon
// keeps the spare's argv after the claim; verified 2026-09-25: pid 90349 on ttys004, S+
// foreground, 8m CPU). UNCLAIMED spares sit on tty "??" and never count (see parsePs).
// TK-12236 fix 2026-09-26: the native installer runs as ".../claude-code/bin/claude.exe" — the old
// /claude(\s|$)/ test rejected it, so a LIVE parked tab (pid 1634 on ttys002) was hidden as
// "recycled". A session REPL is claude / claude.exe / node (npm-installed claude) / codex.
function isClaudeComm(comm) { return /(^|\/)(claude(\.exe)?|node|codex)(\s|$)/.test(String(comm || '').trim()); }

// Parse `ps -axo pid=,tty=,comm=`. Returns null when the probe produced nothing usable
// (ps failed / empty) so callers treat it as NOT-MEASURED rather than "nothing alive".
function parsePs(stdout) {
  const byPid = new Map();
  const claudeTtys = new Set();
  for (const line of String(stdout || '').split('\n')) {
    const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
    if (!m) continue;
    const pid = +m[1], tty = m[2], comm = m[3].trim();
    byPid.set(pid, { tty, comm });
    if (tty !== '??' && isClaudeComm(comm)) claudeTtys.add(ttyBase(tty));
  }
  return byPid.size ? { byPid, claudeTtys } : null;
}

// Board ticket list -> Map(TK-NNNNN -> status). Empty/invalid -> null (NOT-MEASURED: an
// empty board is indistinguishable from a broken read, so it must not hide every ticket).
function ticketStatusMap(list) {
  if (!Array.isArray(list) || !list.length) return null;
  const map = new Map();
  for (const t of list) { const k = tkKey(t && t.id); if (k) map.set(k, String(t.status || '')); }
  return map.size ? map : null;
}

function tabLiveness(e, procs) {
  const tty = ttyBase(e.tty || e.id);
  if (!procs) return { state: 'unknown', reason: 'ps probe failed' };
  if (e.pid) {
    const p = procs.byPid.get(+e.pid);
    if (!p) return { state: 'dead', reason: `pid ${e.pid} not running` };
    if (!isClaudeComm(p.comm)) return { state: 'dead', reason: `pid ${e.pid} recycled (now "${p.comm}")` };
    if (tty && ttyBase(p.tty) !== tty) return { state: 'dead', reason: `pid ${e.pid} is claude on ${p.tty}, not ${tty} (recycled)` };
    return { state: 'live', reason: `claude pid ${e.pid} alive on ${tty}` };
  }
  if (!tty) return { state: 'unknown', reason: 'no pid or tty recorded' };
  return procs.claudeTtys.has(tty)
    ? { state: 'live', reason: `a claude process holds ${tty}` }
    : { state: 'dead', reason: `no claude process on ${tty}` };
}

function ticketLiveness(e, tickets) {
  const k = tkKey(e.id);
  if (!tickets) return { state: 'unknown', reason: 'ticket board unreachable' };
  if (!k) return { state: 'unknown', reason: 'no TK id' };
  if (!tickets.has(k)) return { state: 'unknown', reason: `${k} not in board summary` };
  const status = tickets.get(k);
  if (CLOSED.has(status)) return { state: 'closed', reason: `${k} is ${status}`, status };
  return { state: 'live', reason: `${k} is ${status}`, status };
}

// entries: parked.mjs list-parked --json. procs: parsePs() result or null. tickets: Map or null.
// isAttached(tty) -> bool. Returns the payload the bar renders; count === items.length always.
function buildParked(entries, { procs, tickets, isAttached = () => true, cleanLabel = (s) => s || '', ticketOf = () => '' } = {}) {
  const items = [], hidden = [];
  for (const e of Array.isArray(entries) ? entries : []) {
    if (!e) continue;
    const isTicket = e.kind === 'ticket';
    const lv = isTicket ? ticketLiveness(e, tickets)
      : e.kind === 'tab' ? tabLiveness(e, procs)
      : { state: 'unknown', reason: `unknown kind ${e.kind}` };
    // Only SESSION rows (tabs) are ever hidden. Parked TICKETS always show (Steve's always-show-every-
    // state rule; TK-12174) — a closed one is rendered with liveness 'closed', never dropped.
    if (!isTicket && lv.state === 'dead') { hidden.push({ kind: e.kind, id: e.id, reason: lv.reason }); continue; }
    const base = { kind: e.kind, id: e.id, doing: cleanLabel(e.label), parked_at: e.parked_at,
      liveness: lv.state, liveness_reason: lv.reason };
    if (isTicket) {
      // tty on a ticket park is PROVENANCE (where it was parked from), not a session: never render it.
      items.push({ ...base, ticket: tkKey(e.id) || e.id, tty: '', parked_from: e.tty || '', status: lv.status || '' });
    } else {
      const tty = ttyBase(e.tty || (e.kind === 'tab' ? e.id : ''));
      items.push({ ...base, ticket: ticketOf(e.label), tty, pid: e.pid || null, attached: tty ? isAttached(tty) : true });
    }
  }
  return { count: items.length, items, hidden };
}

// TK-12236: the ACTIVE colour-group rows (allcolordots --json) get the same session probe, so a
// terminal whose claude died drops out of every panel, not just PARKED. rows: [{tty,pid,...}].
// procs null (ps failed) -> everything kept (fail-open). Returns { kept, hidden }.
function filterSessions(rows, procs) {
  const kept = [], hidden = [];
  for (const r of Array.isArray(rows) ? rows : []) {
    if (!r) continue;
    const lv = tabLiveness({ tty: r.tty, pid: r.pid }, procs);
    if (lv.state === 'dead') hidden.push({ tty: ttyBase(r.tty), pid: r.pid || null, reason: lv.reason });
    else kept.push({ ...r, liveness: lv.state });
  }
  return { kept, hidden };
}

module.exports = { filterSessions, isClaudeComm, parsePs, ticketStatusMap, tabLiveness, ticketLiveness, buildParked, tkKey };