#!/usr/bin/env node // agent-queue dashboard — a zero-dependency live TUI for the folder queue. // // Reads the same queue/ state written by agent-queue.sh and re-renders a board // every interval: kanban counts, running workers (engine, elapsed, last log line), // and the most recent shipped/failed jobs. // // Lifecycle: inbox → building → review → testing → shipped (+ failed) // // Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue] // AGENT_QUEUE_ROOT=/path node dashboard.mjs // Quit: q or Ctrl-C import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ── args / config ─────────────────────────────────────────────────── const argv = process.argv.slice(2); const getArg = (flag, def) => { const i = argv.indexOf(flag); return i !== -1 && argv[i + 1] ? argv[i + 1] : def; }; const ROOT = path.resolve(getArg('--root', process.env.AGENT_QUEUE_ROOT || path.join(__dirname, 'queue'))); const INTERVAL = Math.max(1, parseInt(getArg('--interval', '2'), 10)) * 1000; // A running worker is flagged stalled if its log has not changed in this many minutes. const STALL_MIN = Math.max(1, parseInt(process.env.AGENT_QUEUE_STALL_MIN || '10', 10)); const DIRS = { inbox: path.join(ROOT, 'inbox'), building: path.join(ROOT, 'building'), review: path.join(ROOT, 'review'), testing: path.join(ROOT, 'testing'), shipped: path.join(ROOT, 'shipped'), failed: path.join(ROOT, 'failed'), logs: path.join(ROOT, 'logs'), state: path.join(ROOT, '.state'), }; // ── ansi ──────────────────────────────────────────────────────────── const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', gray: '\x1b[90m', }; const c = (col, s) => `${C[col]}${s}${C.reset}`; // ── helpers ───────────────────────────────────────────────────────── const listMd = (dir) => { try { return fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { return []; } }; const count = (dir) => listMd(dir).length; const parseMeta = (file) => { const out = {}; try { for (const line of fs.readFileSync(file, 'utf8').split('\n')) { const i = line.indexOf('='); if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); } } catch { /* ignore */ } return out; }; const pidAlive = (pid) => { if (!pid) return false; try { process.kill(Number(pid), 0); return true; } catch { return false; } }; const lastLogLine = (job) => { try { const txt = fs.readFileSync(path.join(DIRS.logs, `${job}.log`), 'utf8'); const lines = txt.split('\n').map((l) => l.trim()).filter(Boolean); return lines.length ? lines[lines.length - 1] : ''; } catch { return ''; } }; // seconds since a job's log was last modified (no new agent output); null if no log const logAgeSec = (job) => { try { const mt = fs.statSync(path.join(DIRS.logs, `${job}.log`)).mtimeMs; return Math.max(0, Math.floor((Date.now() - mt) / 1000)); } catch { return null; } }; const fmtElapsed = (startSec) => { if (!startSec) return ' -- '; const s = Math.max(0, Math.floor(Date.now() / 1000) - Number(startSec)); const m = Math.floor(s / 60); const h = Math.floor(m / 60); if (h > 0) return `${h}h${String(m % 60).padStart(2, '0')}m`; return `${m}m${String(s % 60).padStart(2, '0')}s`; }; const trunc = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s); const shortPath = (p) => (p || '').replace(process.env.HOME || '~', '~'); const readMetas = () => { let files = []; try { files = fs.readdirSync(DIRS.state).filter((f) => f.endsWith('.meta')); } catch { /* ignore */ } return files.map((f) => parseMeta(path.join(DIRS.state, f))); }; // ── render ────────────────────────────────────────────────────────── const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' }; function render() { const metas = readMetas(); const running = metas.filter((m) => !m.ended && pidAlive(m.pid)); const finished = metas .filter((m) => m.ended) .sort((a, b) => Number(b.ended) - Number(a.ended)); const counts = { inbox: count(DIRS.inbox), building: count(DIRS.building), review: count(DIRS.review), testing: count(DIRS.testing), shipped: count(DIRS.shipped), failed: count(DIRS.failed), }; const out = []; out.push(''); out.push(` ${C.bold}AGENT QUEUE${C.reset} ${c('gray', ROOT)}`); out.push(` ${c('gray', new Date().toLocaleTimeString())} refresh ${INTERVAL / 1000}s ${c('gray', 'press q to quit')}`); out.push(''); out.push( ` ${c('blue', '▢ inbox')} ${String(counts.inbox).padEnd(3)}` + ` ${c('yellow', '◧ building')} ${String(counts.building).padEnd(3)}` + ` ${c('cyan', '◔ review')} ${String(counts.review).padEnd(3)}` + ` ${c('cyan', '◕ testing')} ${String(counts.testing).padEnd(3)}` + ` ${c('green', '▣ shipped')} ${String(counts.shipped).padEnd(3)}` + ` ${c('red', '✕ failed')} ${String(counts.failed).padEnd(3)}` + ` ${C.bold}running ${running.length}${C.reset}` ); out.push(''); // running table out.push(` ${C.bold}RUNNING${C.reset}`); if (running.length === 0) { out.push(` ${c('dim', 'no workers running')}`); } else { for (const m of running) { const eng = m.engine || '?'; const engC = ENGINE_COLOR[eng] || 'gray'; const age = logAgeSec(m.job); const stalled = age !== null && age > STALL_MIN * 60; const line = ` ${c('bold', trunc(m.job || '?', 30).padEnd(30))} ` + `${c(engC, eng.padEnd(7))} ` + `${fmtElapsed(m.started).padStart(7)} ` + `${c('gray', 'pid ' + (m.pid || '?'))}` + `${stalled ? ' ' + c('red', '⚠ stalled') : ''}`; out.push(line); out.push(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`); const last = lastLogLine(m.job); if (last) out.push(` ${c('cyan', '› ')}${c('dim', trunc(last, 70))}`); } } out.push(''); // recent finished out.push(` ${C.bold}RECENT${C.reset}`); const recent = finished.slice(0, 6); if (recent.length === 0) { out.push(` ${c('dim', 'nothing finished yet')}`); } else { for (const m of recent) { const res = m.result || ''; const failedRes = res === 'failed' || res === 'timeout' || res === 'verify_failed' || res === 'rejected'; const ok = !failedRes; const mark = ok ? c('green', '▣') : c('red', '✕'); const when = m.ended ? new Date(Number(m.ended) * 1000).toLocaleTimeString() : ''; let label; if (res === 'shipped') label = c('green', 'shipped'); else if (res === 'testing') label = c('cyan', 'testing (QA)'); else if (res === 'review') label = c('cyan', 'review'); else if (res === 'verify_failed') label = c('red', 'verify failed'); else if (res === 'timeout') label = c('red', 'timeout'); else if (res === 'rejected') label = c('red', 'rejected'); else if (res === 'failed') label = c('red', 'failed rc=' + (m.exit || '?')); else label = c('gray', res || '?'); out.push( ` ${mark} ${trunc(m.job || '?', 34).padEnd(34)} ` + `${c('gray', (m.engine || '').padEnd(7))} ` + `${label} ${c('gray', when)}` ); } } out.push(''); // clear + paint process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n'); } // ── main loop + key handling ──────────────────────────────────────── if (!fs.existsSync(ROOT)) { process.stdout.write(`agent-queue: queue root not found: ${ROOT}\nRun \`agent-queue.sh init\` first.\n`); process.exit(1); } render(); const timer = setInterval(render, INTERVAL); const quit = () => { clearInterval(timer); try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch { /* noop */ } process.stdout.write(C.reset + '\n'); process.exit(0); }; if (process.stdin.isTTY) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.on('data', (key) => { if (key === 'q' || key === '\u0003') quit(); // q or Ctrl-C }); } process.on('SIGINT', quit); process.on('SIGTERM', quit);