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
+282
View File
@@ -0,0 +1,282 @@
/**
* @file http-server.ts
* @description Implements the HTTP server transport for the MCP server, supporting both the newer Streamable HTTP protocol and the legacy SSE-based protocol. The server handles incoming requests, manages active sessions, and routes messages to the appropriate transport handlers. It also includes a health check endpoint and integrates with the MCP server instance to facilitate communication with connected clients. The module provides a shutdown function to gracefully close all active transports and the HTTP server itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## 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`.
*
* ## Internal dependencies
* - `../config/app-config.js`
* - `../core/logger.js`
* - `../ui/banner.js`
*
* ## Public surface
* - `startHttpServer` — exported API; see TSDoc on the symbol for behavior.
*
* ## 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.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **startHttpServer**
* 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.
*
* ----------------------------------------------------------------------------- */
import { randomUUID } from "node:crypto";
import type { Express, Request, Response } from "express";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { AppConfig } from "../config/app-config.js";
import type { Logger } from "../core/logger.js";
import { printBanner, printServerInfo, printReady, printShutdown } from "../ui/banner.js";
import * as c from "../ui/colors.js";
/** One tracked client connection: the underlying MCP SDK transport plus
* which protocol it speaks, so a request for a known session id can be
* rejected if it mismatches the protocol that session was initialized with. */
interface TransportEntry {
transport: Transport;
type: "streamable" | "sse";
}
/**
* Starts the HTTP transport, exposing the current Streamable HTTP protocol
* (2025-11-25) and the legacy HTTP+SSE protocol (2024-11-05) side by side on
* one Express app. Unlike stdio (one `McpServer` for the whole process),
* **every new client session gets its own freshly-built `McpServer`** via
* `buildServerFn`, isolated from other sessions but sharing the same
* {@link AppConfig}/`DashboardApiClient`.
*
* Endpoints:
* - `GET /health` — liveness/uptime/session-count probe for this MCP
* process, distinct from `dashboard_health_check` (which checks the
* dashboard itself).
* - `ALL /mcp` — Streamable HTTP: a POST `initialize` with no
* `mcp-session-id` starts a new session; later requests must carry that
* header and route to the matching transport, rejected with a JSON-RPC
* `-32000` error on a protocol mismatch.
* - `GET /sse` — legacy SSE: a long-lived stream, one `SSEServerTransport` +
* `McpServer` pair per connection.
* - `POST /messages?sessionId=...` — legacy SSE's client-to-server companion
* endpoint (SSE itself is server-to-client only).
*
* On successful bind, prints the banner/info panel/endpoint table to
* stdout — this transport owns stdout, unlike stdio's protocol stream.
* @returns The Express `app` and a `shutdown` closing every tracked
* transport before the HTTP server itself.
*/
export async function startHttpServer(
config: AppConfig,
buildServerFn: () => McpServer,
logger: Logger,
toolCount: number
): Promise<{ app: Express; shutdown: () => Promise<void> }> {
const app = createMcpExpressApp({ host: config.httpHost });
const transports = new Map<string, TransportEntry>();
// ── Health endpoint ───────────────────────────────────────────
app.get("/health", (_req: Request, res: Response) => {
res.json({
status: "ok",
server: config.serverName,
version: config.serverVersion,
transport: "http",
uptime: process.uptime(),
activeSessions: transports.size,
});
});
// ── Streamable HTTP (protocol version 2025-11-25) ─────────────
app.all("/mcp", async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
try {
if (sessionId && transports.has(sessionId)) {
const entry = transports.get(sessionId)!;
if (entry.type !== "streamable") {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Session uses a different transport protocol" },
id: null,
});
return;
}
await (entry.transport as StreamableHTTPServerTransport).handleRequest(req, res, req.body);
return;
}
if (req.method === "POST" && isInitializeRequest(req.body)) {
logger.info("New Streamable HTTP session");
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
transport.onclose = () => {
const sid = (transport as unknown as { sessionId?: string }).sessionId;
if (sid) transports.delete(sid);
logger.debug("Streamable HTTP session closed", { sessionId: sid });
};
const server = buildServerFn();
await server.connect(transport);
const sid = (transport as unknown as { sessionId?: string }).sessionId ?? randomUUID();
transports.set(sid, { transport, type: "streamable" });
await transport.handleRequest(req, res, req.body);
return;
}
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: No valid session or initialization" },
id: null,
});
} catch (err) {
logger.error("Streamable HTTP error", {
error: err instanceof Error ? err.message : String(err),
});
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
// ── Legacy SSE transport (protocol version 2024-11-05) ────────
app.get("/sse", async (_req: Request, res: Response) => {
logger.info("New SSE session");
const transport = new SSEServerTransport("/messages", res);
transports.set(transport.sessionId, { transport, type: "sse" });
res.on("close", () => {
transports.delete(transport.sessionId);
logger.debug("SSE session closed", { sessionId: transport.sessionId });
});
const server = buildServerFn();
await server.connect(transport);
});
app.post("/messages", async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string | undefined;
if (!sessionId || !transports.has(sessionId)) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "No transport found for session" },
id: null,
});
return;
}
const entry = transports.get(sessionId)!;
if (entry.type !== "sse") {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Session uses a different transport protocol" },
id: null,
});
return;
}
await (entry.transport as SSEServerTransport).handlePostMessage(req, res, req.body);
});
// ── Start listening ───────────────────────────────────────────
printBanner();
printServerInfo({
transport: "http (sse + streamable)",
version: config.serverVersion,
dashboard: config.dashboardBaseUrl.toString(),
port: config.httpPort,
mutations: config.allowMutations,
destructive: config.allowDestructive,
tools: toolCount,
});
const httpServer = await new Promise<ReturnType<Express["listen"]>>((resolve, reject) => {
const srv = app.listen(config.httpPort, config.httpHost, () => resolve(srv));
srv.on("error", reject);
});
const endpoints = [
["Streamable HTTP", `http://${config.httpHost}:${config.httpPort}/mcp`, "POST/GET/DELETE"],
["Legacy SSE", `http://${config.httpHost}:${config.httpPort}/sse`, "GET"],
["Legacy Messages", `http://${config.httpHost}:${config.httpPort}/messages`, "POST"],
["Health", `http://${config.httpHost}:${config.httpPort}/health`, "GET"],
];
process.stdout.write(` ${c.bold(c.brightCyan("◆"))} ${c.bold(c.brightWhite("Endpoints"))}\n`);
for (const [name, url, methods] of endpoints) {
process.stdout.write(
` ${c.dim(c.cyan("→"))} ${c.label(name.padEnd(20))} ${c.green(url)} ${c.muted(`[${methods}]`)}\n`
);
}
process.stdout.write("\n");
printReady("http");
// ── Shutdown ──────────────────────────────────────────────────
const shutdown = async () => {
printShutdown();
const closePromises: Promise<void>[] = [];
for (const [sid, entry] of transports) {
logger.debug("Closing transport", { sessionId: sid });
closePromises.push(
entry.transport.close?.().catch((err: unknown) => {
logger.error("Error closing transport", {
sessionId: sid,
error: err instanceof Error ? err.message : String(err),
});
}) ?? Promise.resolve()
);
}
await Promise.allSettled(closePromises);
transports.clear();
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
logger.info("HTTP server stopped");
};
return { app, shutdown };
}
+494
View File
@@ -0,0 +1,494 @@
/**
* @file repl.ts
* @description Implements a REPL (Read-Eval-Print Loop) transport for the MCP server, allowing users to interact with the dashboard API and invoke registered tools directly from the command line. The REPL provides an interactive prompt with command history and tab completion for tool names and commands. It supports built-in commands for listing tools, showing configuration, and performing health checks, as well as invoking any registered tool with JSON or key=value arguments. The REPL is designed for ease of use and quick experimentation during development or debugging sessions.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## 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`.
*
* ## Internal dependencies
* - `../config/app-config.js`
* - `../clients/dashboard-api-client.js`
* - `../core/logger.js`
* - `../ui/banner.js`
* - `../ui/formatter.js`
* - `../core/tool-registry.js`
*
* ## Public surface
* - `startRepl` — exported API; see TSDoc on the symbol for behavior.
* - `ReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
* - `createReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
*
* ## 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.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **startRepl**
* 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.
*
* **ReplToolCollector**
* 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.
*
* **createReplToolCollector**
* 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.
*
* ----------------------------------------------------------------------------- */
import * as readline from "node:readline";
import type { AppConfig } from "../config/app-config.js";
import type { DashboardApiClient } from "../clients/dashboard-api-client.js";
import type { Logger } from "../core/logger.js";
import { printBanner, printServerInfo, printShutdown } from "../ui/banner.js";
import * as c from "../ui/colors.js";
import {
formatToolResult,
formatToolError,
table,
sectionHeader,
divider,
badge,
} from "../ui/formatter.js";
import type { ToolHandler } from "../core/tool-registry.js";
/** A `ToolEntry` (`name`/`description`/`handler`) tagged with a `domain` for
* REPL display/completion only — the domain has no effect on invocation. */
interface ToolEntry {
name: string;
description: string;
handler: ToolHandler;
domain: string;
}
/** Static `dashboard_<tool> -> domain` lookup mirroring `tools/domains/*.ts`
* module boundaries, kept literal since `collectAllTools` has no domain
* concept. Must be updated by hand alongside `index.ts`'s identical copy. */
const TOOL_DOMAINS: Record<string, string> = {
dashboard_health_check: "observability",
dashboard_get_stats: "observability",
dashboard_get_analytics: "observability",
dashboard_get_system_info: "observability",
dashboard_export_data: "observability",
dashboard_get_operational_snapshot: "observability",
dashboard_list_sessions: "sessions",
dashboard_get_session: "sessions",
dashboard_create_session: "sessions",
dashboard_update_session: "sessions",
dashboard_list_agents: "agents",
dashboard_get_agent: "agents",
dashboard_create_agent: "agents",
dashboard_update_agent: "agents",
dashboard_list_events: "events",
dashboard_ingest_hook_event: "events",
dashboard_get_pricing_rules: "pricing",
dashboard_get_total_cost: "pricing",
dashboard_get_session_cost: "pricing",
dashboard_upsert_pricing_rule: "pricing",
dashboard_delete_pricing_rule: "pricing",
dashboard_reset_pricing_defaults: "pricing",
dashboard_cleanup_data: "maintenance",
dashboard_reimport_history: "maintenance",
dashboard_reinstall_hooks: "maintenance",
dashboard_clear_all_data: "maintenance",
};
const DOMAIN_COLORS: Record<string, (t: string) => string> = {
observability: c.brightCyan,
sessions: c.brightGreen,
agents: c.brightMagenta,
events: c.brightYellow,
pricing: (t: string) => c.bold(c.yellow(t)),
maintenance: c.brightRed,
};
/** Renders a `[domain]` badge in that domain's color, or muted if unknown. */
function domainBadge(domain: string): string {
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
return colorFn(`[${domain}]`);
}
/**
* Starts the interactive REPL and owns the process lifecycle from here —
* `index.ts` returns immediately, since `readline`'s `"close"` event is this
* transport's shutdown path. Unlike stdio/http, it never constructs an
* `McpServer`: `tools` (from `collectAllTools`) is a flat, directly-
* invokable handler list, so typing a tool name calls its handler
* in-process, subject to the same `AppConfig` policy flags.
*/
export async function startRepl(
config: AppConfig,
api: DashboardApiClient,
logger: Logger,
tools: ToolEntry[]
): Promise<void> {
printBanner();
printServerInfo({
transport: "repl (interactive)",
version: config.serverVersion,
dashboard: config.dashboardBaseUrl.toString(),
mutations: config.allowMutations,
destructive: config.allowDestructive,
tools: tools.length,
});
process.stdout.write(
` ${c.muted("Type")} ${c.accent("help")} ${c.muted("for commands,")} ${c.accent("tools")} ${c.muted("to list available tools,")} ${c.accent("exit")} ${c.muted("to quit.")}\n\n`
);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: ` ${c.bold(c.brightCyan("mcp"))}${c.dim(c.cyan(""))} `,
completer: (line: string) => {
const allCompletions = [
...tools.map((t) => t.name),
"help",
"tools",
"domains",
"exit",
"quit",
"clear",
"health",
"stats",
"status",
"config",
];
const hits = allCompletions.filter((cmd) => cmd.startsWith(line.trim()));
return [hits.length ? hits : allCompletions, line];
},
});
const toolMap = new Map<string, ToolEntry>();
for (const t of tools) toolMap.set(t.name, t);
rl.prompt();
rl.on("line", async (line) => {
const input = line.trim();
if (!input) {
rl.prompt();
return;
}
try {
await handleCommand(input, config, api, tools, toolMap, logger);
} catch (err) {
process.stdout.write(
` ${c.error("Error:")} ${err instanceof Error ? err.message : String(err)}\n`
);
}
rl.prompt();
});
rl.on("close", () => {
printShutdown();
process.exit(0);
});
}
/**
* Dispatches one entered line to a built-in command, or to
* {@link invokeToolByName} if it matches a known tool name. Built-ins always
* take precedence. `health`/`stats`/`status` are shortcuts invoking
* `dashboard_health_check`/`dashboard_get_stats`/
* `dashboard_get_operational_snapshot` with no arguments.
*/
async function handleCommand(
input: string,
config: AppConfig,
_api: DashboardApiClient,
tools: ToolEntry[],
toolMap: Map<string, ToolEntry>,
logger: Logger
): Promise<void> {
const [command, ...rest] = input.split(/\s+/);
const argsRaw = rest.join(" ").trim();
switch (command.toLowerCase()) {
case "help":
printHelp();
return;
case "tools":
printToolList(tools, argsRaw || undefined);
return;
case "domains":
printDomains(tools);
return;
case "health":
await invokeToolByName("dashboard_health_check", {}, toolMap, logger);
return;
case "stats":
await invokeToolByName("dashboard_get_stats", {}, toolMap, logger);
return;
case "status":
await invokeToolByName("dashboard_get_operational_snapshot", {}, toolMap, logger);
return;
case "config":
printConfig(config);
return;
case "clear":
process.stdout.write("\x1b[2J\x1b[0;0H");
return;
case "exit":
case "quit":
case "q":
printShutdown();
process.exit(0);
default:
if (toolMap.has(command)) {
const args = parseArgs(argsRaw);
await invokeToolByName(command, args, toolMap, logger);
} else {
process.stdout.write(
` ${c.warn("?")} Unknown command: ${c.bold(c.brightWhite(command))} ${c.muted("— type 'help' for available commands")}\n`
);
}
}
}
/** Invokes a tool handler by name directly (no MCP protocol), printing an
* "Invoking..." line then the formatted result/error. This is the REPL's
* own error boundary — a thrown error is caught/logged here, not converted
* to a `CallToolResult`. Args pass through unvalidated (no Zod check). */
async function invokeToolByName(
name: string,
args: Record<string, unknown>,
toolMap: Map<string, ToolEntry>,
logger: Logger
): Promise<void> {
const tool = toolMap.get(name);
if (!tool) {
process.stdout.write(` ${c.error("✘")} Tool not found: ${c.bold(name)}\n`);
return;
}
const domain = tool.domain;
process.stdout.write(
` ${c.dim(c.cyan("⟳"))} ${c.muted("Invoking")} ${c.bold(c.brightWhite(name))} ${domainBadge(domain)}${Object.keys(args).length > 0 ? " " + c.muted(JSON.stringify(args)) : ""}\n`
);
const start = performance.now();
try {
const result = await tool.handler(args);
const elapsed = Math.round(performance.now() - start);
process.stdout.write(formatToolResult(name, result, elapsed) + "\n\n");
} catch (err) {
const elapsed = Math.round(performance.now() - start);
const msg = err instanceof Error ? err.message : String(err);
logger.error("REPL tool invocation failed", { tool: name, error: msg });
process.stdout.write(formatToolError(name, msg, elapsed) + "\n\n");
}
}
/** Parses REPL tool args as a JSON object literal, or (if that fails)
* space-separated `key=value` pairs with `true`/`false`/numeric coercion.
* Not schema-aware. Empty input returns `{}`. */
function parseArgs(raw: string): Record<string, unknown> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
return {};
} catch {
// Try key=value pairs
const args: Record<string, unknown> = {};
const pairs = raw.match(/(\w+)=("(?:\\"|[^"])*"|\S+)/g);
if (pairs) {
for (const pair of pairs) {
const eqIndex = pair.indexOf("=");
const key = pair.slice(0, eqIndex);
let value: unknown = pair.slice(eqIndex + 1);
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
if (value === "true") value = true;
else if (value === "false") value = false;
else if (!isNaN(Number(value)) && value !== "") value = Number(value);
args[key] = value;
}
}
return args;
}
}
/** Prints the built-in command reference and example tool invocations. */
function printHelp(): void {
process.stdout.write(sectionHeader("Available Commands"));
const commands = [
["help", "Show this help message"],
["tools [domain]", "List tools (optionally filtered by domain)"],
["domains", "List all tool domains"],
["health", "Quick dashboard health check"],
["stats", "Dashboard overview statistics"],
["status", "Full operational snapshot"],
["config", "Show current configuration"],
["clear", "Clear the screen"],
["exit", "Quit the REPL"],
["<tool_name> [json]", "Invoke a tool with optional JSON args"],
["<tool_name> k=v ...", "Invoke a tool with key=value args"],
];
const maxCmd = Math.max(...commands.map(([cmd]) => cmd.length));
for (const [cmd, desc] of commands) {
process.stdout.write(` ${c.accent(cmd.padEnd(maxCmd + 2))} ${c.muted(desc)}\n`);
}
process.stdout.write("\n");
process.stdout.write(sectionHeader("Examples"));
process.stdout.write(` ${c.green('dashboard_list_sessions {"limit": 5}')}\n`);
process.stdout.write(` ${c.green("dashboard_get_session session_id=abc123")}\n`);
process.stdout.write(` ${c.green("dashboard_list_agents status=working limit=10")}\n\n`);
}
/** Prints a table of tools (name, domain, truncated description) for
* `tools`/`tools <domain>` (case-insensitive domain match). */
function printToolList(tools: ToolEntry[], domainFilter?: string): void {
const filtered = domainFilter
? tools.filter((t) => t.domain === domainFilter.toLowerCase())
: tools;
if (filtered.length === 0) {
process.stdout.write(
` ${c.warn("!")} No tools found${domainFilter ? ` for domain '${domainFilter}'` : ""}\n`
);
return;
}
const title = domainFilter ? `Tools — ${domainFilter}` : `All Tools (${filtered.length})`;
process.stdout.write(sectionHeader(title));
const rows = filtered.map((t) => ({
name: t.name,
domain: t.domain,
description: t.description.length > 50 ? t.description.slice(0, 47) + "..." : t.description,
}));
process.stdout.write(
table(
[
{ key: "name", label: "Tool", width: 38, color: c.brightWhite },
{
key: "domain",
label: "Domain",
width: 14,
color: (t) => {
const fn = DOMAIN_COLORS[t] ?? c.muted;
return fn(t);
},
},
{ key: "description", label: "Description", width: 52, color: c.muted },
],
rows
) + "\n\n"
);
}
/** Prints tool counts per domain (sorted) for the `domains` command. */
function printDomains(tools: ToolEntry[]): void {
const domainCounts = new Map<string, number>();
for (const t of tools) {
domainCounts.set(t.domain, (domainCounts.get(t.domain) ?? 0) + 1);
}
process.stdout.write(sectionHeader("Tool Domains"));
for (const [domain, count] of [...domainCounts.entries()].sort()) {
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
process.stdout.write(
` ${colorFn("●")} ${c.bold(c.brightWhite(domain.padEnd(18)))} ${c.muted(`${count} tools`)}\n`
);
}
process.stdout.write(
`\n ${c.muted("Use")} ${c.accent("tools <domain>")} ${c.muted("to filter by domain.")}\n\n`
);
}
/** Prints the resolved {@link AppConfig} for `config`, including the live
* Mutations/Destructive policy state (warning color when enabled). */
function printConfig(config: AppConfig): void {
process.stdout.write(sectionHeader("Configuration"));
const pairs: [string, string][] = [
["Server Name", c.brightWhite(config.serverName)],
["Version", c.brightCyan(config.serverVersion)],
["Dashboard URL", c.green(config.dashboardBaseUrl.toString())],
["Transport", c.accent(config.transport.toUpperCase())],
["Timeout", c.muted(`${config.requestTimeoutMs}ms`)],
["Retries", c.muted(String(config.retryCount))],
["Retry Backoff", c.muted(`${config.retryBackoffMs}ms`)],
["Mutations", config.allowMutations ? c.warn("ENABLED") : badge("disabled")],
["Destructive", config.allowDestructive ? c.error("ENABLED") : badge("disabled")],
["Log Level", c.muted(config.logLevel)],
];
for (const [k, v] of pairs) {
process.stdout.write(` ${c.label(k.padEnd(18))} ${v}\n`);
}
process.stdout.write("\n");
}
// ── Exported helper to collect tools from registration ────────
/** Registrar-shaped helper for building a domain-tagged {@link ToolEntry}
* list directly. Not currently wired into REPL startup — `index.ts` instead
* combines `collectAllTools` with its own copy of `TOOL_DOMAINS`. */
export interface ReplToolCollector {
tools: ToolEntry[];
register: (name: string, description: string, handler: ToolHandler) => void;
}
/** Constructs an empty {@link ReplToolCollector}, tagging each registered
* tool via {@link TOOL_DOMAINS} (falling back to `"unknown"`). */
export function createReplToolCollector(): ReplToolCollector {
const tools: ToolEntry[] = [];
return {
tools,
register(name: string, description: string, handler: ToolHandler) {
const domain = TOOL_DOMAINS[name] ?? "unknown";
tools.push({ name, description, handler, domain });
},
};
}
+355
View File
@@ -0,0 +1,355 @@
/**
* @file tool-collector.ts
* @description This module defines the collectAllTools function, which is responsible for collecting and registering all tool handlers available in the MCP application. The function takes the application configuration, a dashboard API client, and a logger as arguments, and returns an array of ToolEntry objects representing each registered tool. The tools cover various domains such as observability, session management, agent management, event handling, pricing, and maintenance. This collector is used in REPL mode to allow direct invocation of tools without requiring an MCP Server instance.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## 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`.
*
* ## Internal dependencies
* - `../config/app-config.js`
* - `../clients/dashboard-api-client.js`
* - `../core/logger.js`
* - `../core/tool-registry.js`
* - `../policy/tool-guards.js`
* - `../tools/schemas.js`
*
* ## Public surface
* - `collectAllTools` — exported API; see TSDoc on the symbol for behavior.
*
* ## 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.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **collectAllTools**
* 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.
*
* ----------------------------------------------------------------------------- */
import type { AppConfig } from "../config/app-config.js";
import type { DashboardApiClient } from "../clients/dashboard-api-client.js";
import type { Logger } from "../core/logger.js";
import {
type ToolEntry,
createCollectorRegistrar,
type ToolHandler,
} from "../core/tool-registry.js";
import { assertMutationsEnabled, assertDestructiveEnabled } from "../policy/tool-guards.js";
import { z } from "zod";
import {
SessionStatusSchema,
AgentStatusSchema,
HookTypeSchema,
JsonObjectSchema,
} from "../tools/schemas.js";
/**
* Collect all tool handlers without requiring an MCP Server instance.
* Used by REPL mode to invoke tools directly.
*
* A hand-maintained, server-less mirror of `tools/index.ts`'s
* `registerAllTools`/`tools/domains/*.ts`: it re-declares the same 29
* `dashboard_*` tools using {@link createCollectorRegistrar} instead of
* {@link createToolRegistrar}, so no `McpServer` or MCP protocol overhead is
* needed — the REPL calls handlers directly and renders results with its
* own formatter. Since this duplicates rather than imports the domain
* modules' definitions, a change to a tool's args/defaults/endpoint must be
* mirrored here by hand. `index.ts`'s HTTP startup also calls this once,
* purely for an accurate startup-banner tool count — each HTTP/SSE session
* still gets its own protocol-registered tools via `buildServer`.
* @param logger Unused here — {@link createCollectorRegistrar} doesn't wrap
* handlers in logging, so errors propagate as real exceptions to the REPL.
*/
export function collectAllTools(
config: AppConfig,
api: DashboardApiClient,
logger: Logger
): ToolEntry[] {
const tools: ToolEntry[] = [];
const register = createCollectorRegistrar(tools);
// ── Observability ───────────────────────────────────────────
register(
"dashboard_health_check",
"Check health of the local Agent Dashboard API.",
{},
async () => api.get("/api/health")
);
register("dashboard_get_stats", "Get dashboard overview stats.", {}, async () =>
api.get("/api/stats")
);
register("dashboard_get_analytics", "Get analytics summary.", {}, async () =>
api.get("/api/analytics")
);
register("dashboard_get_system_info", "Get system info, DB stats, hook status.", {}, async () =>
api.get("/api/settings/info")
);
register("dashboard_export_data", "Export complete dashboard data payload.", {}, async () =>
api.get("/api/settings/export")
);
register(
"dashboard_get_operational_snapshot",
"High-signal operational snapshot.",
{
recent_events_limit: z.number().int().min(1).max(50).optional(),
active_sessions_limit: z.number().int().min(1).max(100).optional(),
active_agents_limit: z.number().int().min(1).max(200).optional(),
},
async (args) => {
const eventsLimit = (args.recent_events_limit as number | undefined) ?? 20;
const sessionsLimit = (args.active_sessions_limit as number | undefined) ?? 25;
const agentsLimit = (args.active_agents_limit as number | undefined) ?? 100;
const [stats, analytics, recentEvents, activeSessions, workingAgents, connectedAgents] =
await Promise.all([
api.get("/api/stats"),
api.get("/api/analytics"),
api.get("/api/events", { query: { limit: eventsLimit, offset: 0 } }),
api.get("/api/sessions", {
query: { status: "active", limit: sessionsLimit, offset: 0 },
}),
api.get("/api/agents", { query: { status: "working", limit: agentsLimit, offset: 0 } }),
api.get("/api/agents", { query: { status: "connected", limit: agentsLimit, offset: 0 } }),
]);
return {
stats,
analytics,
recent_events: recentEvents,
active_sessions: activeSessions,
active_agents: { working: workingAgents, connected: connectedAgents },
generated_at: new Date().toISOString(),
};
}
);
// ── Sessions ────────────────────────────────────────────────
register("dashboard_list_sessions", "List sessions with optional filter.", {}, async (args) => {
return api.get("/api/sessions", {
query: {
limit: (args.limit as number) ?? 50,
offset: (args.offset as number) ?? 0,
status: args.status as string | undefined,
},
});
});
register("dashboard_get_session", "Get one session with agents and events.", {}, async (args) => {
return api.get(`/api/sessions/${encodeURIComponent(args.session_id as string)}`);
});
register("dashboard_create_session", "Create a new session record.", {}, async (args) => {
assertMutationsEnabled(config);
return api.post("/api/sessions", {
body: {
id: args.id,
name: args.name,
cwd: args.cwd,
model: args.model,
metadata: args.metadata,
},
});
});
register("dashboard_update_session", "Update session metadata or status.", {}, async (args) => {
assertMutationsEnabled(config);
return api.patch(`/api/sessions/${encodeURIComponent(args.session_id as string)}`, {
body: {
name: args.name,
status: args.status,
ended_at: args.ended_at,
metadata: args.metadata,
},
});
});
// ── Agents ──────────────────────────────────────────────────
register("dashboard_list_agents", "List agents with filters.", {}, async (args) => {
return api.get("/api/agents", {
query: {
limit: (args.limit as number) ?? 50,
offset: (args.offset as number) ?? 0,
status: args.status as string | undefined,
session_id: args.session_id as string | undefined,
},
});
});
register("dashboard_get_agent", "Get a single agent by ID.", {}, async (args) => {
return api.get(`/api/agents/${encodeURIComponent(args.agent_id as string)}`);
});
register("dashboard_create_agent", "Create a new agent in a session.", {}, async (args) => {
assertMutationsEnabled(config);
return api.post("/api/agents", {
body: {
id: args.id,
session_id: args.session_id,
name: args.name,
type: args.type,
subagent_type: args.subagent_type,
status: args.status,
task: args.task,
parent_agent_id: args.parent_agent_id,
metadata: args.metadata,
},
});
});
register("dashboard_update_agent", "Update agent lifecycle state.", {}, async (args) => {
assertMutationsEnabled(config);
return api.patch(`/api/agents/${encodeURIComponent(args.agent_id as string)}`, {
body: {
name: args.name,
status: args.status,
task: args.task,
current_tool: args.current_tool,
ended_at: args.ended_at,
metadata: args.metadata,
},
});
});
// ── Events ──────────────────────────────────────────────────
register(
"dashboard_list_events",
"List events with optional session filter.",
{},
async (args) => {
return api.get("/api/events", {
query: {
limit: (args.limit as number) ?? 50,
offset: (args.offset as number) ?? 0,
session_id: args.session_id as string | undefined,
},
});
}
);
register("dashboard_ingest_hook_event", "Ingest a Claude Code hook event.", {}, async (args) => {
assertMutationsEnabled(config);
return api.post("/api/hooks/event", { body: { hook_type: args.hook_type, data: args.data } });
});
// ── Pricing ─────────────────────────────────────────────────
register("dashboard_get_pricing_rules", "List all model pricing rules.", {}, async () =>
api.get("/api/pricing")
);
register("dashboard_get_total_cost", "Get total usage cost.", {}, async () =>
api.get("/api/pricing/cost")
);
register(
"dashboard_get_session_cost",
"Get cost breakdown for one session.",
{},
async (args) => {
return api.get(`/api/pricing/cost/${encodeURIComponent(args.session_id as string)}`);
}
);
register(
"dashboard_upsert_pricing_rule",
"Create or update a pricing rule.",
{},
async (args) => {
assertMutationsEnabled(config);
return api.put("/api/pricing", {
body: {
model_pattern: args.model_pattern,
display_name: args.display_name,
input_per_mtok: args.input_per_mtok ?? 0,
output_per_mtok: args.output_per_mtok ?? 0,
cache_read_per_mtok: args.cache_read_per_mtok ?? 0,
cache_write_per_mtok: args.cache_write_per_mtok ?? 0,
},
});
}
);
register("dashboard_delete_pricing_rule", "Delete one pricing rule.", {}, async (args) => {
assertMutationsEnabled(config);
return api.delete(`/api/pricing/${encodeURIComponent(args.model_pattern as string)}`);
});
register("dashboard_reset_pricing_defaults", "Reset pricing rules to defaults.", {}, async () => {
assertMutationsEnabled(config);
return api.post("/api/settings/reset-pricing");
});
// ── Maintenance ─────────────────────────────────────────────
register(
"dashboard_cleanup_data",
"Abandon stale sessions or purge old data.",
{},
async (args) => {
assertMutationsEnabled(config);
const abandonHours = args.abandon_hours as number | undefined;
const purgeDays = args.purge_days as number | undefined;
if (!abandonHours && !purgeDays)
throw new Error("At least one of abandon_hours or purge_days is required.");
return api.post("/api/settings/cleanup", {
body: { abandon_hours: abandonHours, purge_days: purgeDays },
});
}
);
register("dashboard_reimport_history", "Re-import legacy Claude sessions.", {}, async () => {
assertMutationsEnabled(config);
return api.post("/api/settings/reimport");
});
register("dashboard_reinstall_hooks", "Reinstall Claude Code hooks.", {}, async () => {
assertMutationsEnabled(config);
return api.post("/api/settings/reinstall-hooks");
});
register("dashboard_clear_all_data", "Delete all data. Highly destructive.", {}, async (args) => {
assertDestructiveEnabled(config, args.confirmation_token as string);
return api.post("/api/settings/clear-data");
});
// ── Remote Data Sources ─────────────────────────────────────
register(
"dashboard_list_remote_sources",
"List configured Remote Data Sources (SSH machines).",
{},
async () => api.get("/api/remote-sources")
);
register(
"dashboard_sync_remote_source",
"Trigger an immediate SSH pull+import for one Remote Data Source.",
{
source_id: z.string().min(1),
},
async (args) => {
assertMutationsEnabled(config);
return api.post(`/api/remote-sources/${encodeURIComponent(args.source_id as string)}/sync`);
}
);
register(
"dashboard_sync_all_remote_sources",
"Trigger an immediate SSH pull+import for every enabled Remote Data Source.",
{},
async () => {
assertMutationsEnabled(config);
return api.post("/api/remote-sources/sync-all");
}
);
return tools;
}