#!/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';
import { fleetConfig, fetchBoard, fetchEvents, jobAction } from './lib/fleet-dash.mjs';

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() };
  }
};

// ── fleet mode (Phase 3: TUI re-pointed at /fleet) ──────────────────
// Opt-in via AQ_FLEET_DASH=1. When ON, the board is sourced from the
// platform-service /fleet REST API instead of the local queue; when OFF, every
// fleet code path below is skipped and the dashboard is byte-for-byte the local
// tool. All fleet I/O lives in lib/fleet-dash.mjs (injectable + unit-tested).
const FLEET = fleetConfig();
// Latest fleet board snapshot. On a refresh failure we KEEP the last good board
// (no destructive flicker) and surface a staleness banner instead.
let fleetState = { board: null, error: null, loading: FLEET.enabled, lastOk: 0 };
let fleetRefreshing = false;       // single-flight guard (no overlapping fetches)
let fleetEvents = { jobId: null, lines: [], error: null, loading: false };

// refreshFleet() — single-flight board refresh. Applies a successful board;
// on failure preserves the previous board and records the error.
const refreshFleet = async () => {
  if (fleetRefreshing) return;
  fleetRefreshing = true;
  try {
    const r = await fetchBoard(FLEET);
    if (r.ok) fleetState = { board: r.board, error: null, loading: false, lastOk: Date.now() };
    else fleetState = { ...fleetState, error: r.error, loading: false };
  } catch (e) {
    fleetState = { ...fleetState, error: (e && e.message) || 'refresh failed', loading: false };
  } finally {
    fleetRefreshing = false;
  }
};

// refreshFleetEvents(job) — load a job's event stream into the log view state.
const refreshFleetEvents = (job) => {
  fleetEvents = { jobId: job, lines: [], error: null, loading: true };
  fetchEvents(FLEET, job).then((r) => {
    fleetEvents = { jobId: job, lines: r.lines || [], error: r.ok ? null : r.error, loading: false };
    if (mode === 'log' && logJob === job) draw();
  });
};


// 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 = () => {
  if (FLEET.enabled) {
    const b = fleetState.board;
    if (!b) return [];
    return b.items.map((it) => ({ stage: it.stage, job: it.id, fleet: it }));
  }
  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. In fleet mode it
// calls the /fleet API (lib/fleet-dash.mjs); otherwise it shells out to
// agent-queue.sh. promote is unavailable in fleet mode (no safe server contract).
const doAction = (verb) => {
  const it = items[selIdx];
  if (!it) { setFlash(c('gray', 'no job selected')); return; }
  if (FLEET.enabled && verb === 'promote') {
    setFlash(c('gray', 'promote n/a in fleet mode (use ship/requeue/reject)'));
    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;
  }
  if (FLEET.enabled) {
    setFlash(c('gray', `${verb}…`));
    mode = 'board'; confirmAction = null;
    jobAction(FLEET, it.fleet, verb).then((r) => {
      setFlash((r.ok ? c('green', '✓ ') : c('red', '✗ ')) + (r.message || `${verb} ${it.job}`));
      refreshFleet().then(draw);
    });
    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' };

const FLEET_STAGE_COLOR = {
  queued: 'blue', assigned: 'yellow', building: 'yellow',
  review: 'cyan', testing: 'cyan', shipped: 'green',
  failed: 'red', dead_letter: 'red',
};

// drawFleetBoard() — the board sourced from the /fleet API (AQ_FLEET_DASH=1).
// Mirrors the local board layout; running rows reflect lease/factory status
// (there is no local PID/liveness in fleet mode).
function drawFleetBoard() {
  items = buildItems();
  syncSelection();
  const board = fleetState.board;
  const counts = board ? board.counts : { inbox: 0, building: 0, review: 0, testing: 0, shipped: 0, failed: 0 };
  const running = board ? board.running : [];
  const recent = board ? board.recent : [];

  const out = [];
  out.push('');
  out.push(`  ${C.bold}AGENT QUEUE${C.reset}  ${c('cyan', 'fleet')} ${c('gray', FLEET.api)}`);
  const staleSec = fleetState.lastOk ? Math.floor((Date.now() - fleetState.lastOk) / 1000) : null;
  let statusBit;
  if (fleetState.loading && !board) statusBit = c('gray', '◌ loading…');
  else if (fleetState.error) statusBit = c('red', `⚠ ${trunc(fleetState.error, 40)}${board ? ` (stale ${staleSec}s)` : ''}`);
  else statusBit = c('green', `● live${staleSec !== null ? ` (${staleSec}s ago)` : ''}`);
  out.push(
    `  ${c('gray', new Date().toLocaleTimeString())}   refresh ${INTERVAL / 1000}s   ${statusBit}` +
    `   ${c('gray', `product ${FLEET.productId}`)}   ${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('');

  // factories (per-factory rows when /fleet/factories exists; else metrics aggregate)
  out.push(`  ${C.bold}FACTORIES${C.reset}`);
  const factories = board ? board.factories : [];
  const metrics = board ? board.metrics : null;
  if (factories.length > 0) {
    for (const f of factories) {
      const health = String(f.health || 'ok');
      const hc = health === 'ok' ? 'green' : health === 'degraded' ? 'yellow' : 'red';
      out.push(
        `    ${c('bold', trunc(f.factoryId || f.id || '?', 24).padEnd(24))} ` +
        `${c(hc, health.padEnd(9))} ` +
        `${c('gray', `load ${f.load ?? '?'}/${f.seatLimit ?? '?'}`)}  ` +
        `${c('gray', trunc((Array.isArray(f.capabilities) ? f.capabilities.join(', ') : f.capabilities) || '', 36))}`
      );
    }
  } else if (metrics && metrics.factory) {
    const fm = metrics.factory;
    const bh = fm.byHealth || {};
    out.push(
      `    ${c('green', `ok ${bh.ok ?? 0}`)}  ${c('yellow', `degraded ${bh.degraded ?? 0}`)}  ${c('red', `down ${bh.down ?? 0}`)}` +
      `   ${c('gray', `live ${fm.live ?? '?'} · stale ${fm.stale ?? '?'}`)}` +
      `   ${c('gray', `seats ${fm.seatsUsed ?? '?'}/${fm.seatsTotal ?? '?'}`)}` +
      `   ${c('gray', `util ${metrics.utilizationPct ?? '?'}%`)}`
    );
  } else {
    out.push(`    ${c('dim', 'no factory data')}`);
  }
  // alerts (from metrics)
  if (metrics && Array.isArray(metrics.alerts) && metrics.alerts.length > 0) {
    for (const a of metrics.alerts) {
      const sev = a.severity === 'critical' ? 'red' : 'yellow';
      out.push(`    ${c(sev, '⚠ ')}${c(sev, a.kind || 'alert')}${a.message ? c('gray', ` — ${trunc(a.message, 50)}`) : ''}`);
    }
  }
  out.push('');

  // running (lease/factory status — no local pid)
  out.push(`  ${C.bold}RUNNING${C.reset}`);
  if (running.length === 0) {
    out.push(`    ${c('dim', 'no jobs in flight')}`);
  } else {
    for (const r of running) {
      const sc = FLEET_STAGE_COLOR[r.fleetStage] || 'gray';
      out.push(
        `    ${c('bold', trunc(r.id, 30).padEnd(30))} ` +
        `${c(sc, String(r.fleetStage).padEnd(9))} ` +
        `${c('gray', r.factoryId ? `@${trunc(r.factoryId, 18)}` : 'unassigned')}`
      );
      const mtags = manifestTags(r);
      if (mtags) out.push(`      ${mtags}`);
    }
  }
  out.push('');

  // actionable jobs (numbered + selectable) — reuses STAGE_TAG buckets
  out.push(`  ${C.bold}JOBS${C.reset}  ${c('gray', '(review · testing · failed · inbox)')}`);
  if (items.length === 0) {
    out.push(`    ${c('dim', board ? 'no actionable jobs' : 'waiting for fleet…')}`);
  } 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(it.fleet);
      if (jtags) out.push(`        ${jtags}`);
    });
  }
  out.push('');

  // recent (shipped + failed)
  out.push(`  ${C.bold}RECENT${C.reset}`);
  if (recent.length === 0) {
    out.push(`    ${c('dim', 'nothing finished yet')}`);
  } else {
    for (const r of recent) {
      const failedRes = r.stage === 'failed';
      const mark = failedRes ? c('red', '✕') : c('green', '▣');
      const label = failedRes ? c('red', r.fleetStage) : c('green', r.fleetStage);
      const when = r.updatedAt ? new Date(r.updatedAt).toLocaleTimeString() : '';
      out.push(`    ${mark} ${trunc(r.id, 34).padEnd(34)} ${label}  ${c('gray', when)}`);
    }
  }
  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 events · s ship · x reject · u requeue'));
    out.push(c('gray', '  g refresh · ? help · q quit'));
  }
  process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
}

function drawBoard() {
  if (FLEET.enabled) return drawFleetBoard();
  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;
  if (FLEET.enabled) {
    let body;
    if (fleetEvents.loading) body = c('gray', '  loading events…');
    else if (fleetEvents.error) body = c('red', `  ${fleetEvents.error}`);
    else if (!fleetEvents.lines.length) body = c('gray', '  no events for this job');
    else body = fleetEvents.lines.slice(-rows).join('\n');
    const head = `  ${C.bold}EVENTS${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');
    return;
  }
  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}`,
    FLEET.enabled ? `  ${c('cyan', 'fleet mode')} ${c('gray', '— board sourced from /fleet API; run/stop/promote disabled')}` : '', '',
    `  ${c('cyan', '↑/↓, j/k, 1-9')}   select a job in the JOBS list`,
    `  ${c('cyan', 'enter / l')}       ${FLEET.enabled ? "view the selected job's events" : "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 ────────────────────────────────────────
// Fleet mode (AQ_FLEET_DASH=1) sources the board from the /fleet API on an async,
// single-flight tick loop. Local mode keeps the original synchronous setInterval
// path byte-for-byte. `timer` holds whichever timer is live so quit() can clear it.
let timer = null;

if (FLEET.enabled) {
  if (!FLEET.ok) {
    process.stdout.write(
      `agent-queue: fleet dashboard enabled (AQ_FLEET_DASH=1) but missing config:\n` +
      `  ${FLEET.missing.join(', ')}\n` +
      `Set AQ_FLEET_API, AQ_FLEET_TOKEN and AQ_PRODUCT_ID, or unset AQ_FLEET_DASH.\n`
    );
    process.exit(1);
  }
  draw(); // initial frame (loading…)
  const tick = async () => {
    await refreshFleet();
    if (mode !== 'log' && mode !== 'help') draw();
    timer = setTimeout(tick, INTERVAL); // single-flight: schedule only after the await
  };
  tick();
} else {
  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();
  timer = setInterval(draw, INTERVAL);
}

const quit = () => {
  if (timer) { clearTimeout(timer); 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') { if (FLEET.enabled && logJob) refreshFleetEvents(logJob); }
    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';
        if (FLEET.enabled) refreshFleetEvents(logJob);
      }
      break;
    case 'p': doAction('promote'); break;
    case 's': doAction('ship'); break;
    case 'x': doAction('reject'); break;
    case 'u': doAction('requeue'); break;
    case 'r': if (FLEET.enabled) setFlash(c('gray', 'run loop n/a in fleet mode')); else startRun(); break;
    case 'S':
      if (FLEET.enabled) { setFlash(c('gray', 'stop n/a in fleet mode')); break; }
      { 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);
