← 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));