← 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
A live-filter.jsM public/index.htmlM server.jsA test/live-filter.test.js
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,'&').replace(/"/g,'"').replace(/</g,'<');
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,'"')}">${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,'"')}">${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 →