215cae7393
Analytics parity, phase 1 (data hygiene), from the analytics-parity session. events.data is now stored through the SQL function trim_event_data(?) (server/lib/event-payload.js, registered in server/db.js) on every event INSERT and in the duplicate guard: Edit/MultiEdit/NotebookEdit/Write drop the whole-file mirrors (originalFile, oldString/newString, Write content), Read drops file.content/base64, and any string in tool_input/tool_response over 8 KB keeps a 6 KB head + 2 KB tail. structuredPatch and every field outside those two stay whole; _trimmed names what was dropped. The full text stays in the transcript and its snapshot. Rows stored before this are trimmed once in the background (sweepEventPayloads, 200 rows per write transaction, user_version 4). No VACUUM, no row pruned. The session list now prices every token column /api/pricing/cost/:id does (speed, geo, tier, the 1h cache write, server-tool fees) through one tokensForSessions helper, so a row's cost equals the session's own.
1243 lines
52 KiB
JavaScript
1243 lines
52 KiB
JavaScript
/**
|
|
* @file Sets up the Express server with API routes and WebSocket, serves the React client in production, and includes periodic maintenance tasks like session cleanup and compaction scanning.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
|
|
|
// A server started from inside a Claude Code session (the bootstrap fallback,
|
|
// always the case on macOS) inherits that session's own variables, and tmux
|
|
// hands them to every lane pane — whose `claude` then believes it is nested.
|
|
// ponytail: a DENYLIST of parent-session variables only, so user config
|
|
// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through;
|
|
// it must grow when Claude Code adds a new per-session variable.
|
|
const PARENT_SESSION_VARS =
|
|
/^(CLAUDECODE|AI_AGENT|CLAUDE_PID|CLAUDE_PROJECT_DIR|CLAUDE_ENV_FILE|CLAUDE_PLUGIN_(ROOT|DATA)|CLAUDE_CODE_(ENTRYPOINT|SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|EXECPATH|MESSAGING_.*))$/;
|
|
for (const k of Object.keys(process.env)) {
|
|
if (PARENT_SESSION_VARS.test(k)) delete process.env[k];
|
|
}
|
|
// Claude Code sets GIT_EDITOR=true so its own commits never block; a user's
|
|
// real editor setting is anything else and stays.
|
|
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
|
|
|
|
// Load the repo `.env` (simple key=value), then persisted settings. The real
|
|
// environment always wins, then a checkout's own `.env`; a dashboard-saved
|
|
// value (CLAUDE_HOME, CODEX_HOME, the pinned DASHBOARD_DATA_DIR) lives in the
|
|
// settings file, outside the plugin cache an update replaces — see
|
|
// server/lib/settings-env.js. Only when this
|
|
// file IS the process: the test suite requires it dozens of times, and a
|
|
// developer's real settings file pins DASHBOARD_DATA_DIR at their live database.
|
|
if (require.main === module) {
|
|
const settingsEnv = require("./lib/settings-env");
|
|
settingsEnv.loadEnvFiles({
|
|
owned: [require("path").resolve(__dirname, "..", ".env")],
|
|
});
|
|
}
|
|
|
|
// Carry a pre-rebrand `~/.claude/agent-dashboard` over to `~/.claude/agenthub`.
|
|
//
|
|
// Placement is load-bearing in two directions. It must run before the router
|
|
// requires below, because those pull in db.js, which resolves its path and opens
|
|
// SQLite at module load — migrating after that would move the database out from
|
|
// under a live handle. And it must run only when this file *is* the process, not
|
|
// whenever it is required: the test suite imports it dozens of times, and an
|
|
// unguarded call would relocate the developer's real database as a side effect
|
|
// of running `npm test`. It sits after the .env loader so an explicit
|
|
// DASHBOARD_DATA_DIR still suppresses it.
|
|
if (require.main === module) require("./lib/claude-home").migrateLegacyDataDir();
|
|
|
|
const express = require("express");
|
|
const cors = require("cors");
|
|
const path = require("path");
|
|
const http = require("http");
|
|
const swaggerUi = require("swagger-ui-express");
|
|
const { initWebSocket, initPtyWebSocket } = require("./websocket");
|
|
const { createOpenApiSpec } = require("./openapi");
|
|
const { redocBundlePath, renderRedocHtml } = require("./lib/redoc");
|
|
const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./lib/server-info");
|
|
const { getDataDir } = require("./lib/claude-home");
|
|
const {
|
|
resolveHost,
|
|
isLoopbackHostname,
|
|
corsOptions,
|
|
hostGuard,
|
|
tokenGuard,
|
|
getDashboardToken,
|
|
} = require("./lib/security");
|
|
|
|
const sessionsRouter = require("./routes/sessions");
|
|
const agentsRouter = require("./routes/agents");
|
|
const eventsRouter = require("./routes/events");
|
|
const statsRouter = require("./routes/stats");
|
|
const hooksRouter = require("./routes/hooks");
|
|
const analyticsRouter = require("./routes/analytics");
|
|
const pricingRouter = require("./routes/pricing");
|
|
const settingsRouter = require("./routes/settings");
|
|
const workflowsRouter = require("./routes/workflows");
|
|
const pushRouter = require("./routes/push");
|
|
const importRouter = require("./routes/import");
|
|
const updatesRouter = require("./routes/updates");
|
|
const ccConfigRouter = require("./routes/cc-config");
|
|
const codexConfigRouter = require("./routes/codex-config");
|
|
const aiRoutersRouter = require("./routes/ai-routers");
|
|
const gitCredentialsRouter = require("./routes/git-credentials");
|
|
const aiToolsRouter = require("./routes/ai-tools");
|
|
const runRouter = require("./routes/run");
|
|
const alertsRouter = require("./routes/alerts");
|
|
const webhooksRouter = require("./routes/webhooks");
|
|
const remoteSourcesRouter = require("./routes/remote-sources");
|
|
const metricsRouter = require("./routes/metrics");
|
|
const lanesRouter = require("./routes/lanes");
|
|
const projectsRouter = require("./routes/projects");
|
|
const laneSourcesRouter = require("./routes/lane-sources");
|
|
const locksRouter = require("./routes/locks");
|
|
const petsRouter = require("./routes/pets");
|
|
|
|
const APP_VERSION = (() => {
|
|
try {
|
|
return require("../package.json").version || "0.0.0";
|
|
} catch {
|
|
return "0.0.0";
|
|
}
|
|
})();
|
|
|
|
function createApp() {
|
|
const app = express();
|
|
const openApiSpec = createOpenApiSpec();
|
|
|
|
// Security hardening (GHSA-gr74-4xfh-6jw9): loopback-only CORS, a Host-header
|
|
// allowlist (anti DNS-rebinding), and an optional bearer-token gate on /api/*.
|
|
app.use(cors(corsOptions()));
|
|
app.use(hostGuard);
|
|
app.use(express.json({ limit: "1mb" }));
|
|
app.use("/api", tokenGuard);
|
|
|
|
app.use("/api/sessions", sessionsRouter);
|
|
app.use("/api/agents", agentsRouter);
|
|
app.use("/api/events", eventsRouter);
|
|
app.use("/api/stats", statsRouter);
|
|
app.use("/api/hooks", hooksRouter);
|
|
app.use("/api/analytics", analyticsRouter);
|
|
app.use("/api/pricing", pricingRouter);
|
|
app.use("/api/settings", settingsRouter);
|
|
app.use("/api/workflows", workflowsRouter);
|
|
app.use("/api/push", pushRouter);
|
|
app.use("/api/import", importRouter);
|
|
app.use("/api/updates", updatesRouter);
|
|
app.use("/api/cc-config", ccConfigRouter);
|
|
app.use("/api/codex-config", codexConfigRouter);
|
|
app.use("/api/ai-routers", aiRoutersRouter);
|
|
app.use("/api/git-credentials", gitCredentialsRouter);
|
|
app.use("/api/ai-tools", aiToolsRouter);
|
|
app.use("/api/run", runRouter);
|
|
// Before the lanes router, whose `/:id/:action` would otherwise see these.
|
|
app.use("/api/lanes", laneSourcesRouter);
|
|
app.use("/api/lanes", lanesRouter);
|
|
app.use("/api/projects", projectsRouter);
|
|
app.use("/api/locks", locksRouter);
|
|
app.use("/api/pets", petsRouter);
|
|
app.use("/api/alerts", alertsRouter);
|
|
app.use("/api/webhooks", webhooksRouter);
|
|
app.use("/api/remote-sources", remoteSourcesRouter);
|
|
app.use("/api/metrics", metricsRouter);
|
|
app.get("/api/openapi.json", (_req, res) => {
|
|
res.json(openApiSpec);
|
|
});
|
|
app.use(
|
|
"/api/docs",
|
|
swaggerUi.serve,
|
|
swaggerUi.setup(openApiSpec, {
|
|
customSiteTitle: "Agent Dashboard API Docs",
|
|
})
|
|
);
|
|
|
|
// ReDoc — a read-optimized, three-panel rendering of the same OpenAPI spec
|
|
// (complements Swagger UI's interactive console at /api/docs). The bundle is
|
|
// served from node_modules, never a CDN, so the reference works offline.
|
|
app.get("/api/redoc/redoc.standalone.js", (_req, res) => {
|
|
res.sendFile(redocBundlePath(), (err) => {
|
|
if (err && !res.headersSent) res.status(500).end();
|
|
});
|
|
});
|
|
app.get("/api/redoc", (_req, res) => {
|
|
res
|
|
.type("html")
|
|
.send(
|
|
renderRedocHtml(
|
|
"/api/openapi.json",
|
|
"/api/redoc/redoc.standalone.js",
|
|
"Agent Dashboard API Reference"
|
|
)
|
|
);
|
|
});
|
|
|
|
app.get("/api/health", (_req, res) => {
|
|
res.json({ status: "ok", version: APP_VERSION, timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
function startServer(app, port) {
|
|
const server = http.createServer(app);
|
|
initWebSocket(server);
|
|
initPtyWebSocket(server);
|
|
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
if (isProduction) {
|
|
// The bundle normally sits in the checkout. A plugin install runs the
|
|
// server out of a read-only plugin cache directory, so DASHBOARD_CLIENT_DIST
|
|
// points it at the writable runtime dir instead (see
|
|
// scripts/plugin-bootstrap.js). A missing directory is harmless: the API
|
|
// serves fine and only the UI route 404s until the bundle is built.
|
|
const clientDist =
|
|
process.env.DASHBOARD_CLIENT_DIST || path.join(__dirname, "..", "client", "dist");
|
|
// Cache policy designed to survive client rebuilds without forcing a hard
|
|
// refresh:
|
|
// - Hashed bundles under /assets/ never change for a given URL, so cache
|
|
// them aggressively (immutable).
|
|
// - index.html, /sw.js, and /manifest.json *are* the cache-bust signal,
|
|
// so they must revalidate every load — without this the browser's
|
|
// heuristic cache happily serves a stale index.html that references
|
|
// asset hashes that no longer exist on disk.
|
|
app.use(
|
|
express.static(clientDist, {
|
|
etag: true,
|
|
lastModified: true,
|
|
setHeaders(res, filePath) {
|
|
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
return;
|
|
}
|
|
const base = path.basename(filePath);
|
|
if (base === "index.html" || base === "sw.js" || base === "manifest.json") {
|
|
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
|
return;
|
|
}
|
|
// Other static files (favicon, og-image, etc.): short revalidation
|
|
// window — long enough to be friendly, short enough to recover from
|
|
// a typo without telling users to hard-refresh.
|
|
res.setHeader("Cache-Control", "public, max-age=300, must-revalidate");
|
|
},
|
|
})
|
|
);
|
|
app.get("*", (_req, res) => {
|
|
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
|
res.sendFile(path.join(clientDist, "index.html"));
|
|
});
|
|
}
|
|
|
|
// Bind to loopback by default so the dashboard is not network-reachable out
|
|
// of the box (GHSA-gr74-4xfh-6jw9). Operators opt into a wider bind with
|
|
// DASHBOARD_HOST=0.0.0.0 — and are warned to set DASHBOARD_TOKEN when they do.
|
|
const host = resolveHost();
|
|
const boundLoopback = isLoopbackHostname(host);
|
|
|
|
return new Promise((resolve) => {
|
|
server.listen(port, host, () => {
|
|
// Publish the live port so the Claude Code hook handler can find this
|
|
// server even when it bound a non-default port (the desktop app falls
|
|
// back off 4820 when that port is already taken).
|
|
writeServerInfo(port);
|
|
const sharedDbPeers = peersSharingDataDir();
|
|
if (sharedDbPeers.length > 0) {
|
|
const peerPorts = sharedDbPeers.map((p) => p.port).join(", ");
|
|
const ingestPort = Math.min(port, ...sharedDbPeers.map((p) => p.port));
|
|
console.warn(
|
|
`⚠️ Another dashboard is running on port(s) ${peerPorts} using the same database ` +
|
|
`(${getDataDir()}). Hooks ingest through port ${ingestPort} only to avoid duplicate events. ` +
|
|
`Stop extra instances if you do not need them.`
|
|
);
|
|
}
|
|
const mode = isProduction ? "production" : "development";
|
|
const shown = boundLoopback ? "localhost" : host;
|
|
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
|
|
if (!boundLoopback) {
|
|
console.warn(
|
|
`⚠️ Dashboard bound to ${host} — reachable from the network. ` +
|
|
(getDashboardToken()
|
|
? "DASHBOARD_TOKEN is set (API + WebSocket require it)."
|
|
: "Set DASHBOARD_TOKEN to require auth, or it is OPEN to anyone who can reach this port.")
|
|
);
|
|
}
|
|
if (!isProduction) {
|
|
console.log(`Client dev server expected at http://localhost:5173`);
|
|
}
|
|
resolve(server);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* One-time bootstrap import of legacy Claude Code sessions from `~/.claude/`.
|
|
*
|
|
* Runs at most once per data directory, tracked by a `.legacy-import.done`
|
|
* marker file written next to the database. A marker — rather than an "is the
|
|
* DB empty?" check — is essential: the desktop app captures a live session via
|
|
* hooks before the user ever thinks about history, so an emptiness check would
|
|
* see a non-empty DB and skip the backfill forever, leaving every pre-existing
|
|
* session missing from the dashboard. The import itself is idempotent
|
|
* (per-session dedup), so running it against a DB that already holds some
|
|
* sessions simply adds the missing ones.
|
|
*
|
|
* Fire-and-forget — the server does not await it. It lives in its own function
|
|
* (rather than inline in the `require.main` block, where it used to sit) so
|
|
* embedded hosts that call `startBackgroundServices()` — notably the desktop
|
|
* app — get the same first-launch backfill instead of an empty dashboard.
|
|
*/
|
|
function autoImportLegacySessions() {
|
|
try {
|
|
const fs = require("fs");
|
|
const dbModule = require("./db");
|
|
const markerPath = path.join(path.dirname(dbModule.DB_PATH), ".legacy-import.done");
|
|
if (fs.existsSync(markerPath)) return;
|
|
|
|
const { importAllSessions, backfillCompactions } = require("../scripts/import-history");
|
|
importAllSessions(dbModule)
|
|
.then(({ imported, errors }) => {
|
|
if (imported > 0) console.log(`Imported ${imported} legacy sessions from ~/.claude/`);
|
|
if (errors > 0) console.log(`${errors} session files had errors during import`);
|
|
})
|
|
.then(() => backfillCompactions(dbModule))
|
|
.then(({ backfilled }) => {
|
|
if (backfilled > 0)
|
|
console.log(`Backfilled ${backfilled} compaction events from ~/.claude/`);
|
|
})
|
|
// Backfill Workflow-tool run journals (issue #167) for all imported
|
|
// sessions. Inner agents emit no hooks, so this on-disk scan is the only
|
|
// way historical workflows surface.
|
|
.then(() => require("./lib/workflow-ingest").ingestAllWorkflows(dbModule))
|
|
.then(({ workflows }) => {
|
|
if (workflows > 0) console.log(`Backfilled ${workflows} workflow run(s) from ~/.claude/`);
|
|
})
|
|
// Write the marker only after the import completes, so a crash mid-import
|
|
// retries on the next start instead of being skipped forever.
|
|
.then(() => {
|
|
try {
|
|
fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`);
|
|
} catch {
|
|
/* non-fatal — worst case the (idempotent) import re-runs next start */
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
} catch (err) {
|
|
console.warn("legacy session auto-import failed:", err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the background services the dashboard relies on once the HTTP server
|
|
* is listening: a one-time legacy-session import, the upstream update
|
|
* scheduler, the Claude Code config watcher, and a one-time reconciliation of
|
|
* orphaned run rows.
|
|
*
|
|
* Exported so alternative hosts can bring up the same services the standalone
|
|
* `node server/index.js` path does. The desktop Electron shell `require()`s
|
|
* this module instead of running it as the main entry, so the
|
|
* `require.main === module` block below never executes for it.
|
|
*/
|
|
function startBackgroundServices() {
|
|
// One-time legacy-session backfill (a no-op once its marker file exists).
|
|
autoImportLegacySessions();
|
|
|
|
// The service's PATH is whatever its unit captured at install time; read the
|
|
// login shell's so a CLI installed since is detected and reaches lane panes.
|
|
require("./lib/shell-path")
|
|
.hydrateShellPath()
|
|
.then(() => require("./lib/ai-tools").refresh())
|
|
.catch(() => {});
|
|
|
|
// Hourly DB upkeep: a non-blocking checkpoint (the WAL then shrinks to
|
|
// journal_size_limit) and PRAGMA optimize. Off with DASHBOARD_DB_MAINTAIN_MS=0.
|
|
{
|
|
const every = Number(process.env.DASHBOARD_DB_MAINTAIN_MS ?? 60 * 60 * 1000);
|
|
if (every > 0) {
|
|
const t = setInterval(() => require("./db").maintainDatabase(), every);
|
|
if (t.unref) t.unref();
|
|
}
|
|
}
|
|
|
|
// Rates for models seen in token_usage with no pricing row of their own.
|
|
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
|
// The AI Tools catalog of the newest release, when it is newer than this build.
|
|
require("./lib/ai-tools-sync").startAiToolsSync();
|
|
|
|
// A provisioning job belongs to the previous Node process and cannot survive
|
|
// a restart. Recover those rows now so the dashboard never shows a permanent
|
|
// provisioning spinner for work that was interrupted by the restart.
|
|
try {
|
|
const { recoverInterruptedProvisioning } = require("./lib/lanes");
|
|
const recovered = recoverInterruptedProvisioning();
|
|
if (recovered > 0) {
|
|
console.log(`[lanes] recovered ${recovered} interrupted provisioning lane(s) → failed`);
|
|
}
|
|
} catch (err) {
|
|
console.warn("lane provisioning recovery failed:", err.message);
|
|
}
|
|
|
|
// Boot liveness reap. When the user quit Claude Code while the dashboard
|
|
// was DOWN, the SessionEnd hook was lost and only the process probe can
|
|
// tell the session is dead — without this, such sessions sit in Waiting
|
|
// until a watchdog tick. Two passes, both fail-safe and off the startup
|
|
// critical path:
|
|
// 1. Immediately (next tick): reaps dead sessions ALREADY in the DB from
|
|
// a previous dashboard run — the common "app was up, app stopped,
|
|
// session quit, app starts" flow — so they never render as Waiting at
|
|
// all.
|
|
// 2. ~5 s later: reaps sessions the startup project sync just IMPORTED
|
|
// (rows that didn't exist at boot). The 15 s watchdog remains the
|
|
// safety net for anything later (kill -9 / crashes fire no SessionEnd
|
|
// either), and its probe is skipped whenever no active session
|
|
// qualifies, so the steady-state cost is nil.
|
|
// Both boot passes run with ignoreIdleGate: at boot the probe alone is the
|
|
// truth — a session quit even ONE second before launch must clear
|
|
// immediately, not after the LIVENESS_IDLE_SECONDS gate ages out (the gate
|
|
// exists to protect long-running steady-state work on watchdog ticks, and
|
|
// there is no in-flight work at boot).
|
|
{
|
|
const bootReap = (label) => {
|
|
try {
|
|
require("./routes/hooks").livenessReap({ ignoreIdleGate: true });
|
|
} catch (err) {
|
|
console.warn(`${label} liveness reap failed:`, err?.message || err);
|
|
}
|
|
};
|
|
setImmediate(() => bootReap("boot"));
|
|
const t = setTimeout(() => bootReap("post-import"), 5_000);
|
|
if (t.unref) t.unref();
|
|
}
|
|
|
|
// Backfill per-agent token metadata onto subagent rows that predate per-agent
|
|
// cost tracking, so their cards show their own cost instead of nothing. Runs
|
|
// deferred and non-blocking; self-limiting (rows with a tokens key are
|
|
// skipped), and metadata-only (never touches session token_usage).
|
|
{
|
|
const dbModule = require("./db");
|
|
const { backfillSubagentTokenMetadata } = require("../scripts/import-history");
|
|
const t = setTimeout(() => {
|
|
Promise.resolve()
|
|
.then(() => backfillSubagentTokenMetadata(dbModule))
|
|
.then((r) => {
|
|
if (r && r.stamped > 0)
|
|
console.log(
|
|
`Backfilled per-agent token cost for ${r.stamped} subagent(s) across ${r.sessions} session(s)`
|
|
);
|
|
})
|
|
.catch((err) => console.warn("subagent token backfill failed:", err?.message || err));
|
|
}, 500);
|
|
if (t.unref) t.unref();
|
|
}
|
|
|
|
// One-time repair (user_version 3) of two parser bugs fixed in v1.6.16:
|
|
//
|
|
// 1. The transcript parser summed the `usage` of EVERY JSONL line of an
|
|
// assistant message, and Claude Code writes one line per content block —
|
|
// each repeating the same message-level totals. Stored tokens (and cost)
|
|
// ran ~1.5-2x high, and `replaceTokenUsage`'s high-water baselines mean
|
|
// the fixed parser alone can never bring them down: only the exact-write
|
|
// reconcile can.
|
|
// 2. Every `TurnDuration` row was stamped NOW while its dedup guard matched
|
|
// on the transcript timestamp, so each hook re-inserted the session's
|
|
// whole turn history — 75% of a typical events table.
|
|
//
|
|
// Marker 3 and not 2: an install that ran the v1.6.15 pass had its corrected
|
|
// rows immediately re-inflated by whatever old build was still ingesting
|
|
// hooks, so that marker cannot be trusted to mean "already correct".
|
|
// Deferred and non-blocking; the marker is written only on success, so a
|
|
// crash mid-run just retries on the next start.
|
|
{
|
|
const dbModule = require("./db");
|
|
if (dbModule.db.pragma("user_version", { simple: true }) < 3) {
|
|
const { reconcileTokens } = require("../scripts/import-history");
|
|
const t = setTimeout(() => {
|
|
Promise.resolve()
|
|
.then(() => {
|
|
// Duplicates differ only by their insertion time, so the earliest
|
|
// row of each (session, payload) group is the one real turn.
|
|
const del = dbModule.db
|
|
.prepare(
|
|
`DELETE FROM events WHERE event_type = 'TurnDuration' AND id NOT IN (
|
|
SELECT MIN(id) FROM events WHERE event_type = 'TurnDuration'
|
|
GROUP BY session_id, data
|
|
)`
|
|
)
|
|
.run();
|
|
if (del.changes > 0) {
|
|
console.log(`Removed ${del.changes} duplicated TurnDuration event(s)`);
|
|
}
|
|
})
|
|
.then(() => reconcileTokens(dbModule))
|
|
.then((c) => {
|
|
console.log(
|
|
`Token re-parse: corrected ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
|
|
);
|
|
dbModule.db.pragma("user_version = 3");
|
|
})
|
|
.catch((err) => console.warn("token re-parse failed:", err?.message || err));
|
|
}, 1_000);
|
|
if (t.unref) t.unref();
|
|
}
|
|
}
|
|
|
|
// One-time (user_version 4) trim of the hook payloads stored before
|
|
// `trim_event_data` existed — see `server/lib/event-payload.js`. Waits for
|
|
// marker 3, so it never claims a DB whose token repair has not run yet.
|
|
{
|
|
const dbModule = require("./db");
|
|
if (dbModule.db.pragma("user_version", { simple: true }) === 3) {
|
|
const t = setTimeout(() => {
|
|
require("./lib/event-payload")
|
|
.sweepEventPayloads(dbModule.db)
|
|
.then((r) => {
|
|
if (r.rows > 0) {
|
|
console.log(
|
|
`Trimmed ${r.rows} stored event payload(s), ${(r.bytesSaved / 1048576).toFixed(0)} MB`
|
|
);
|
|
}
|
|
dbModule.db.pragma("user_version = 4");
|
|
})
|
|
.catch((err) => console.warn("event payload trim failed:", err?.message || err));
|
|
}, 5_000);
|
|
if (t.unref) t.unref();
|
|
}
|
|
}
|
|
|
|
// Per-hour tokens for sessions recorded before `token_usage_hourly`, so the
|
|
// daily cost split covers them too. Picks only sessions with no hourly rows,
|
|
// so a later start re-reads nothing already done; non-blocking.
|
|
{
|
|
const t = setTimeout(() => {
|
|
require("./lib/hourly-usage")
|
|
.backfillHourly()
|
|
.then((n) => n > 0 && console.log(`Hourly token usage: back-filled ${n} session(s)`))
|
|
.catch((err) => console.warn("hourly token backfill failed:", err?.message || err));
|
|
}, 2_000);
|
|
if (t.unref) t.unref();
|
|
}
|
|
|
|
const { startUpdateScheduler } = require("./update-scheduler");
|
|
const { broadcast } = require("./websocket");
|
|
startUpdateScheduler({ broadcast });
|
|
try {
|
|
const { startCcWatcher } = require("./lib/cc-watcher");
|
|
startCcWatcher({ broadcast });
|
|
} catch (err) {
|
|
console.warn("cc-watcher failed to start:", err.message);
|
|
}
|
|
// Near-real-time Workflow-tool run ingestion. The run journal is written when
|
|
// a workflow finishes — which may not coincide with a hook — so a fast,
|
|
// change-fingerprinted poll over active sessions keeps the UI fresh without
|
|
// waiting for the next Stop or the slow maintenance sweep.
|
|
try {
|
|
startWorkflowPoll(broadcast);
|
|
} catch (err) {
|
|
console.warn("workflow poll failed to start:", err.message);
|
|
}
|
|
// Codex lanes carry no pipeline. Older builds inferred one and wrote a stage
|
|
// block into their AGENTS.md; take both back out. Idempotent, every start.
|
|
try {
|
|
const lanesLib = require("./lib/lanes");
|
|
const { removeStageDoc } = require("./lib/lane-agents");
|
|
const wiped = lanesLib.clearCodexPipelines();
|
|
if (wiped > 0) console.log(`Cleared leftover pipeline data from ${wiped} codex lane(s)`);
|
|
for (const lane of lanesLib.listLanes().filter((l) => l.tool === "codex")) {
|
|
if (removeStageDoc(lane.cwd))
|
|
console.log(`Removed the AgentHub stage block from ${lane.cwd}/AGENTS.md`);
|
|
}
|
|
} catch (err) {
|
|
console.warn("codex pipeline cleanup failed:", err.message);
|
|
}
|
|
// Codex lane status. Codex emits no hooks at all, so its rollout journals on
|
|
// disk are the only signal a `codex` lane is working. `broadcastLane` (not the
|
|
// bare `broadcast(type, data)` above) is the single-lane push the hook route
|
|
// uses.
|
|
try {
|
|
const { broadcastLane } = require("./routes/lanes");
|
|
require("./lib/codex-ingest").startCodexPoll(broadcastLane);
|
|
} catch (err) {
|
|
console.warn("codex poll failed to start:", err.message);
|
|
}
|
|
// Codex sessions and token usage, from the same journals (codex-usage.js):
|
|
// a first pass over every rollout in the background, then the same beat.
|
|
try {
|
|
require("./lib/codex-usage").startCodexUsagePoll(broadcast);
|
|
} catch (err) {
|
|
console.warn("codex usage poll failed to start:", err.message);
|
|
}
|
|
// Antigravity CLI sessions and token usage, from agy's conversation files.
|
|
try {
|
|
const { broadcastLane } = require("./routes/lanes");
|
|
require("./lib/antigravity-usage").startAntigravityUsagePoll(broadcast, broadcastLane);
|
|
} catch (err) {
|
|
console.warn("antigravity usage poll failed to start:", err.message);
|
|
}
|
|
// Codex/Antigravity hooks the user installed forward through a script whose
|
|
// content names this build; point it here (the command in the tool's
|
|
// hooks.json, which Codex trusts, stays the same).
|
|
for (const [name, mod] of [
|
|
["codex-hooks", "./lib/codex-hooks"],
|
|
["antigravity-hooks", "./lib/antigravity-hooks"],
|
|
]) {
|
|
try {
|
|
const refreshed = require(mod).refreshScript();
|
|
if (refreshed === "written") console.log(`[${name}] forwarder updated to this build`);
|
|
if (refreshed === "failed") console.warn(`[${name}] could not update the forwarder`);
|
|
} catch (err) {
|
|
console.warn(`${name} forwarder refresh failed:`, err.message);
|
|
}
|
|
}
|
|
// A codex/antigravity pane that exits fires no hook, so release its lane on
|
|
// a beat instead (one `tmux has-session` per lane with a run), and end the
|
|
// run record of any pane that is gone. Off with DASHBOARD_RUN_HEAL_MS=0.
|
|
const HEAL_MS = Number(process.env.DASHBOARD_RUN_HEAL_MS ?? 10_000);
|
|
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
|
|
const { healAllRunIds } = require("./routes/lanes");
|
|
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
|
const { hasSession } = require("./lib/tmux");
|
|
const healTimer = setInterval(() => {
|
|
try {
|
|
healAllRunIds();
|
|
// A pane that quit (Ctrl+C, `exit`) ends its run record too.
|
|
reconcileOrphans(hasSession);
|
|
} catch (err) {
|
|
console.warn("run heal failed:", err.message);
|
|
}
|
|
}, HEAL_MS);
|
|
if (healTimer.unref) healTimer.unref();
|
|
}
|
|
// Another dashboard on the same database (hooks reach only one of them)
|
|
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
|
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
|
|
const LANE_SYNC_MS = Number(process.env.DASHBOARD_LANE_SYNC_MS ?? 2_000);
|
|
if (Number.isFinite(LANE_SYNC_MS) && LANE_SYNC_MS > 0) {
|
|
const { broadcastForeignLaneChanges } = require("./routes/lanes");
|
|
const laneSyncTimer = setInterval(() => {
|
|
try {
|
|
broadcastForeignLaneChanges();
|
|
} catch (err) {
|
|
console.warn("lane sync failed:", err.message);
|
|
}
|
|
}, LANE_SYNC_MS);
|
|
if (laneSyncTimer.unref) laneSyncTimer.unref();
|
|
}
|
|
// Continuous discovery of sessions under ~/.claude/projects. The one-time
|
|
// legacy backfill above runs only once (marker-gated), so a project added
|
|
// later whose sessions never flow through hooks would otherwise stay invisible
|
|
// until a manual rescan. This incremental, mtime-fingerprinted poll keeps the
|
|
// default folder in sync without re-parsing unchanged files.
|
|
try {
|
|
startSessionSync(broadcast);
|
|
} catch (err) {
|
|
console.warn("session sync failed to start:", err.message);
|
|
}
|
|
// Pull Claude Code history from enabled remote (SSH) sources on an interval so
|
|
// usage collected on other machines shows up here in near real time. Off by
|
|
// default cost-wise: the loop only does work when the user has configured at
|
|
// least one enabled source. Disable entirely with DASHBOARD_REMOTE_SYNC_MS=0.
|
|
try {
|
|
startRemoteSourceSync(broadcast);
|
|
} catch (err) {
|
|
console.warn("remote source sync failed to start:", err.message);
|
|
}
|
|
// Flip the dashboard_runs rows still flagged running/spawning whose tmux
|
|
// pane is gone (a reboot, a pane that quit while no server was up). A pane
|
|
// that is still there survived the restart and stays running — tmux, not
|
|
// this process, owns it.
|
|
try {
|
|
const { reconcileOrphans } = require("./lib/dashboard-runs");
|
|
const { hasSession } = require("./lib/tmux");
|
|
const reconciled = reconcileOrphans(hasSession);
|
|
if (reconciled > 0) {
|
|
console.log(`[runs] reconciled ${reconciled} orphan run(s) → abandoned`);
|
|
}
|
|
} catch (err) {
|
|
console.warn("dashboard-runs reconciliation failed:", err.message);
|
|
}
|
|
// A router is chosen per session now: take the old per-workspace one out of
|
|
// each workspace's settings.local.json (a no-op once done).
|
|
try {
|
|
require("./lib/router-apply").clearLaneRouters();
|
|
} catch (err) {
|
|
console.warn("clearing workspace routers failed:", err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Periodic pull of Claude Code history from enabled remote (SSH) sources. Each
|
|
* tick rsyncs every enabled source's `~/.claude/projects` into a sandboxed
|
|
* staging dir and feeds it through the shared importer (see
|
|
* server/lib/remote-sync.js), so remote usage appears here in near real time.
|
|
* A first pass runs shortly after boot; thereafter every DASHBOARD_REMOTE_SYNC_MS
|
|
* (default 15s). Set the interval to 0 to disable. Unref'd so it never blocks
|
|
* shutdown; overlapping ticks queue one follow-up sweep (same as local sync).
|
|
*/
|
|
function startRemoteSourceSync(broadcast) {
|
|
const POLL_MS = process.env.DASHBOARD_REMOTE_SYNC_MS
|
|
? Number(process.env.DASHBOARD_REMOTE_SYNC_MS)
|
|
: 15_000;
|
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
|
|
|
const dbModule = require("./db");
|
|
const { syncAllEnabled } = require("./lib/remote-sync");
|
|
let running = false;
|
|
let queued = false;
|
|
|
|
const tick = () => {
|
|
if (running) {
|
|
queued = true;
|
|
return;
|
|
}
|
|
// Cheap gate: skip all SSH work unless the user has an enabled source.
|
|
let count = 0;
|
|
try {
|
|
count = dbModule.stmts.listEnabledRemoteSources.all().length;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (count === 0) return;
|
|
running = true;
|
|
Promise.resolve()
|
|
.then(() => syncAllEnabled(dbModule, { broadcast }))
|
|
.catch((err) => console.warn("remote source sync tick failed:", err?.message || err))
|
|
.finally(() => {
|
|
running = false;
|
|
if (queued) {
|
|
queued = false;
|
|
tick();
|
|
}
|
|
});
|
|
};
|
|
|
|
// First pass 2s after boot (let local import settle), then interval.
|
|
const boot = setTimeout(tick, 2_000);
|
|
if (boot.unref) boot.unref();
|
|
const timer = setInterval(tick, POLL_MS);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
|
|
/**
|
|
* Fast, change-fingerprinted poll that ingests Workflow-tool run journals for
|
|
* active sessions in near real time. Inner agent() calls emit no hooks and the
|
|
* journal lands at workflow completion, so this fills the gap between disk
|
|
* writes and the next hook/sweep. Skips sessions whose workflow artifacts are
|
|
* unchanged since the last ingest (cheap mtime fingerprint). Unref'd so it
|
|
* never blocks shutdown; disable with DASHBOARD_WORKFLOW_POLL_MS=0.
|
|
*/
|
|
function startWorkflowPoll(broadcast) {
|
|
const POLL_MS = process.env.DASHBOARD_WORKFLOW_POLL_MS
|
|
? Number(process.env.DASHBOARD_WORKFLOW_POLL_MS)
|
|
: 12_000;
|
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
|
|
|
const dbModule = require("./db");
|
|
const { ingestWorkflowsForSession, workflowsMaxMtime } = require("./lib/workflow-ingest");
|
|
const lastSeen = new Map(); // sessionId → newest workflow-artifact mtime ingested
|
|
|
|
const timer = setInterval(() => {
|
|
let active;
|
|
try {
|
|
active = dbModule.db
|
|
.prepare(
|
|
"SELECT id, transcript_path AS tp FROM sessions WHERE status = 'active' AND transcript_path IS NOT NULL ORDER BY updated_at DESC LIMIT 50"
|
|
)
|
|
.all();
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const row of active) {
|
|
if (!row.tp) continue;
|
|
let mtime = 0;
|
|
try {
|
|
mtime = workflowsMaxMtime(row.tp);
|
|
} catch {
|
|
mtime = 0;
|
|
}
|
|
if (mtime === 0 || lastSeen.get(row.id) === mtime) continue; // none / unchanged
|
|
lastSeen.set(row.id, mtime);
|
|
ingestWorkflowsForSession(dbModule, { id: row.id, transcript_path: row.tp })
|
|
.then((changed) => {
|
|
if (!changed || changed.length === 0) return;
|
|
for (const wf of changed) broadcast("workflow_upserted", wf);
|
|
const sess = dbModule.stmts.getSession.get(row.id); // nudge cost refresh
|
|
if (sess) broadcast("session_updated", sess);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}, POLL_MS);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
|
|
/**
|
|
* Keep the default `~/.claude/projects` directory in sync via three triggers
|
|
* that share one `mtimeCache` and a single coalesced sweep:
|
|
*
|
|
* 1. **Immediate** — one sweep at startup, so a project the one-time backfill
|
|
* (`autoImportLegacySessions`, marker-gated) missed surfaces right away
|
|
* instead of after the first interval.
|
|
* 2. **Watcher** — a debounced `fs.watch` on the projects tree fires a sweep
|
|
* the instant a *new* session file or project folder appears, so no-hook
|
|
* sessions show up immediately rather than on the next poll. Events for
|
|
* files already in `mtimeCache` (active transcripts being appended to) are
|
|
* ignored, so a busy session never thrashes the importer — the poll picks
|
|
* up its growth. Recursive watching is used only on macOS/Windows (native,
|
|
* stable); on Linux, where Node's userland recursive watcher trips on the
|
|
* high-churn projects tree (see lib/cc-watcher.js), we watch the root plus
|
|
* each immediate child folder non-recursively instead.
|
|
* 3. **Poll** — a periodic safety-net sweep (watchers can miss events / not
|
|
* fire on network filesystems). Tunable via `DASHBOARD_SESSION_SYNC_MS`
|
|
* (default 30 s); `0` disables the poll but leaves the watcher running.
|
|
*
|
|
* Each sweep parses only files whose mtime is new or has advanced, then
|
|
* broadcasts `session_created` for newly imported sessions / `session_updated`
|
|
* for grown ones — the same events hooks emit, so the UI refreshes live. All
|
|
* timers and watchers are `unref`'d and best-effort; nothing here can block
|
|
* shutdown or take down the server.
|
|
*/
|
|
function startSessionSync(broadcast) {
|
|
const fs = require("fs");
|
|
const dbModule = require("./db");
|
|
const { getProjectsDir } = require("./lib/claude-home");
|
|
const { syncDefaultProjects } = require("../scripts/import-history");
|
|
|
|
const projectsDir = getProjectsDir();
|
|
const mtimeCache = new Map(); // filePath → newest mtime (ms) already imported
|
|
let running = false;
|
|
let queued = false; // a trigger arrived mid-sweep → run exactly once more
|
|
|
|
function runSweep() {
|
|
if (running) {
|
|
queued = true;
|
|
return;
|
|
}
|
|
running = true;
|
|
syncDefaultProjects(dbModule, { mtimeCache })
|
|
.then(({ changed }) => {
|
|
for (const { sessionId, isNew } of changed) {
|
|
let row;
|
|
try {
|
|
row = dbModule.stmts.getSession.get(sessionId);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!row) continue;
|
|
broadcast(isNew ? "session_created" : "session_updated", row);
|
|
// Also surface the session's main agent, so a synced session appears
|
|
// live on the Agents board too (not just the Sessions board). Hooks
|
|
// emit both a session and an agent frame; mirror that here.
|
|
try {
|
|
const mainAgent = dbModule.db
|
|
.prepare("SELECT * FROM agents WHERE session_id = ? AND type = 'main' LIMIT 1")
|
|
.get(sessionId);
|
|
if (mainAgent) broadcast(isNew ? "agent_created" : "agent_updated", mainAgent);
|
|
} catch {
|
|
/* best-effort — the session frame already refreshed the UI */
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
running = false;
|
|
if (queued) {
|
|
queued = false;
|
|
runSweep();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 1. Deferred initial sweep — let the HTTP server and WebSocket handshake
|
|
// come up and serve the first page load before the (potentially heavy)
|
|
// cold catch-up sweep runs. On a machine with many grown transcripts, the
|
|
// cold sweep re-parses every file whose mtime is newer than its DB
|
|
// updated_at; running it inline at startup can monopolize the event loop
|
|
// long enough that the Vite `/ws` proxy handshake times out ("WebSocket is
|
|
// closed before the connection is established") and the dashboard looks
|
|
// stuck for a minute-plus. The sweep itself yields between heavy re-parses
|
|
// (see syncDefaultProjects), so once it starts it stays cooperative.
|
|
const initialSweep = setTimeout(runSweep, 250);
|
|
if (initialSweep.unref) initialSweep.unref();
|
|
|
|
// 3. Periodic safety net.
|
|
const POLL_MS = process.env.DASHBOARD_SESSION_SYNC_MS
|
|
? Number(process.env.DASHBOARD_SESSION_SYNC_MS)
|
|
: 30_000;
|
|
if (Number.isFinite(POLL_MS) && POLL_MS > 0) {
|
|
const timer = setInterval(runSweep, POLL_MS);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
|
|
// 2. Filesystem watcher — debounced, ignoring known-file churn.
|
|
const DEBOUNCE_MS = 800;
|
|
let debounce = null;
|
|
function scheduleSweep() {
|
|
if (debounce) return;
|
|
debounce = setTimeout(() => {
|
|
debounce = null;
|
|
runSweep();
|
|
}, DEBOUNCE_MS);
|
|
if (debounce.unref) debounce.unref();
|
|
}
|
|
// Only a path we don't already track is interesting (a new session file or a
|
|
// new project folder). Appends to a known active transcript are left to the
|
|
// poll, so the watcher never re-parses a busy session every write.
|
|
function onFsEvent(fullPath) {
|
|
if (fullPath && mtimeCache.has(fullPath)) return;
|
|
scheduleSweep();
|
|
}
|
|
|
|
const watchers = [];
|
|
function addWatcher(w) {
|
|
w.on("error", () => {});
|
|
if (w.unref) w.unref();
|
|
watchers.push(w);
|
|
}
|
|
const recursiveOk = process.platform === "darwin" || process.platform === "win32";
|
|
try {
|
|
if (fs.existsSync(projectsDir)) {
|
|
if (recursiveOk) {
|
|
addWatcher(
|
|
fs.watch(projectsDir, { recursive: true }, (_e, filename) => {
|
|
onFsEvent(filename ? path.join(projectsDir, filename) : null);
|
|
})
|
|
);
|
|
} else {
|
|
// Linux: watch the root (new folders) + each immediate child folder
|
|
// (new session files), adding a child watcher when a folder appears.
|
|
const watchChild = (dir) => {
|
|
try {
|
|
addWatcher(
|
|
fs.watch(dir, (_e, filename) => onFsEvent(filename ? path.join(dir, filename) : null))
|
|
);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
};
|
|
addWatcher(
|
|
fs.watch(projectsDir, (_e, filename) => {
|
|
if (filename) {
|
|
const child = path.join(projectsDir, filename);
|
|
try {
|
|
if (fs.statSync(child).isDirectory()) watchChild(child);
|
|
} catch {
|
|
/* removed before we could stat — ignore */
|
|
}
|
|
}
|
|
onFsEvent(filename ? path.join(projectsDir, filename) : null);
|
|
})
|
|
);
|
|
for (const ent of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
|
if (ent.isDirectory()) watchChild(path.join(projectsDir, ent.name));
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* best-effort — the poll still keeps things in sync */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve true when a healthy dashboard already answers `/api/health` on
|
|
* `port`. Used by the standalone entry point to avoid starting a SECOND server
|
|
* on the now-shared database — two live servers would each persist the
|
|
* fanned-out hook events and double-count them. Never rejects; any
|
|
* error/timeout (nothing listening, or a non-dashboard process) resolves false.
|
|
*/
|
|
function probeDashboardHealth(port, timeoutMs = 1500) {
|
|
return new Promise((resolve) => {
|
|
const req = http.get(
|
|
{ host: "127.0.0.1", port, path: "/api/health", timeout: timeoutMs },
|
|
(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);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const PORT = parseInt(process.env.DASHBOARD_PORT || "4820", 10);
|
|
let httpServer = null;
|
|
|
|
// Single-server guard: if a healthy dashboard already owns this port, don't
|
|
// start a second one — both would write the fanned-out hook events into the
|
|
// shared database, double-counting them. Point the user at the running
|
|
// instance and exit. (`npm run dev` binds a free fallback port via
|
|
// scripts/dev.js, so this only trips when the conventional port is already
|
|
// serving a healthy dashboard — e.g. the desktop app, or another `npm start`.)
|
|
//
|
|
// Skip the guard under `node --watch` (dev:server): a watch restart briefly
|
|
// races the old process on the same port, and adopting there would wedge
|
|
// hot-reload. Dev already runs its own isolated server by design.
|
|
const isWatchMode = process.execArgv.some((a) => a.startsWith("--watch"));
|
|
probeDashboardHealth(PORT).then((alreadyRunning) => {
|
|
if (alreadyRunning && !isWatchMode) {
|
|
console.log(
|
|
`Agent Dashboard is already running on http://localhost:${PORT} — not starting a ` +
|
|
`second instance. Open that URL, or stop the other dashboard first.`
|
|
);
|
|
process.exit(0);
|
|
return;
|
|
}
|
|
const app = createApp();
|
|
startServer(app, PORT).then((server) => {
|
|
httpServer = server;
|
|
startBackgroundServices();
|
|
});
|
|
});
|
|
|
|
// Graceful shutdown — close connections and DB cleanly
|
|
let shutdownInProgress = false;
|
|
const shutdown = (signal) => {
|
|
if (shutdownInProgress) {
|
|
console.log(`\n${signal} received again — forcing immediate exit.`);
|
|
process.exit(1);
|
|
}
|
|
shutdownInProgress = true;
|
|
console.log(`\n${signal} received — shutting down gracefully… (hit Ctrl+C again to force)`);
|
|
|
|
// Drop realtime clients first — open WS sockets otherwise hold the HTTP
|
|
// server open and stall the shutdown until the force-exit backstop fires.
|
|
try {
|
|
require("./websocket").closeWebSocket();
|
|
} catch {
|
|
/* websocket may not be initialised */
|
|
}
|
|
|
|
const closeDb = () => {
|
|
try {
|
|
require("./db").db.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
|
|
if (httpServer) {
|
|
// Close the DB only AFTER the HTTP server has fully drained. Closing it
|
|
// while requests are still in flight makes handlers throw "The database
|
|
// connection is not open" (e.g. server/routes/agents.js).
|
|
httpServer.close(() => {
|
|
console.log("HTTP server closed.");
|
|
closeDb();
|
|
process.exit(0);
|
|
});
|
|
// Drop lingering IDLE keep-alive sockets so close() fires promptly (under
|
|
// `node --watch` this turns a multi-second "waiting for graceful
|
|
// termination" stall into a near-instant restart) while letting in-flight
|
|
// requests finish and drain — the whole point of closing the DB in the
|
|
// close() callback. closeAllConnections() would kill in-flight requests
|
|
// too, so use it only as a fallback on runtimes without
|
|
// closeIdleConnections; the 5s backstop below covers a genuinely stuck
|
|
// request either way.
|
|
if (typeof httpServer.closeIdleConnections === "function") {
|
|
httpServer.closeIdleConnections();
|
|
} else if (typeof httpServer.closeAllConnections === "function") {
|
|
httpServer.closeAllConnections();
|
|
}
|
|
} else {
|
|
closeDb();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Drop the port discovery file so a later run on a different port is not
|
|
// shadowed by a stale entry. (A crash skips this — the PID-liveness check
|
|
// in resolveDashboardPort() is the backstop for that case.)
|
|
removeServerInfo();
|
|
// Backstop: force exit if something still holds the event loop open. Close
|
|
// the DB here too — if close() never drained (a stuck in-flight request),
|
|
// the callback above never ran, so this is the only path that flushes
|
|
// SQLite before exit (closeDb is idempotent, so a normal drain is fine).
|
|
setTimeout(() => {
|
|
closeDb();
|
|
process.exit(0);
|
|
}, 5000).unref();
|
|
};
|
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
|
|
// Auto-install Claude Code hooks on every startup so users don't have to.
|
|
// Skipped inside containers (issue #193): a container-internal handler path
|
|
// would poison a bind-mounted host ~/.claude and break every host hook, so
|
|
// hooks must be installed on the host (`npm run install-hooks`).
|
|
try {
|
|
const { installHooks, isInsideContainer } = require("../scripts/install-hooks");
|
|
if (installHooks(true)) {
|
|
console.log("Claude Code hooks auto-configured.");
|
|
} else if (isInsideContainer()) {
|
|
console.log(
|
|
"Claude Code hooks NOT auto-configured: running inside a container. " +
|
|
"Run `npm run install-hooks` on the host so hooks point at a host path and " +
|
|
"POST to http://localhost:4820 (this container's published port)."
|
|
);
|
|
}
|
|
} catch {
|
|
// Non-fatal — user can run npm run install-hooks manually
|
|
}
|
|
|
|
// Periodic maintenance sweep:
|
|
// 1. Mark abandoned sessions that slipped through event-based detection
|
|
// 2. Scan active sessions' JSONL files for new compaction entries
|
|
// (/compact fires no hooks, so compaction agents only appear on next hook event
|
|
// without this scanner)
|
|
//
|
|
// Stale threshold: configurable via DASHBOARD_STALE_MINUTES env var.
|
|
// Default 180 (3 hours) — long enough that a coffee break, lunch, or even
|
|
// a meeting doesn't cause a Waiting session to flip to Abandoned/Completed
|
|
// out from under the user. The previous 5-min default was the main reason
|
|
// agents appeared to "go straight to completed" the moment Claude finished
|
|
// a turn: any pause longer than 5 min reaped the session, marking its main
|
|
// agent completed and emptying the Waiting column.
|
|
const STALE_MINUTES = (() => {
|
|
const raw = parseInt(process.env.DASHBOARD_STALE_MINUTES, 10);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 180;
|
|
})();
|
|
// Sweep interval: 1/4 of the stale threshold, clamped to [60s, 5 min].
|
|
// Frequent enough to catch real abandonments quickly, cheap enough that
|
|
// we're not hammering SQLite for nothing.
|
|
const SWEEP_INTERVAL_MS = Math.max(60_000, Math.min(300_000, (STALE_MINUTES * 60_000) / 4));
|
|
|
|
const cleanupDb = require("./db");
|
|
const { broadcast } = require("./websocket");
|
|
const { importCompactions } = require("../scripts/import-history");
|
|
const { transcriptCache } = require("./routes/hooks");
|
|
// Per-session newest workflow-artifact mtime already ingested by this sweep,
|
|
// so step 3 below skips sessions whose workflow files are unchanged (the same
|
|
// cheap fingerprint startWorkflowPoll uses). Declared once so it persists
|
|
// across sweep ticks.
|
|
const sweepWorkflowSeen = new Map();
|
|
setInterval(() => {
|
|
// 1. Stale session cleanup — batch agent updates to avoid N+1 queries
|
|
const stale = cleanupDb.stmts.findStaleSessions.all("__periodic__", STALE_MINUTES);
|
|
const now = new Date().toISOString();
|
|
if (stale.length > 0) {
|
|
const staleIds = stale.map((s) => s.id);
|
|
const placeholders = staleIds.map(() => "?").join(",");
|
|
|
|
// Batch update all non-terminal agents across all stale sessions
|
|
cleanupDb.db
|
|
.prepare(
|
|
`UPDATE agents SET status = 'completed', ended_at = COALESCE(ended_at, ?), updated_at = ?
|
|
WHERE session_id IN (${placeholders}) AND status NOT IN ('completed', 'error')`
|
|
)
|
|
.run(now, now, ...staleIds);
|
|
|
|
for (const s of stale) {
|
|
cleanupDb.stmts.updateSession.run(null, "abandoned", now, null, s.id);
|
|
broadcast("session_updated", cleanupDb.stmts.getSession.get(s.id));
|
|
|
|
// Evict transcript cache for abandoned sessions to bound memory growth.
|
|
// Reads transcript_path off the session row (populated by hooks
|
|
// ensureSession + one-time db.js backfill) instead of scanning events.
|
|
const tpRow = cleanupDb.db
|
|
.prepare("SELECT transcript_path AS tp FROM sessions WHERE id = ?")
|
|
.get(s.id);
|
|
if (tpRow?.tp) transcriptCache.invalidate(tpRow.tp);
|
|
}
|
|
|
|
// Broadcast updated agents once per stale session (not per-agent)
|
|
for (const s of stale) {
|
|
const agents = cleanupDb.stmts.listAgentsBySession.all(s.id);
|
|
for (const agent of agents) {
|
|
if (agent.status === "completed") {
|
|
broadcast("agent_updated", agent);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Scan active sessions for new compaction entries.
|
|
// Reads from sessions.transcript_path (populated by hooks ensureSession +
|
|
// one-time backfill in db.js migration) rather than scanning events —
|
|
// O(active sessions) instead of O(events rows).
|
|
const active = cleanupDb.db
|
|
.prepare(
|
|
"SELECT id AS session_id, transcript_path AS tp FROM sessions WHERE status = 'active' AND transcript_path IS NOT NULL ORDER BY updated_at DESC"
|
|
)
|
|
.all();
|
|
for (const row of active) {
|
|
if (!row.tp) continue;
|
|
try {
|
|
const compactions = transcriptCache.extractCompactions(row.tp);
|
|
if (compactions.length === 0) continue;
|
|
const mainAgentId = `${row.session_id}-main`;
|
|
const created = importCompactions(cleanupDb, row.session_id, mainAgentId, compactions);
|
|
if (created > 0) {
|
|
broadcast(
|
|
"agent_created",
|
|
cleanupDb.stmts.getAgent.get(
|
|
`${row.session_id}-compact-${compactions[compactions.length - 1].uuid}`
|
|
)
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`[SWEEP] Compaction scan failed for session ${row.session_id}:`,
|
|
err?.message || err
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// 3. Scan active sessions for Workflow-tool run journals (issue #167).
|
|
// Catches workflows that complete without a subsequent hook and flips
|
|
// launch-detected "running" rows to "completed" once their journal lands.
|
|
const { ingestWorkflowsForSession, workflowsMaxMtime } = require("./lib/workflow-ingest");
|
|
// Forget fingerprints for sessions that are no longer active so the map
|
|
// can't grow without bound over the process lifetime.
|
|
const activeIds = new Set(active.map((r) => r.session_id));
|
|
for (const id of sweepWorkflowSeen.keys()) {
|
|
if (!activeIds.has(id)) sweepWorkflowSeen.delete(id);
|
|
}
|
|
for (const row of active) {
|
|
if (!row.tp) continue;
|
|
// Skip sessions whose workflow artifacts are unchanged since the last
|
|
// ingest — the same cheap mtime fingerprint startWorkflowPoll uses.
|
|
// Without this the sweep full-re-parses every workflow journal and every
|
|
// inner agent-*.jsonl for every active session every cycle; on a large
|
|
// corpus that re-parse exceeds the sweep interval, sweeps overlap, and
|
|
// the event loop pegs (dashboard stops responding — white page).
|
|
let mtime = 0;
|
|
try {
|
|
mtime = workflowsMaxMtime(row.tp);
|
|
} catch {
|
|
mtime = 0;
|
|
}
|
|
if (mtime === 0 || sweepWorkflowSeen.get(row.session_id) === mtime) continue;
|
|
sweepWorkflowSeen.set(row.session_id, mtime);
|
|
ingestWorkflowsForSession(cleanupDb, { id: row.session_id, transcript_path: row.tp })
|
|
.then((changed) => {
|
|
if (!changed || changed.length === 0) return;
|
|
for (const wf of changed) broadcast("workflow_upserted", wf);
|
|
const sess = cleanupDb.stmts.getSession.get(row.session_id);
|
|
if (sess) broadcast("session_updated", sess);
|
|
})
|
|
.catch((err) => {
|
|
// Forget the fingerprint so the next sweep retries this session
|
|
// instead of skipping it until its artifacts change again.
|
|
sweepWorkflowSeen.delete(row.session_id);
|
|
console.warn(
|
|
`[SWEEP] Workflow scan failed for session ${row.session_id}:`,
|
|
err?.message || err
|
|
);
|
|
});
|
|
}
|
|
}, SWEEP_INTERVAL_MS);
|
|
|
|
// The one-time legacy-session import runs from startBackgroundServices()
|
|
// (called above) so the embedded desktop server backfills history too — not
|
|
// just this standalone path. See autoImportLegacySessions().
|
|
}
|
|
|
|
module.exports = { createApp, startServer, startBackgroundServices };
|