[object Object]

← back to Desktop Dotbar

dotbar: parked panel shows only live rows (pid/claude probe, open tickets), unknown shown not hidden, chip==rows (TK-12236)

355916c75cebf09c51974be8eb40763c0e477024 · 2026-09-25 10:11:19 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 355916c75cebf09c51974be8eb40763c0e477024
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 10:11:19 2026 -0700

    dotbar: parked panel shows only live rows (pid/claude probe, open tickets), unknown shown not hidden, chip==rows (TK-12236)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---
 live-filter.js           | 90 +++++++++++++++++++++++++++++++++++++++++++++
 public/index.html        | 49 ++++++++++++++++++-------
 server.js                | 30 ++++++++++-----
 test/live-filter.test.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 240 insertions(+), 24 deletions(-)

diff --git a/live-filter.js b/live-filter.js
new file mode 100644
index 0000000..3d82494
--- /dev/null
+++ b/live-filter.js
@@ -0,0 +1,90 @@
+'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.
+function isClaudeComm(comm) { return /(^|\/)claude(\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: 'dead', reason: `${k} not on board (archived done)` };
+  const status = tickets.get(k);
+  if (CLOSED.has(status)) return { state: 'dead', 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}` };
+    if (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, attached: tty ? isAttached(tty) : true });
+    }
+  }
+  return { count: items.length, items, hidden };
+}
+
+module.exports = { isClaudeComm, parsePs, ticketStatusMap, tabLiveness, ticketLiveness, buildParked, tkKey };
diff --git a/public/index.html b/public/index.html
index 54c69d1..070d4a3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -111,6 +111,10 @@
   .orphan { -webkit-app-region:no-drag; font-size:11px; padding:5px 9px; border-radius:7px;
     border:1px dashed var(--line); background:transparent; color:var(--dim); white-space:nowrap; opacity:.7; }
   .empty { padding:14px; color:var(--dim); }
+  /* TK-12236: liveness NOT-MEASURED (ps/board probe failed) — shown, but never as live-green. */
+  .unk { font-size:11px; padding:5px 9px; border-radius:7px; border:1px dashed #d4a017;
+    color:#d4a017; white-space:nowrap; }
+  .chip.zero { opacity:.42; }
 </style>
 </head>
 <body class="orient-top">
@@ -166,13 +170,16 @@ function renderBar(){
     bar.appendChild(chip);
   }
   // Durable PARKED chip (TK-11946) — collapsed badge, distinct from live pink; opens a
-  // panel listing the registry (parked tabs + parked tickets). Hidden when the registry is empty.
-  if (data.parked && data.parked.count){
+  // panel listing the registry (parked tabs + parked tickets). TK-12236: the count is the
+  // LIVE-filtered row list the panel renders (same array => chip == panel rows), and the chip
+  // stays visible (dimmed) at 0 per the always-show-every-state rule.
+  {
+    const pItems = (data.parked && data.parked.items) || [];
     const pchip = document.createElement('div');
-    pchip.className = 'chip' + (openColor==='parked' ? ' active':'');
+    pchip.className = 'chip' + (openColor==='parked' ? ' active':'') + (pItems.length ? '' : ' zero');
     const showName = (openColor==='parked') || cfg.orientation !== 'top';
     pchip.innerHTML = `<span class="dot" style="background:#ff8fc8"></span>`
-      + `<span class="cnt">${data.parked.count}</span>`
+      + `<span class="cnt">${pItems.length}</span>`
       + (showName ? `<span class="nm">parked</span>` : '');
     pchip.onclick = () => toggle('parked');
     bar.appendChild(pchip);
@@ -256,19 +263,33 @@ function renderPanel(){
   if (openColor==='parked'){
     panel.className = 'open';
     const items = (data.parked && data.parked.items) || [];
-    if (!items.length){ panel.innerHTML = `<div class="empty">No parked items.</div>`; return; }
-    let html = `<div class="phead">🩷 parked (durably held) — ${items.length}</div>`;
+    const nHidden = (data.parked && data.parked.hidden || []).length;
+    const hiddenNote = nHidden ? ` · ${nHidden} dead hidden` : '';
+    if (!items.length){ panel.innerHTML = `<div class="empty">No live parked items${hiddenNote}.</div>`; return; }
+    let html = `<div class="phead">🩷 parked (durably held) — ${items.length}${hiddenNote}</div>`;
+    const esc = (v) => String(v||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
     for (const s of items){
+      const unk = s.liveness === 'unknown';
+      let tail;
+      if (s.kind === 'ticket') {
+        // Ticket park: tty is provenance only — no tty, no orphan badge, no open button.
+        tail = unk ? `<span class="unk" title="${esc(s.liveness_reason)} — ticket status not measured, shown anyway">status unknown</span>` : '';
+      } else if (unk) {
+        tail = `<span class="unk" title="${esc(s.liveness_reason)} — liveness not measured, shown anyway">liveness unknown</span>`
+          + (s.tty && s.attached !== false ? `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>` : '');
+      } else if (!s.tty) {
+        tail = '';
+      } else if (s.attached === false) {
+        tail = `<span class="orphan" title="Claude is running on ${esc(s.tty)} but has no iTerm2 tab">running · no tab</span>`;
+      } else {
+        tail = `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`;
+      }
       html += `<div class="row">`
         + `<span class="rdot" style="background:#ff8fc8"></span>`
         + `<span class="tk">${s.ticket || s.id || '—'}</span>`
-        + `<span class="doing" title="${(s.doing||'').replace(/"/g,'&quot;')}">${s.doing || ('('+s.kind+')')}</span>`
-        + `<span class="tty">${s.tty || s.kind}</span>`
-        + (s.tty
-            ? (s.attached === false
-                ? `<span class="orphan" title="Live process, but its iTerm2 tab was closed — nothing to focus.">orphaned · no tab</span>`
-                : `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`)
-            : '')
+        + `<span class="doing" title="${esc(s.doing)}">${s.doing || ('('+s.kind+')')}</span>`
+        + `<span class="tty" title="${esc(s.liveness_reason)}">${s.kind === 'ticket' ? (s.status || 'ticket') : (s.tty || s.kind)}</span>`
+        + tail
         + `</div>`;
     }
     panel.innerHTML = html; return;
@@ -284,7 +305,7 @@ function renderPanel(){
       + `<span class="doing" title="${(s.doing||'').replace(/"/g,'&quot;')}">${s.doing || '(no label)'}</span>`
       + `<span class="tty">${s.tty}</span>`
       + (s.attached === false
-          ? `<span class="orphan" title="Live process, but its iTerm2 tab was closed — nothing to focus.">orphaned · no tab</span>`
+          ? `<span class="orphan" title="Claude is running on ${s.tty} but has no iTerm2 tab">running · no tab</span>`
           : `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`)
       + `</div>`;
   }
diff --git a/server.js b/server.js
index 57ce537..72f716f 100755
--- a/server.js
+++ b/server.js
@@ -9,6 +9,7 @@ const { execFile, spawn } = require('child_process');
 const fs = require('fs');
 const path = require('path');
 const jevDots = require('./jev-dots');  // re-classifies each dot COLOR via Jev ($0 builtin; paid flip gated)
+const liveFilter = require('./live-filter');  // TK-12236: hide dead parked rows (pure; unit-tested)
 
 const ALLCOLORDOTS = `${process.env.HOME}/.claude/skills/allcolordots/allcolordots.sh`;
 const ROUTER = `${process.env.HOME}/.claude/skills/dot-screen-router/router.sh`;
@@ -120,15 +121,14 @@ async function getDots() {
   const out = ORDER
     .map(color => ({ color, ...META[color], count: groups[color].length, sessions: groups[color] }))
     .filter(g => g.count > 0 || g.color === 'green'); // always show green; hide empty others
-  const parkedItems = parked.map(e => {
-    const tty = e.tty || (e.kind === 'tab' ? e.id : '');
-    return {
-      kind: e.kind, id: e.id, ticket: e.kind === 'ticket' ? e.id : ticketOf(e.label),
-      tty, attached: tty ? isAttached(tty) : true, doing: cleanLabel(e.label), parked_at: e.parked_at,
-    };
-  });
-  return { updated: Date.now(), total: rows.length, groups: out,
-    parked: { count: parkedItems.length, items: parkedItems } };
+  // TK-12236: parked rows show only if LIVE. One batched ps per refresh (never per row);
+  // ticket parks resolve against the board status map refreshed by refreshTickets().
+  const { err: psErr, stdout: psOut } = await run('ps', ['-axo', 'pid=,tty=,comm='], 8000, 8 << 20);
+  const procs = psErr ? null : liveFilter.parsePs(psOut);
+  if (!ticketStatus.map) await refreshTickets();
+  const tickets = ticketStatus.map && Date.now() - ticketStatus.at < TICKET_STATUS_MAX_AGE_MS ? ticketStatus.map : null;
+  const parkedOut = liveFilter.buildParked(parked, { procs, tickets, isAttached, cleanLabel, ticketOf });
+  return { updated: Date.now(), total: rows.length, groups: out, parked: parkedOut };
 }
 
 // Bring the iTerm2 session whose tty matches to the front.
@@ -258,14 +258,21 @@ let snapshot = { updated: 0, total: 0, groups: [], stale: true };
 let ramSnapshot = { updated: 0, ram_pct: 0, cpu_pct: 0, on: false, hogs: [] };
 let ticketsSnapshot = { updated: 0, blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
 let refreshing = false;
+// Board ticket-status map for the parked filter; null until first good fetch. Older than the
+// max age = NOT-MEASURED (shown with an "unknown" marker), never trusted as current.
+let ticketStatus = { map: null, at: 0 };
+const TICKET_STATUS_MAX_AGE_MS = 5 * 60 * 1000;
+const ACTIVE_TICKET_STATUSES = new Set(['doing', 'blocked', 'open', 'stopped']);
 async function refreshRam() { try { ramSnapshot = await getRam(); } catch (e) { /* keep last */ } }
 // Fetch ticket-board summary: blocked / open / idle(stopped) / doing / parked + latest event.
 // Board needs Basic Auth (admin:DW2024!); rows carry status + a `parked` boolean flag.
 async function getTickets() {
   // The board returns the full ~4.5MB ticket array (it ignores fields=), so give curl an 8MB
   // buffer — the default 1MB silently truncated the response and every count parsed as 0.
+  // fields=summary over ALL statuses (~0.5MB, board-cached) so the same fetch also yields the
+  // ticket-status map the parked filter needs (a closed ticket must be KNOWN closed, not absent).
   const { stdout: boardData } = await run('curl', ['-s', '-m', '4', '-u', 'admin:DW2024!',
-    'http://127.0.0.1:9794/api/tickets?status=doing,blocked,open,stopped'], 6000, 8 << 20);
+    'http://127.0.0.1:9794/api/tickets?fields=summary'], 6000, 8 << 20);
   // KEEP-LAST-GOOD (mirrors getDots): curl returns '' on connection-refused/timeout (board down),
   // and '' -> JSON.parse('[]') parses CLEANLY as [] — only a *parse* error hit the old catch, so a
   // dead board silently zeroed every count. Treat an empty/whitespace OR unparseable body as a
@@ -275,10 +282,13 @@ async function getTickets() {
   let j;
   try { j = JSON.parse(boardData); } catch (e) { return null; }
   const tickets = { blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
+  const statusMap = liveFilter.ticketStatusMap(j);
+  if (statusMap) ticketStatus = { map: statusMap, at: Date.now() };
   if (Array.isArray(j)) {
     let newest = null;
     for (const row of j) {
       const status = row.status || '';
+      if (!ACTIVE_TICKET_STATUSES.has(status)) continue;   // counts + latest cover only the active board states
       // Peel-out (mirrors the board / old TicketBar): a ticket with parked=true in an active
       // status counts as PARKED and is removed from its status bucket. "idle" = stopped tickets.
       const isParked = row.parked && ['open', 'blocked', 'doing'].includes(status);
diff --git a/test/live-filter.test.js b/test/live-filter.test.js
new file mode 100644
index 0000000..f77b586
--- /dev/null
+++ b/test/live-filter.test.js
@@ -0,0 +1,95 @@
+'use strict';
+// TK-12236: the PARKED panel shows only LIVE rows. Negative tests inject a dead pid, a recycled
+// pid, a closed ticket and a probe failure, and assert the right row is hidden / kept / marked.
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const assert = require('node:assert');
+const lf = require('../live-filter');
+
+const PS = [
+  '  100 ttys020  claude',
+  '  200 ttys004  claude bg-spare',
+  '  300 ttys011  /usr/bin/vim',          // pid 300 was a claude once; now recycled to vim
+  '  400 ttys030  claude',                // a claude, but on a different tty than recorded
+  '  500 ttys021  claude',                // holds ttys021 (for the no-pid fallback)
+  '  600 ??       /usr/sbin/cfprefsd',
+].join('\n');
+const procs = lf.parsePs(PS);
+const tickets = lf.ticketStatusMap([
+  { id: 'TK-11155-ios-fleet', status: 'doing' },
+  { id: 'TK-12103-phillipe-romano', status: 'blocked' },
+  { id: 'TK-9001-finished', status: 'done' },
+  { id: 'TK-9002-cancelled', status: 'cancelled' },
+]);
+const tab = (tty, pid) => ({ kind: 'tab', id: tty, tty, pid, label: `TK-1 · ${tty}` });
+const tkt = (id, tty) => ({ kind: 'ticket', id, tty, label: 'parked by Steve' });
+const ids = (out) => out.items.map(i => i.id);
+
+test('parsePs keeps renamed claude comms and rejects an empty probe', () => {
+  assert.ok(procs.claudeTtys.has('ttys004'), '"claude bg-spare" is a claude process');
+  assert.ok(!procs.claudeTtys.has('ttys011'), 'vim is not claude');
+  assert.equal(lf.parsePs(''), null, 'empty ps output is NOT-MEASURED, not "nothing alive"');
+  assert.equal(lf.ticketStatusMap([]), null, 'empty board is NOT-MEASURED (0 of 0)');
+});
+
+test('NEGATIVE: dead pid, recycled pid, and claude-on-another-tty are all hidden', () => {
+  const out = lf.buildParked([
+    tab('ttys020', 100),  // live
+    tab('ttys016', 999),  // pid gone
+    tab('ttys011', 300),  // pid recycled to vim
+    tab('ttys012', 400),  // pid is a claude, but on ttys030 -> recycled
+  ], { procs, tickets });
+  assert.deepEqual(ids(out), ['ttys020']);
+  assert.deepEqual(out.hidden.map(h => h.id).sort(), ['ttys011', 'ttys012', 'ttys016']);
+  assert.equal(out.items[0].liveness, 'live');
+});
+
+test('tab with no pid falls back to "some claude holds that tty"', () => {
+  const out = lf.buildParked([tab('ttys021'), tab('ttys099')], { procs, tickets });
+  assert.deepEqual(ids(out), ['ttys021']);
+});
+
+test('NEGATIVE: closed / archived tickets hidden; open tickets kept with no tty rendered', () => {
+  const out = lf.buildParked([
+    tkt('TK-11155', 'ttys026'), tkt('TK-12103', 'ttys016'),
+    tkt('TK-9001', 'ttys026'), tkt('TK-9002', 'ttys026'), tkt('TK-4', 'ttys026'),
+  ], { procs, tickets });
+  assert.deepEqual(ids(out), ['TK-11155', 'TK-12103']);
+  for (const i of out.items) {
+    assert.equal(i.tty, '', 'ticket tty is provenance, never rendered as a session');
+    assert.equal(i.parked_from, i.id === 'TK-11155' ? 'ttys026' : 'ttys016');
+    assert.equal(i.attached, undefined, 'no orphan/attached state on a ticket park');
+  }
+  assert.deepEqual(out.hidden.map(h => h.id), ['TK-9001', 'TK-9002', 'TK-4']);
+});
+
+test('FAIL-OPEN: probe failures show the row with liveness unknown (never hidden, never live)', () => {
+  const out = lf.buildParked([tab('ttys016', 999), tkt('TK-9001', 'ttys026')], { procs: null, tickets: null });
+  assert.deepEqual(ids(out), ['ttys016', 'TK-9001']);
+  for (const i of out.items) assert.equal(i.liveness, 'unknown');
+  assert.equal(out.hidden.length, 0);
+});
+
+test('live tab without an iTerm2 tab keeps attached:false (renders "running · no tab")', () => {
+  const out = lf.buildParked([tab('ttys020', 100)], { procs, tickets, isAttached: () => false });
+  assert.equal(out.items[0].attached, false);
+});
+
+test('parked chip count == panel row count (payload + UI read the same array)', () => {
+  const fixtures = [
+    [tab('ttys020', 100), tab('ttys016', 999), tkt('TK-11155'), tkt('TK-9001')],
+    [],
+    [tab('ttys016', 999)],
+  ];
+  for (const f of fixtures) {
+    for (const probes of [{ procs, tickets }, { procs: null, tickets: null }]) {
+      const out = lf.buildParked(f, probes);
+      assert.equal(out.count, out.items.length);
+    }
+  }
+  const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');
+  assert.match(html, /const pItems = \(data\.parked && data\.parked\.items\) \|\| \[\];/);
+  assert.match(html, /<span class="cnt">\$\{pItems\.length\}<\/span>/, 'chip count must be the panel item array length');
+  assert.match(html, /const items = \(data\.parked && data\.parked\.items\) \|\| \[\];/, 'panel renders data.parked.items');
+});

← bbbb012 auto-data-snapshot: 2026-09-25T09:51:23 (2 data files) — .cl  ·  back to Desktop Dotbar  ·  dotbar: carry pid on live parked tab rows so folder/realm cw 9c338a8 →