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.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Clears all sessions, agents, events, and token usage from the database.
* Destructive — requires explicit confirmation.
*
* Usage:
* node scripts/clear-data.js --yes Wipe everything (irrevocable)
* node scripts/clear-data.js --yes --backup Snapshot DB to data/backups/ first
* node scripts/clear-data.js --demo-only --yes Delete only seed-fixture rows
* node scripts/clear-data.js Dry run — print counts, do nothing
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
let Database;
try {
Database = require("better-sqlite3");
} catch {
try {
Database = require("../server/compat-sqlite");
} catch {
console.error(
"Error: No SQLite backend available. Upgrade to Node.js 22+ or install build tools."
);
process.exit(1);
}
}
const fs = require("fs");
const path = require("path");
const { getDataDir } = require("../server/lib/claude-home");
const args = new Set(process.argv.slice(2));
const CONFIRMED = args.has("--yes") || args.has("-y");
const BACKUP = args.has("--backup");
const DEMO_ONLY = args.has("--demo-only");
const DRY_RUN = args.has("--dry-run") || !CONFIRMED;
// Mirror server/db.js resolution so we clear the same shared database the
// servers actually use (DASHBOARD_DB_PATH override → shared data dir).
const DB_PATH = process.env.DASHBOARD_DB_PATH || path.join(getDataDir(), "dashboard.db");
if (!fs.existsSync(DB_PATH)) {
console.error(`No database at ${DB_PATH} — nothing to clear.`);
process.exit(0);
}
const db = new Database(DB_PATH);
db.pragma("foreign_keys = OFF");
const counts = {
token_usage: db.prepare("SELECT COUNT(*) as n FROM token_usage").get()?.n ?? 0,
events: db.prepare("SELECT COUNT(*) as n FROM events").get()?.n ?? 0,
agents: db.prepare("SELECT COUNT(*) as n FROM agents").get()?.n ?? 0,
sessions: db.prepare("SELECT COUNT(*) as n FROM sessions").get()?.n ?? 0,
};
const totalRows = counts.sessions + counts.agents + counts.events + counts.token_usage;
console.log("");
console.log(`Target DB: ${DB_PATH}`);
console.log("Current row counts:");
console.log(` Sessions: ${counts.sessions.toLocaleString()}`);
console.log(` Agents: ${counts.agents.toLocaleString()}`);
console.log(` Events: ${counts.events.toLocaleString()}`);
console.log(` Tokens: ${counts.token_usage.toLocaleString()}`);
console.log("");
if (DRY_RUN) {
db.close();
console.log("⚠️ DRY RUN — no data was deleted.");
console.log("");
console.log("This is a DESTRUCTIVE operation. To actually wipe the database,");
console.log("re-run with --yes:");
console.log("");
console.log(" node scripts/clear-data.js --yes");
console.log("");
console.log("Strongly recommended: also pass --backup to snapshot the DB first:");
console.log("");
console.log(" node scripts/clear-data.js --yes --backup");
console.log("");
if (DEMO_ONLY) {
console.log("(--demo-only would delete only rows tagged as seed fixtures.)");
}
process.exit(0);
}
// Confirmed path — actually delete.
if (BACKUP) {
const backupDir = path.join(path.dirname(DB_PATH), "backups");
fs.mkdirSync(backupDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(backupDir, `dashboard.${stamp}.db`);
// Use SQLite VACUUM INTO for a consistent snapshot
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
console.log(`📦 Backup written: ${backupPath}`);
}
if (DEMO_ONLY) {
// Delete only fixture rows. These IDs are stable across seed runs.
const FIXTURE_SESSION_IDS = [
"demo-solo-0001-0001-0001-000000000001",
"demo-nested-0001-0001-0001-000000000001",
];
const placeholders = FIXTURE_SESSION_IDS.map(() => "?").join(",");
const tx = db.transaction(() => {
db.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`).run(
...FIXTURE_SESSION_IDS
);
db.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`).run(
...FIXTURE_SESSION_IDS
);
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(
...FIXTURE_SESSION_IDS
);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...FIXTURE_SESSION_IDS);
});
tx();
console.log(
`Cleared demo fixture rows (${FIXTURE_SESSION_IDS.length} sessions and their children).`
);
} else {
console.log(`⚠️ Wiping ${totalRows.toLocaleString()} rows…`);
db.exec("DELETE FROM token_usage; DELETE FROM events; DELETE FROM agents; DELETE FROM sessions;");
console.log("Database cleared.");
}
db.pragma("foreign_keys = ON");
db.close();
console.log("");
console.log(
"Tip: run `npm run import-history` to restore sessions from ~/.claude/ JSONL transcripts."
);
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Dev orchestrator. Picks a free port for the dev server (starting at the
* conventional 4820), exports it via `DASHBOARD_PORT`, then spawns the
* existing concurrently pipeline. Both `dev:server` (server/index.js) and
* `dev:client` (vite.config.ts) read the same env var, so they stay in
* lockstep.
*
* Why this exists: on machines that hold 4820 via an SSH `LocalForward`,
* SSH binds the loopback specifically (`127.0.0.1:4820` and `[::1]:4820`),
* Node's wildcard `server.listen(4820)` "succeeds" without binding the
* loopback, and every Vite proxy request to `localhost:4820` lands on SSH
* instead of Express — silent `ECONNRESET`s everywhere. Probing both IP
* families before we ever try to bind catches that.
*
* Built atop the macOS desktop app groundwork in PR #151 by @shuvamk.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const net = require("node:net");
const http = require("node:http");
const { spawn } = require("node:child_process");
const START = parseInt(process.env.DASHBOARD_PORT || "4820", 10);
const RANGE = 40;
/** Resolve true if a healthy dashboard already answers /api/health on `port`. */
function healthyDashboardOn(port) {
return new Promise((resolve) => {
const req = http.get({ host: "127.0.0.1", port, path: "/api/health", timeout: 600 }, (res) => {
let buf = "";
res.setEncoding("utf8");
res.on("data", (c) => (buf += c));
res.on("end", () => {
try {
resolve(JSON.parse(buf)?.status === "ok");
} catch {
resolve(false);
}
});
});
req.on("error", () => resolve(false));
req.on("timeout", () => {
req.destroy();
resolve(false);
});
});
}
function probeHost(host, port, timeoutMs) {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port });
const done = (busy) => {
sock.destroy();
resolve(busy);
};
sock.setTimeout(timeoutMs);
sock.once("connect", () => done(true));
sock.once("error", () => done(false));
sock.once("timeout", () => done(false));
});
}
async function busy(port) {
// IPv4 first (most common), IPv6 second. Either bind shadowing Node's
// wildcard listen is enough to break the proxy.
if (await probeHost("127.0.0.1", port, 600)) return true;
if (await probeHost("::1", port, 300)) return true;
return false;
}
async function pickPort() {
for (let p = START; p < START + RANGE; p++) {
if (!(await busy(p))) return p;
}
throw new Error(`No free port found in ${START}-${START + RANGE - 1}`);
}
(async () => {
let port;
try {
port = await pickPort();
} catch (err) {
console.error(`[dev] ${err.message}`);
process.exit(1);
}
if (port !== START) {
console.log(
`[dev] port ${START} is busy (something is on the loopback already — likely an SSH LocalForward); using ${port} instead`
);
// If the thing on the conventional port is itself a healthy dashboard, this
// dev server will run alongside it on the SAME shared database. Claude Code
// hooks fan out to every live dashboard, so each live event would be written
// twice — inflating counts. Warn so the developer can stop the other one.
if (await healthyDashboardOn(START)) {
console.log(
`[dev] ⚠ another dashboard is already running on :${START} and shares this database. ` +
`Live hook events will be counted by BOTH — stop the other dashboard (e.g. the desktop app) for accurate dev data.`
);
}
} else {
console.log(`[dev] dashboard server will listen on :${port}`);
}
// On Windows `npx` is a `npx.cmd` shim that `spawn` can only launch through a
// shell; without `shell: true` it fails with `spawn npx ENOENT`. POSIX has a
// real `npx` on PATH and is unaffected. With a shell, Node does not re-quote
// args, so the two space-containing `concurrently` commands must be quoted
// ourselves to survive as single tokens (on POSIX they're already one array
// element each, so we leave them bare).
const isWin = process.platform === "win32";
const cmd = (s) => (isWin ? `"${s}"` : s);
const child = spawn(
"npx",
[
"--no-install",
"concurrently",
"-n",
"server,client",
"-c",
"blue,green",
cmd("npm run dev:server"),
cmd("npm run dev:client"),
],
{
stdio: "inherit",
shell: isWin,
env: { ...process.env, DASHBOARD_PORT: String(port) },
}
);
// Propagate Ctrl-C / SIGTERM so concurrently can shut both legs down
// gracefully instead of being orphaned.
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => child.kill(sig));
}
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code || 0);
});
})();
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
expand-ts-module-docs.py — append rich TSDoc blocks to TypeScript modules (comments only).
Used to deepen in-file documentation for client, MCP, and desktop packages without
changing runtime behavior. Idempotent: skips files that already contain MODULE_GUIDE.
@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
AUTHOR = "@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>"
MARKER = "MODULE_GUIDE"
EXPORT_RE = re.compile(
r"^export\s+(?:async\s+)?(?:function|const|class|type|interface|enum)\s+(\w+)",
re.MULTILINE,
)
IMPORT_RE = re.compile(
r"""^import\s+(?:type\s+)?(?:\{[^}]+\}|\w+)\s+from\s+['"]([^'"]+)['"]""",
re.MULTILINE,
)
def topic_blurb(path: Path) -> str:
rel = path.as_posix()
name = path.stem
hints: list[str] = []
if "RemoteSources" in name or "remote" in rel.lower():
hints.append(
"Supports federated dashboards: register SSH-backed or file-synced remote "
"machines, health-check tunnels, and scope the entire UI to local vs all vs "
"selected sources."
)
if "prometheus" in rel.lower() or "metrics" in rel.lower():
hints.append(
"Relates to the `/api/metrics` Prometheus exposition endpoint and the optional "
"native/Docker Grafana stack under `monitoring/`."
)
if "ssh" in rel.lower():
hints.append(
"Covers SSH key management, jump-host tunnels, and secure remote ingestion "
"paths used by Remote Data Sources."
)
if path.parts[0:2] == ("client", "src") and path.parts[2:3] == ("pages",):
hints.append(
"Route-level screen mounted by `App.tsx`; fetches scoped REST data, subscribes "
"to WebSocket deltas via `eventBus`, and renders inside `Layout`."
)
if path.parts[0:2] == ("mcp", "src"):
hints.append(
"Part of the local MCP server (`npm run mcp:start`) that exposes dashboard "
"operations as MCP tools for Claude Code and other hosts."
)
if path.parts[0:2] == ("desktop", "src"):
hints.append(
"Electron main/preload process code for the packaged desktop app — embeds the "
"Express server, manages tray/window lifecycle, and writes discovery metadata."
)
if "workflow" in rel.lower():
hints.append(
"Workflow analytics visualization built on D3; consumes aggregated session/run "
"metrics from the workflows API."
)
if "conversation" in rel.lower():
hints.append(
"Renders Claude transcript rows (user, assistant, tool calls) inside Session "
"Detail with markdown, syntax highlighting, and TUI-style segments."
)
if "Tabby" in rel:
hints.append(
"Tabby is the optional on-screen cat assistant — quips, intents, and lightweight "
"event reactions layered above the dashboard chrome."
)
if "hook" in rel.lower() or name.startswith("use"):
hints.append(
"React hook: isolates side effects and subscription wiring so presentational "
"components stay declarative."
)
if rel.endswith("lib/api.ts"):
hints.append(
"Central typed HTTP client for every REST route; attaches auth token, data-scope "
"`sources` query params, and normalizes error payloads."
)
if rel.endswith("lib/types.ts"):
hints.append(
"Shared wire-format types for REST + WebSocket messages — keep in sync with "
"`server/` serializers and OpenAPI."
)
if rel.endswith("eventBus.ts"):
hints.append(
"In-memory pub/sub bus bridging `useWebSocket` to any page without prop drilling."
)
if not hints:
hints.append(
"Dashboard module consumed by the React client, MCP tools, or desktop shell "
"depending on deployment mode."
)
return " ".join(hints)
def list_exports(source: str) -> list[str]:
return EXPORT_RE.findall(source)
def list_imports(source: str) -> list[str]:
seen: list[str] = []
for m in IMPORT_RE.finditer(source):
mod = m.group(1)
if mod.startswith(".") and mod not in seen:
seen.append(mod)
return seen[:12]
def build_guide(path: Path, source: str) -> str:
exports = list_exports(source)
imports = list_imports(source)
rel = path.as_posix()
blurb = topic_blurb(path)
lines = [
"",
"/* =============================================================================",
f" * {MARKER} — extended in-file reference (comments only; safe to read, never executed)",
" * =============================================================================",
f" * **Path:** `{rel}`",
f" * **Purpose:** {blurb}",
" *",
" * ## Design constraints",
" * - Local-first: no telemetry leaves the machine unless the user configures webhooks.",
" * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that",
" * philosophy by degrading gracefully (empty states, stale badges, reconnect loops).",
" * - Destructive flows stay behind explicit confirmation modals and server-side gates.",
" * - Internationalization: user-visible strings belong in i18n JSON, not literals here.",
" *",
" * ## Remote data & SSH",
" * Remote Data Sources let operators aggregate multiple machines. SSH entries describe",
" * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every",
" * scoped GET via `?sources=`. Health checks and import history surface in Settings.",
" *",
" * ## Observability",
" * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four",
" * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and",
" * Docker Compose profiles are documented in `monitoring/README.md`.",
" *",
]
if imports:
lines.append(" * ## Internal dependencies")
for imp in imports:
lines.append(f" * - `{imp}`")
lines.append(" *")
if exports:
lines.append(" * ## Public surface")
for name in exports[:40]:
lines.append(f" * - `{name}` — exported API; see TSDoc on the symbol for behavior.")
if len(exports) > 40:
lines.append(f" * - … plus {len(exports) - 40} additional exports")
lines.append(" *")
lines.extend(
[
" * ## Testing pointers",
" * - Prefer colocated `__tests__` with Vitest + Testing Library for UI.",
" * - Server contract changes require `npm run test:server` and OpenAPI sync.",
" * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.",
" *",
" * ## Related docs",
" * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.",
" * - `docs/API.md` — REST reference.",
" * - `.claude/skills/file-headers/` — mandatory `@author` header policy.",
" * ============================================================================= */",
]
)
block = "\n".join(lines)
if exports:
catalog = [
"",
"/* -----------------------------------------------------------------------------",
" * EXPORT CATALOG — quick index of symbols defined below (documentation only).",
" * -----------------------------------------------------------------------------",
]
for name in exports:
catalog.extend(
[
f" * **{name}**",
" * Part of this module's public contract. Downstream imports should treat",
" * the signature and return type as stable unless release notes say otherwise.",
" * When behavior changes, update the `@file` overview and relevant tests.",
" *",
]
)
catalog.append(" * ----------------------------------------------------------------------------- */")
block += "\n".join(catalog) + "\n"
return block
def insert_guide(path: Path) -> bool:
source = path.read_text(encoding="utf-8")
if MARKER in source:
return False
if AUTHOR not in source:
print(f"SKIP (no author header): {path}")
return False
m = re.search(r"/\*\*[\s\S]*?\*/", source)
if not m:
print(f"SKIP (no file header block): {path}")
return False
guide = build_guide(path, source)
updated = source[: m.end()] + guide + source[m.end() :]
path.write_text(updated, encoding="utf-8")
return True
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("Usage: expand-ts-module-docs.py <glob-root> [...]", file=sys.stderr)
return 1
root = Path(__file__).resolve().parents[1]
changed = 0
for arg in argv[1:]:
for path in sorted(root.glob(arg)):
if not path.is_file():
continue
if path.suffix not in {".ts", ".tsx"}:
continue
if "__snapshots__" in path.parts or "__tests__" in path.parts:
continue
if insert_guide(path):
print(f"expanded: {path.relative_to(root)}")
changed += 1
print(f"Done — {changed} file(s) updated.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* @file Regenerates the repo-root `openapi.yaml` from the single source of
* truth — `createOpenApiSpec()` in `server/openapi.js`. The JSON spec served at
* `/api/openapi.json` and this committed YAML mirror are therefore always in
* sync: run `npm run openapi:yaml` after any spec change. Never hand-edit
* `openapi.yaml`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const yaml = require("js-yaml");
const { createOpenApiSpec } = require("../server/openapi");
const OUT = path.join(__dirname, "..", "openapi.yaml");
function main() {
const spec = createOpenApiSpec();
const body = yaml.dump(spec, {
lineWidth: -1, // don't wrap long strings (keeps descriptions/examples intact)
noRefs: true, // inline any shared object references for a portable document
sortKeys: false, // preserve authored key order
});
const header =
"# DO NOT EDIT BY HAND. Generated from server/openapi.js via `npm run openapi:yaml`.\n" +
"# This YAML mirrors the live spec served at GET /api/openapi.json.\n";
fs.writeFileSync(OUT, header + body, "utf8");
const stat = fs.statSync(OUT);
const pathCount = Object.keys(spec.paths || {}).length;
console.log(`Wrote ${OUT} (${pathCount} paths, ${stat.size} bytes).`);
}
main();
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Claude Code hook handler.
* Receives hook event JSON on stdin and forwards it to every live Agent
* Dashboard server. Designed to fail silently so it never blocks Claude
* Code, and to fan out across multiple dashboards that use **different**
* SQLite data directories (e.g. the macOS desktop app alongside `npm run dev`
* when each has its own DB). Servers sharing one database receive hooks through
* a single ingest port so events are never duplicated.
*
* Delivery is fire-and-forget: we exit as soon as the request body is on the
* wire, WITHOUT waiting for the dashboard's HTTP response. The hook only needs
* to *deliver* the event — on loopback the local server reads the buffered
* request and processes it even after this short-lived process exits. Waiting
* for the response is what made Claude Code sit at "running hooks" for seconds
* whenever a dashboard was busy, slow, or wedged.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const http = require("http");
const hookType = process.argv[2] || "unknown";
/**
* Resolve every live dashboard server's port via the discovery file. Falls
* back to the `CLAUDE_DASHBOARD_PORT` override or the conventional 4820 if
* the discovery module can't load for any reason. Never throws.
*/
function resolvePorts() {
try {
return require("../server/lib/server-info").resolveHookIngestPorts();
} catch {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
return [Number.isInteger(envPort) && envPort > 0 ? envPort : 4820];
}
}
const ports = resolvePorts();
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
let parsedData;
try {
parsedData = JSON.parse(input);
} catch {
parsedData = { raw: input };
}
const payload = JSON.stringify({
hook_type: hookType,
data: parsedData,
});
const contentLength = Buffer.byteLength(payload);
// Fan out one POST per live server. Each per-target promise resolves the
// moment the request body has been flushed — NOT when the dashboard replies
// — so a busy, slow, or wedged dashboard can't stall the hook. Each promise
// always resolves (never rejects), so one dead listener can't starve the
// others and Promise.all can't be left hanging by a single failure.
const sends = ports.map(
(port) =>
new Promise((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
resolve();
};
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/hooks/event",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": contentLength,
},
timeout: 2000,
},
// Drain any response so the socket closes cleanly if the server does
// reply before we exit. We never block on it.
(res) => res.resume()
);
req.on("error", done); // dead listener (ECONNREFUSED) — nothing to deliver
req.on("timeout", () => {
req.destroy();
done();
});
req.write(payload);
// The 'end' callback fires once the body is on the wire: delivery is
// done and the local server will process it on its own schedule.
req.end(done);
})
);
// Give the kernel one tick to hand the buffered request bytes to the local
// server before our sockets close, then exit. The hook returns in ms.
Promise.all(sends).finally(() => setImmediate(() => process.exit(0)));
});
// Safety net — guarantees the hook never blocks Claude Code even if a send
// somehow never settles. Shorter than the old 5s wait because we no longer
// block on the dashboard's response, only on the request flush.
setTimeout(() => process.exit(0), 2500);
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Installs Claude Code hooks that forward events to the Agent Dashboard.
* Modifies ~/.claude/settings.json to add hook entries.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getSettingsPath } = require("../server/lib/claude-home");
const SETTINGS_PATH = getSettingsPath();
const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/");
function envFlag(name) {
return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase());
}
/**
* True when this process is running inside a container (Docker, Podman, or a
* Kubernetes pod). Detected via the Docker/Podman marker files, the OCI/systemd
* `container` env var, and a Linux cgroup heuristic. `CCAM_FORCE_CONTAINER=1`
* forces a positive result and `CCAM_FORCE_HOST=1` forces a negative result
* (used by tests / to override misfiring detection).
*
* Why this matters (GitHub #193): the hook command written into
* `~/.claude/settings.json` embeds the absolute handler path resolved here.
* Inside a container that path (e.g. `/app/scripts/hook-handler.js`) does not
* exist on the host. When `~/.claude` is bind-mounted, installing from the
* container poisons the host settings and every host hook fails with
* `MODULE_NOT_FOUND`. Claude Code runs on the host, so hooks must be installed
* on the host.
*
* @returns {boolean}
*/
function isInsideContainer() {
if (envFlag("CCAM_FORCE_CONTAINER")) return true;
if (envFlag("CCAM_FORCE_HOST")) return false;
try {
if (fs.existsSync("/.dockerenv")) return true; // Docker
if (fs.existsSync("/run/.containerenv")) return true; // Podman
} catch {
/* fs probe failed — fall through to other signals */
}
// systemd-nspawn / Podman (and often Docker) export `container`.
if (typeof process.env.container === "string" && process.env.container.length > 0) return true;
// Linux cgroup heuristic — covers Docker, containerd, Kubernetes, Podman.
try {
const cgroup = fs.readFileSync("/proc/self/cgroup", "utf8");
if (/\b(docker|containerd|kubepods|libpod|podman)\b/.test(cgroup)) return true;
} catch {
/* not Linux / no cgroup file — not a container by this signal */
}
return false;
}
/** Multi-line message explaining why a container install is refused. */
function containerRefusalMessage() {
return [
"✖ Refusing to install Claude Code hooks from inside a container.",
"",
` The hook command would embed this handler path:`,
` ${HOOK_HANDLER}`,
` written into:`,
` ${SETTINGS_PATH}`,
"",
" Claude Code runs on the HOST. When ~/.claude is bind-mounted, a",
" container-internal handler path does not exist on the host, so every host",
" hook fails with MODULE_NOT_FOUND (e.g. the SessionEnd hook). See issue #193.",
"",
" → Install hooks ON THE HOST instead:",
" npm run install-hooks",
" # or: node /path/to/Claude-Code-Agent-Monitor/scripts/install-hooks.js",
"",
" The host handler POSTs to http://localhost:4820, which the container already",
" publishes — so a host-installed hook reaches the containerized dashboard.",
"",
" If you genuinely run Claude Code inside this same container, override with:",
" CCAM_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks",
].join("\n");
}
// Hook types to install. Some support matchers, some don't.
const HOOKS_WITH_MATCHER = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification"];
// UserPromptSubmit fires the instant the user hits enter — the only reliable
// signal that the user has resumed for *text-only* turns (no PreToolUse will
// fire until Claude calls a tool, which never happens for plain-text replies).
// Without it the Waiting badge persists through the entire generation of a
// text response. SessionStart / SessionEnd / UserPromptSubmit don't take
// tool-name matchers, hence the separate list.
const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"];
const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER];
function makeHookEntry(hookType) {
const entry = {
hooks: [
{
type: "command",
command: `node "${HOOK_HANDLER}" ${hookType}`,
},
],
};
if (HOOKS_WITH_MATCHER.includes(hookType)) {
entry.matcher = "*";
}
return entry;
}
function isOurEntry(entry) {
// Matches old format (entry.command) and new format (entry.hooks[].command)
if (entry.command && entry.command.includes("hook-handler.js")) return true;
if (Array.isArray(entry.hooks)) {
return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js"));
}
return false;
}
function installHooks(silent = false) {
// Host-only guard (issue #193): never write a container-internal handler path
// into a (potentially bind-mounted) host settings file. Honors an explicit
// opt-out for the rare case of running Claude Code inside this same container.
if (isInsideContainer() && !envFlag("CCAM_ALLOW_CONTAINER_HOOKS")) {
if (!silent) console.error(containerRefusalMessage());
return false;
}
let settings = {};
if (fs.existsSync(SETTINGS_PATH)) {
try {
const raw = fs.readFileSync(SETTINGS_PATH, "utf8");
settings = JSON.parse(raw);
} catch (err) {
if (!silent) console.error(`Failed to parse ${SETTINGS_PATH}:`, err.message);
return false;
}
}
if (!settings.hooks) settings.hooks = {};
let installed = 0;
let updated = 0;
for (const hookType of HOOK_TYPES) {
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
const existing = settings.hooks[hookType].findIndex(isOurEntry);
const entry = makeHookEntry(hookType);
if (existing >= 0) {
settings.hooks[hookType][existing] = entry;
updated++;
} else {
settings.hooks[hookType].push(entry);
installed++;
}
}
const dir = path.dirname(SETTINGS_PATH);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf8");
if (!silent) {
console.log(`Hook handler: ${HOOK_HANDLER}`);
console.log(`Settings file: ${SETTINGS_PATH}`);
console.log(`Installed: ${installed} new, updated: ${updated} existing`);
console.log("Claude Code hooks configured. Start a new Claude Code session to begin tracking.");
}
return true;
}
if (require.main === module) {
// Non-zero exit on refusal/failure so CI and shell users notice it.
if (!installHooks(false)) process.exitCode = 1;
}
module.exports = { installHooks, isInsideContainer };
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* @file postinstall.js
* @description Root `postinstall` hook: after a bare `npm install` at the repo
* root, install the React client's dependencies too, so a single root install
* yields a buildable/runnable tree (the client's fonts and build deps live in
* `client/package.json`). The step is a safe no-op when the `client/` workspace
* is absent — production/Docker stages that copy only the root manifest, the
* MCP image's `file:..` link, and the published tarball all install without a
* client checkout, and must not fail here. Skipped entirely under
* `npm install --ignore-scripts` (run `cd client && npm install` manually then).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const clientDir = path.join(__dirname, "..", "client");
const clientManifest = path.join(clientDir, "package.json");
// No client checkout in this context (Docker server/MCP stages, packed tarball,
// server-only installs). Nothing to do — succeed quietly so the parent install
// is not broken.
if (!fs.existsSync(clientManifest)) {
console.log("[postinstall] client/ not present — skipping client dependency install.");
process.exit(0);
}
console.log("[postinstall] installing client dependencies (client/)...");
// `shell: true` is required on Windows so npm's `.cmd` shim resolves (Node
// rejects spawning `.cmd`/`.bat` directly since 18.20 / CVE-2024-27980); the
// fixed arg list has no shell-significant characters, so this stays safe.
const result = spawnSync("npm", ["install"], {
cwd: clientDir,
stdio: "inherit",
shell: true,
});
if (result.error) {
console.error("[postinstall] failed to launch npm for the client install:", result.error.message);
process.exit(1);
}
process.exit(result.status === null ? 1 : result.status);
+634
View File
@@ -0,0 +1,634 @@
#!/usr/bin/env node
/**
* Seeds the database with sample data for development and demo purposes.
*
* Default behavior is ADDITIVE and IDEMPOTENT:
* node scripts/seed.js Insert the three stable test fixtures
* (single-agent, deeply-nested, and a
* waiting-on-input session that exercises
* the Waiting badge / reason chip /
* banner UI). Re-runs are no-ops if
* fixtures already exist.
*
* node scripts/seed.js --full Also insert the random/demo sessions
* (old behavior; produces unbounded data
* on repeat runs — use intentionally).
*
* node scripts/seed.js --reset Remove existing fixture rows before
* re-inserting them (e.g. to refresh
* timestamps). Only deletes fixture
* sessions, never user data.
*
* This script NEVER deletes non-fixture data. To wipe the DB, use
* scripts/clear-data.js (which now requires --yes).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { v4: uuidv4 } = require("uuid");
const { db, stmts } = require("../server/db");
// ── Stable fixture IDs ─────────────────────────────────────────────────────
// These IDs are intentionally non-UUID-shaped strings prefixed with `demo-`
// so they are easy to recognize, never collide with real Claude Code session
// UUIDs, and stay stable across seed runs.
const FIXTURES = {
solo: {
sessionId: "demo-solo-0001-0001-0001-000000000001",
mainAgentId: "demo-solo-0001-main",
},
waiting: {
sessionId: "demo-waiting-0001-0001-0001-000000000001",
mainAgentId: "demo-waiting-0001-main",
},
nested: {
sessionId: "demo-nested-0001-0001-0001-000000000001",
mainAgentId: "demo-nested-0001-main",
agents: {
l1Explorer: "demo-nested-0001-l1-explorer",
l2Researcher: "demo-nested-0001-l2-researcher",
l3TestWriter: "demo-nested-0001-l3-testwriter",
l4Debugger: "demo-nested-0001-l4-debugger",
l2Reviewer: "demo-nested-0001-l2-reviewer",
l1Architect: "demo-nested-0001-l1-architect",
l1DocWriter: "demo-nested-0001-l1-docwriter",
l2ExampleGen: "demo-nested-0001-l2-examplegen",
},
},
};
const FIXTURE_SESSION_IDS = [
FIXTURES.solo.sessionId,
FIXTURES.waiting.sessionId,
FIXTURES.nested.sessionId,
];
const args = new Set(process.argv.slice(2));
const FULL = args.has("--full");
const RESET = args.has("--reset");
function randomItem(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function minutesAgo(minutes) {
return new Date(Date.now() - minutes * 60000).toISOString();
}
const AGENT_NAMES = [
"Main Agent",
"Code Explorer",
"Test Runner",
"Code Reviewer",
"Security Auditor",
"Doc Writer",
"Debugger",
"Knowledge Base",
"TDD Assistant",
"UI Engineer",
];
const SUBAGENT_TYPES = [
"Explore",
"general-purpose",
"Plan",
"code-reviewer",
"tdd-assistant",
"debugger",
"security-auditor",
"doc-writer",
"knowledge-base",
"ui-engineer",
];
const TOOL_NAMES = [
"Read",
"Write",
"Edit",
"Bash",
"Grep",
"Glob",
"Agent",
"WebSearch",
"WebFetch",
];
const TASKS = [
"Searching for authentication middleware patterns",
"Running test suite for user service",
"Reviewing PR #42 for security vulnerabilities",
"Analyzing database schema for optimization",
"Exploring component structure in src/components",
"Writing unit tests for payment processor",
"Debugging failing integration test",
"Documenting API endpoints",
"Scanning for OWASP Top 10 vulnerabilities",
"Refactoring utility functions",
];
function sessionExists(id) {
return !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id);
}
function deleteFixtureRows() {
const placeholders = FIXTURE_SESSION_IDS.map(() => "?").join(",");
const tx = db.transaction(() => {
db.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`).run(
...FIXTURE_SESSION_IDS
);
db.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`).run(
...FIXTURE_SESSION_IDS
);
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(
...FIXTURE_SESSION_IDS
);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...FIXTURE_SESSION_IDS);
});
tx();
}
// ── Stable fixtures (AgentCard click behavior + the Waiting overlay demo) ──
function seedFixtures() {
const result = { inserted: [], skipped: [] };
const tx = db.transaction(() => {
// 1. Single-agent session (no subagents — leaf-only; click should NAVIGATE)
if (sessionExists(FIXTURES.solo.sessionId)) {
result.skipped.push("Single Agent: Quick Hotfix");
} else {
stmts.insertSession.run(
FIXTURES.solo.sessionId,
"Single Agent: Quick Hotfix",
"active",
"/home/dev/hotfix",
"claude-sonnet-4-6",
null
);
stmts.insertAgent.run(
FIXTURES.solo.mainAgentId,
FIXTURES.solo.sessionId,
"Main Agent",
"main",
null,
"working",
"Patching null-pointer in checkout handler",
null,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run(
"Edit",
FIXTURES.solo.mainAgentId
);
result.inserted.push("Single Agent: Quick Hotfix");
}
// 2. Waiting-on-input session — exercises the yellow Waiting overlay end
// to end: awaiting_input_since + awaiting_reason drive the Waiting
// badge, the reason chip/tooltip (urgent "notification" → amber), the
// Kanban Waiting column, and SessionDetail's waiting-for-input banner.
if (sessionExists(FIXTURES.waiting.sessionId)) {
result.skipped.push("Waiting Demo: Permission Prompt");
} else {
stmts.insertSession.run(
FIXTURES.waiting.sessionId,
"Waiting Demo: Permission Prompt",
"active",
"/home/dev/waiting-demo",
"claude-opus-4-6",
null
);
stmts.insertAgent.run(
FIXTURES.waiting.mainAgentId,
FIXTURES.waiting.sessionId,
"Main Agent",
"main",
null,
"waiting",
"Blocked on a permission prompt (Bash: npm publish)",
null,
null
);
// Stamp the awaiting overlay a few minutes in the past so the banner's
// "how long" readout shows something real on first render.
const awaitingTs = new Date(Date.now() - 4 * 60 * 1000).toISOString();
stmts.setSessionAwaitingInput.run(awaitingTs, "notification", FIXTURES.waiting.sessionId);
stmts.setAgentAwaitingInput.run(awaitingTs, "notification", FIXTURES.waiting.mainAgentId);
result.inserted.push("Waiting Demo: Permission Prompt");
}
// 3. Deeply-nested session (depth 4, branching — click PARENT toggles, LEAF navigates)
if (sessionExists(FIXTURES.nested.sessionId)) {
result.skipped.push("Deep Nesting: Multi-Agent Research Pipeline");
} else {
const ids = FIXTURES.nested.agents;
stmts.insertSession.run(
FIXTURES.nested.sessionId,
"Deep Nesting: Multi-Agent Research Pipeline",
"active",
"/home/dev/research-pipeline",
"claude-opus-4-6",
null
);
stmts.insertAgent.run(
FIXTURES.nested.mainAgentId,
FIXTURES.nested.sessionId,
"Main Agent",
"main",
null,
"waiting",
"Orchestrating multi-agent research pipeline",
null,
null
);
// Depth 1: Main → Codebase Explorer (working)
stmts.insertAgent.run(
ids.l1Explorer,
FIXTURES.nested.sessionId,
"Codebase Explorer",
"subagent",
"Explore",
"working",
"Mapping authentication module dependencies",
FIXTURES.nested.mainAgentId,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run("Glob", ids.l1Explorer);
// Depth 2: Explorer → Security Researcher (working)
stmts.insertAgent.run(
ids.l2Researcher,
FIXTURES.nested.sessionId,
"Security Researcher",
"subagent",
"general-purpose",
"working",
"Analyzing OAuth2 token validation patterns",
ids.l1Explorer,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run(
"WebSearch",
ids.l2Researcher
);
// Depth 3: Researcher → Test Engineer (working)
stmts.insertAgent.run(
ids.l3TestWriter,
FIXTURES.nested.sessionId,
"Test Engineer",
"subagent",
"test-engineer",
"working",
"Writing integration tests for token refresh flow",
ids.l2Researcher,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run("Write", ids.l3TestWriter);
// Depth 4: Test Engineer → Test Debugger (deepest leaf)
stmts.insertAgent.run(
ids.l4Debugger,
FIXTURES.nested.sessionId,
"Test Debugger",
"subagent",
"debugger",
"working",
"Investigating flaky assertion in token expiry test",
ids.l3TestWriter,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run("Bash", ids.l4Debugger);
// Depth 2 branch (sibling of Researcher): Code Reviewer (completed leaf)
stmts.insertAgent.run(
ids.l2Reviewer,
FIXTURES.nested.sessionId,
"Code Reviewer",
"subagent",
"code-reviewer",
"completed",
"Reviewed middleware chain for injection risks",
ids.l1Explorer,
null
);
db.prepare("UPDATE agents SET ended_at = ? WHERE id = ?").run(minutesAgo(5), ids.l2Reviewer);
// Depth 1 sibling: Architecture Planner (completed leaf)
stmts.insertAgent.run(
ids.l1Architect,
FIXTURES.nested.sessionId,
"Architecture Planner",
"subagent",
"Plan",
"completed",
"Designed auth service boundary and API contracts",
FIXTURES.nested.mainAgentId,
null
);
db.prepare("UPDATE agents SET ended_at = ? WHERE id = ?").run(
minutesAgo(12),
ids.l1Architect
);
// Depth 1 sibling: Documentation Writer (working — has its own child)
stmts.insertAgent.run(
ids.l1DocWriter,
FIXTURES.nested.sessionId,
"Documentation Writer",
"subagent",
"doc-writer",
"working",
"Writing API docs for /auth/* endpoints",
FIXTURES.nested.mainAgentId,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run("Edit", ids.l1DocWriter);
// Depth 2: Doc Writer → Example Generator (connected leaf)
stmts.insertAgent.run(
ids.l2ExampleGen,
FIXTURES.nested.sessionId,
"Example Generator",
"subagent",
"general-purpose",
"working",
"Generating cURL examples for auth endpoints",
ids.l1DocWriter,
null
);
result.inserted.push("Deep Nesting: Multi-Agent Research Pipeline (9 agents, depth 4)");
}
// Sprinkle a few events on freshly inserted fixtures only
for (const sid of FIXTURE_SESSION_IDS) {
const hasEvents =
db.prepare("SELECT 1 FROM events WHERE session_id = ? LIMIT 1").get(sid) !== undefined;
if (hasEvents) continue;
const agents = stmts.listAgentsBySession.all(sid);
const eventCount = Math.floor(Math.random() * 8) + 3;
for (let i = 0; i < eventCount; i++) {
const agent = randomItem(agents);
const eventType = randomItem(["PreToolUse", "PostToolUse", "Notification"]);
const tool = randomItem(TOOL_NAMES);
stmts.insertEvent.run(
sid,
agent?.id ?? null,
eventType,
eventType.includes("Tool") ? tool : null,
eventType === "PreToolUse"
? `Using tool: ${tool}`
: eventType === "PostToolUse"
? `Tool completed: ${tool}`
: `Agent ${agent?.name || "unknown"} notification`,
JSON.stringify({ tool_name: tool })
);
}
}
});
tx();
return result;
}
// ── Random demo data (old behavior — opt-in with --full) ───────────────────
function seedFullDemo() {
console.log("⚠️ --full mode: inserting random demo sessions on top of existing data.");
console.log(" These get fresh UUIDs each run, so re-runs accumulate. Use intentionally.\n");
const tx = db.transaction(() => {
const sessions = [];
const activeSessionId = uuidv4();
stmts.insertSession.run(
activeSessionId,
"Feature: User Authentication",
"active",
"/home/dev/my-app",
"claude-opus-4-6",
null
);
sessions.push(activeSessionId);
const activeSessionId2 = uuidv4();
stmts.insertSession.run(
activeSessionId2,
"Bug Fix: Payment Processing",
"active",
"/home/dev/payment-service",
"claude-sonnet-4-6",
null
);
sessions.push(activeSessionId2);
for (let i = 0; i < 5; i++) {
const id = uuidv4();
stmts.insertSession.run(
id,
randomItem([
"Refactor: Database Layer",
"Feature: Email Notifications",
"Fix: Memory Leak in Worker",
"Test: API Integration Suite",
"Docs: README Update",
]),
"completed",
randomItem(["/home/dev/api", "/home/dev/frontend", "/home/dev/worker"]),
randomItem(["claude-opus-4-6", "claude-sonnet-4-6"]),
null
);
db.prepare("UPDATE sessions SET ended_at = ? WHERE id = ?").run(
minutesAgo(Math.floor(Math.random() * 120)),
id
);
sessions.push(id);
}
const errSessionId = uuidv4();
stmts.insertSession.run(
errSessionId,
"Deploy: Production Release",
"error",
"/home/dev/infra",
"claude-opus-4-6",
null
);
db.prepare("UPDATE sessions SET ended_at = ? WHERE id = ?").run(minutesAgo(45), errSessionId);
sessions.push(errSessionId);
const mainAgent1 = `${activeSessionId}-main`;
stmts.insertAgent.run(
mainAgent1,
activeSessionId,
"Main Agent",
"main",
null,
"working",
"Implementing JWT authentication middleware",
null,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run("Edit", mainAgent1);
for (let i = 0; i < 3; i++) {
const subId = uuidv4();
const status = randomItem(["working", "working", "working"]);
stmts.insertAgent.run(
subId,
activeSessionId,
AGENT_NAMES[i + 1],
"subagent",
SUBAGENT_TYPES[i + 1],
status,
TASKS[i],
mainAgent1,
null
);
if (status === "working") {
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run(
randomItem(TOOL_NAMES),
subId
);
}
}
const mainAgent2 = `${activeSessionId2}-main`;
stmts.insertAgent.run(
mainAgent2,
activeSessionId2,
"Main Agent",
"main",
null,
"working",
"Investigating payment webhook failures",
null,
null
);
const sub2 = uuidv4();
stmts.insertAgent.run(
sub2,
activeSessionId2,
"Debugger",
"subagent",
"debugger",
"working",
"Tracing webhook request flow",
mainAgent2,
null
);
db.prepare("UPDATE agents SET current_tool = ? WHERE id = ?").run("Grep", sub2);
for (const sid of sessions.slice(2)) {
const mainId = `${sid}-main`;
stmts.insertAgent.run(mainId, sid, "Main Agent", "main", null, "completed", null, null, null);
db.prepare("UPDATE agents SET ended_at = ? WHERE id = ?").run(
minutesAgo(Math.floor(Math.random() * 60)),
mainId
);
const subCount = Math.floor(Math.random() * 3) + 1;
for (let i = 0; i < subCount; i++) {
const subId = uuidv4();
const name = randomItem(AGENT_NAMES.slice(1));
stmts.insertAgent.run(
subId,
sid,
name,
"subagent",
randomItem(SUBAGENT_TYPES.slice(1)),
sid === sessions[sessions.length - 1] ? "error" : "completed",
randomItem(TASKS),
mainId,
null
);
db.prepare("UPDATE agents SET ended_at = ? WHERE id = ?").run(
minutesAgo(Math.floor(Math.random() * 60)),
subId
);
}
}
for (const sid of sessions) {
const eventCount = Math.floor(Math.random() * 15) + 5;
const agents = stmts.listAgentsBySession.all(sid);
for (let i = 0; i < eventCount; i++) {
const agent = randomItem(agents);
const eventType = randomItem([
"PreToolUse",
"PostToolUse",
"PreToolUse",
"PostToolUse",
"Notification",
]);
const tool = randomItem(TOOL_NAMES);
const summary =
eventType === "PreToolUse"
? `Using tool: ${tool}`
: eventType === "PostToolUse"
? `Tool completed: ${tool}`
: `Agent ${agent?.name || "unknown"} notification`;
stmts.insertEvent.run(
sid,
agent?.id ?? null,
eventType,
eventType.includes("Tool") ? tool : null,
summary,
JSON.stringify({ tool_name: tool })
);
}
const session = stmts.getSession.get(sid);
if (session && session.status !== "active") {
stmts.insertEvent.run(
sid,
null,
"Stop",
null,
`Session ended: ${session.status}`,
JSON.stringify({ stop_reason: session.status })
);
}
}
});
tx();
}
function main() {
console.log("Seeding database (additive — existing data is preserved)...\n");
if (RESET) {
console.log("--reset: removing existing fixture rows before re-inserting.");
deleteFixtureRows();
}
const fixtureResult = seedFixtures();
if (fixtureResult.inserted.length > 0) {
console.log("Inserted fixtures:");
for (const name of fixtureResult.inserted) console.log(` + ${name}`);
}
if (fixtureResult.skipped.length > 0) {
console.log("Skipped (already present — pass --reset to recreate):");
for (const name of fixtureResult.skipped) console.log(` · ${name}`);
}
if (FULL) {
console.log("");
seedFullDemo();
}
const stats = stmts.stats.get();
console.log("");
console.log(
`Total in DB: ${stats.total_sessions} sessions, ${stats.total_agents} agents, ${stats.total_events} events.`
);
console.log("");
console.log("Test URLs:");
console.log(` Single-agent: /sessions/${FIXTURES.solo.sessionId}`);
console.log(` Waiting (reason): /sessions/${FIXTURES.waiting.sessionId}`);
console.log(` Nested (depth 4): /sessions/${FIXTURES.nested.sessionId}`);
}
main();