[object Object]

← back to Desktop Dotbar

dotbar: hide dead sessions from ALL panels (colour groups + parked), fix claude.exe liveness, never hide parked tickets (TK-12236, TK-12174)

d2b3a95e693303c063311d211479c69c12f863f3 · 2026-09-26 10:01:01 -0700 · Steve Abrams

- isClaudeComm accepted only /claude(\s|$)/, so the native installer's .../bin/claude.exe
  made a LIVE parked tab read as 'recycled' and vanish; now claude/claude.exe/node/codex
- filterSessions: same ps probe drops dead-tty rows from the colour groups; ps failure = fail-open
- parked TICKETS are never hidden (always-show rule); closed -> liveness 'closed', off-board -> unknown
- negative tests: fake dead tty hidden, live kept, unknown kept; mutation-verified red

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GEE4jWy9RBmz5QnR2xcvno

Files touched

Diff

commit d2b3a95e693303c063311d211479c69c12f863f3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 10:01:01 2026 -0700

    dotbar: hide dead sessions from ALL panels (colour groups + parked), fix claude.exe liveness, never hide parked tickets (TK-12236, TK-12174)
    
    - isClaudeComm accepted only /claude(\s|$)/, so the native installer's .../bin/claude.exe
      made a LIVE parked tab read as 'recycled' and vanish; now claude/claude.exe/node/codex
    - filterSessions: same ps probe drops dead-tty rows from the colour groups; ps failure = fail-open
    - parked TICKETS are never hidden (always-show rule); closed -> liveness 'closed', off-board -> unknown
    - negative tests: fake dead tty hidden, live kept, unknown kept; mutation-verified red
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01GEE4jWy9RBmz5QnR2xcvno
---
 evidence/dotbar-parked-tk12236.png | Bin 0 -> 39643 bytes
 live-filter.js                     |  29 ++++++++++++++++++----
 server.js                          |  12 +++++++---
 test/live-filter.test.js           |  48 +++++++++++++++++++++++++++++++++----
 4 files changed, 77 insertions(+), 12 deletions(-)

diff --git a/evidence/dotbar-parked-tk12236.png b/evidence/dotbar-parked-tk12236.png
new file mode 100644
index 0000000..b5d7181
Binary files /dev/null and b/evidence/dotbar-parked-tk12236.png differ
diff --git a/live-filter.js b/live-filter.js
index 10d2f81..124787a 100644
--- a/live-filter.js
+++ b/live-filter.js
@@ -14,7 +14,10 @@ const tkKey = (id) => { const m = /^(TK-\d+)/i.exec(String(id || '')); return m
 // 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).
-function isClaudeComm(comm) { return /(^|\/)claude(\s|$)/.test(String(comm || '').trim()); }
+// 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".
@@ -60,9 +63,9 @@ 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)` };
+  if (!tickets.has(k)) return { state: 'unknown', reason: `${k} not in board summary` };
   const status = tickets.get(k);
-  if (CLOSED.has(status)) return { state: 'dead', reason: `${k} is ${status}`, status };
+  if (CLOSED.has(status)) return { state: 'closed', reason: `${k} is ${status}`, status };
   return { state: 'live', reason: `${k} is ${status}`, status };
 }
 
@@ -76,7 +79,9 @@ function buildParked(entries, { procs, tickets, isAttached = () => true, cleanLa
     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; }
+    // 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) {
@@ -90,4 +95,18 @@ function buildParked(entries, { procs, tickets, isAttached = () => true, cleanLa
   return { count: items.length, items, hidden };
 }
 
-module.exports = { isClaudeComm, parsePs, ticketStatusMap, tabLiveness, ticketLiveness, buildParked, tkKey };
+// 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 };
diff --git a/server.js b/server.js
index 0404305..c627204 100755
--- a/server.js
+++ b/server.js
@@ -102,6 +102,12 @@ async function getDots() {
   // A durably-parked live tab peels OUT of the active colour groups into the PARKED
   // section, so the active dots stay uncluttered (allcolordots --json carries `parked`).
   rows = rows.filter(r => r && r.live && !r.parked);
+  // TK-12236: ONE batched ps per refresh (never per row). A session row whose claude died drops out of
+  // EVERY panel (colour groups here, PARKED below); ps failure -> procs null -> nothing hidden (fail-open).
+  const { err: psErr, stdout: psOut } = await run('ps', ['-axo', 'pid=,tty=,comm='], 8000, 8 << 20);
+  const procs = psErr ? null : liveFilter.parsePs(psOut);
+  const sess = liveFilter.filterSessions(rows, procs);
+  rows = sess.kept;
   // Re-decide each ACTIVE dot's COLOR through Jev (System One typed choice). Scoped to
   // the dotbar's own read path — allcolordots.sh is untouched. Jev unavailable/capped/
   // errors -> we keep allcolordots' original heuristic color (never blank, never throw).
@@ -119,6 +125,7 @@ async function getDots() {
       attached: isAttached(r.tty),   // false => orphaned (live claude, no iTerm2 tab): UI hides the dead-end open button
       ticket: ticketOf(r.label),
       doing: cleanLabel(r.label),
+      liveness: r.liveness,          // 'live' | 'unknown' (dead rows never reach here)
     });
   }
   // Folder tag per session (DW vs Non-DW) so the bar can be scoped to one folder. Fail-open to 'other'.
@@ -130,13 +137,12 @@ async function getDots() {
     .filter(g => g.count > 0 || g.color !== 'none'); // ALWAYS show every real colour (dimmed at 0, Steve 2026-09-25); hide only an empty 'no dot'
   // 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 });
   await tagAll(parkedOut.items);   // TK-12234 folder tag on the LIVE parked rows only
-  return { updated: Date.now(), total: rows.length, groups: out, parked: parkedOut };
+  return { updated: Date.now(), total: rows.length, groups: out, parked: parkedOut,
+    hidden_sessions: sess.hidden, liveness_measured: !!procs };
 }
 
 // Bring the iTerm2 session whose tty matches to the front.
diff --git a/test/live-filter.test.js b/test/live-filter.test.js
index 5276fca..d4c4fb8 100644
--- a/test/live-filter.test.js
+++ b/test/live-filter.test.js
@@ -14,6 +14,9 @@ const PS = [
   '  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',
+  ' 1634 ttys002  /Users/x/.npm-global/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe',
+  ' 1700 ttys040  node',
+  ' 1800 ttys041  /bin/zsh',
 ].join('\n');
 const procs = lf.parsePs(PS);
 const tickets = lf.ticketStatusMap([
@@ -50,18 +53,21 @@ test('tab with no pid falls back to "some claude holds that tty"', () => {
   assert.deepEqual(ids(out), ['ttys021']);
 });
 
-test('NEGATIVE: closed / archived tickets hidden; open tickets kept with no tty rendered', () => {
+test('parked TICKETS are NEVER hidden (TK-12174 / always-show rule): open, closed, off-board all shown', () => {
   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']);
+  assert.deepEqual(ids(out), ['TK-11155', 'TK-12103', 'TK-9001', 'TK-9002', 'TK-4']);
+  assert.equal(out.hidden.length, 0, 'no ticket park is ever hidden');
+  const by = Object.fromEntries(out.items.map(i => [i.id, i]));
+  assert.equal(by['TK-11155'].liveness, 'live');
+  assert.equal(by['TK-9001'].liveness, 'closed'); assert.equal(by['TK-9001'].status, 'done');
+  assert.equal(by['TK-4'].liveness, 'unknown', 'not in board summary = NOT-MEASURED, shown');
   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)', () => {
@@ -102,3 +108,37 @@ test('NEGATIVE: an UNCLAIMED daemon spare (tty ??) never makes a tty or pid row
   const out = lf.buildParked([tab('ttys099'), tab('ttys098', 700)], { procs: p, tickets });
   assert.deepEqual(ids(out), [], 'no-pid row on a spare-less tty and a pid that is an unclaimed spare elsewhere are both hidden');
 });
+
+test('REGRESSION: native-installer comm ".../bin/claude.exe" counts as live (was hidden as "recycled")', () => {
+  assert.ok(lf.isClaudeComm('/Users/x/.npm-global/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe'));
+  assert.ok(!lf.isClaudeComm('/usr/bin/vim'));
+  assert.ok(!lf.isClaudeComm('/bin/zsh'));
+  const out = lf.buildParked([tab('ttys002', 1634)], { procs, tickets });
+  assert.deepEqual(ids(out), ['ttys002']);
+  assert.equal(out.items[0].liveness, 'live');
+});
+
+test('NEGATIVE (all panels): filterSessions hides a fake dead-tty row, keeps a live one, keeps unknown', () => {
+  const rows = [
+    { tty: 'ttys020', pid: '100', color: 'green' },   // live claude
+    { tty: 'ttys040', pid: '1700', color: 'yellow' }, // live node session
+    { tty: 'ttys041', pid: '1800', color: 'pink' },   // pid is a bare shell -> claude gone -> dead
+    { tty: 'ttys077', pid: '4242', color: 'purple' }, // FAKE dead tty: pid not running
+    { tty: 'ttys078', color: 'orange' },              // no pid, no claude on tty -> dead
+  ];
+  const { kept, hidden } = lf.filterSessions(rows, procs);
+  assert.deepEqual(kept.map(r => r.tty), ['ttys020', 'ttys040']);
+  assert.ok(kept.every(r => r.liveness === 'live'));
+  assert.deepEqual(hidden.map(h => h.tty).sort(), ['ttys041', 'ttys077', 'ttys078']);
+  // UNKNOWN liveness (ps probe failed) -> every row shown, none hidden (fail-open)
+  const blind = lf.filterSessions(rows, null);
+  assert.equal(blind.kept.length, rows.length);
+  assert.equal(blind.hidden.length, 0);
+  assert.ok(blind.kept.every(r => r.liveness === 'unknown'));
+});
+
+test('server wires filterSessions into the colour groups (not just PARKED)', () => {
+  const src = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
+  assert.match(src, /liveFilter\.filterSessions\(rows, procs\)/);
+  assert.match(src, /rows = sess\.kept;/);
+});

← a5d20f8 bar: add dw / non-dw ticket chips under the five state chips  ·  back to Desktop Dotbar  ·  auto-data-snapshot: 2026-09-26T11:13:09 (1 data files) — .cl 7956e2f →