← back to Desktop Dotbar
server.js
414 lines
#!/usr/bin/env node
// desktop-dotbar — always-on-top top strip showing each color dot + live count,
// click a dot to see its tickets + what each is doing, click a ticket to jump to
// that iTerm2 terminal. Zero deps (Node built-ins only). $0 local.
'use strict';
const http = require('http');
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 realm = require('./realm'); // DW / Non-DW folder per session (TK-12234)
const { ticketStates } = require('./ticket-states'); // always-render ticket-state row (blocked/open/idle/doing/parked/dw/non-dw)
const { isDW } = require('./dw-segment'); // board-parity DW segmentation for the dw / non-dw chips
const ALLCOLORDOTS = `${process.env.HOME}/.claude/skills/allcolordots/allcolordots.sh`;
const ROUTER = `${process.env.HOME}/.claude/skills/dot-screen-router/router.sh`;
const PARKED_MJS = `${process.env.HOME}/.claude/skills/parked/parked.mjs`; // durable PARKED registry (TK-11946)
const SNIPER = `${process.env.HOME}/bin/ram-sniper.sh`; // priority-throttle daemon (renice only, never kills)
// Urgency order (matches allcolordots): needs-Steve first, working/parked last.
const ORDER = ['lightblue', 'orange', 'purple', 'yellow', 'green', 'pink', 'none'];
const META = {
lightblue: { emoji: '🔵', css: '#3aa0ff', name: 'needs Steve' },
orange: { emoji: '🟠', css: '#ff8c00', name: 'paste waiting' },
purple: { emoji: '🟣', css: '#a45cff', name: 'gated' },
yellow: { emoji: '🟡', css: '#ffd21e', name: 'needs direction' },
green: { emoji: '🟢', css: '#2ecc57', name: 'working' },
pink: { emoji: '🩷', css: '#ff8fc8', name: 'parked' },
none: { emoji: '⚪', css: '#8a8a8a', name: 'no dot' },
};
function run(cmd, args, timeoutMs = 8000, maxBuffer = 1 << 20) {
return new Promise((resolve) => {
execFile(cmd, args, { timeout: timeoutMs, maxBuffer }, (err, stdout) => {
resolve({ err, stdout: stdout || '' });
});
});
}
// Strip a leading dot emoji + spaces from a label so the text reads cleanly.
function cleanLabel(label) {
if (!label) return '';
return label.replace(/^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}️\s]+/u, '').trim();
}
function ticketOf(label) {
const m = /(TK-\d+)/i.exec(label || '');
return m ? m[1].toUpperCase() : '';
}
// Set of tty basenames (e.g. "ttys014") that iTerm2 actually has an ATTACHED session for.
// A claude process can be "live" (allcolordots sees it via a ps scan) yet ORPHANED — its
// iTerm2 tab/window was closed, so the process survives under iTermServer with no session to
// focus. reveal() (iTerm2-only) can never open an orphan, so we cross-reference here and mark
// each row `attached`; the UI then hides the dead-end "open terminal" button for orphans.
// Uses the split-pane-SAFE index walk: the `repeat with s in sessions of t` element-reference
// form throws -1719 on split-pane tabs and silently drops those sessions (undercount), so we
// index by number with a per-session try, exactly like reveal().
async function iterm2AttachedTtys() {
const script = `set out to ""
tell application "iTerm2"
repeat with wi from 1 to (count of windows)
set w to window wi
repeat with ti from 1 to (count of tabs of w)
set t to tab ti of w
repeat with si from 1 to (count of sessions of t)
try
set out to out & (tty of (session si of t)) & " "
end try
end repeat
end repeat
end repeat
end tell
return out`;
const { err, stdout } = await run('osascript', ['-e', script], 12000);
const set = new Set();
if (err) return null; // iTerm2 unreachable -> don't mark anything orphaned (fail-open)
for (const dev of String(stdout || '').trim().split(/\s+/)) {
const b = dev.split('/').pop();
if (b) set.add(b);
}
return set;
}
const ttyBase = (t) => String(t || '').split('/').pop();
async function getDots() {
// allcolordots scans every terminal; on a busy/loaded box (many sessions) it can take 60-90s.
// The default 8s cap timed out every refresh -> empty output -> the bar showed "0 live". Give a
// generous cap; keep-last-good (in refresh()) covers the interim so the bar never flaps to 0.
const { stdout } = await run('bash', [ALLCOLORDOTS, '--json'], 120000);
// Which live rows are actually attached to an iTerm2 tab (vs orphaned). null = iTerm2 unreachable.
const attachedSet = await iterm2AttachedTtys();
const isAttached = (tty) => attachedSet === null ? true : attachedSet.has(ttyBase(tty));
let rows = [];
try { rows = JSON.parse(stdout || '[]'); } catch { rows = []; }
// Durable PARKED group straight from the registry (both tab + ticket kinds),
// collapsed and distinct from the transient live-pink dots.
const { stdout: pj } = await run(process.execPath, [PARKED_MJS, 'list-parked', '--json'], 8000);
let parked = [];
try { parked = JSON.parse(pj || '[]'); } catch { parked = []; }
// 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).
let jevMap = new Map();
try { jevMap = await jevDots.classifyDots(rows); } catch { jevMap = new Map(); }
const groups = {};
for (const key of ORDER) groups[key] = [];
for (const r of rows) {
const jv = jevMap.get(r.tty);
const jevColor = jv && META[jv.color] ? jv.color : null;
const color = jevColor || (META[r.color] ? r.color : 'none');
groups[color].push({
tty: r.tty,
pid: r.pid,
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'.
try { realm.indexTickets(); } catch { /* keep going; realmOf falls back to label + cwd */ }
const tagAll = (list) => Promise.all(list.map(async s => { try { s.realm = await realm.realmOf(s); } catch { s.realm = 'other'; } }));
await Promise.all(ORDER.map(c => tagAll(groups[c])));
const out = ORDER
.map(color => ({ color, ...META[color], count: groups[color].length, sessions: groups[color] }))
.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().
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,
hidden_sessions: sess.hidden, liveness_measured: !!procs };
}
// Bring the iTerm2 session whose tty matches to the front.
async function reveal(tty) {
const safe = String(tty || '').replace(/[^a-z0-9]/gi, '');
if (!safe) return { ok: false, error: 'bad tty' };
// Index-based walk with try around each access — iTerm2's `repeat with s in
// sessions of t` throws -1719 when a tab has split panes; this form doesn't.
const script = `
tell application "iTerm2"
activate
repeat with wi from 1 to (count of windows)
set w to window wi
repeat with ti from 1 to (count of tabs of w)
set t to tab ti of w
repeat with si from 1 to (count of sessions of t)
try
set s to session si of t
if (tty of s) contains "${safe}" then
select w
tell t to select
tell s to select
return "ok"
end if
end try
end repeat
end repeat
end repeat
end tell
return "notfound"`;
const { stdout, err } = await run('osascript', ['-e', script]);
return { ok: /ok/.test(stdout) && !err, result: (stdout || '').trim(), error: err ? String(err) : null };
}
// One-click re-tile: run the dot-screen router NOW (force a full grid re-pack of every screen —
// green tiled on the left, colour bands row-major on the right). The launchd loop already does
// this every ~30s; the Arrange button makes it instant after Steve manually drags windows around.
// ONE pass only: a single router pass reads every window's current position and moves ALL the
// misplaced ones to their slots in one osascript batch, so one click snaps everything back. (An
// earlier 3-pass "converge" loop cost ~74s on a loaded box AND could never settle, because dot
// colours change live between passes — it was chasing a moving target. The 30s loop covers drift.)
async function arrange() {
const { stdout, err } = await run('bash', [ROUTER], 45000);
const m = /routed (\d+)/.exec(stdout || '');
const busy = /router busy/i.test(stdout || '');
return { ok: !err, moved: m ? parseInt(m[1], 10) : 0,
master: true, busy, err: busy ? null : (err ? String(err).slice(0, 120) : null) };
}
// ---- RAM Sniper panel: live RAM%/CPU%, top memory hogs, daemon on/off ----
// PIDs of the ACTUAL ram-sniper daemon — a shell interpreter EXECUTING the script
// (ps shows `/bin/zsh /Users/.../ram-sniper.sh`), NOT an editor with the file open,
// a pgrep/grep shell that merely mentions the path, or this node server. The old
// `-f` substring match hit all of those (see pgrep-f-matches-own-watcher-shell) —
// during testing it SIGTERM'd the caller's own `pgrep -f bin/ram-sniper.sh` shells.
async function sniperPids() {
const { stdout } = await run('ps', ['-axo', 'pid=,command=']);
const pids = [];
for (const line of stdout.split('\n')) {
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
if (!m) continue;
const pid = parseInt(m[1], 10), cmd = m[2];
if (pid === process.pid) continue; // never us
// a shell interpreter (^ or /-prefixed) directly followed by a path ending in ram-sniper.sh
if (/(^|\/)(?:zsh|bash|dash|sh)\s+\S*ram-sniper\.sh(\s|$)/.test(cmd)) pids.push(pid);
}
return pids;
}
async function sniperOn() { return (await sniperPids()).length > 0; }
// Zero-dep stats: system RAM%/CPU% from `top`, top hogs from `ps` (no psutil).
async function getRam() {
const [{ stdout: topOut }, { stdout: memBytes }, { stdout: psOut }, mp] = await Promise.all([
run('top', ['-l', '1', '-n', '0']),
run('sysctl', ['-n', 'hw.memsize']),
run('ps', ['-axo', 'pid=,rss=,comm=']),
run('memory_pressure', []).then(r => r.stdout).catch(() => ''), // resilient: never rejects the batch
]);
// CPU: "CPU usage: 4.1% user, 5.2% sys, 90.6% idle" -> used = 100 - idle.
let cpu = 0;
const idle = /CPU usage:.*?([\d.]+)%\s*idle/i.exec(topOut);
if (idle) cpu = Math.max(0, Math.min(100, 100 - parseFloat(idle[1])));
// RAM: use macOS memory-pressure (Activity Monitor's real strain signal) so the chip reflects
// genuine pressure, not cache. "used" = 100 - free%. macOS keeps RAM ~90% full with reclaimable
// file cache, so the old `top` PhysMem used/(used+unused) read ~96% and cried wolf (Steve 2026-09-23).
let ramPct = 0;
const freeM = /free percentage:\s*([\d.]+)%/i.exec(mp);
if (freeM) {
ramPct = Math.max(0, Math.min(100, 100 - parseFloat(freeM[1])));
} else {
// fallback if memory_pressure is unavailable: the legacy top PhysMem used/(used+unused)
const toGB = (n, u) => parseFloat(n) * ({ K: 1 / 1048576, M: 1 / 1024, G: 1, T: 1024 }[u] || 1);
const pm = /PhysMem:\s*([\d.]+)([KMGT])\s*used.*?([\d.]+)([KMGT])\s*unused/i.exec(topOut);
if (pm) { const used = toGB(pm[1], pm[2]), unused = toGB(pm[3], pm[4]); if (used + unused) ramPct = used / (used + unused) * 100; }
}
const totalBytes = parseInt(memBytes, 10) || 0;
const hogs = psOut.trim().split('\n').map(l => {
const m = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(l);
if (!m) return null;
const rssKB = parseInt(m[2], 10);
return { pid: +m[1], name: (m[3].split('/').pop() || m[3]).slice(0, 40), cmd: m[3].slice(0, 120),
gb: (rssKB / 1048576).toFixed(1),
pct: totalBytes ? ((rssKB * 1024 / totalBytes) * 100).toFixed(1) : '0' };
}).filter(Boolean).sort((a, b) => +b.gb - +a.gb).slice(0, 12);
return { updated: Date.now(), ram_pct: Math.round(ramPct), cpu_pct: Math.round(cpu), on: await sniperOn(), hogs };
}
// Toggle the daemon: kill the exact daemon PID(s) if running (reversible — it only
// reniced), else spawn detached. Kills by resolved PID, never `pkill -f`, so it can't
// SIGTERM an editor or a pgrep shell that merely mentions the script path.
async function toggleSniper() {
const pids = await sniperPids();
if (pids.length) {
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch {} }
return { on: false };
}
try { spawn(SNIPER, [], { detached: true, stdio: 'ignore' }).unref(); } catch (e) { return { on: false, error: String(e) }; }
return { on: true };
}
function send(res, code, body, type = 'application/json') {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
// allcolordots is slow (scans every terminal), so refresh in the background and
// serve a cached snapshot — /api/dots must return instantly for a smooth bar.
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, ok: false, blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, dw: 0, nondw: 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?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
// FAILED fetch: return null so refreshTickets keeps the last-good snapshot instead of flipping the
// bar's ticket summary to all-zeros. A genuine empty board returns the literal '[]' (non-empty).
if (!boardData || !boardData.trim()) return null;
let j;
try { j = JSON.parse(boardData); } catch (e) { return null; }
// dw / nondw partition the SAME active population the five state chips count, so
// dw + nondw == blocked + open + idle + doing + parked exactly (asserted in test).
const tickets = { blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, dw: 0, nondw: 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);
if (isParked) tickets.parked++;
else if (status === 'blocked') tickets.blocked++;
else if (status === 'open') tickets.open++;
else if (status === 'doing') tickets.doing++;
else if (status === 'stopped') tickets.idle++;
if (isDW(row)) tickets.dw++; else tickets.nondw++;
const ts = Date.parse(row.updated_at || row.status_since || '') || 0;
if (!newest || ts > newest.ts) newest = { ts, label: row.title ? `${row.id}: ${row.title}` : row.id };
}
if (newest) tickets.latest = newest.label;
}
return { updated: Date.now(), ok: true, ...tickets };
}
// Keep the last-good snapshot when getTickets signals a failed fetch (null), mirroring getDots.
async function refreshTickets() {
// A failed fetch keeps the last-good counts but flips ok:false so the bar shows the row as
// OFFLINE ("–") instead of presenting stale/zero counts as if they were measured.
let t = null;
try { t = await getTickets(); } catch (e) { t = null; }
if (t) ticketsSnapshot = t;
else ticketsSnapshot = { ...ticketsSnapshot, ok: false, failed_at: Date.now() };
}
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
const next = await getDots();
// KEEP-LAST-GOOD: a slow/timed-out/errored scan returns 0 rows; never let that zero out a
// known-good count (the "0 live" flap). Accept a real result, or the very first run.
if (next.total > 0 || snapshot.updated === 0) snapshot = { ...next, stale: false };
} catch (e) { /* keep last */ }
finally { refreshing = false; }
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://x');
if (url.pathname === '/health') return send(res, 200, { ok: true, port: server.address() && server.address().port });
if (url.pathname === '/api/dots') { if (!snapshot.updated) await refresh(); refresh(); return send(res, 200, snapshot); }
if (url.pathname === '/api/jev') { return send(res, 200, jevDots.getStats()); }
if (url.pathname === '/api/arrange' && req.method === 'POST') {
return send(res, 200, await arrange());
}
if (url.pathname === '/api/ram') { if (!ramSnapshot.updated) await refreshRam(); return send(res, 200, ramSnapshot); }
if (url.pathname === '/api/ram/toggle' && req.method === 'POST') {
const r = await toggleSniper(); await refreshRam(); return send(res, 200, { ...ramSnapshot, ...r });
}
if (url.pathname === '/api/tickets') { if (!ticketsSnapshot.updated) await refreshTickets(); return send(res, 200, { ...ticketsSnapshot, states: ticketStates(ticketsSnapshot) }); }
if (url.pathname === '/api/reveal' && req.method === 'POST') {
let raw = '';
req.on('data', c => (raw += c));
req.on('end', async () => {
let tty = '';
try { tty = JSON.parse(raw).tty; } catch {}
send(res, 200, await reveal(tty));
});
return;
}
// Open the Fleet ticket board (filtered to one section) in the DEFAULT BROWSER. The Electron
// renderer can't window.open() an external URL, so it POSTs here and the Node server shells out
// to macOS `open`. Section is whitelisted (never interpolate user input into a shell/URL blindly).
if (url.pathname === '/api/open-board' && req.method === 'POST') {
let raw = '';
req.on('data', c => (raw += c));
req.on('end', async () => {
let section = 'all';
try { section = JSON.parse(raw).section || 'all'; } catch {}
const OK = ['all', 'blocked', 'open', 'stopped', 'doing', 'parked', 'agents', 'skills', 'dw', 'nondw', 'done'];
if (!OK.includes(section)) section = 'all';
const url2 = `http://127.0.0.1:9794/?section=${section}&layout=grid`;
const { err } = await run('open', [url2]); // execFile — args are NOT shell-parsed, so no injection
send(res, 200, { ok: !err, section, url: url2 });
});
return;
}
if (url.pathname === '/' || url.pathname === '/index.html') {
return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'), 'text/html');
}
send(res, 404, { error: 'not found' });
} catch (e) {
send(res, 500, { error: String(e) });
}
});
// Bind to the requested PORT; if taken, walk upward to the next free one.
function listen(port, tries = 20) {
server.once('error', (e) => {
if (e.code === 'EADDRINUSE' && tries > 0) return listen(port + 1, tries - 1);
throw e;
});
server.listen(port, '127.0.0.1', () => {
const p = server.address().port;
fs.writeFileSync(path.join(__dirname, '.port'), String(p));
console.log(`desktop-dotbar on http://127.0.0.1:${p}`);
refresh(); // warm the cache
setInterval(refresh, 2500); // keep it fresh in the background
refreshRam(); // warm RAM/CPU stats
setInterval(refreshRam, 2000); // keep RAM/CPU fresh
refreshTickets(); // warm ticket board summary
setInterval(refreshTickets, 6000); // refresh board every 6s (~4.5MB payload; keep it light)
});
}
listen(parseInt(process.env.PORT || '9787', 10));