#!/usr/bin/env node

/**
 * @file ccam — the Claude Code Agent Monitor command-line interface.
 *
 * A dependency-free umbrella CLI that brings the full dashboard feature
 * surface to the terminal. After the normal project setup (`npm run setup`,
 * which links this binary via `npm link`), every command is available
 * directly from any shell as `ccam <command>`, or interactively at the
 * `ccam ›` prompt via `ccam repl`.
 *
 * Server                status · start · repl (interactive shell)
 * Monitoring            health · stats · kanban · tail
 * Data browsing         sessions · session <id> · agents · events
 * Insights              analytics · workflows · runs · cost
 * Alerting              alerts · alerts ack <id> · alerts ack-all · rules
 * Webhooks              webhooks · webhooks test <id>
 * Pricing               pricing · pricing set/delete/reset
 * Import                import rescan · import path <dir> · import-data <file>
 * Administration        doctor · info · export · cleanup · reinstall-hooks ·
 *                       update-check · clear-data --yes · open · version
 *
 * The REPL (`ccam repl`, aliases `shell` / `i`) runs each entered line as a
 * short-lived child `ccam` process, so its behavior is identical to the
 * one-shot CLI and no single command — a non-zero exit, a blocking `tail`, an
 * offline refusal — can take the shell down with it. It adds tab-completion,
 * persisted arrow-key history, and a live server-status prompt.
 *
 * Port resolution mirrors the hook handler: an explicit
 * CLAUDE_DASHBOARD_PORT / DASHBOARD_PORT env var wins, otherwise the live
 * server is discovered from ~/.claude/.agent-dashboard.json (written by every
 * running dashboard, PID-liveness-checked on read), falling back to 4820.
 *
 * Read commands are always safe. Mutating commands are explicit user actions
 * (ack, cleanup, pricing set, import) and the one destructive command —
 * `clear-data` — additionally requires the --yes flag before it will run.
 *
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const path = require("node:path");
const fs = require("node:fs");
const { spawn } = require("node:child_process");

// Resolve the repo root relative to this script's REAL location so the CLI
// works both from a checkout (./bin/ccam.js) and through the global symlink
// `npm link` creates (which points back into the checkout).
const REPO_ROOT = path.resolve(path.dirname(fs.realpathSync(__filename)), "..");

// ── Presentation layer ──────────────────────────────────────────────────────
// Styling follows the informal CLI conventions: colors are enabled on a TTY,
// disabled when output is piped or redirected, force-disabled by the NO_COLOR
// env var (https://no-color.org) or a --no-color flag anywhere on the command
// line, and force-enabled by FORCE_COLOR / CCAM_COLOR=1 (useful for `watch`
// or CI logs that render ANSI). Every helper degrades to plain text, so piped
// output stays grep/script-friendly byte-for-byte.
const useColor = (() => {
  if (process.env.NO_COLOR || process.argv.includes("--no-color")) return false;
  const force = process.env.FORCE_COLOR;
  if (force != null && force !== "" && force !== "0" && force !== "false") return true;
  if (process.env.CCAM_COLOR === "1") return true;
  return Boolean(process.stdout.isTTY);
})();

/** Build a style function from SGR open/close codes (close restores state so
 *  styles nest — e.g. bold inside a colored string). */
const sgr = (open, close) => (s) => (useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
const c = {
  bold: sgr(1, 22),
  dim: sgr(2, 22),
  italic: sgr(3, 23),
  underline: sgr(4, 24),
  red: sgr(31, 39),
  green: sgr(32, 39),
  yellow: sgr(33, 39),
  blue: sgr(34, 39),
  magenta: sgr(35, 39),
  cyan: sgr(36, 39),
  gray: sgr(90, 39),
};

/** Per-status icon + color, shared by every table, lane, and detail view. */
const STATUS_THEME = {
  active: { icon: "●", paint: c.green },
  working: { icon: "◐", paint: c.green },
  waiting: { icon: "○", paint: c.yellow },
  completed: { icon: "✔", paint: c.gray },
  error: { icon: "✖", paint: c.red },
  abandoned: { icon: "◦", paint: c.gray },
  connected: { icon: "●", paint: c.green },
  idle: { icon: "·", paint: c.gray },
  running: { icon: "●", paint: c.green },
};

function colorStatus(s) {
  const t = STATUS_THEME[s];
  return t ? t.paint(`${t.icon} ${s}`) : s || "-";
}

/** Hook/event types get stable colors so the feed is scannable at a glance. */
const EVENT_COLOR = {
  SessionStart: c.green,
  SessionEnd: c.gray,
  Stop: c.magenta,
  SubagentStop: c.magenta,
  PreToolUse: c.cyan,
  PostToolUse: c.blue,
  UserPromptSubmit: c.yellow,
  Notification: c.yellow,
  PreCompact: c.gray,
};
const paintEvent = (t) => (EVENT_COLOR[t] || c.cyan)(t);

/** Section heading: a colored sidebar glyph + bold title + dim subtitle. */
function heading(title, sub) {
  console.log(c.cyan("▍") + c.bold(title) + (sub ? c.dim(` — ${sub}`) : ""));
}

/** Aligned key/value line used by detail views. */
function kvLine(key, value, keyWidth = 9) {
  console.log(`  ${c.dim(String(key).padEnd(keyWidth))} ${value}`);
}

/** Horizontal bar for inline charts: value scaled against max. */
function bar(value, max, width = 16, paint = c.cyan) {
  const v = Number(value) || 0;
  const m = Math.max(Number(max) || 0, 1);
  // Any non-zero value renders at least one block so small counts stay visible
  // next to a dominant maximum.
  let filled = Math.min(width, Math.round((v / m) * width));
  if (v > 0 && filled === 0) filled = 1;
  return paint("█".repeat(Math.max(0, filled))) + c.dim("░".repeat(Math.max(0, width - filled)));
}

/** Usable terminal width, with a sane default when not a TTY (pipes, tests). */
function termWidth() {
  const w = process.stdout.columns;
  return Number.isFinite(w) && w > 40 ? w : 120;
}

/**
 * Resolve the dashboard base URL. Env override first (matches the hook
 * handler's contract), then the live-server discovery file, then the default
 * port. The discovery module is best-effort and never throws.
 */
function baseUrl() {
  const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT;
  if (envPort) return `http://127.0.0.1:${envPort}`;
  try {
    const { resolveDashboardPort } = require(
      path.join(REPO_ROOT, "server", "lib", "server-info.js")
    );
    const port = resolveDashboardPort();
    if (port) return `http://127.0.0.1:${port}`;
  } catch {
    /* discovery unavailable — fall through to the default */
  }
  return "http://127.0.0.1:4820";
}

/** Thrown when the dashboard server does not answer — the dispatcher decides
 *  whether the active command has an offline fallback or must abort. */
class ServerDownError extends Error {}

/** Perform an API request; throws ServerDownError when the server is down. */
async function api(method, pathname, body, options = {}) {
  const url = `${baseUrl()}${pathname}`;
  let res;
  try {
    res = await fetch(url, {
      method,
      headers: body ? { "Content-Type": "application/json" } : undefined,
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(30_000),
    });
  } catch {
    throw new ServerDownError();
  }
  let data = null;
  try {
    data = await res.json();
  } catch {
    /* non-JSON body */
  }
  if (!res.ok) {
    if (options.allowError) return { status: res.status, data };
    const msg = data?.error?.message || `HTTP ${res.status}`;
    console.error(c.red(`✖ ${method} ${pathname} → ${msg}`));
    process.exit(1);
  }
  return data;
}
const get = (p, b, options) => api("GET", p, undefined, options);
const post = (p, b, options) => api("POST", p, b, options);
const patch = (p, b, options) => api("PATCH", p, b, options);

/**
 * Print the standard "server is not running" indicator and exit 1. Every
 * command that needs the API funnels through this, so the guidance is
 * identical everywhere: the ccam CLI talks to the local dashboard server,
 * and `ccam start` (or npm run dev / npm start) brings one up.
 */
function serverDownExit(reason) {
  console.error(`${c.red("○ Dashboard server is NOT running")} ${c.dim(`(tried ${baseUrl()})`)}`);
  if (reason) console.error(c.dim(`  No offline fallback for this command: ${reason}.`));
  console.error(c.dim("  This command needs the server. Start it with one of:"));
  console.error(
    `    ${c.bold("ccam start")}        ${c.dim("# production server in the background")}`
  );
  console.error(
    `    ${c.bold("npm run dev")}       ${c.dim("# dev mode (hot reload), foreground")}`
  );
  console.error(`    ${c.bold("npm start")}         ${c.dim("# production mode, foreground")}`);
  process.exit(1);
}

/** True when the dashboard answers /api/health at the resolved URL. */
async function serverIsUp() {
  try {
    const res = await fetch(`${baseUrl()}/api/health`, { signal: AbortSignal.timeout(2_500) });
    return res.ok;
  } catch {
    return false;
  }
}

/** Minimal flag parser: --key value / --key. Positionals returned in order. */
function parseArgs(argv) {
  const flags = {};
  const positional = [];
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a.startsWith("--")) {
      const key = a.slice(2);
      const next = argv[i + 1];
      if (next != null && !next.startsWith("--")) {
        flags[key] = next;
        i++;
      } else {
        flags[key] = true;
      }
    } else {
      positional.push(a);
    }
  }
  return { flags, positional };
}

const stripAnsi = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*m/g, "");

/**
 * Render rows as a box-drawn table: bold headers, dim borders, right-aligned
 * numeric columns, and width fitting — when the natural table is wider than
 * the terminal, the widest column is progressively narrowed and its cells
 * clipped with an ellipsis, so the frame never wraps mid-row.
 */
function table(headers, rows) {
  const cells = rows.map((r) => r.map((x) => String(x ?? "")));
  // A column is numeric (→ right-aligned) when every non-empty cell looks
  // like a number, money amount, token count, or percentage.
  const numeric = headers.map(
    (_, i) =>
      cells.length > 0 &&
      cells.every((r) => {
        const v = stripAnsi(r[i]).trim();
        return v === "" || v === "-" || /^\$?[\d,.]+[%kMB]?$/.test(v);
      })
  );
  const widths = headers.map((h, i) =>
    Math.max(stripAnsi(h).length, ...cells.map((r) => stripAnsi(r[i]).length), 1)
  );
  const frameWidth = () => widths.reduce((a, w) => a + w + 3, 1);
  while (frameWidth() > termWidth() && Math.max(...widths) > 8) {
    widths[widths.indexOf(Math.max(...widths))]--;
  }
  // Clipping drops per-cell styling for simplicity — a truncated cell is
  // plain text with a trailing ellipsis.
  const clip = (s, w) => {
    const plain = stripAnsi(s);
    return plain.length <= w ? s : `${plain.slice(0, Math.max(0, w - 1))}…`;
  };
  const pad = (s, w, right) => {
    const v = clip(s, w);
    const gap = " ".repeat(Math.max(0, w - stripAnsi(v).length));
    return right ? gap + v : v + gap;
  };
  const rule = (l, m, r) => c.dim(l + widths.map((w) => "─".repeat(w + 2)).join(m) + r);
  const line = (cols, styleFn) =>
    c.dim("│") +
    cols
      .map(
        (cell, i) =>
          ` ${styleFn ? styleFn(pad(cell, widths[i], numeric[i])) : pad(cell, widths[i], numeric[i])} `
      )
      .join(c.dim("│")) +
    c.dim("│");
  console.log(rule("╭", "┬", "╮"));
  console.log(line(headers, c.bold));
  console.log(rule("├", "┼", "┤"));
  for (const r of cells) console.log(line(r));
  if (!cells.length) {
    const inner = widths.reduce((a, w) => a + w + 2, 0) + widths.length - 1;
    console.log(c.dim("│") + c.dim(pad("  (no rows)", inner)) + c.dim("│"));
  }
  console.log(rule("╰", "┴", "╯"));
}

function fmtDuration(startIso, endIso) {
  if (!startIso) return "-";
  const ms = (endIso ? new Date(endIso) : new Date()) - new Date(startIso);
  if (!Number.isFinite(ms) || ms < 0) return "-";
  const m = Math.floor(ms / 60000);
  if (m < 1) return `${Math.floor(ms / 1000)}s`;
  if (m < 60) return `${m}m`;
  return `${Math.floor(m / 60)}h${m % 60}m`;
}

const fmtTime = (iso) => (iso ? String(iso).replace("T", " ").slice(0, 19) : "-");
/** Compact relative timestamp ("4m ago") for freshness-at-a-glance columns. */
function fmtAgo(iso) {
  if (!iso) return "-";
  const ms = Date.now() - new Date(iso).getTime();
  if (!Number.isFinite(ms)) return "-";
  if (ms < 0) return "now";
  const s = Math.floor(ms / 1000);
  if (s < 60) return `${s}s ago`;
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 48) return `${h}h ago`;
  return `${Math.floor(h / 24)}d ago`;
}
const fmtModel = (m) => (m ? m.replace(/^claude-/, "").slice(0, 22) : "-");
const fmtCost = (n) => `$${Number(n ?? 0).toFixed(4)}`;
const fmtTokens = (n) => {
  const v = Number(n ?? 0);
  if (v >= 1e9) return `${(v / 1e9).toFixed(1)}B`;
  if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
  if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
  return String(v);
};

// ── Offline mode ────────────────────────────────────────────────────────────
// When the server is down, read-only commands fall back to reading the
// SQLite database directly (readonly, second reader is safe under WAL).
// Commands that need server-side logic (cost math, analytics aggregation,
// live capture, mutations with broadcasts) refuse instead — running them
// against the raw DB would produce unreliable or divergent results.

/** DB path resolution mirrors server/db.js: env override, then data/. */
function dbPath() {
  return process.env.DASHBOARD_DB_PATH || path.join(REPO_ROOT, "data", "dashboard.db");
}

/**
 * Open the dashboard database for reading. Tries better-sqlite3 from the
 * repo's node_modules first, then Node's built-in node:sqlite (Node 22+).
 * Never creates a database file (existence is checked first), and the CLI
 * only ever issues SELECTs through the returned handle. The connection is
 * deliberately NOT opened with SQLite's readonly flag: a strict readonly
 * connection cannot attach a live WAL's shared-memory index and would
 * silently read the pre-WAL (stale/empty) state when another process has the
 * database open — a normal connection under WAL reads consistently instead.
 */
function openDbReadonly() {
  const file = dbPath();
  if (!fs.existsSync(file)) return null;
  try {
    const Database = require(path.join(REPO_ROOT, "node_modules", "better-sqlite3"));
    const db = new Database(file, { fileMustExist: true });
    return { all: (sql, ...p) => db.prepare(sql).all(...p) };
  } catch {
    /* fall through to node:sqlite */
  }
  try {
    const { DatabaseSync } = require("node:sqlite");
    const db = new DatabaseSync(file);
    return { all: (sql, ...p) => db.prepare(sql).all(...p) };
  } catch {
    return null;
  }
}

/** One-time banner explaining that results come straight from the DB file. */
function offlineBanner() {
  console.log(
    `${c.yellow("⚠ Offline mode")} ${c.dim(`— server not running; reading ${dbPath()} directly.`)}`
  );
  console.log(
    c.dim("  Data is as of the last capture — live capture and full features need the server: ") +
      c.bold("ccam start")
  );
  console.log();
}

/**
 * Display-side staleness correction. While the server is down, its
 * dead-session liveness reap is not running, so sessions that were quit
 * after the server stopped still sit in the DB as active/waiting. Rather
 * than print those stale statuses, run the SAME process-liveness probe the
 * server's watchdog uses (server/lib/session-liveness.js) and correct the
 * DISPLAYED status of any active session whose cwd has no running `claude`
 * process — the database itself is never modified (that stays the server's
 * job). Returns the number of corrected sessions, the set of their ids (so
 * agent rows can be corrected consistently), and whether the probe could
 * answer at all (it can't on Windows or inside containers — in that case a
 * staleness caveat is printed instead).
 */
function livenessCorrect(sessions) {
  let probe;
  try {
    probe = require(path.join(REPO_ROOT, "server", "lib", "session-liveness.js")).probeLiveCwds();
  } catch {
    probe = { available: false };
  }
  const hadActive = sessions.some((s) => s.status === "active");
  if (!probe.available) return { available: false, hadActive, corrected: 0, deadIds: new Set() };
  const deadIds = new Set();
  for (const s of sessions) {
    if (s.status !== "active" || !s.cwd) continue;
    let resolved;
    try {
      resolved = path.resolve(s.cwd);
    } catch {
      continue;
    }
    if (!probe.cwds.has(resolved)) {
      s.status = "completed";
      s.awaiting_input_since = null;
      deadIds.add(s.id);
    }
  }
  return { available: true, corrected: deadIds.size, deadIds };
}

/** Correct agent rows belonging to sessions the probe found dead. */
function livenessCorrectAgents(agents, deadIds) {
  let n = 0;
  for (const a of agents) {
    if (deadIds.has(a.session_id) && a.status !== "completed" && a.status !== "error") {
      a.status = "completed";
      a.awaiting_input_since = null;
      n++;
    }
  }
  return n;
}

/** Footnote for corrected output / caveat when the probe cannot answer. */
function livenessNote(result) {
  if (!result.available) {
    if (!result.hadActive) return; // nothing that could be stale was shown
    console.log(
      c.dim(
        "※ Statuses are as stored: sessions that ended while the server was down may still show active/waiting (liveness probe unavailable on this platform)."
      )
    );
  } else if (result.corrected > 0) {
    console.log(
      c.dim(
        `※ ${result.corrected} session(s) displayed as completed by the process-liveness probe — no running claude process owns them. The database is only updated once the server runs again.`
      )
    );
  }
}

/** Offline data providers shaped exactly like their API counterparts. */
const offlineData = {
  sessions(db, flags) {
    const conds = [];
    const params = [];
    if (flags.status) {
      conds.push("s.status = ?");
      params.push(flags.status);
    }
    if (flags.q) {
      conds.push("(s.id LIKE ? OR s.name LIKE ? OR s.cwd LIKE ?)");
      const like = `%${flags.q}%`;
      params.push(like, like, like);
    }
    const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
    const limit = Number(flags.limit || 20);
    const rows = db.all(
      `SELECT s.*, (SELECT COUNT(*) FROM agents a WHERE a.session_id = s.id) AS agent_count
       FROM sessions s ${where} ORDER BY s.updated_at DESC LIMIT ?`,
      ...params,
      limit
    );
    const total = db.all(`SELECT COUNT(*) AS n FROM sessions s ${where}`, ...params)[0].n;
    return { sessions: rows, total };
  },
  agents(db, flags) {
    const conds = [];
    const params = [];
    if (flags.status) {
      conds.push("status = ?");
      params.push(flags.status);
    }
    if (flags.session) {
      conds.push("session_id = ?");
      params.push(flags.session);
    }
    const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
    return {
      agents: db.all(
        `SELECT * FROM agents ${where} ORDER BY started_at DESC LIMIT ?`,
        ...params,
        Number(flags.limit || 20)
      ),
    };
  },
  events(db, flags) {
    const conds = [];
    const params = [];
    if (flags.session) {
      conds.push("session_id = ?");
      params.push(flags.session);
    }
    const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
    return {
      events: db.all(
        `SELECT * FROM events ${where} ORDER BY created_at DESC LIMIT ?`,
        ...params,
        Number(flags.limit || 20)
      ),
    };
  },
  stats(db) {
    const one = (sql, ...p) => db.all(sql, ...p)[0].n;
    const dist = {};
    for (const r of db.all("SELECT status, COUNT(*) AS n FROM sessions GROUP BY status")) {
      dist[r.status] = r.n;
    }
    const midnight = new Date();
    midnight.setHours(0, 0, 0, 0);
    return {
      total_sessions: one("SELECT COUNT(*) AS n FROM sessions"),
      active_sessions: one("SELECT COUNT(*) AS n FROM sessions WHERE status = 'active'"),
      total_agents: one("SELECT COUNT(*) AS n FROM agents"),
      active_agents: one("SELECT COUNT(*) AS n FROM agents WHERE status = 'working'"),
      total_events: one("SELECT COUNT(*) AS n FROM events"),
      events_today: one(
        "SELECT COUNT(*) AS n FROM events WHERE created_at >= ?",
        midnight.toISOString()
      ),
      ws_connections: 0,
      sessions_by_status: dist,
    };
  },
};

// ── Monitoring ──────────────────────────────────────────────────────────────

async function cmdHealth() {
  const h = await get("/api/health");
  const ver = h.version ? ` v${h.version}` : "";
  console.log(`${c.green("●")} Dashboard ${c.bold("up")}${ver} at ${baseUrl()} (${h.timestamp})`);
}

function renderStats(s, source) {
  heading("Dashboard stats", source);
  table(
    ["Metric", "Value"],
    [
      ["Total sessions", s.total_sessions],
      ["Active sessions", s.active_sessions],
      ["Total agents", s.total_agents],
      ["Active agents", s.active_agents],
      ["Total events", s.total_events],
      ["Events today", s.events_today],
      ["WS connections", s.ws_connections],
    ]
  );
  const entries = Object.entries(s.sessions_by_status || {});
  if (entries.length) {
    console.log(`\n${c.bold("Sessions by status")}`);
    const max = Math.max(...entries.map(([, v]) => v));
    const w = Math.max(...entries.map(([k]) => k.length)) + 2;
    for (const [k, v] of entries) {
      const t = STATUS_THEME[k] || { icon: "·", paint: (x) => x };
      console.log(`  ${t.paint(`${t.icon} ${k}`.padEnd(w))}  ${bar(v, max)} ${c.bold(String(v))}`);
    }
  }
}

async function cmdStats() {
  renderStats(await get("/api/stats"), baseUrl());
}

/** Text rendering of the Kanban board: sessions and agents grouped by status
 *  lanes, each lane a colored header rule with tree-branch item rows. */
function renderKanban(sess, ag) {
  const group = (items, key) => {
    const g = {};
    for (const it of items) (g[it[key]] ||= []).push(it);
    return g;
  };
  const lane = (col, items, render) => {
    const t = STATUS_THEME[col] || { icon: "·", paint: (x) => x };
    const label = `${t.icon} ${col} (${items.length})`;
    const ruleLen = Math.max(2, 34 - stripAnsi(label).length);
    console.log(`\n  ${t.paint(label)} ${c.dim("─".repeat(ruleLen))}`);
    const shown = items.slice(0, 10);
    shown.forEach((it, i) => {
      const branch = i === items.length - 1 ? "└─" : "├─";
      render(it, c.dim(branch));
    });
    if (items.length > 10) console.log(c.dim(`  └─ … ${items.length - 10} more`));
  };
  heading("Sessions");
  const sg = group(sess.sessions || [], "status");
  for (const col of ["active", "waiting", "completed", "error", "abandoned"]) {
    lane(col, sg[col] || [], (s, branch) => {
      console.log(`  ${branch} ${c.dim(s.id.slice(0, 8))}  ${(s.name || "").slice(0, 52)}`);
    });
  }
  console.log();
  heading("Agents");
  const agr = group(ag.agents || [], "status");
  for (const col of ["working", "waiting", "completed", "error"]) {
    lane(col, agr[col] || [], (a, branch) => {
      const tool = a.current_tool ? c.cyan(` [${a.current_tool}]`) : "";
      console.log(`  ${branch} ${c.dim(a.id.slice(0, 8))}  ${(a.name || "").slice(0, 48)}${tool}`);
    });
  }
}

async function cmdKanban() {
  const [sess, ag] = await Promise.all([
    get("/api/sessions?limit=200"),
    get("/api/agents?limit=400"),
  ]);
  renderKanban(sess, ag);
}

/**
 * Live event feed (the Activity Feed, in the terminal). Polls /api/events on
 * a short interval and prints only rows newer than the last one seen —
 * dependency-free tailing without a WebSocket client. Ctrl+C to stop.
 */
async function cmdTail(flags) {
  const params = new URLSearchParams();
  if (flags.session) params.set("session_id", flags.session);
  params.set("limit", "25");
  let lastSeen = null;
  await get(`/api/health`); // fail fast (and route offline messaging) before announcing
  console.log(c.dim(`Tailing events from ${baseUrl()} — Ctrl+C to stop`));
  for (;;) {
    const data = await get(`/api/events?${params}`);
    const events = (data.events || []).slice().reverse(); // oldest → newest
    for (const e of events) {
      const key = `${e.id ?? e.created_at}:${e.event_type}`;
      if (lastSeen && key <= lastSeen && e.id == null) continue;
      if (e.id != null && lastSeen != null && Number(e.id) <= Number(lastSeen)) continue;
      if (lastSeen !== null || events.indexOf(e) >= events.length - 10) {
        console.log(
          `${c.dim(fmtTime(e.created_at))}  ${paintEvent((e.event_type || "").padEnd(16))}  ${(e.summary || "").slice(0, 90)}`
        );
      }
      lastSeen = e.id != null ? Number(e.id) : key;
    }
    await new Promise((r) => setTimeout(r, 2000));
  }
}

// ── Data browsing ───────────────────────────────────────────────────────────

function renderSessions(data) {
  const rows = (data.sessions || []).map((s) => [
    s.id.slice(0, 8),
    colorStatus(s.status),
    (s.name || "").slice(0, 44),
    s.agent_count ?? "-",
    fmtDuration(s.started_at, s.ended_at),
    fmtModel(s.model),
    c.dim(fmtAgo(s.updated_at || s.started_at)),
  ]);
  table(["ID", "Status", "Name", "Agents", "Duration", "Model", "Updated"], rows);
  console.log(c.dim(`\n${rows.length} of ${data.total ?? rows.length} session(s)`));
}

// ── Session detail building blocks (shared by online and offline paths so
//    the two render byte-identically) ────────────────────────────────────────

/** Title + aligned metadata card for one session row. */
function renderSessionMeta(s) {
  console.log(`${c.cyan("▍")}${c.bold(s.name || s.id)} ${c.dim(`(${s.id})`)}`);
  kvLine("Status", colorStatus(s.status));
  kvLine("Model", fmtModel(s.model));
  kvLine("Duration", fmtDuration(s.started_at, s.ended_at));
  kvLine("Cwd", s.cwd || "-");
}

/** Agent hierarchy as a real tree (├─/└─ with continuation rails). */
function renderAgentTree(agents) {
  console.log(`\n${c.cyan("▍")}${c.bold("Agents")} ${c.dim(`(${agents.length})`)}`);
  const byParent = {};
  for (const a of agents) (byParent[a.parent_agent_id || ""] ||= []).push(a);
  const walk = (parentId, prefix) => {
    const kids = byParent[parentId] || [];
    kids.forEach((a, i) => {
      const last = i === kids.length - 1;
      const tool = a.current_tool ? c.cyan(` [${a.current_tool}]`) : "";
      console.log(
        `  ${c.dim(prefix + (last ? "└─ " : "├─ "))}${colorStatus(a.status)} ` +
          `${a.type === "main" ? c.bold(a.name) : a.name}${tool} ${c.dim(fmtDuration(a.started_at, a.ended_at))}`
      );
      walk(a.id, prefix + (last ? "   " : "│  "));
    });
  };
  walk("", "");
}

/** Recent-events block with per-type colors. */
function renderEventLines(events) {
  console.log(`\n${c.cyan("▍")}${c.bold("Recent events")}`);
  for (const e of events) {
    console.log(
      `  ${c.dim(fmtTime(e.created_at))}  ${paintEvent((e.event_type || "").padEnd(16))}  ${(e.summary || "").slice(0, 70)}`
    );
  }
}

async function cmdSessions(flags) {
  const params = new URLSearchParams();
  if (flags.status) params.set("status", flags.status);
  if (flags.q) params.set("q", flags.q);
  params.set("limit", flags.limit || "20");
  renderSessions(await get(`/api/sessions?${params}`));
}

/** Session detail: metadata, cost, agent tree, and the most recent events. */
async function cmdSession(positional) {
  const id = positional[0];
  if (!id) {
    console.error(c.red("✖ Usage: ccam session <session-id>"));
    process.exit(1);
  }
  const d = await get(`/api/sessions/${encodeURIComponent(id)}`);
  const s = d.session || d;
  renderSessionMeta(s);
  let cost = null;
  try {
    cost = await get(`/api/pricing/cost/${encodeURIComponent(id)}`);
  } catch {
    /* pricing may 404 for unknown ids */
  }
  if (cost) kvLine("Cost", c.cyan(c.bold(fmtCost(cost.total_cost))));

  const agents = d.agents || [];
  if (agents.length) renderAgentTree(agents);

  const events = (d.events || []).slice(0, 10);
  if (events.length) renderEventLines(events);
}

function renderAgents(data) {
  const rows = (data.agents || []).map((a) => [
    a.id.slice(0, 8),
    colorStatus(a.status),
    a.type,
    (a.name || "").slice(0, 40),
    a.current_tool || "-",
    fmtDuration(a.started_at, a.ended_at),
  ]);
  table(["ID", "Status", "Type", "Name", "Tool", "Duration"], rows);
}

async function cmdAgents(flags) {
  const params = new URLSearchParams();
  if (flags.status) params.set("status", flags.status);
  if (flags.session) params.set("session_id", flags.session);
  params.set("limit", flags.limit || "20");
  renderAgents(await get(`/api/agents?${params}`));
}

function renderEvents(data) {
  const rows = (data.events || []).map((e) => [
    fmtTime(e.created_at),
    e.event_type,
    e.tool_name || "-",
    (e.summary || "").slice(0, 60),
  ]);
  table(["Time", "Type", "Tool", "Summary"], rows);
}

async function cmdEvents(flags) {
  const params = new URLSearchParams();
  if (flags.session) params.set("session_id", flags.session);
  params.set("limit", flags.limit || "20");
  renderEvents(await get(`/api/events?${params}`));
}

// ── Insights ────────────────────────────────────────────────────────────────

async function cmdAnalytics() {
  const a = await get("/api/analytics");
  heading("Analytics", baseUrl());
  const t = a.tokens || {};
  table(
    ["Tokens", "Count"],
    [
      ["Input", fmtTokens(t.total_input)],
      ["Output", fmtTokens(t.total_output)],
      ["Cache read", fmtTokens(t.total_cache_read)],
      ["Cache write", fmtTokens(t.total_cache_write)],
    ]
  );
  const tools = (a.tool_usage || []).slice(0, 10);
  if (tools.length) {
    console.log(`\n${c.bold("Top tools")}`);
    const max = Math.max(...tools.map((x) => Number(x.count) || 0));
    const w = Math.max(...tools.map((x) => String(x.tool_name || x.tool).length));
    for (const x of tools) {
      console.log(
        `  ${String(x.tool_name || x.tool).padEnd(w)}  ${bar(x.count, max)} ${c.bold(String(x.count))}`
      );
    }
  }
  const types = (a.agent_types || []).slice(0, 8);
  if (types.length) {
    console.log(`\n${c.bold("Agent types")}`);
    const max = Math.max(...types.map((x) => Number(x.count) || 0));
    const w = Math.max(...types.map((x) => String(x.subagent_type || x.type || "main").length));
    for (const x of types) {
      const name = String(x.subagent_type || x.type || "main");
      console.log(
        `  ${name.padEnd(w)}  ${bar(x.count, max, 16, c.magenta)} ${c.bold(String(x.count))}`
      );
    }
  }
  if (a.avg_events_per_session != null) {
    console.log(`\nAvg events/session: ${c.cyan(Number(a.avg_events_per_session).toFixed(1))}`);
  }
}

async function cmdWorkflows(flags) {
  if (flags.session) {
    const d = await get(`/api/workflows/session/${encodeURIComponent(flags.session)}`);
    heading("Workflow drill-in", `session ${flags.session}`);
    const agents = d.agents || d.tree || [];
    console.log(`agents: ${Array.isArray(agents) ? agents.length : "-"}`);
    return;
  }
  const w = await get("/api/workflows");
  const s = w.stats || {};
  heading("Workflow intelligence", baseUrl());
  table(
    ["Metric", "Value"],
    [
      ["Sessions analyzed", s.totalSessions ?? "-"],
      ["Total agents", s.totalAgents ?? "-"],
      ["Subagents", s.totalSubagents ?? "-"],
      ["Avg subagents/session", s.avgSubagents ?? "-"],
      ["Success rate", s.successRate != null ? `${s.successRate}%` : "-"],
      ["Avg depth", s.avgDepth ?? "-"],
      ["Compactions", s.totalCompactions ?? "-"],
    ]
  );
  const patterns = (w.patterns && w.patterns.patterns) || [];
  if (patterns.length) {
    console.log(`\n${c.bold("Detected patterns")} (top ${Math.min(5, patterns.length)})`);
    for (const p of patterns.slice(0, 5)) {
      console.log(
        `  ${c.cyan(String(p.count ?? "-"))}× ${(p.label || p.chain || "").toString().slice(0, 80)}`
      );
    }
  }
}

async function cmdRuns(flags) {
  const params = new URLSearchParams();
  if (flags.session) params.set("session_id", flags.session);
  const data = await get(`/api/workflows/runs?${params}`);
  const runs = data.runs || data.items || [];
  const rows = runs
    .slice(0, Number(flags.limit || 20))
    .map((r) => [
      (r.run_id || "").slice(0, 14),
      colorStatus(r.status),
      (r.name || "").slice(0, 28),
      r.agent_count ?? "-",
      fmtTokens(r.total_tokens),
      r.total_tool_calls ?? "-",
      fmtDuration(r.started_at, r.ended_at),
    ]);
  table(["Run", "Status", "Name", "Agents", "Tokens", "Tools", "Duration"], rows);
}

async function cmdCost(flags) {
  // Mirror the two cost endpoints: the whole-database aggregate, or one
  // session's cost when --session is given (same response shape, so the
  // rendering below is shared).
  const sessionId = flags.session;
  const cost = sessionId
    ? await get(`/api/pricing/cost/${encodeURIComponent(sessionId)}`)
    : await get("/api/pricing/cost");
  if (sessionId) heading("Session cost", sessionId);
  console.log(`${c.bold("Total estimated cost:")} ${c.cyan(c.bold(fmtCost(cost.total_cost)))}`);
  const breakdown = (cost.breakdown || []).slice(0, 15);
  if (breakdown.length) {
    console.log();
    const max = Math.max(...breakdown.map((b) => Number(b.cost) || 0));
    const w = Math.max(...breakdown.map((b) => fmtModel(b.model).length));
    for (const b of breakdown) {
      console.log(
        `  ${fmtModel(b.model).padEnd(w)}  ${bar(b.cost, max, 20, c.green)} ${c.bold(fmtCost(b.cost))}`
      );
    }
  }
  // Server-tool surcharges billed on top of tokens (web search $/1k, code
  // execution container-time beyond the org free allowance). The API always
  // returns feature_costs; show the line only when something is actually
  // billed so a plain token-only total stays uncluttered.
  const fc = cost.feature_costs || {};
  const featureLines = [];
  if ((fc.web_search_cost || 0) > 0)
    featureLines.push(`web search ${c.bold(fmtCost(fc.web_search_cost))}`);
  if ((fc.code_execution_cost || 0) > 0)
    featureLines.push(`code execution ${c.bold(fmtCost(fc.code_execution_cost))}`);
  if (featureLines.length) {
    console.log();
    console.log(`${c.dim("Server-tool surcharges:")} ${featureLines.join(c.dim(" · "))}`);
  }
  // The API prices unmatched models at $0 and reports them in unpriced_models
  // so the total stays honest — surface that here instead of silently showing
  // an under-reported number (e.g. right after a brand-new model id ships).
  const unpriced = cost.unpriced_models || [];
  if (unpriced.length) {
    console.log();
    console.log(
      c.yellow(
        `⚠ ${unpriced.length} model(s) have usage but no pricing rule — excluded from the total:`
      )
    );
    for (const u of unpriced) {
      const tokens =
        (u.input_tokens || 0) +
        (u.output_tokens || 0) +
        (u.cache_read_tokens || 0) +
        (u.cache_write_tokens || 0);
      console.log(`  ${c.bold(u.model)}  ${c.dim(`${fmtTokens(tokens)} tokens`)}`);
    }
    console.log(c.dim("  Add a rule with: ccam pricing set <pattern> --input N --output N"));
  }
}

// ── Alerts & webhooks ───────────────────────────────────────────────────────

async function cmdAlerts(flags, positional) {
  const sub = positional[0];
  if (sub === "ack" && positional[1]) {
    await post(`/api/alerts/${positional[1]}/ack`);
    console.log(`${c.green("✔")} Alert ${positional[1]} acknowledged`);
    return;
  }
  if (sub === "ack-all") {
    const r = await post("/api/alerts/ack-all");
    console.log(`${c.green("✔")} Acknowledged ${r.acknowledged ?? "all"} alert(s)`);
    return;
  }
  const params = new URLSearchParams();
  if (flags.unacked) params.set("unacked", "true");
  params.set("limit", flags.limit || "20");
  const data = await get(`/api/alerts?${params}`);
  const rows = (data.alerts || []).map((a) => [
    a.id,
    a.acknowledged_at ? c.dim("acked") : c.yellow("open"),
    fmtTime(a.triggered_at),
    (a.rule_name || "").slice(0, 24),
    (a.message || "").slice(0, 52),
  ]);
  table(["ID", "State", "Triggered", "Rule", "Message"], rows);
  console.log(c.dim(`\n${data.unacked ?? 0} unacknowledged of ${data.total ?? rows.length}`));
}

async function cmdRules() {
  const data = await get("/api/alerts/rules");
  const rows = (data.rules || []).map((r) => [
    r.id,
    r.enabled ? c.green("on") : c.dim("off"),
    r.rule_type,
    (r.name || "").slice(0, 32),
    `${r.cooldown_minutes ?? "-"}m`,
  ]);
  table(["ID", "Enabled", "Type", "Name", "Cooldown"], rows);
}

async function cmdWebhooks(flags, positional) {
  const sub = positional[0];
  if (sub === "test" && positional[1]) {
    const r = await post(`/api/webhooks/${positional[1]}/test`);
    const ok = r.ok || r.success;
    console.log(
      ok
        ? `${c.green("✔")} Test delivery succeeded (HTTP ${r.status ?? "?"}, ${r.attempts ?? 1} attempt(s))`
        : `${c.red("✖")} Test delivery failed: ${r.error || `HTTP ${r.status}`}`
    );
    process.exit(ok ? 0 : 1);
  }
  const data = await get("/api/webhooks");
  const rows = (data.targets || []).map((t) => [
    t.id,
    t.enabled ? c.green("on") : c.dim("off"),
    t.type,
    (t.name || "").slice(0, 28),
    t.url_masked || t.url || "-",
  ]);
  table(["ID", "Enabled", "Provider", "Name", "URL"], rows);
}

// ── Remote data sources ─────────────────────────────────────────────────────

/**
 * `ccam remote-sources [list|add|test|sync|rm]` — manage the SSH machines this
 * dashboard pulls Claude Code history from (server/routes/remote-sources.js).
 * No secrets are handled here; auth defers to the host's SSH stack.
 */
async function cmdRemoteSources(flags, positional) {
  const sub = positional[0] || "list";

  if (sub === "add") {
    if (!flags.label || !flags.host) {
      console.error(c.red("✖ add requires --label and --host"));
      console.error(
        c.dim(
          "  e.g. ccam remote-sources add --label 'Dev box' --host son@dev --port 22 --identity ~/.ssh/id_ed25519"
        )
      );
      process.exit(1);
    }
    const body = {
      label: String(flags.label),
      host: String(flags.host),
      ssh_port: flags.port != null ? Number(flags.port) : null,
      identity_file: flags.identity != null ? String(flags.identity) : null,
      remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null,
      enabled: flags.disabled ? false : true,
    };
    const r = await post("/api/remote-sources", body);
    console.log(`${c.green("✔")} Added remote source ${c.bold(r.source.label)} (${r.source.id})`);
    return;
  }

  if (sub === "test" && positional[1]) {
    const r = await post(`/api/remote-sources/${positional[1]}/test`);
    console.log(r.ok ? `${c.green("✔")} ${r.message}` : `${c.red("✖")} ${r.message}`);
    process.exit(r.ok ? 0 : 1);
  }

  if (sub === "sync") {
    if (positional[1]) {
      const r = await post(`/api/remote-sources/${positional[1]}/sync`);
      console.log(
        `${c.green("✔")} Synced ${positional[1]}: ${r.imported ?? 0} imported, ${r.sessions_tagged ?? 0} tagged`
      );
    } else {
      // No id → sync every source sequentially.
      const { sources = [] } = await get("/api/remote-sources");
      for (const s of sources) {
        const r = await post(`/api/remote-sources/${s.id}/sync`);
        console.log(
          `  ${c.bold(s.label)}: ${r.imported ?? 0} imported, ${r.sessions_tagged ?? 0} tagged`
        );
      }
      console.log(`${c.green("✔")} Synced ${sources.length} source(s)`);
    }
    return;
  }

  if (sub === "rm" && positional[1]) {
    const purge = !!flags.purge;
    const r = await api(
      "DELETE",
      `/api/remote-sources/${positional[1]}${purge ? "?purge=true" : ""}`
    );
    console.log(
      `${c.green("✔")} Removed ${positional[1]}${purge ? ` (purged ${r.purged} session(s))` : " (data kept)"}`
    );
    return;
  }

  // Default: list.
  const { sources = [] } = await get("/api/remote-sources");
  const rows = sources.map((s) => [
    s.id,
    s.enabled ? c.green("on") : c.dim("off"),
    s.status,
    (s.label || "").slice(0, 24),
    `${s.host}${s.ssh_port ? `:${s.ssh_port}` : ""}`,
    String(s.session_count ?? 0),
    s.last_sync_at ? new Date(s.last_sync_at).toLocaleString() : "-",
  ]);
  table(["ID", "Auto", "Status", "Label", "Host", "Sessions", "Last sync"], rows);
  if (sources.length > 0) {
    const totalSessions = sources.reduce((n, s) => n + (s.session_count || 0), 0);
    const enabled = sources.filter((s) => s.enabled).length;
    console.log(
      c.dim(
        `  ${sources.length} source(s), ${enabled} auto-syncing, ${totalSessions} session(s) collected`
      )
    );
  } else {
    console.log(c.dim("  No remote sources configured. Add one: ccam remote-sources add --help"));
  }
}

// ── Pricing ─────────────────────────────────────────────────────────────────

/** Render pricing rules — shared by the online list and the offline fallback
 *  so both show the full rate surface (standard, fast-mode, intro promo). */
function renderPricingTable(rules) {
  const fastCol = (p) =>
    (p.fast_input_per_mtok || 0) > 0 ? `$${p.fast_input_per_mtok}/$${p.fast_output_per_mtok}` : "-";
  const introCol = (p) =>
    p.intro_until
      ? `$${p.intro_input_per_mtok}/$${p.intro_output_per_mtok} ≤${p.intro_until}`
      : "-";
  const rows = (rules || []).map((p) => [
    p.model_pattern,
    (p.display_name || "").slice(0, 24),
    `$${p.input_per_mtok}`,
    `$${p.output_per_mtok}`,
    `$${p.cache_read_per_mtok}`,
    `$${p.cache_write_per_mtok}`,
    fastCol(p),
    introCol(p),
  ]);
  table(
    ["Pattern", "Name", "In/M", "Out/M", "CacheR/M", "CacheW/M", "Fast In/Out", "Intro In/Out"],
    rows
  );
}

async function cmdPricing(flags, positional) {
  const sub = positional[0];
  if (sub === "set" && positional[1]) {
    const body = {
      model_pattern: positional[1],
      display_name: flags.name || positional[1],
      input_per_mtok: Number(flags.input ?? 0),
      output_per_mtok: Number(flags.output ?? 0),
      cache_read_per_mtok: Number(flags["cache-read"] ?? 0),
      cache_write_per_mtok: Number(flags["cache-write"] ?? 0),
    };
    if (flags["cache-write-1h"] !== undefined)
      body.cache_write_1h_per_mtok = Number(flags["cache-write-1h"]);
    if (flags["fast-input"] !== undefined) body.fast_input_per_mtok = Number(flags["fast-input"]);
    if (flags["fast-output"] !== undefined)
      body.fast_output_per_mtok = Number(flags["fast-output"]);
    // The intro block is only sent when at least one --intro-* flag is present:
    // per the API contract, a PUT that omits every intro field preserves an
    // existing promo, so a plain rate edit can never clobber one.
    const INTRO_FLAGS = {
      "intro-input": "intro_input_per_mtok",
      "intro-output": "intro_output_per_mtok",
      "intro-cache-read": "intro_cache_read_per_mtok",
      "intro-cache-write": "intro_cache_write_per_mtok",
      "intro-cache-write-1h": "intro_cache_write_1h_per_mtok",
    };
    const introProvided =
      flags["intro-until"] !== undefined ||
      Object.keys(INTRO_FLAGS).some((f) => flags[f] !== undefined);
    if (introProvided) {
      for (const [flag, field] of Object.entries(INTRO_FLAGS))
        body[field] = Number(flags[flag] ?? 0);
      // A bare --intro-until (no date) clears the promo, mirroring the API.
      body.intro_until = typeof flags["intro-until"] === "string" ? flags["intro-until"] : "";
    }
    await api("PUT", "/api/pricing", body);
    console.log(`${c.green("✔")} Pricing rule saved for ${c.bold(positional[1])}`);
    return;
  }
  if (sub === "delete" && positional[1]) {
    await api("DELETE", `/api/pricing/${encodeURIComponent(positional[1])}`);
    console.log(`${c.green("✔")} Pricing rule deleted: ${positional[1]}`);
    return;
  }
  if (sub === "reset") {
    await post("/api/settings/reset-pricing");
    console.log(`${c.green("✔")} Pricing rules reset to defaults`);
    return;
  }
  const data = await get("/api/pricing");
  renderPricingTable(data.pricing || data.rules || []);
}

// ── Updates ─────────────────────────────────────────────────────────────────

async function cmdUpdateCheck() {
  console.log(c.dim("Checking the canonical remote — this can take a few seconds…"));
  // POST /check (rather than GET /status) so a dashboard open in the browser
  // sees the same fresh result via the update_status websocket broadcast.
  const s = await post("/api/updates/check");
  heading("Dashboard updates", s.repo_root || baseUrl());
  if (!s.git_repo) {
    console.log(`${c.yellow("○")}  ${s.message}`);
    return;
  }
  if (s.fetch_error) {
    console.log(`${c.yellow("!")}  ${s.message} ${c.dim(`(${s.fetch_error})`)}`);
    return;
  }
  if (s.update_available) {
    console.log(`${c.yellow("⬆")}  ${s.message}`);
    if (s.situation_note) console.log(c.dim(`   ${s.situation_note}`));
    if (s.manual_command) {
      console.log(`\n  ${c.bold("To update, run:")}`);
      console.log(`  ${c.cyan(s.manual_command)}`);
    }
  } else {
    console.log(`${c.green("✔")}  ${s.message || "Your checkout is up to date."}`);
  }
}

// ── Import ──────────────────────────────────────────────────────────────────

async function cmdImport(flags, positional) {
  const sub = positional[0];
  if (sub === "rescan") {
    console.log(c.dim("Rescanning ~/.claude/projects — this can take a while…"));
    const r = await post("/api/import/rescan");
    console.log(
      `${c.green("✔")} imported ${r.imported ?? 0}, backfilled ${r.backfilled ?? 0}, skipped ${r.skipped ?? 0}, errors ${r.errors ?? 0}`
    );
    return;
  }
  if (sub === "path" && positional[1]) {
    console.log(c.dim(`Scanning ${positional[1]} …`));
    const r = await post("/api/import/scan-path", { path: positional[1] });
    console.log(
      `${c.green("✔")} imported ${r.imported ?? 0}, backfilled ${r.backfilled ?? 0}, skipped ${r.skipped ?? 0}, errors ${r.errors ?? 0}`
    );
    return;
  }
  console.error(c.red("✖ Usage: ccam import rescan | ccam import path <dir>"));
  process.exit(1);
}

// ── Administration ──────────────────────────────────────────────────────────

async function cmdDoctor() {
  let failed = false;
  await get("/api/health");
  heading("ccam doctor", baseUrl());
  console.log(`${c.green("✔")}  API reachable  ${baseUrl()}`);
  const info = await get("/api/settings/info");
  const hooks = info.hooks || {};
  if (hooks.installed) {
    console.log(
      `${c.green("✔")}  Claude Code hooks  installed (${hooks.path || "~/.claude/settings.json"})`
    );
  } else {
    failed = true;
    console.log(`${c.red("✖")}  Claude Code hooks  NOT installed — run: npm run install-hooks`);
  }
  const db = info.db || {};
  console.log(
    `${c.green("✔")}  Database  ${db.path || "?"} (${((db.size || 0) / 1048576).toFixed(1)} MB)`
  );
  for (const [t, n] of Object.entries(db.counts || {})) {
    console.log(`${c.dim("·")}    rows: ${t}  ${n}`);
  }
  const srv = info.server || {};
  console.log(
    `${c.green("✔")}  Server uptime  ${Math.floor((srv.uptime || 0) / 60)} min (node ${srv.node_version || "?"})`
  );
  console.log(`${c.green("✔")}  WS connections  ${srv.ws_connections ?? 0}`);

  try {
    const { sources = [] } = await get("/api/remote-sources");
    if (sources.length === 0) {
      console.log(`${c.green("✔")}  Remote sources  none configured`);
    } else {
      const errored = sources.filter((s) => s.status === "error");
      console.log(
        `${errored.length ? c.yellow("!") : c.green("✔")}  Remote sources  ${sources.length} configured` +
          (errored.length ? ` (${errored.length} in error)` : "")
      );
      for (const s of sources) {
        const mark =
          s.status === "error" ? c.red("✖") : s.status === "ok" ? c.green("·") : c.dim("·");
        console.log(
          `${mark}    ${s.label || s.id}  ${s.status}` +
            (s.last_sync_at ? `  last sync ${s.last_sync_at}` : "") +
            (s.last_error ? `  ${c.red(s.last_error)}` : "")
        );
      }
      if (errored.length) failed = true;
    }
  } catch (err) {
    failed = true;
    console.log(`${c.red("✖")}  Remote sources  ${err.message || err}`);
  }

  if (failed) process.exit(1);
}

async function cmdInfo() {
  const info = await get("/api/settings/info");
  console.log(JSON.stringify(info, null, 2));
}

async function cmdExport(positional) {
  const file = positional[0] || `ccam-export-${new Date().toISOString().slice(0, 10)}.json`;
  const data = await get("/api/settings/export");
  fs.writeFileSync(file, JSON.stringify(data, null, 2));
  console.log(
    `${c.green("✔")} Exported to ${c.bold(file)} (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)`
  );
}

// Restore a bundle produced by `ccam export` (or the dashboard's Export data).
// The local server reads the file from disk, so we pass an absolute path rather
// than uploading — idempotent and non-destructive (existing sessions skipped).
async function cmdImportData(positional) {
  const target = positional[0];
  if (!target) {
    console.error(c.red("✖ Usage: ccam import-data <export.json>"));
    process.exit(1);
  }
  const abs = path.resolve(process.cwd(), target);
  if (!fs.existsSync(abs)) {
    console.error(c.red(`✖ File not found: ${abs}`));
    process.exit(1);
  }
  const r = await post("/api/settings/import", { path: abs });
  console.log(
    `${c.green("✔")} Restored: ${r.sessions_imported ?? 0} sessions added, ` +
      `${r.sessions_skipped ?? 0} already present, ${r.events ?? 0} events, ` +
      `${r.model_pricing ?? 0} pricing rules ` +
      `(${r.agents ?? 0} agents · ${r.workflows ?? 0} workflows · ${r.dashboard_runs ?? 0} runs · ${r.alert_rules ?? 0} rules)`
  );
}

async function cmdCleanup(flags) {
  const body = {};
  if (flags.hours) body.abandon_hours = Number(flags.hours);
  if (flags.days) body.purge_days = Number(flags.days);
  if (!flags.hours && !flags.days) {
    console.error(c.red("✖ Usage: ccam cleanup --hours <N> and/or --days <M>"));
    console.error(c.dim("  --hours N  abandon active sessions idle for N hours"));
    console.error(c.dim("  --days M   purge completed sessions older than M days"));
    process.exit(1);
  }
  const r = await post("/api/settings/cleanup", body);
  console.log(`${c.green("✔")} Cleanup done: ${JSON.stringify(r)}`);
}

async function cmdClearData(flags) {
  if (flags.yes !== true) {
    console.error(c.red("✖ clear-data deletes ALL sessions, agents, events, and token usage."));
    console.error(c.dim("  Re-run with --yes to confirm: ccam clear-data --yes"));
    process.exit(1);
  }
  await post("/api/settings/clear-data");
  console.log(`${c.green("✔")} All data cleared (schema preserved)`);
}

async function cmdReinstallHooks() {
  await post("/api/settings/reinstall-hooks");
  console.log(`${c.green("✔")} Claude Code hooks reinstalled`);
}

/**
 * Start the dashboard server in the background (production mode, serving the
 * built client) and wait until /api/health answers. No-ops with a pointer to
 * the live URL when a server is already up. The child is fully detached with
 * its output appended to data/ccam-server.log, so closing this terminal does
 * not stop the dashboard; stop it later with `kill <pid>` (the PID is
 * printed and registered in ~/.claude/.agent-dashboard.json).
 */
async function cmdStart(flags) {
  if (await serverIsUp()) {
    console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
    return;
  }
  const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
  if (!fs.existsSync(clientDist)) {
    console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
    console.error(c.dim("  Build it once with: npm run build   (then re-run: ccam start)"));
    console.error(c.dim("  Or run the dev servers instead:     npm run dev"));
    process.exit(1);
  }
  const logDir = path.join(REPO_ROOT, "data");
  fs.mkdirSync(logDir, { recursive: true });
  const logFile = path.join(logDir, "ccam-server.log");
  const out = fs.openSync(logFile, "a");
  const env = { ...process.env, NODE_ENV: "production" };
  if (flags.port) env.DASHBOARD_PORT = String(flags.port);
  const child = spawn(process.execPath, [path.join(REPO_ROOT, "server", "index.js")], {
    detached: true,
    stdio: ["ignore", out, out],
    env,
    cwd: REPO_ROOT,
  });
  child.unref();
  // On a TTY, animate a braille spinner in place; when piped, fall back to a
  // dot-per-poll trail so progress still shows without cursor control.
  const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  const live = Boolean(process.stdout.isTTY);
  let tick = 0;
  const announce = `Starting dashboard server (pid ${child.pid}, log ${logFile})`;
  if (live) {
    process.stdout.write(`${c.cyan(spinner[0])} ${c.dim(announce)}`);
  } else {
    process.stdout.write(c.dim(`${announce} `));
  }
  const clearLine = () => {
    if (live) process.stdout.write("\r\x1b[K");
    else process.stdout.write("\n");
  };
  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline) {
    if (await serverIsUp()) {
      clearLine();
      console.log(
        `${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
      );
      console.log(c.dim(`  Stop it with: kill ${child.pid}`));
      return;
    }
    tick++;
    if (live) {
      process.stdout.write(`\r${c.cyan(spinner[tick % spinner.length])} ${c.dim(announce)}`);
    } else {
      process.stdout.write(c.dim("."));
    }
    await new Promise((r) => setTimeout(r, live ? 250 : 500));
  }
  clearLine();
  console.error(`${c.red("✖ Server did not become healthy within 30 s")} — check ${logFile}`);
  process.exit(1);
}

/** Up/down indicator without exiting non-zero noise — the at-a-glance check. */
async function cmdStatus() {
  if (await serverIsUp()) {
    const h = await get("/api/health");
    console.log(
      `${c.green("●")} Dashboard server is ${c.bold("running")} at ${baseUrl()} (${h.timestamp})`
    );
  } else {
    console.log(
      `${c.red("○")} Dashboard server is ${c.bold("NOT running")} ${c.dim(`(tried ${baseUrl()})`)}`
    );
    console.log(c.dim("  Start it with: ccam start   (or npm run dev / npm start)"));
    process.exit(1);
  }
}

function cmdOpen() {
  const url = baseUrl();
  const opener =
    process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
  spawn(opener, [url], {
    shell: process.platform === "win32",
    detached: true,
    stdio: "ignore",
  }).unref();
  console.log(`${c.green("✔")} Opening ${url}`);
}

/**
 * `ccam lanes add` — adopt a working directory or provision a managed worktree.
 */
async function cmdLanesAdd(args) {
  const flag = (name) => {
    const i = args.indexOf(`--${name}`);
    return i > -1 ? args[i + 1] : undefined;
  };
  const cwd = flag("cwd");
  const repo = flag("repo");
  const title = flag("title");
  const pipeline = flag("pipeline");
  const base = flag("base");
  const slug = flag("slug");
  if (repo) {
    if (cwd || pipeline) {
      console.error(
        "usage: ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]"
      );
      process.exitCode = 1;
      return;
    }
    const body = { sourceRepo: repo, title: title || path.basename(repo) };
    if (base) body.base = base;
    if (slug) body.slug = slug;
    const { lane: provisioning } = await post("/api/lanes/worktree", body);
    const deadline = Date.now() + 30_000;
    let lane = provisioning;
    while (lane.status === "provisioning" && Date.now() < deadline) {
      await new Promise((resolve) => setTimeout(resolve, 250));
      ({ lane } = await get(`/api/lanes/${provisioning.id}`));
    }
    if (lane.status === "provisioning") {
      console.error(`✖ Timed out waiting for worktree lane #${lane.id} to finish provisioning.`);
      process.exitCode = 1;
      return;
    }
    if (lane.status === "failed") {
      console.error(
        `✖ Worktree lane #${lane.id} failed: ${lane.notes || "no failure details reported"}`
      );
      process.exitCode = 1;
      return;
    }
    console.log(
      `${c.green("✔")} Worktree lane #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
    );
    return;
  }
  if (!cwd || !title) {
    console.error("usage: ccam lanes add --cwd <path> --title <text> [--pipeline <id>]");
    process.exitCode = 1;
    return;
  }
  const body = { cwd, title };
  if (pipeline) body.pipeline = pipeline;
  const { lane } = await post("/api/lanes", body);
  console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
}

/**
 * `ccam skills install` — copy this checkout's `.claude/skills/ship-feature-lane/`
 * into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
 * discoverable from a session running inside any lane's own working
 * directory, not just inside this repo. Pure filesystem action; does not
 * talk to the dashboard server at all. Reinstall, not merge — overwrites an
 * existing global copy with the current one.
 */
function cmdSkillsInstall() {
  const { installShipFeatureLaneSkill } = require(
    path.join(REPO_ROOT, "server", "lib", "skills-install.js")
  );
  try {
    const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
    console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
  } catch (err) {
    console.error(`✖ ${err.message}`);
    process.exitCode = 1;
  }
}

/**
 * `ccam lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
 * (owning session died) and cap oversized hook logs, across every lane on
 * this machine. Machine-wide, not one lane's; pure local process/file
 * action, no HTTP round-trip.
 */
function cmdLanesGc(args) {
  const dryRun = args.includes("--dry-run");
  const laneGc = require(path.join(REPO_ROOT, "server", "lib", "lane-gc.js"));

  const reaped = laneGc.reapOrphanMcp({ dryRun });
  if (reaped.length) {
    console.log(
      `${dryRun ? "would reap" : "reaped"} ${reaped.length} orphaned MCP process(es): ${reaped.join(", ")}`
    );
  } else {
    console.log("no orphaned MCP processes found");
  }

  const capped = laneGc.capOversizedLogs({ dryRun });
  if (capped.length) {
    for (const { path: logPath, sizeBefore } of capped) {
      const mb = (sizeBefore / (1024 * 1024)).toFixed(1);
      console.log(`${dryRun ? "would cap" : "capped"} ${logPath} (${mb}MB -> 2MB)`);
    }
  } else {
    console.log("no oversized logs found");
  }
}

/**
 * `ccam lanes profile init <repo>` — detect a Node.js project and scaffold
 * `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not
 * talk to the dashboard server at all.
 */
function cmdLanesProfileInit(args) {
  const repo = args.find((arg) => !arg.startsWith("--"));
  const force = args.includes("--force");
  if (!repo) {
    console.error("usage: ccam lanes profile init <repo> [--force]");
    process.exitCode = 1;
    return;
  }
  const resolved = path.resolve(repo);
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
    console.error(`✖ not a directory: ${resolved}`);
    process.exitCode = 1;
    return;
  }

  const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
  const facts = laneDetect.detectNode(resolved);
  if (!facts) {
    console.error(
      `✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` +
        "  frontend/package.json, or a root package.json).\n" +
        "  Auto-scaffolding currently supports Node.js repos in that layout only.\n" +
        "  Write .ccam/profile/ by hand — see docs/LANES.md."
    );
    process.exitCode = 1;
    return;
  }

  let result;
  try {
    result = laneDetect.scaffoldProfile(resolved, facts, { force });
  } catch (err) {
    if (err.code === "EPROFILEEXISTS") {
      console.error(`✖ ${err.message} — pass --force to overwrite it.`);
      process.exitCode = 1;
      return;
    }
    throw err;
  }

  console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`);
  for (const file of result.written) console.log(`  wrote  ${file}`);
  if (result.todos.length) {
    console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
    for (const todo of result.todos) console.log(`  ${todo}`);
  }
  console.log(`\nNext: ccam lanes profile check ${repo}`);
}

/**
 * `ccam lanes profile check [<path>]` — validate a profile without needing a
 * lane to exist for it yet. Defaults to the current directory, NOT lane-id
 * resolution (unlike every other `lanes` subcommand) — this is meant to run
 * against a bare repo right after `profile init`.
 */
async function cmdLanesProfileCheck(args) {
  const target = args.find((arg) => !arg.startsWith("--")) || process.cwd();
  const resolved = path.resolve(target);
  const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
  const result = await laneDetect.checkProfile(resolved);

  if (result.errors.length === 0) {
    console.log(`${c.green("✔")} profile at ${resolved} looks good`);
  } else {
    console.log(`${c.red(`✖ ${result.errors.length} problem(s) at ${resolved}:`)}`);
    for (const error of result.errors) console.log(`  ${error}`);
  }
  for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`);
  process.exitCode = result.ok ? 0 : 1;
}

// Keep these confirmation facts in lockstep with expectedFields() in
// server/routes/lanes.js. The server remains authoritative and rejects an
// incomplete or stale echo, while the CLI shows exactly what it will send.
const LANE_PREFLIGHT_FIELDS = {
  reset: ["head", "dirty", "untracked", "unpushed"],
  remove: ["head", "dirty", "untracked", "unpushed"],
  purge: ["sessions", "events", "tokenRows"],
};

function printLaneFacts(title, facts, fields) {
  const width = Math.max(...fields.map((field) => field.length));
  console.log(title);
  for (const field of fields) {
    const value = facts[field] === null ? "(none)" : String(facts[field]);
    console.log(`  ${field.padEnd(width)}  ${value}`);
  }
}

/** `ccam lanes reset|remove|purge` — destructive work only after a fact check. */
async function cmdLanesLifecycle(action, args) {
  const id = args.find((arg) => !arg.startsWith("--"));
  const fields = LANE_PREFLIGHT_FIELDS[action];
  if (!id) {
    const extra = action === "reset" ? " [--keep-db]" : "";
    console.error(`usage: ccam lanes ${action} <id> [--force]${extra} --yes`);
    process.exitCode = 1;
    return;
  }

  const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
  printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
  const keepDb = action === "reset" && args.includes("--keep-db");
  if (preflight.database) {
    const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
    console.log(`  database: ${preflight.database} (${fate})`);
  }
  if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
    console.log("Warnings:");
    for (const warning of preflight.warnings) console.log(`  ${warning}`);
  }

  if (!args.includes("--yes")) {
    console.error(`Refusing to ${action} lane #${id} without --yes. Re-run with --yes to proceed.`);
    process.exitCode = 1;
    return;
  }
  // Only `reset` is impossible for an adopted lane. `remove` IS supported by the
  // server: it drops the dashboard's record and never touches the directory
  // (server/routes/lanes.js scopes the worktree teardown to managed lanes), so
  // refusing it here would make a permitted action unreachable from the CLI.
  if (
    action === "reset" &&
    Array.isArray(preflight.blocked) &&
    preflight.blocked.includes("adopted")
  ) {
    console.error(
      `Lane #${id} points at a directory you own. It cannot be reset; use \`ccam lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
    );
    process.exitCode = 1;
    return;
  }

  const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
  const body = { confirm: true, expect };
  if (args.includes("--force")) body.force = true;
  if (keepDb) body.keepDb = true;
  const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
  if (result.status) {
    const error = result.data?.error || {};
    console.error(`✖ ${action} lane #${id} → ${error.message || `HTTP ${result.status}`}`);
    if (result.status === 409 && error.code === "ESTALE") {
      printLaneFacts("Expected preflight:", error.expected || {}, fields);
      printLaneFacts("Current preflight:", error.current || {}, fields);
    }
    process.exitCode = 1;
    return;
  }

  if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`);
  if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`);
  if (action === "purge") {
    const purged = result.purged || {};
    console.log(
      `${c.green("✔")} Purged lane #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
    );
  }
}

/**
 * `ccam lanes` — one row per lane: what it is, where it is in its pipeline, and
 * whether the driving session is still breathing.
 */
/**
 * Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the
 * pipeline's node order — the same rule `LaneCard.tsx`'s
 * `detectionLeadsDeclaration` uses, so the terminal and the browser never
 * disagree about which one is the headline. Unmatched ids sort as -1, so an
 * unmatched detected stage never outranks a matched declaration.
 */
function detectionLeadsDeclaration(l) {
  if (!l.detected_stage) return false;
  const ids = (l.pipeline_nodes || []).map((n) => n.id);
  return ids.indexOf(l.detected_stage) > ids.indexOf(l.stage);
}

async function cmdLanes() {
  const { lanes, counts } = await get("/api/lanes");
  if (!lanes.length) {
    console.log("no lanes yet — create one with: ccam lanes add --cwd <path> --title <text>");
    return;
  }
  for (const l of lanes) {
    const needs = l.needs_action ? `  ⚠ ${l.needs_action}` : "";
    const detected = detectionLeadsDeclaration(l) ? `  ⇢ detected:${l.detected_stage}` : "";
    console.log(
      `#${l.id}  ${(l.title || l.cwd).padEnd(38).slice(0, 38)}  ` +
        `${String(l.stage).padEnd(12)} ${String(l.status).padEnd(9)} ` +
        `${String(l.liveness).padEnd(6)} ${String(l.progress).padStart(3)}%${needs}${detected}`
    );
  }
  console.log(
    `\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
  );
}

/**
 * Which lane a command is about: an explicit id, or the lane owning the working
 * directory (longest path-boundary match, the same rule the server uses). A
 * session running inside a lane never has to know its own id.
 *
 * Only the FIRST positional counts as an id, and only when it is all digits.
 * Scanning the whole argv for a number would swallow flag values —
 * `ccam lanes logs web --tail 4096` would have addressed lane 4096.
 *
 * @param {string[]} args - Arguments after the subcommand.
 * @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
 */
async function resolveLaneArg(args) {
  const flagValue = (name) => {
    const i = args.indexOf(`--${name}`);
    return i > -1 ? args[i + 1] : undefined;
  };

  const explicit = flagValue("lane");
  if (explicit) return { laneId: explicit, rest: args };
  if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };

  const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
  const { lanes } = await get("/api/lanes");
  const match = lanes
    .filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
    .sort((a, b) => b.cwd.length - a.cwd.length)[0];
  if (!match) {
    console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
    process.exitCode = 1;
    return null;
  }
  return { laneId: match.id, rest: args };
}

/** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) {
  if (!runtime.available) {
    console.log("no .ccam/profile for this lane — nothing to run.");
    if (runtime.searched) for (const p of runtime.searched) console.log(`  looked in ${p}`);
    return;
  }
  if (!runtime.provisioned) {
    console.log("profile found, runtime not provisioned yet — run: ccam lanes up");
    return;
  }
  console.log(`slot ${runtime.slot}  ·  profile ${runtime.profileDir}`);
  if (runtime.database) {
    console.log(`  database   ${runtime.database.name}  (test: ${runtime.database.testName})`);
  }
  if (runtime.redisIndex != null) {
    console.log(`  redis      logical db ${runtime.redisIndex}`);
  }
  for (const [name, info] of Object.entries(runtime.ports)) {
    const drift =
      info.port && info.port !== info.expected ? `  ⚠ base expects ${info.expected}` : "";
    console.log(
      `  ${name.padEnd(10)} :${String(info.port ?? "-").padEnd(6)} ` +
        `${info.listening ? "listening" : "down"}${drift}`
    );
  }
  for (const service of runtime.services) {
    console.log(
      `  ${service.name.padEnd(10)} pid ${service.pid} ${service.alive ? "alive" : "gone"}`
    );
  }
  if (runtime.lastError) {
    console.log(`  last error: ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
  }
  if (runtime.logs.length) console.log(`  logs: ${runtime.logs.join(", ")}  (${runtime.logDir})`);
}

/**
 * `ccam lanes up|down|runtime|logs|hook` — a lane's own application stack, as
 * opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
 * lane id.
 *
 * Every subcommand resolves the lane from the working directory when no id is
 * given, so a session inside a lane can call them without knowing its id — this
 * is the surface the driving skill uses.
 */
async function cmdLanesRuntime(sub, args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const { laneId, rest: laneArgs } = resolved;

  if (sub === "runtime") {
    printRuntime(await get(`/api/lanes/${laneId}/runtime`));
    return;
  }

  if (sub === "up") {
    const body = {};
    if (laneArgs.includes("--no-build")) body.build = false;
    if (laneArgs.includes("--qc")) body.qc = true;
    const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
    if (result.status) {
      console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`);
      process.exitCode = 1;
      return;
    }
    // The server answers 202 and boots in the background; poll until the stack
    // reports healthy or a boot error lands, so the command exits on a real
    // outcome rather than on "accepted".
    console.log(`lane #${laneId} booting…`);
    const deadline = Date.now() + 15 * 60 * 1000;
    for (;;) {
      await new Promise((r) => setTimeout(r, 2000));
      const runtime = await get(`/api/lanes/${laneId}/runtime`);
      if (runtime.healthy) {
        printRuntime(runtime);
        return;
      }
      if (runtime.lastError) {
        console.error(`✖ ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
        printRuntime(runtime);
        process.exitCode = 1;
        return;
      }
      if (Date.now() > deadline) {
        console.error("✖ timed out waiting for the stack to become healthy");
        printRuntime(runtime);
        process.exitCode = 1;
        return;
      }
    }
  }

  if (sub === "down") {
    const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
    if (result.status) {
      console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`);
      process.exitCode = 1;
      return;
    }
    console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
    return;
  }

  if (sub === "logs") {
    const svc = laneArgs.find((arg) => !arg.startsWith("--"));
    if (!svc) {
      console.error("usage: ccam lanes logs [<id>] <service> [--tail bytes]");
      process.exitCode = 1;
      return;
    }
    const i = laneArgs.indexOf("--tail");
    const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
    const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
    if (!log.available) {
      console.log("no logs for this lane yet.");
      return;
    }
    if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
    process.stdout.write(log.text);
    return;
  }

  if (sub === "hook") {
    const name = laneArgs[0];
    if (!name || name.startsWith("--")) {
      console.error("usage: ccam lanes hook [<id>] <name> [args…]");
      process.exitCode = 1;
      return;
    }
    const result = await post(
      `/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
      { args: laneArgs.slice(1) },
      { allowError: true }
    );
    if (result.status) {
      console.error(`✖ hook ${name} → ${result.data?.error?.message || result.status}`);
      process.exitCode = 1;
      return;
    }
    console.log(
      `lane #${laneId} running hook ${name} — follow it with: ccam lanes logs ${laneId} ${name}`
    );
  }

  if (sub === "sync-base") {
    const mode = laneArgs.includes("--check")
      ? "check"
      : laneArgs.includes("--continue")
        ? "continue"
        : "merge";
    const branch = laneArgs.find((arg) => !arg.startsWith("--"));
    const result = await post(
      `/api/lanes/${laneId}/sync-base`,
      { mode, branch },
      { allowError: true }
    );
    if (result.status) {
      console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
      process.exitCode = 1;
      return;
    }

    if (result.code === 5) {
      console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
      for (const c of result.collisions) {
        console.error(`  ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
      }
      process.exitCode = 5;
      return;
    }

    if (result.code === 4) {
      console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
      console.error(`  conflicted: ${result.conflictedFiles.join(", ")}`);
      console.error("  resolve, then: git add <resolved files> && git commit --no-edit");
      console.error(
        `  then: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
      );
      process.exitCode = 4;
      return;
    }

    if (mode === "check") {
      if (result.devDelta === null) {
        console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
      } else {
        console.log(
          `DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
        );
        for (const f of result.devDelta) console.log(`  ${f}`);
        if (result.overlap.length) {
          console.log(
            `DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
          );
          for (const f of result.overlap) console.log(`  ${f}`);
        } else {
          console.log("DEV_OVERLAP: none");
        }
      }
      console.log(`lane #${laneId} preflight vs origin/development: OK`);
      return;
    }

    console.log(
      `lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
    );
    return;
  }
}

/**
 * The default lock holder identity for the calling lane: `lane<slot>` when
 * the lane has one allocated, else `lane<id>` — a lane's own row id — as a
 * fallback for a lane that has never brought its runtime up. `--holder`
 * always overrides both.
 *
 * @param {string[]} argsAfterName - Args AFTER the lock name has already been
 *   consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))`
 *   call) — `resolveLaneArg` treats a leading all-digits positional as a lane
 *   id, so the lock name itself must never reach it (a lock literally named
 *   e.g. "3" would otherwise be misread as lane 3).
 */
async function defaultHolder(argsAfterName) {
  const explicit = (() => {
    const i = argsAfterName.indexOf("--holder");
    return i > -1 ? argsAfterName[i + 1] : undefined;
  })();
  if (explicit) return explicit;

  const resolved = await resolveLaneArg(argsAfterName);
  if (!resolved) return null;
  const { lane } = await get(`/api/lanes/${resolved.laneId}`);
  return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
}

function fmtLockRow(lock) {
  const mins = Math.floor(lock.ageSec / 60);
  return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`;
}

/** `ccam lock status [<name>]` — one lock, or every held lock. */
async function cmdLockStatus(args) {
  const name = args.find((arg) => !arg.startsWith("--"));
  if (name) {
    const { locks } = await get("/api/locks");
    const lock = locks.find((l) => l.name === name);
    console.log(lock ? fmtLockRow(lock) : `${name}: free`);
    return;
  }
  const { locks } = await get("/api/locks");
  if (!locks.length) {
    console.log("no locks held");
    return;
  }
  for (const lock of locks) console.log(fmtLockRow(lock));
}

/**
 * `ccam lock acquire <name> [--holder X] [--timeout N]` — polls until the
 * lock is free (or `--timeout` seconds elapse). Prints a status line every
 * ~60s of continued waiting so a long wait never reads as a hung command —
 * this is the CLI-side "heartbeat" the design calls for; it is terminal
 * output, not a dashboard liveness signal.
 */
async function cmdLockAcquire(args) {
  const name = args.find((arg) => !arg.startsWith("--"));
  if (!name) {
    console.error("usage: ccam lock acquire <name> [--holder X] [--timeout seconds]");
    process.exitCode = 1;
    return;
  }
  // Strip the lock name before handing args to defaultHolder/resolveLaneArg —
  // see defaultHolder's doc comment for why the name must never reach it.
  const holder = await defaultHolder(args.filter((a) => a !== name));
  if (!holder) return; // resolveLaneArg already printed an error

  const timeoutIdx = args.indexOf("--timeout");
  const timeoutMs =
    timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null;
  const deadline = timeoutMs ? Date.now() + timeoutMs : null;
  const startedAt = Date.now();
  let lastPrinted = 0;

  for (;;) {
    const result = await post(
      `/api/locks/${encodeURIComponent(name)}/acquire`,
      { holder },
      {
        allowError: true,
      }
    );
    if (result.status === undefined || result.data?.acquired) {
      console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
      return;
    }
    if (Date.now() - lastPrinted >= 60_000) {
      const waited = Math.floor((Date.now() - startedAt) / 1000);
      console.log(
        `… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)`
      );
      lastPrinted = Date.now();
    }
    if (deadline && Date.now() >= deadline) {
      console.error(`✖ timed out waiting for lock "${name}"`);
      process.exitCode = 1;
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

/** `ccam lock release <name> [--holder X]`. */
async function cmdLockRelease(args) {
  const name = args.find((arg) => !arg.startsWith("--"));
  if (!name) {
    console.error("usage: ccam lock release <name> [--holder X]");
    process.exitCode = 1;
    return;
  }
  const holder = await defaultHolder(args.filter((a) => a !== name));
  if (!holder) return;
  const result = await post(
    `/api/locks/${encodeURIComponent(name)}/release`,
    { holder },
    {
      allowError: true,
    }
  );
  if (result.status) {
    console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`);
    process.exitCode = 1;
    return;
  }
  console.log(`${c.green("✔")} released lock "${name}"`);
}

/**
 * `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
 * `state.sh N set stage=…`. A skill calls this at each phase boundary so the
 * dashboard shows a declared stage instead of an inferred one.
 */
function fmtFeatureRow(f) {
  const marker = f.archived_at ? "  " : "▶ ";
  return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${
    f.archived_at ? `  (archived ${fmtTime(f.archived_at)})` : ""
  }`;
}

/** `ccam feature list [<id>] [--cwd path]` — every feature this lane has activated. */
async function cmdFeatureList(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
  if (!features.length) {
    console.log("no features activated yet — start one with: ccam feature activate <slug>");
    return;
  }
  for (const f of features) console.log(fmtFeatureRow(f));
}

/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
async function cmdFeatureActivate(args) {
  const slug = args.find((arg) => !arg.startsWith("--"));
  if (!slug) {
    console.error("usage: ccam feature activate <slug> [--title text]");
    process.exitCode = 1;
    return;
  }
  const flag = (name) => {
    const i = args.indexOf(`--${name}`);
    return i > -1 ? args[i + 1] : undefined;
  };
  const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
  if (!resolved) return;
  const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
    slug,
    title: flag("title"),
  });
  console.log(
    `${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
  );
}

/** `ccam feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `ccam lanes proof-link [<id>] [--cwd path]` — converge the clone-root
 *  `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
 *  by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
  console.log(
    linked
      ? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
      : "proof/ already linked, nothing to do"
  );
}

/** `ccam lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
 *  agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
 *  git-excluded. Never run automatically; a session calls it explicitly. */
async function cmdLanesAgentsInstall(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const result = await post(
    `/api/lanes/${resolved.laneId}/agents/install`,
    {},
    { allowError: true }
  );
  if (result.status) {
    console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
    process.exitCode = 1;
    return;
  }
  console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
}

/** `ccam lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
 *  already-configured MCP servers into <lane>/.mcp.json and seed Chromium
 *  profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
  if (result.status) {
    console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
    process.exitCode = 1;
    return;
  }
  console.log(`${c.green("✔")} synced: ${result.servers.join(", ")}`);
  if (result.profilesSeeded.length) {
    console.log(`  seeded profiles: ${result.profilesSeeded.join(", ")}`);
  }
}

/** `ccam lanes integration <name> [<id>]` — check whether a named
 *  integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
 *  its .ccam/profile/integrations.env. Exit 0 = on, 1 = off. */
async function cmdLanesIntegration(args) {
  const name = args.find((arg) => !arg.startsWith("--"));
  if (!name) {
    console.error("usage: ccam lanes integration <name> [<id>]");
    process.exitCode = 1;
    return;
  }
  const resolved = await resolveLaneArg(args.filter((a) => a !== name));
  if (!resolved) return;
  const { enabled } = await get(
    `/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
  );
  console.log(enabled ? "on" : "off");
  process.exitCode = enabled ? 0 : 1;
}

/**
 * `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
 * template a lane renders against. `ccam lanes add --pipeline` could only set
 * this at creation time, so every lane added from the dashboard's "+ Add lane"
 * was stuck on `default` with no way back to a 16-node template.
 */
async function cmdLanesPipeline(args) {
  const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
  const resolved = await resolveLaneArg(args.filter((a) => a !== target));
  if (!resolved) return;

  if (!target) {
    const { lane } = await get(`/api/lanes/${resolved.laneId}`);
    const { pipelines } = await get("/api/lanes/pipelines");
    console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
    console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
    return;
  }

  const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
  console.log(
    `${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
      `(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
  );
  // Switching templates re-resolves the SAME declared stage string against a
  // different node list, so a stage that meant something in the old pipeline
  // can land nowhere in the new one. Same warning as `ccam stage`, same reason.
  if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
    console.error(
      c.yellow(
        `! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
          `declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
      )
    );
  }
}

/**
 * Attach a real terminal to the exact tmux session the dashboard uses for
 * this lane's run. Creates it if it doesn't exist yet (`tmux new-session -A`
 * is create-or-attach, the same idempotent semantics as clicking Start on
 * the dashboard). The user types `claude` themselves inside — this command's
 * only job is landing them in the right named session.
 */
async function cmdLanesShell(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const { lane } = await get(`/api/lanes/${resolved.laneId}`);
  const sessionName = `ccam-lane-${lane.id}`;
  const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
    stdio: "inherit",
  });
  await new Promise((resolve) => {
    child.on("close", resolve);
    child.on("error", (err) => {
      console.error(c.red(`✖ ${err?.message || err}`));
      process.exitCode = 1;
      resolve();
    });
  });
}

async function cmdFeatureShow(args) {
  const slug = args.find((arg) => !arg.startsWith("--"));
  if (!slug) {
    console.error("usage: ccam feature show <slug>");
    process.exitCode = 1;
    return;
  }
  const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
  if (!resolved) return;
  const result = await get(
    `/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
    undefined,
    { allowError: true }
  );
  if (result.status) {
    console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`);
    process.exitCode = 1;
    return;
  }
  const f = result.feature;
  console.log(`${f.slug}  ${f.archived_at ? "(archived)" : "(active)"}`);
  console.log(`  stage: ${f.stage}  status: ${f.status}  progress: ${f.progress}%`);
  for (const node of f.pipeline_nodes) console.log(`  ${node.state.padEnd(18)} ${node.label}`);
}

async function cmdStage(args) {
  const stage = args[0];
  if (!stage || stage.startsWith("--")) {
    console.error(
      "usage: ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
    );
    process.exitCode = 1;
    return;
  }
  const flag = (name) => {
    const i = args.indexOf(`--${name}`);
    return i > -1 ? args[i + 1] : undefined;
  };

  // `--lane` wins, otherwise the lane owning this directory. The stage name is
  // args[0], so only what follows it can carry a lane reference.
  const resolved = await resolveLaneArg(args.slice(1));
  if (!resolved) return;
  const laneId = resolved.laneId;

  const { lane } = await post(`/api/lanes/${laneId}/stage`, {
    stage,
    status: flag("status"),
    evidence: flag("evidence"),
    note: flag("note"),
    result: flag("result"),
  });
  console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);

  // A stage name matching no node (nor alias) still stores — setStage takes the
  // string verbatim — but phaseIdx() then returns -1, so nothing renders as
  // `current` and progress reads 0. Warn, never fail: a typo must not break a
  // declaration the pipeline can still record, but it must not pass silently.
  // Skipped for `--result fail`, which paints the node `failed` rather than
  // `current` and would otherwise look identical to an unknown stage.
  const nodes = lane.pipeline_nodes || [];
  if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
    console.error(
      c.yellow(
        `! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
          `pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
      )
    );
  }
}

// ── Command catalog ─────────────────────────────────────────────────────────
// One source of truth for every command's group, invocation, and one-line
// description. The one-shot `help`, the REPL's categorized help / `commands` /
// per-command `help <cmd>`, the tab-completer's command list, and unknown-
// command detection are all derived from this so they can never drift apart.
// Each item: [invocation, argsHint, description].
const COMMAND_GROUPS = [
  [
    "Server",
    [
      ["status", "", "Up/down indicator for the dashboard server"],
      ["start", "[--port N]", "Start the server in the background and wait for healthy"],
      ["repl", "", "Open the interactive shell (also: shell, i)"],
    ],
  ],
  [
    "Monitoring",
    [
      ["health", "", "Check the dashboard is up"],
      ["stats", "", "Totals, today's events, status distribution chart"],
      ["kanban", "", "Sessions + agents grouped by status columns"],
      ["tail", "[--session id]", "Live event feed in the terminal (Ctrl+C stops)"],
    ],
  ],
  [
    "Data",
    [
      ["sessions", "[opts]", "List sessions   (--status, --q, --limit)"],
      ["session <id>", "", "Session detail: agents tree, cost, recent events"],
      ["agents", "[opts]", "List agents     (--status, --session, --limit)"],
      ["events", "[opts]", "List events     (--session, --limit)"],
    ],
  ],
  [
    "Insights",
    [
      ["analytics", "", "Token totals, top-tool and agent-type charts"],
      ["workflows", "[--session id]", "Workflow intelligence stats and patterns"],
      ["runs", "[--session id]", "Dynamic Workflow-tool runs"],
      ["cost", "[--session id]", "Total estimated cost (per-model chart; --session scopes to one)"],
    ],
  ],
  [
    "Alerts & Webhooks",
    [
      ["alerts", "[--unacked]", "Fired-alert feed"],
      ["alerts ack <id>", "", "Acknowledge one alert"],
      ["alerts ack-all", "", "Acknowledge every unacked alert"],
      ["rules", "", "List alert rules"],
      ["webhooks", "", "List webhook targets"],
      ["webhooks test <id>", "", "Send a synthetic test alert to a target"],
    ],
  ],
  [
    "Pricing",
    [
      ["pricing", "", "List model pricing rules"],
      [
        "pricing set <pattern>",
        "",
        "--input/--output N, plus --cache-*, --fast-*, --intro-* --intro-until YYYY-MM-DD",
      ],
      ["pricing delete <pattern>", "", "Delete a pricing rule"],
      ["pricing reset", "", "Reset pricing rules to defaults"],
    ],
  ],
  [
    "Import",
    [
      ["import rescan", "", "Re-scan ~/.claude/projects"],
      ["import path <dir>", "", "Import every .jsonl under a directory"],
      ["import-data <file>", "", "Restore a dashboard export (.json) — merge machines"],
    ],
  ],
  [
    "Remote sources",
    [
      ["remote-sources", "", "List remote (SSH) data sources + status"],
      [
        "remote-sources add",
        "",
        "--label X --host user@host [--port N --identity path --remote-home path]",
      ],
      ["remote-sources test <id>", "", "Probe SSH connectivity to a source"],
      ["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
      ["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
    ],
  ],
  [
    "Lanes",
    [
      ["lanes", "", "List lanes with stage, liveness and progress"],
      ["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
      [
        "lanes add",
        "--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
        "Provision a managed worktree lane",
      ],
      [
        "lanes profile init",
        "<repo> [--force]",
        "Detect a Node.js project and scaffold .ccam/profile/",
      ],
      [
        "lanes profile check",
        "[<path>]",
        "Validate a profile (path defaults to cwd, not a lane id)",
      ],
      [
        "lanes pipeline",
        "[<template-id>] [<id>]",
        "Show, or switch, which pipeline template a lane renders against",
      ],
      [
        "lanes shell",
        "[<id>]",
        "Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
      ],
      [
        "lanes reset|remove|purge",
        "<id> [--force] [--keep-db] --yes",
        "Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
      ],
      [
        "lanes up|down",
        "[<id>] [--no-build] [--qc]",
        "Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
      ],
      ["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
      ["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
      ["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
      [
        "lanes sync-base",
        "[<id>] [--check|--continue] [branch]",
        "Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
      ],
      [
        "lanes proof-link",
        "[<id>]",
        "Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
      ],
      [
        "lanes agents install",
        "[<id>]",
        "Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
      ],
      [
        "lanes mcp sync",
        "[<id>]",
        "Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
      ],
      [
        "lanes integration",
        "<name> [<id>]",
        "Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
      ],
      [
        "lanes gc",
        "[--dry-run]",
        "Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
      ],
      [
        "skills install",
        "",
        "Install .claude/skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
      ],
      [
        "lock status|acquire|release",
        "[<name>] [--holder X] [--timeout N]",
        "Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
      ],
      ["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
      ["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
      [
        "feature activate",
        "<slug> [--title text] [<id>]",
        "Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
      ],
      [
        "feature show",
        "<slug> [<id>]",
        "Show one feature's saved pipeline (works on an archived one too)",
      ],
    ],
  ],
  [
    "Administration",
    [
      ["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"],
      ["info", "", "Raw system info JSON"],
      ["export", "[file.json]", "Export all data as JSON"],
      ["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
      ["reinstall-hooks", "", "Reinstall Claude Code hooks"],
      ["update-check", "", "Check whether the dashboard checkout is behind upstream"],
      ["clear-data", "--yes", "Delete ALL data (requires --yes)"],
      ["open", "", "Open the dashboard in your browser"],
      ["version", "", "Print the ccam version (also: --version, -v)"],
    ],
  ],
];

/** Render one catalog line: `  cmd  args        description`. */
function catalogRow(name, args, desc) {
  const left = `${c.cyan(name)}${args ? ` ${c.dim(args)}` : ""}`;
  const pad = " ".repeat(Math.max(2, 30 - stripAnsi(left).length));
  return `  ${left}${pad}${desc}`;
}

/** The shared discovery/output/note footer shown under the command list. */
function helpFooter() {
  return `
${c.bold("Server discovery:")} CLAUDE_DASHBOARD_PORT / DASHBOARD_PORT env vars win,
otherwise the live server is found via ~/.claude/.agent-dashboard.json,
falling back to http://127.0.0.1:4820.

${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / CCAM_COLOR=1.

${c.bold("Note:")} ccam talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("ccam start")} brings one up in the background).
Prefer an interactive session? Run ${c.bold("ccam repl")} for a shell with completion,
history, and a live status prompt.`;
}

function cmdHelp() {
  const section = (title) => `\n${c.cyan("▍")}${c.bold(title)}`;
  const body = COMMAND_GROUPS.map(
    ([group, items]) =>
      `${section(group)}\n` + items.map(([n, a, d]) => catalogRow(n, a, d)).join("\n")
  ).join("\n");
  console.log(
    `${c.cyan("▍")}${c.bold("ccam")} — Claude Code Agent Monitor CLI\n\n` +
      `${c.bold("Usage:")} ccam <command> [options]\n` +
      body +
      "\n" +
      helpFooter()
  );
}

/** Read the package version (single source of truth: package.json). */
function pkgVersion() {
  try {
    return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version;
  } catch {
    return null;
  }
}

/** Print the CLI/package version. */
function cmdVersion() {
  const v = pkgVersion();
  console.log(v ? `ccam ${v}` : "ccam (version unknown)");
}

// ── Offline command handlers & dispatch ────────────────────────────────────
// Each handler mirrors its online command's output using offlineData
// providers. Commands absent from this map cannot run correctly without the
// server (live capture, server-side aggregation/cost math, or mutations that
// must go through the server's transaction + broadcast path) — the
// dispatcher tells the user so, with the reason.

const OFFLINE_HANDLERS = {
  async stats() {
    const db = requireDb();
    const s = offlineData.stats(db);
    // Correct the status distribution the same way the session list is
    // corrected, so counts and rows never disagree.
    const rows = db.all("SELECT id, status, cwd FROM sessions");
    const fix = livenessCorrect(rows);
    if (fix.available) {
      const dist = {};
      for (const r of rows) dist[r.status] = (dist[r.status] || 0) + 1;
      s.sessions_by_status = dist;
      s.active_sessions = dist.active || 0;
    }
    renderStats(s, `${dbPath()} (offline)`);
    livenessNote(fix);
  },
  async sessions(flags) {
    const data = offlineData.sessions(requireDb(), flags);
    const fix = livenessCorrect(data.sessions);
    renderSessions(data);
    livenessNote(fix);
  },
  async agents(flags) {
    const db = requireDb();
    const data = offlineData.agents(db, flags);
    const sessions = db.all("SELECT id, status, cwd FROM sessions WHERE status = 'active'");
    const fix = livenessCorrect(sessions);
    if (fix.deadIds.size) livenessCorrectAgents(data.agents, fix.deadIds);
    renderAgents(data);
    livenessNote(fix);
  },
  async events(flags) {
    renderEvents(offlineData.events(requireDb(), flags));
  },
  async kanban() {
    const db = requireDb();
    const sess = offlineData.sessions(db, { limit: "200" });
    const ag = offlineData.agents(db, { limit: "400" });
    const fix = livenessCorrect(sess.sessions);
    if (fix.deadIds.size) livenessCorrectAgents(ag.agents, fix.deadIds);
    renderKanban(sess, ag);
    livenessNote(fix);
  },
  async session(flags, positional) {
    const id = positional[0];
    if (!id) {
      console.error(c.red("✖ Usage: ccam session <session-id>"));
      process.exit(1);
    }
    const db = requireDb();
    const rows = db.all("SELECT * FROM sessions WHERE id = ?", id);
    if (!rows.length) {
      console.error(c.red(`✖ Session not found: ${id}`));
      process.exit(1);
    }
    const s = rows[0];
    const fix = livenessCorrect([s]);
    renderSessionMeta(s);
    kvLine("Cost", c.dim("requires the server (pricing math runs server-side)"));
    const agents = db.all("SELECT * FROM agents WHERE session_id = ? ORDER BY started_at ASC", id);
    if (fix.deadIds.size) livenessCorrectAgents(agents, fix.deadIds);
    if (agents.length) renderAgentTree(agents);
    const events = db.all(
      "SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC LIMIT 10",
      id
    );
    if (events.length) renderEventLines(events);
    livenessNote(fix);
  },
  async pricing(flags, positional) {
    if (positional[0]) {
      serverDownExit(
        "pricing changes must go through the server (cache invalidation + broadcasts)"
      );
    }
    const db = requireDb();
    renderPricingTable(db.all("SELECT * FROM model_pricing ORDER BY LENGTH(model_pattern) DESC"));
  },
  async alerts(flags, positional) {
    if (positional[0]) {
      serverDownExit("acknowledging alerts is a server-side mutation");
    }
    const db = requireDb();
    const conds = flags.unacked ? "WHERE acknowledged_at IS NULL" : "";
    const rows = db
      .all(
        `SELECT * FROM alert_events ${conds} ORDER BY triggered_at DESC LIMIT ?`,
        Number(flags.limit || 20)
      )
      .map((a) => [
        a.id,
        a.acknowledged_at ? c.dim("acked") : c.yellow("open"),
        fmtTime(a.triggered_at),
        (a.rule_name || "").slice(0, 24),
        (a.message || "").slice(0, 52),
      ]);
    const unacked = db.all(
      "SELECT COUNT(*) AS n FROM alert_events WHERE acknowledged_at IS NULL"
    )[0].n;
    const total = db.all("SELECT COUNT(*) AS n FROM alert_events")[0].n;
    table(["ID", "State", "Triggered", "Rule", "Message"], rows);
    console.log(c.dim(`\n${unacked} unacknowledged of ${total}`));
  },
  async rules() {
    const db = requireDb();
    const rows = db
      .all("SELECT * FROM alert_rules ORDER BY id")
      .map((r) => [
        r.id,
        r.enabled ? c.green("on") : c.dim("off"),
        r.rule_type,
        (r.name || "").slice(0, 32),
        `${r.cooldown_minutes ?? "-"}m`,
      ]);
    table(["ID", "Enabled", "Type", "Name", "Cooldown"], rows);
  },
  async export(flags, positional) {
    const db = requireDb();
    const file = positional[0] || `ccam-export-${new Date().toISOString().slice(0, 10)}.json`;
    const payload = {
      exported_at: new Date().toISOString(),
      exported_offline: true,
      sessions: db.all("SELECT * FROM sessions ORDER BY started_at DESC"),
      agents: db.all("SELECT * FROM agents ORDER BY started_at DESC"),
      events: db.all("SELECT * FROM events ORDER BY created_at DESC"),
      token_usage: db.all("SELECT * FROM token_usage"),
      model_pricing: db.all("SELECT * FROM model_pricing ORDER BY LENGTH(model_pattern) DESC"),
    };
    fs.writeFileSync(file, JSON.stringify(payload, null, 2));
    console.log(
      `${c.green("✔")} Exported to ${c.bold(file)} (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)`
    );
  },
  async doctor() {
    heading("ccam doctor", "offline");
    let failed = true; // offline is always a failure for live monitoring
    console.log(
      `${c.red("○")}  Dashboard server  NOT running ${c.dim(`(tried ${baseUrl()})`)} — start with: ${c.bold("ccam start")}`
    );
    console.log(
      `${c.dim("·")}  Remote sources  require a live server (ccam remote-sources / Settings)`
    );
    const file = dbPath();
    if (fs.existsSync(file)) {
      const db = requireDb();
      console.log(
        `${c.green("✔")}  Database  ${file} (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)`
      );
      for (const t of ["sessions", "agents", "events", "model_pricing", "token_usage"]) {
        try {
          console.log(
            `${c.dim("·")}    rows: ${t}  ${db.all(`SELECT COUNT(*) AS n FROM ${t}`)[0].n}`
          );
        } catch {
          /* table may not exist on very old DBs */
        }
      }
    } else {
      failed = true;
      console.log(`${c.red("✖")}  Database  not found at ${file}`);
    }
    try {
      const settingsPath = path.join(
        process.env.CLAUDE_HOME || path.join(require("node:os").homedir(), ".claude"),
        "settings.json"
      );
      const installed =
        fs.existsSync(settingsPath) &&
        fs.readFileSync(settingsPath, "utf8").includes("hook-handler.js");
      if (installed) {
        console.log(`${c.green("✔")}  Claude Code hooks  installed (${settingsPath})`);
      } else {
        failed = true;
        console.log(`${c.red("✖")}  Claude Code hooks  NOT installed — run: npm run install-hooks`);
      }
    } catch {
      console.log(`${c.dim("·")}  Claude Code hooks  could not be checked`);
    }
    if (failed) process.exit(1);
  },
};

/** Reasons shown when a server-only command is attempted offline. */
const SERVER_ONLY_REASONS = {
  tail: "live capture needs the running server (hooks only post to a live server)",
  analytics: "analytics aggregation runs server-side",
  workflows: "workflow intelligence is computed server-side",
  runs: "workflow-run reconstruction runs server-side",
  cost: "cost math (pricing rules, compaction baselines) runs server-side",
  webhooks: "webhook configuration and test deliveries are server-side",
  import: "imports must go through the server's ingestion pipeline",
  "import-data": "restoring an export writes to the database through the server",
  cleanup: "cleanup is a server-side mutation",
  "clear-data": "data wipes must go through the server",
  "reinstall-hooks": "hook installation is performed by the server",
  "update-check": "the update check runs server-side (git fetch against the canonical remote)",
  info: "system info (uptime, memory, WS connections) only exists on a running server",
  health: "health is, by definition, a check against the running server",
};

/** Open the DB read-only or exit with guidance. */
function requireDb() {
  const db = openDbReadonly();
  if (!db) {
    console.error(c.red(`✖ No readable database at ${dbPath()}`));
    console.error(c.dim("  Nothing has been captured yet, or no SQLite driver is available."));
    console.error(c.dim("  Start the server to begin capturing: ccam start"));
    process.exit(1);
  }
  return db;
}

// ── Interactive REPL (ccam shell) ───────────────────────────────────────────
// `ccam repl` opens a persistent prompt where commands are typed WITHOUT the
// `ccam` prefix. Each command runs as a short-lived child `ccam` process, so
// behavior is byte-identical to the one-shot CLI and a command that exits
// non-zero, blocks (tail), or refuses offline can never take the shell down
// with it. Colors, tab-completion, arrow-key history (persisted), and a live
// server-status prompt make it a comfortable place to live while monitoring.

/** Split a REPL input line into argv, honoring single/double quotes so
 *  `session "my id"` and `pricing set 'foo%'` tokenize correctly. */
function tokenizeLine(line) {
  const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
  const out = [];
  let m;
  while ((m = re.exec(line)) !== null) out.push(m[1] ?? m[2] ?? m[3]);
  return out;
}

/** Known flags, offered by the completer after a command word. */
const REPL_FLAGS = [
  "--status",
  "--session",
  "--limit",
  "--q",
  "--unacked",
  "--yes",
  "--port",
  "--hours",
  "--days",
  "--input",
  "--output",
  "--cache-read",
  "--cache-write",
  "--cache-write-1h",
  "--fast-input",
  "--fast-output",
  "--intro-input",
  "--intro-output",
  "--intro-cache-read",
  "--intro-cache-write",
  "--intro-cache-write-1h",
  "--intro-until",
  "--name",
  "--no-color",
];

/** readline completer: commands on the first word, subcommands on the second,
 *  flags once a `--` token is being typed. Returns [hits, prefix]. */
function replCompleter(line) {
  const toks = line.split(/\s+/);
  const word = toks[toks.length - 1];
  if (toks.length <= 1) {
    const hits = COMMANDS.filter((cmd) => cmd.startsWith(word));
    return [hits.length ? hits : COMMANDS, word];
  }
  const subs = SUBCOMMANDS[toks[0]];
  if (subs && toks.length === 2 && !word.startsWith("--")) {
    const hits = subs.filter((s) => s.startsWith(word));
    return [hits.length ? hits : subs, word];
  }
  if (word.startsWith("--")) {
    const hits = REPL_FLAGS.filter((f) => f.startsWith(word));
    return [hits.length ? hits : REPL_FLAGS, word];
  }
  return [[], word];
}

// The CCAM word-mark, shown once when the interactive shell starts (and via
// the `banner` built-in). Kept as raw text so the backslashes render verbatim.
const BANNER_ART = String.raw`
          _____                    _____                    _____                    _____
         /\    \                  /\    \                  /\    \                  /\    \
        /::\    \                /::\    \                /::\    \                /::\____\
       /::::\    \              /::::\    \              /::::\    \              /::::|   |
      /::::::\    \            /::::::\    \            /::::::\    \            /:::::|   |
     /:::/\:::\    \          /:::/\:::\    \          /:::/\:::\    \          /::::::|   |
    /:::/  \:::\    \        /:::/  \:::\    \        /:::/__\:::\    \        /:::/|::|   |
   /:::/    \:::\    \      /:::/    \:::\    \      /::::\   \:::\    \      /:::/ |::|   |
  /:::/    / \:::\    \    /:::/    / \:::\    \    /::::::\   \:::\    \    /:::/  |::|___|______
 /:::/    /   \:::\    \  /:::/    /   \:::\    \  /:::/\:::\   \:::\    \  /:::/   |::::::::\    \
/:::/____/     \:::\____\/:::/____/     \:::\____\/:::/  \:::\   \:::\____\/:::/    |:::::::::\____\
\:::\    \      \::/    /\:::\    \      \::/    /\::/    \:::\  /:::/    /\::/    / ~~~~~/:::/    /
 \:::\    \      \/____/  \:::\    \      \/____/  \/____/ \:::\/:::/    /  \/____/      /:::/    /
  \:::\    \               \:::\    \                       \::::::/    /               /:::/    /
   \:::\    \               \:::\    \                       \::::/    /               /:::/    /
    \:::\    \               \:::\    \                      /:::/    /               /:::/    /
     \:::\    \               \:::\    \                    /:::/    /               /:::/    /
      \:::\    \               \:::\    \                  /:::/    /               /:::/    /
       \:::\____\               \:::\____\                /:::/    /               /:::/    /
        \::/    /                \::/    /                \::/    /                \::/    /
         \/____/                  \/____/                  \/____/                  \/____/
`;

/** Print the entry banner: the word-mark (when the terminal is wide enough),
 *  a tagline with version and live status, and the one-line orientation tips. */
function replBanner(up) {
  const v = pkgVersion();
  if (termWidth() >= 100) {
    console.log(c.cyan(BANNER_ART));
  } else {
    console.log(`\n${c.cyan("▍")}${c.bold("ccam")}`);
  }
  const dot = up ? c.green("●") : c.red("○");
  const where = up ? baseUrl().replace(/^https?:\/\//, "") : "offline";
  console.log(
    `  ${c.bold("Claude Code Agent Monitor")} ${c.dim("· interactive shell")}` +
      (v ? c.dim(` · v${v}`) : "") +
      `   ${dot} ${c.dim(where)}`
  );
  console.log(
    c.dim("  Type commands without the 'ccam' prefix — e.g. ") + c.bold("sessions --limit 5")
  );
  console.log(
    c.dim("  ") +
      c.bold("help") +
      c.dim(" all commands · ") +
      c.bold("help <cmd>") +
      c.dim(" details · Tab completes · ↑/↓ history · ") +
      c.bold("exit") +
      c.dim(" to quit")
  );
  console.log();
}

/** Categorized REPL help: shell built-ins first, then the full command
 *  catalog (identical groups/descriptions to `ccam help`). */
function replHelp() {
  const row = (name, desc) => `  ${c.cyan(name.padEnd(20))}${desc}`;
  console.log(`\n${c.cyan("▍")}${c.bold("ccam shell")} ${c.dim("— built-ins")}`);
  console.log(row("help", "This help. " + c.dim("`help <command>` shows one command's details")));
  console.log(row("commands", "Compact list of every command, grouped"));
  console.log(
    row("watch <cmd> [secs]", "Re-run a command on a timer, screen-clearing (Ctrl+C stops)")
  );
  console.log(row("history", "Show recent command history"));
  console.log(row("banner", "Reprint the welcome banner"));
  console.log(row("clear, cls", "Clear the screen"));
  console.log(row("exit, quit, q", "Leave the shell (also Ctrl+D)"));

  for (const [group, items] of COMMAND_GROUPS) {
    console.log(`\n${c.cyan("▍")}${c.bold(group)}`);
    for (const [name, args, desc] of items) console.log(catalogRow(name, args, desc));
  }
  console.log(
    `\n${c.dim("Read commands work offline; server-only commands print the reason. Each line runs isolated, so nothing takes the shell down.")}\n`
  );
}

/** Detailed help for one command (or command prefix): every catalog entry
 *  whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) {
  const matches = [];
  for (const [group, items] of COMMAND_GROUPS) {
    for (const [name, args, desc] of items) {
      if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]);
    }
  }
  if (!matches.length) return false;
  console.log(`\n${c.cyan("▍")}${c.bold(token)} ${c.dim(`— ${matches[0][0]}`)}`);
  for (const [, name, args, desc] of matches) {
    console.log(catalogRow(name, args, desc));
  }
  const subs = SUBCOMMANDS[token];
  if (subs) console.log(c.dim(`  subcommands: ${subs.join(", ")}`));
  console.log();
  return true;
}

/** Compact grouped command list for the `commands` built-in. */
function replCommandsList() {
  for (const [group, items] of COMMAND_GROUPS) {
    const names = items
      .map(([n]) => c.cyan(n.split(" ")[0]))
      .filter((v, i, a) => a.indexOf(v) === i);
    console.log(`  ${c.bold(group.padEnd(20))}${[...new Set(names)].join("  ")}`);
  }
}

/**
 * The interactive shell. Reads lines, dispatches each as a child `ccam`
 * process (inheriting stdio so tables/colors/streaming render natively),
 * and keeps a persisted history. On a TTY the prompt shows live server
 * status (● up / ○ offline) and the resolved host; piped input (tests,
 * scripts) runs each line and exits at EOF.
 */
async function cmdRepl() {
  const readline = require("node:readline");
  const isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
  const historyFile = path.join(REPO_ROOT, "data", ".ccam_repl_history");

  let history = [];
  try {
    history = fs.readFileSync(historyFile, "utf8").split("\n").filter(Boolean).slice(-500);
  } catch {
    /* no history yet */
  }

  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: isTty,
    completer: isTty ? replCompleter : undefined,
    history: history.slice().reverse(), // readline wants most-recent first
    historySize: 500,
    prompt: "",
  });

  // Cache the health probe briefly so the prompt does not hammer the server.
  let statusCache = { at: 0, up: false };
  async function serverUpCached() {
    const now = Date.now();
    if (now - statusCache.at < 3000) return statusCache.up;
    const up = await serverIsUp();
    statusCache = { at: now, up };
    return up;
  }

  async function renderPrompt() {
    if (!isTty) return; // piped input needs no prompt
    const up = await serverUpCached();
    const dot = up ? c.green("●") : c.red("○");
    const where = up ? c.dim(baseUrl().replace(/^https?:\/\//, "")) : c.dim("offline");
    rl.setPrompt(`${dot} ${c.bold("ccam")} ${where} ${c.cyan("›")} `);
    rl.prompt();
  }

  // Ctrl+C must never kill the shell: a no-op process handler keeps the
  // parent alive while a child (e.g. `tail`) is the real SIGINT target; the
  // readline SIGINT event (fires only at the prompt) just resets the line.
  let childActive = false;
  const sigintNoop = () => {};
  process.on("SIGINT", sigintNoop);
  rl.on("SIGINT", () => {
    if (childActive) return;
    console.log(c.dim("  (type exit or press Ctrl+D to quit)"));
    renderPrompt();
  });

  /** Run one child `ccam` invocation with inherited stdio. */
  function runChild(argv) {
    return new Promise((resolve) => {
      childActive = true;
      const env = { ...process.env };
      if (useColor) env.FORCE_COLOR = "1";
      env.CCAM_REPL = "1";
      const child = spawn(process.execPath, [__filename, ...argv], {
        stdio: "inherit",
        env,
      });
      child.on("close", () => {
        childActive = false;
        resolve();
      });
      child.on("error", (err) => {
        childActive = false;
        console.error(c.red(`✖ ${err?.message || err}`));
        resolve();
      });
    });
  }

  /**
   * `watch <cmd…>` — re-run a command on a timer, clearing the screen each
   * tick, until Ctrl+C. A dedicated SIGINT listener sets an abort flag so the
   * loop unwinds cleanly (during a child the signal also reaches the child;
   * between ticks the interruptible sleep notices the flag) — the shell
   * itself always survives.
   */
  async function runWatch(argv, intervalMs) {
    let abort = false;
    const onSig = () => {
      abort = true;
    };
    process.prependListener("SIGINT", onSig);
    try {
      while (!abort) {
        if (isTty) process.stdout.write("\x1b[2J\x1b[H");
        console.log(
          c.dim(
            `⟳ watch: ccam ${argv.join(" ")} — every ${Math.round(intervalMs / 1000)}s · ${new Date().toLocaleTimeString()} · Ctrl+C to stop`
          )
        );
        await runChild(argv);
        if (abort) break;
        await new Promise((r) => {
          const poll = setInterval(() => {
            if (abort) {
              clearInterval(poll);
              r();
            }
          }, 100);
          setTimeout(() => {
            clearInterval(poll);
            r();
          }, intervalMs);
        });
      }
    } finally {
      process.removeListener("SIGINT", onSig);
    }
  }

  // Welcome banner (word-mark + status + tips) on an interactive terminal.
  if (isTty) replBanner(await serverUpCached());

  // `for await` pulls one line at a time and only requests the next once the
  // body finishes — this serializes command execution (crucial for piped
  // input, where naive 'line' listeners would interleave async handlers).
  await renderPrompt();
  for await (const raw of rl) {
    const line = raw.trim();
    if (!line) {
      await renderPrompt();
      continue;
    }
    const toks = tokenizeLine(line);
    const head = toks[0];

    // Shell built-ins run in-process (no child spawn).
    if (["exit", "quit", "q", ":q"].includes(head)) break;
    if (head === "clear" || head === "cls") {
      if (isTty) process.stdout.write("\x1b[2J\x1b[H");
      await renderPrompt();
      continue;
    }
    if (head === "banner") {
      replBanner(await serverUpCached());
      await renderPrompt();
      continue;
    }
    if (head === "help" || head === "?") {
      // `help` → everything; `help <cmd>` → that command's details.
      if (toks.length === 1) replHelp();
      else if (!commandHelp(toks[1])) {
        console.log(c.dim(`No such command: ${toks[1]}. Type `) + c.bold("commands") + c.dim("."));
      }
      await renderPrompt();
      continue;
    }
    if (head === "commands") {
      replCommandsList();
      await renderPrompt();
      continue;
    }
    if (head === "watch") {
      if (!isTty) {
        console.log(c.dim("watch needs an interactive terminal."));
        await renderPrompt();
        continue;
      }
      // `watch <cmd…>` (default 2s) or `watch <secs> <cmd…>`.
      let rest2 = toks.slice(1);
      let secs = 2;
      if (rest2.length && /^\d+$/.test(rest2[0])) {
        secs = Math.max(1, Number(rest2[0]));
        rest2 = rest2.slice(1);
      }
      if (!rest2.length) {
        console.log(c.dim("Usage: watch [seconds] <command …>   e.g. watch 5 stats"));
      } else {
        await runWatch(rest2, secs * 1000);
      }
      await renderPrompt();
      continue;
    }
    if (head === "history") {
      const recent = history.slice(-30);
      recent.forEach((h, i) =>
        console.log(`${c.dim(String(history.length - recent.length + i + 1).padStart(4))}  ${h}`)
      );
      await renderPrompt();
      continue;
    }
    if (head === "repl" || head === "shell" || head === "i") {
      console.log(c.dim("Already in the ccam shell."));
      await renderPrompt();
      continue;
    }

    // Record history (skip consecutive duplicates), then dispatch as a child.
    if (history[history.length - 1] !== line) {
      history.push(line);
      try {
        fs.mkdirSync(path.dirname(historyFile), { recursive: true });
        fs.appendFileSync(historyFile, line + "\n");
      } catch {
        /* history is best-effort */
      }
    }
    await runChild(toks);
    await renderPrompt();
  }

  rl.close();
  if (isTty) console.log(c.dim("Bye."));
  process.removeListener("SIGINT", sigintNoop);
}

// ── Command dispatch ────────────────────────────────────────────────────────
// The single source of truth for "argv → command handler". Both the top-level
// entry point and the interactive REPL route through here, so every command
// behaves identically whether typed as `ccam <cmd>` or entered at the `ccam ›`
// prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal.

/** All top-level command tokens, derived from COMMAND_GROUPS (first word of
 *  each invocation) plus the always-available `help`. Used by the REPL
 *  completer and unknown-command detection so they never drift from help. */
const COMMANDS = (() => {
  const set = new Set();
  for (const [, items] of COMMAND_GROUPS) {
    for (const [name] of items) set.add(name.split(" ")[0]);
  }
  set.add("help");
  return [...set];
})();

/** Subcommand tokens per command, used by the REPL's tab completer. */
const SUBCOMMANDS = {
  alerts: ["ack", "ack-all"],
  pricing: ["set", "delete", "reset"],
  webhooks: ["test"],
  import: ["rescan", "path"],
};

/** Run one parsed command. Returns the handler's promise; may throw
 *  ServerDownError (for the caller's offline routing) or call process.exit
 *  for usage/validation failures. `cmd === "repl"` is handled by the entry
 *  points, not here, to avoid recursive REPLs. */
async function runCommand(argv) {
  const [cmd, ...rest] = argv;
  const { flags, positional } = parseArgs(rest);
  switch (cmd) {
    case "health":
      return cmdHealth();
    case "status":
      return cmdStatus();
    case "start":
      return cmdStart(flags);
    case "stats":
      return cmdStats();
    case "kanban":
      return cmdKanban();
    case "tail":
      return cmdTail(flags);
    case "sessions":
      return cmdSessions(flags);
    case "session":
      return cmdSession(positional);
    case "agents":
      return cmdAgents(flags);
    case "events":
      return cmdEvents(flags);
    case "analytics":
      return cmdAnalytics();
    case "workflows":
      return cmdWorkflows(flags);
    case "runs":
      return cmdRuns(flags);
    case "cost":
      return cmdCost(flags);
    case "alerts":
      return cmdAlerts(flags, positional);
    case "rules":
      return cmdRules();
    case "webhooks":
      return cmdWebhooks(flags, positional);
    case "remote-sources":
    case "remotes":
      return cmdRemoteSources(flags, positional);
    case "pricing":
      return cmdPricing(flags, positional);
    case "import":
      return cmdImport(flags, positional);
    case "doctor":
      return cmdDoctor();
    case "info":
      return cmdInfo();
    case "export":
      return cmdExport(positional);
    case "import-data":
      return cmdImportData(positional);
    case "cleanup":
      return cmdCleanup(flags);
    case "clear-data":
      return cmdClearData(flags);
    case "reinstall-hooks":
      return cmdReinstallHooks();
    case "update-check":
      return cmdUpdateCheck();
    case "lanes":
      if (rest[0] === "add") {
        return cmdLanesAdd(rest.slice(1));
      }
      if (rest[0] === "profile") {
        if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
        if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
        console.error(
          "usage: ccam lanes profile init <repo> [--force] | ccam lanes profile check [<path>]"
        );
        process.exitCode = 1;
        return;
      }
      if (["reset", "remove", "purge"].includes(rest[0])) {
        return cmdLanesLifecycle(rest[0], rest.slice(1));
      }
      if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
        return cmdLanesRuntime(rest[0], rest.slice(1));
      }
      if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
      if (rest[0] === "agents" && rest[1] === "install") {
        return cmdLanesAgentsInstall(rest.slice(2));
      }
      if (rest[0] === "mcp" && rest[1] === "sync") {
        return cmdLanesMcpSync(rest.slice(2));
      }
      if (rest[0] === "integration") {
        return cmdLanesIntegration(rest.slice(1));
      }
      if (rest[0] === "pipeline") {
        return cmdLanesPipeline(rest.slice(1));
      }
      if (rest[0] === "shell") {
        return cmdLanesShell(rest.slice(1));
      }
      if (rest[0] === "gc") {
        return cmdLanesGc(rest.slice(1));
      }
      return cmdLanes();
    case "skills": {
      if (rest[0] === "install") return cmdSkillsInstall();
      console.error("usage: ccam skills install");
      process.exitCode = 1;
      return;
    }
    case "lock": {
      const sub = rest[0];
      if (sub === "status") return cmdLockStatus(rest.slice(1));
      if (sub === "acquire") return cmdLockAcquire(rest.slice(1));
      if (sub === "release") return cmdLockRelease(rest.slice(1));
      console.error(
        "usage: ccam lock status [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]"
      );
      process.exitCode = 1;
      return;
    }
    case "stage":
      return cmdStage(rest);
    case "feature": {
      const sub = rest[0];
      if (sub === "list") return cmdFeatureList(rest.slice(1));
      if (sub === "activate") return cmdFeatureActivate(rest.slice(1));
      if (sub === "show") return cmdFeatureShow(rest.slice(1));
      console.error(
        "usage: ccam feature list | ccam feature activate <slug> [--title text] | ccam feature show <slug>"
      );
      process.exitCode = 1;
      return;
    }
    case "open":
      return cmdOpen();
    case "version":
    case "--version":
    case "-v":
      return cmdVersion();
    case "help":
    case "--help":
    case "-h":
    case undefined:
      return cmdHelp();
    default:
      console.error(c.red(`✖ Unknown command: ${cmd}`));
      cmdHelp();
      process.exit(1);
  }
}

/**
 * Route a ServerDownError: run the command's offline handler under the
 * offline banner if one exists, otherwise print the server-required refusal.
 * Shared by the top-level entry and (indirectly, via a child process) the
 * REPL. `throwOnExit` makes the terminal refusal throw instead of exiting so
 * an embedding loop can stay alive.
 */
async function handleServerDown(argv) {
  const cmd = argv[0];
  const { flags, positional } = parseArgs(argv.slice(1));
  const handler = OFFLINE_HANDLERS[cmd];
  if (handler) {
    offlineBanner();
    try {
      await handler(flags, positional);
      return;
    } catch (offErr) {
      console.error(c.red(`✖ Offline read failed: ${offErr?.message || offErr}`));
      process.exit(1);
    }
  }
  serverDownExit(SERVER_ONLY_REASONS[cmd]);
}

// ── Entry point ─────────────────────────────────────────────────────────────

async function main() {
  // --no-color is a global presentation flag (already consumed by the color
  // detector at module load) — strip it so it can appear anywhere without
  // being mistaken for a command or a subcommand flag.
  const argv = process.argv.slice(2).filter((a) => a !== "--no-color");
  const cmd = argv[0];
  if (cmd === "repl" || cmd === "shell" || cmd === "i") return cmdRepl();
  return runCommand(argv);
}

main().catch(async (err) => {
  if (err instanceof ServerDownError) {
    const argv = process.argv.slice(2).filter((a) => a !== "--no-color");
    await handleServerDown(argv);
    return;
  }
  console.error(c.red(`✖ ${err?.message || err}`));
  process.exit(1);
});
