Files
Claude-Code-Monitor/bin/ccam.js
T
nntrivi2001 d2fc4a4701 feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:05:51 +07:00

2643 lines
98 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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) => api("GET", p);
const post = (p, b, options) => api("POST", 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}`);
}
// 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) {
console.error(`usage: ccam lanes ${action} <id> [--force] --yes`);
process.exitCode = 1;
return;
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
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;
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`
);
}
/**
* `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.
*/
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;
};
let laneId = flag("lane");
if (!laneId) {
const cwd = require("path").resolve(flag("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;
}
laneId = match.id;
}
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}%)`);
}
// ── 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 reset|remove|purge",
"<id> [--force] --yes",
"Show preflight facts, then perform a destructive lane action",
],
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
],
],
[
"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 (["reset", "remove", "purge"].includes(rest[0])) {
return cmdLanesLifecycle(rest[0], rest.slice(1));
}
return cmdLanes();
case "stage":
return cmdStage(rest);
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);
});