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:
@@ -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
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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))
|
||||
@@ -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();
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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();
|
||||
Reference in New Issue
Block a user