#!/usr/bin/env node // agent-queue dashboard — a zero-dependency, INTERACTIVE 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 a navigable, numbered job list you can act on without leaving the screen. // // Lifecycle: inbox → building → review → testing → shipped (+ failed) // // Interactive keys (when run in a TTY): // ↑/↓ or j/k or 1-9 select a job enter / l view its log // p promote s ship (testing→shipped) x reject // u requeue r run loop S stop g refresh now // ? help q / Ctrl-C quit // All actions shell out to agent-queue.sh — it stays the single source of truth. // // Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue] // AGENT_QUEUE_ROOT=/path node dashboard.mjs // AQ_TRACKER_WEB=https://tracker.example.com node dashboard.mjs // (makes job tracker-item tags clickable terminal hyperlinks) import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync, spawn } from 'node:child_process'; 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; }; // Compact per-job insights (read-only from meta; agent-queue.sh is the source of // truth). Surfaces tokens or cost + attempts + line deltas for finished jobs. const insightsTag = (m) => { const parts = []; if (m.attempts && m.attempts !== '1') parts.push(`x${m.attempts}`); if (m.cost_usd) parts.push(`$${m.cost_usd}${m.usage_estimated ? '~' : ''}`); else if (m.tokens_in || m.tokens_out) parts.push(`tok ${m.tokens_in || 0}/${m.tokens_out || 0}`); if (m.lines_added || m.lines_deleted) parts.push(`+${m.lines_added || 0}/-${m.lines_deleted || 0}`); if (m.duration_s) parts.push(`${m.duration_s}s`); return parts.join(' '); }; // Manifest tags (read-only): the routing inputs an operator cares about when // scanning the board — priority, profile, capabilities, and a tracker-item // reference. Rendered from a job's meta (launched jobs) or, for never-launched // inbox jobs, parsed from the .md frontmatter (see readManifest). The // tracker-item becomes a real terminal hyperlink when AQ_TRACKER_WEB is set. const TRACKER_WEB = (process.env.AQ_TRACKER_WEB || '').replace(/\/+$/, ''); const osc8 = (url, label) => `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`; const trackerTag = (id) => { if (!id) return ''; const label = `⎘ ${id}`; return TRACKER_WEB ? osc8(`${TRACKER_WEB}/${encodeURIComponent(id)}`, label) : label; }; const PRIORITY_COLOR = { critical: 'red', high: 'yellow', medium: 'gray', low: 'gray' }; const manifestTags = (m) => { if (!m) return ''; const parts = []; if (m.priority && m.priority !== 'medium') { parts.push(c(PRIORITY_COLOR[m.priority] || 'gray', `⚑${m.priority}`)); } if (m.profile) parts.push(c('blue', `◆${m.profile}`)); if (m.capabilities) { const caps = String(m.capabilities).replace(/^\[|\]$/g, '').trim(); if (caps) parts.push(c('gray', `caps ${trunc(caps, 36)}`)); } if (m.tracker_item) parts.push(c('cyan', trackerTag(m.tracker_item))); return parts.join(' '); }; 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))); }; // readManifest(stage, job) — manifest tags for a job that has no launched meta // yet (e.g. queued in inbox/). Parses the leading --- frontmatter block of the // job's .md and maps the few fields manifestTags renders. Never throws. const FM_TAG_KEYS = { priority: 'priority', profile: 'profile', capabilities: 'capabilities', 'tracker-item': 'tracker_item', }; const readManifest = (stage, job) => { const out = {}; try { const lines = fs.readFileSync(path.join(DIRS[stage], `${job}.md`), 'utf8').split('\n'); if ((lines[0] || '').trim() !== '---') return out; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') break; const line = lines[i].replace(/^\s+/, ''); const ci = line.indexOf(':'); if (ci <= 0) continue; const key = line.slice(0, ci).trim(); if (!FM_TAG_KEYS[key]) continue; out[FM_TAG_KEYS[key]] = line.slice(ci + 1).trim().replace(/^["']|["']$/g, ''); } } catch { /* ignore */ } return out; }; // ── agent-queue.sh control (single source of truth) ───────────────── const AQ = path.join(__dirname, 'agent-queue.sh'); const stripAnsi = (s) => (s || '').replace(/\x1b\[[0-9;]*m/g, ''); const lastLine = (s) => { const lines = stripAnsi(s).split('\n').map((l) => l.trim()).filter(Boolean); return lines.length ? lines[lines.length - 1] : ''; }; // aq(args) — run an agent-queue.sh subcommand, capturing output (never throws). const aq = (args) => { try { const out = execFileSync('bash', [AQ, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, AGENT_QUEUE_ROOT: ROOT }, }); return { ok: true, out }; } catch (e) { return { ok: false, out: ((e.stdout || '') + (e.stderr || '') || e.message || '').toString() }; } }; // daemonPid() — pid of a live `run` loop, or null. const daemonPid = () => { try { const pid = fs.readFileSync(path.join(DIRS.state, 'daemon.pid'), 'utf8').trim(); return pid && pidAlive(pid) ? pid : null; } catch { return null; } }; // startRun() — spawn a detached `run` loop writing to logs/run-loop.log. const startRun = () => { if (daemonPid()) { setFlash(c('yellow', 'run loop already active')); return; } try { const fd = fs.openSync(path.join(DIRS.logs, 'run-loop.log'), 'a'); const child = spawn('bash', [AQ, 'run'], { detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, AGENT_QUEUE_ROOT: ROOT }, }); child.unref(); setFlash(c('green', `▶ run loop started (max ${process.env.AGENT_QUEUE_MAX || 3})`)); } catch (e) { setFlash(c('red', `run failed: ${e.message}`)); } }; // ── interactive state ─────────────────────────────────────────────── const INTERACTIVE = !!process.stdin.isTTY; const ACTION_STAGES = ['review', 'testing', 'failed', 'inbox']; let mode = 'board'; // 'board' | 'log' | 'help' | 'confirm' let items = []; // actionable jobs, rebuilt each draw let selIdx = 0; // selected index into items let selJob = null; // selected job name (stable across refreshes) let flash = ''; // transient status message let flashUntil = 0; let logJob = null; // job whose log is being viewed let confirmAction = null; // { verb, job, run } const setFlash = (msg, ms = 4000) => { flash = msg; flashUntil = Date.now() + ms; }; const flashLine = () => (flash && Date.now() < flashUntil ? flash : ''); const buildItems = () => { const list = []; for (const st of ACTION_STAGES) { for (const f of listMd(DIRS[st]).sort()) list.push({ stage: st, job: f.replace(/\.md$/, '') }); } return list; }; const syncSelection = () => { if (selJob) { const i = items.findIndex((it) => it.job === selJob); if (i >= 0) { selIdx = i; return; } } selIdx = Math.max(0, Math.min(selIdx, items.length - 1)); selJob = items[selIdx]?.job ?? null; }; const STAGE_TAG = { review: () => c('cyan', '[review ]'), testing: () => c('cyan', '[testing]'), failed: () => c('red', '[failed ]'), inbox: () => c('blue', '[inbox ]'), }; // gate(verb, stage) — is this action valid for a job in this stage? const gate = (verb, stage) => ({ promote: stage === 'review' || stage === 'testing', ship: stage === 'testing', reject: stage === 'review' || stage === 'testing', requeue: stage === 'failed' || stage === 'review' || stage === 'testing', logs: true, }[verb]); // doAction(verb) — run the gated action on the selected job via agent-queue.sh. const doAction = (verb) => { const it = items[selIdx]; if (!it) { setFlash(c('gray', 'no job selected')); return; } if (!gate(verb, it.stage)) { setFlash(c('gray', `${verb} not valid for a ${it.stage} job`)); return; } if ((verb === 'reject' || verb === 'requeue') && mode !== 'confirm') { confirmAction = { verb, job: it.job, run: () => doAction(verb) }; mode = 'confirm'; return; } const r = aq([verb, it.job]); setFlash((r.ok ? c('green', '✓ ') : c('red', '✗ ')) + (lastLine(r.out) || `${verb} ${it.job}`)); mode = 'board'; confirmAction = null; }; // ── render ────────────────────────────────────────────────────────── const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' }; function drawBoard() { const metas = readMetas(); const metaByJob = Object.fromEntries(metas.filter((m) => m.job).map((m) => [m.job, m])); 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), }; // rebuild actionable list + keep selection stable items = buildItems(); syncSelection(); const loop = daemonPid(); 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 ` + (loop ? c('green', `● run loop pid ${loop}`) : c('gray', '○ run loop stopped')) + ` ${c('gray', INTERACTIVE ? 'press ? for help' : 'read-only')}` ); 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; out.push( ` ${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(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`); const mtags = manifestTags(m); if (mtags) out.push(` ${mtags}`); const last = lastLogLine(m.job); if (last) out.push(` ${c('cyan', '› ')}${c('dim', trunc(last, 70))}`); } } out.push(''); // actionable job list (numbered + selectable) out.push(` ${C.bold}JOBS${C.reset} ${c('gray', '(review · testing · failed · inbox)')}`); if (items.length === 0) { out.push(` ${c('dim', 'no actionable jobs')}`); } else { items.forEach((it, i) => { const sel = i === selIdx; const ptr = sel ? c('cyan', '▶') : ' '; const num = c('gray', String(i + 1).padStart(2) + '.'); const tag = (STAGE_TAG[it.stage] || (() => `[${it.stage}]`))(); const name = sel ? `${C.bold}${trunc(it.job, 46)}${C.reset}` : trunc(it.job, 46); out.push(` ${ptr} ${num} ${tag} ${name}`); const jtags = manifestTags(metaByJob[it.job] || readManifest(it.stage, it.job)); if (jtags) out.push(` ${jtags}`); }); } out.push(''); // recent finished out.push(` ${C.bold}RECENT${C.reset}`); const recent = finished.slice(0, 5); 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' || res === 'retries_exhausted' || res === 'capability_mismatch' || res === 'budget_exceeded' || res === 'no_engine'; const mark = failedRes ? c('red', '✕') : c('green', '▣'); 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 === 'budget_exceeded') label = c('red', 'budget exceeded'); else if (res === 'rejected') label = c('red', 'rejected'); else if (res === 'retries_exhausted') label = c('red', 'retries exhausted'); 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)} ${c('cyan', insightsTag(m))}` ); } } out.push(''); // flash + footer const fl = flashLine(); if (fl) out.push(` ${fl}`); if (mode === 'confirm' && confirmAction) { out.push(` ${c('yellow', `${confirmAction.verb} "${confirmAction.job}" ? `)}${C.bold}y${C.reset}${c('gray', '/')}${C.bold}n${C.reset}`); } else if (INTERACTIVE) { out.push(c('gray', ' ↑/↓ select · enter logs · p promote · s ship · x reject · u requeue')); out.push(c('gray', ' r run · S stop · g refresh · ? help · q quit')); } process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n'); } function drawLog() { const rows = (process.stdout.rows || 30) - 6; let body = `no log for ${logJob}`; try { const txt = fs.readFileSync(path.join(DIRS.logs, `${logJob}.log`), 'utf8'); body = txt.split('\n').slice(-rows).join('\n'); } catch { /* keep default */ } const head = ` ${C.bold}LOG${C.reset} ${c('cyan', logJob)} ${c('gray', 'q/esc back · g refresh')}`; process.stdout.write('\x1b[2J\x1b[H' + head + '\n' + c('gray', ' ' + '─'.repeat(60)) + '\n' + body + '\n'); } function drawHelp() { const L = [ '', ` ${C.bold}AGENT QUEUE — keys${C.reset}`, '', ` ${c('cyan', '↑/↓, j/k, 1-9')} select a job in the JOBS list`, ` ${c('cyan', 'enter / l')} view the selected job's log (live)`, ` ${c('cyan', 'p')} promote (review → testing → shipped)`, ` ${c('cyan', 's')} ship (testing/QA → shipped, the manual gate)`, ` ${c('cyan', 'x')} reject (review/testing → failed) ${c('gray', '[confirm]')}`, ` ${c('cyan', 'u')} requeue (failed/review/testing → inbox) ${c('gray', '[confirm]')}`, '', ` ${c('cyan', 'r')} start the run loop (detached, max ${process.env.AGENT_QUEUE_MAX || 3})`, ` ${c('cyan', 'S')} stop the run loop + running workers`, ` ${c('cyan', 'g')} refresh now`, ` ${c('cyan', '? / h')} toggle this help`, ` ${c('cyan', 'q / Ctrl-C')} quit`, '', ` ${c('gray', 'Lifecycle: inbox → building → review → testing → shipped (+ failed)')}`, ` ${c('gray', 'auto: rc=0 → review; verify pass → testing; verify fail → failed')}`, ` ${c('gray', 'manual: ship (testing → shipped)')}`, '', ` ${c('gray', 'press any key to return')}`, '', ]; process.stdout.write('\x1b[2J\x1b[H' + L.join('\n') + '\n'); } const draw = () => { if (mode === 'log') drawLog(); else if (mode === 'help') drawHelp(); else drawBoard(); }; // ── 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); } draw(); const timer = setInterval(draw, 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); }; const moveSel = (delta) => { if (items.length === 0) return; selIdx = (selIdx + delta + items.length) % items.length; selJob = items[selIdx]?.job ?? null; }; function onKey(key) { // global quit if (key === '\u0003') return quit(); // Ctrl-C always quits if (mode === 'help') { mode = 'board'; return draw(); } if (mode === 'log') { if (key === 'q' || key === '\u001b' || key === '\r' || key === '\n') { mode = 'board'; logJob = null; } else if (key === 'g') { /* fallthrough to redraw */ } return draw(); } if (mode === 'confirm') { if (key === 'y' || key === 'Y') confirmAction?.run(); else { mode = 'board'; confirmAction = null; setFlash(c('gray', 'cancelled')); } return draw(); } // board mode switch (key) { case 'q': return quit(); case '?': case 'h': mode = 'help'; break; case 'g': break; // just redraw case 'j': case '\u001b[B': moveSel(1); break; case 'k': case '\u001b[A': moveSel(-1); break; case '\r': case '\n': case 'l': if (items[selIdx]) { logJob = items[selIdx].job; mode = 'log'; } break; case 'p': doAction('promote'); break; case 's': doAction('ship'); break; case 'x': doAction('reject'); break; case 'u': doAction('requeue'); break; case 'r': startRun(); break; case 'S': { const res = aq(['stop']); setFlash(c('red', '■ ') + (lastLine(res.out) || 'stopped')); break; } default: if (/^[1-9]$/.test(key)) { const i = parseInt(key, 10) - 1; if (i < items.length) { selIdx = i; selJob = items[i].job; } } else { return; } // ignore unknown keys (no redraw) } draw(); } if (INTERACTIVE) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.on('data', onKey); } process.on('SIGINT', quit); process.on('SIGTERM', quit);