/** * @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ĩ */ // First, before any module destructures child_process: no console window per // git/npm call on Windows (server/lib/windows-hide.js). require("./lib/windows-hide").install(); // S3 system unit: children never inherit its ambient CAP_DAC_READ_SEARCH // (server/lib/drop-caps.js) — same "before child_process is read" rule. require("./lib/drop-caps").install(); 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"); // Express 4 ignores the promise an async handler returns: a rejection (a // throw after the first await) became an unhandled rejection, and Node exits // on one — any request that made a handler throw took the whole server down. // Every handler's rejection goes to `next(err)` (a 500) instead, the way a // synchronous throw already did. Express 5 does this itself, and has no // `lib/router/layer` to patch — so only a 4.x gets it. if (require("express/package.json").version.split(".")[0] === "4") { const Layer = require("express/lib/router/layer"); Layer.prototype.handle_request = function handle(req, res, next) { const fn = this.handle; if (fn.length > 3) return next(); // an error handler: not for a request try { const r = fn(req, res, next); if (r && typeof r.then === "function") r.then(undefined, next); } catch (err) { next(err); } }; } const cors = require("cors"); const fs = require("fs"); 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 aiProxiesRouter = require("./routes/ai-proxies"); const gitCredentialsRouter = require("./routes/git-credentials"); const aiToolsRouter = require("./routes/ai-tools"); const runRouter = require("./routes/run"); const alertsRouter = require("./routes/alerts"); const budgetsRouter = require("./routes/budgets"); const queryRouter = require("./routes/query"); const reportsRouter = require("./routes/reports"); 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 snapshotsRouter = require("./routes/snapshots"); const authRouter = require("./routes/auth"); const meRouter = require("./routes/me"); const accountsRouter = require("./routes/accounts"); const usersRouter = require("./routes/users"); const { auditGuard } = require("./lib/audit"); const { authGuard, isServerMode, hasAdmin } = require("./lib/auth"); const { scopeGuard } = require("./lib/scope"); 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); // Server mode's AI Proxy key gateway (server/lib/proxy-gateway.js): before // the body parser (it streams the raw body) and the token/login gates (a // pane authenticates with its own pane token, loopback only). app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway); app.use(express.json({ limit: "1mb" })); app.use("/api", tokenGuard); // Server mode's login gate (server/lib/auth.js); a no-op in local mode. app.use("/api", authGuard); // Server mode, phase S2: a member reaches only rows of their own workspaces. app.use("/api", scopeGuard); app.use("/api", auditGuard); app.use("/api/auth", authRouter); app.use("/api/me", meRouter); app.use("/api/accounts", accountsRouter); app.use("/api/users", usersRouter); 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); // `/api/ai-routers` is the pre-rename path, kept for a client older than it. app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter); app.use("/api/git-credentials", gitCredentialsRouter); app.use("/api/ai-tools", aiToolsRouter); app.use("/api/run", runRouter); // `/api/workspaces` is the same router under the UI's word; `/api/lanes` // stays for every client and script that already names it (whether it ever // goes is step 4c of docs/plans/workspace-rename/README.md, default keep). // Sources first: the lanes router's `/:id/:action` would otherwise see them. const LANE_MOUNTS = ["/api/lanes", "/api/workspaces"]; app.use(LANE_MOUNTS, laneSourcesRouter); app.use(LANE_MOUNTS, lanesRouter); app.use("/api/projects", projectsRouter); app.use("/api/locks", locksRouter); app.use("/api/pets", petsRouter); app.use("/api/snapshots", snapshotsRouter); app.use("/api/alerts", alertsRouter); app.use("/api/budgets", budgetsRouter); app.use("/api/query", queryRouter); app.use("/api/reports", reportsRouter); 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. // // A hashed asset's `.br`/`.gz` sibling (written by the client build) goes // to a browser that accepts it: ~75 % fewer bytes over a network, and no // compression CPU here. Only a flat file name is looked up. const assetsDir = path.join(clientDist, "assets"); const ENCODINGS = [ ["br", ".br", /\bbr\b/], ["gzip", ".gz", /\bgzip\b/], ]; app.use("/assets", (req, res, next) => { res.setHeader("Vary", "Accept-Encoding"); if (req.method !== "GET" && req.method !== "HEAD") return next(); const name = req.path.slice(1); if (!/^[\w.-]+$/.test(name) || name.startsWith(".")) return next(); const accept = String(req.headers["accept-encoding"] || ""); for (const [encoding, ext, re] of ENCODINGS) { if (!re.test(accept) || !fs.existsSync(path.join(assetsDir, name + ext))) continue; res.setHeader("Content-Encoding", encoding); res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); res.type(path.extname(name)); // the original's type, not the sibling's return res.sendFile(name + ext, { root: assetsDir }, (err) => err && next(err)); } next(); }); 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); // The AI Proxy gateway URL a server-mode pane is given names this port. require("./lib/proxy-gateway").setListenPort(server.address().port); // S3: what members' panes run — this install's scripts and every // installed AI tool, copied under /opt/agenthub (idle, in the background). if (require("./lib/linux-accounts").s3Enabled()) { const shared = require("./lib/shared-tools"); const t = setTimeout(() => { shared .publishApp() .then(() => shared.publishAll()) .then((r) => { const bad = r.filter((x) => x.error); if (bad.length) console.warn("[shared-tools]", bad.map((x) => `${x.id}: ${x.error}`).join("; ")); }) .catch((e) => console.warn("[shared-tools]", e.message)); }, 5000); if (t.unref) t.unref(); } // A server that starts ends a Quit: the bootstrap may start it again. require("./lib/quit").clearQuit(); 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 (isServerMode()) { console.log("Server mode: every page and API call needs a login."); if (!hasAdmin()) { console.warn("⚠️ Server mode has no admin yet — run: agenthub server setup"); } } else 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(); // Before any tmux call: without a UTF-8 locale every attach prints `_` for // non-ASCII text (a macOS LaunchAgent sets none). require("./lib/shell-path").ensureUtf8Locale(); // 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(); } } // WAL checkpoints off the event loop. SQLite's automatic checkpoint runs // inside whichever COMMIT crosses 1000 pages — a hook's, on this thread — // and measured up to 2.7 s per commit under load. This process stops doing // that and checkpoints every few seconds from the read worker instead; a // failed checkpoint puts the automatic one back. Other processes on the same // file (a second dashboard, the CLI) keep their own setting. Off with // DASHBOARD_WAL_CHECKPOINT_MS=0. { const every = Number(process.env.DASHBOARD_WAL_CHECKPOINT_MS ?? 5000); if (every > 0) { const { db } = require("./db"); const { runRead } = require("./lib/read-pool"); db.pragma("wal_autocheckpoint = 0"); const t = setInterval(() => { runRead("walCheckpoint").catch(() => { db.pragma("wal_autocheckpoint = 1000"); clearInterval(t); }); }, every); if (t.unref) t.unref(); } } // Scheduled reports (Settings → Reports). Off with DASHBOARD_REPORTS_MS=0. require("./lib/reports").startReportScheduler(); // Daily database backup (Settings → Data → Backup). Off until a folder is set. require("./lib/db-backup").startBackupScheduler(); // 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); } // A Codex/Antigravity session imported before it could be tied to the // workspace pane it ran in gets that workspace now (dashboard-runs.js). require("./lib/dashboard-runs").claimToolSessions(); // 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); } // Cursor sessions (no tokens: its transcripts carry none), from its agent transcripts. try { require("./lib/cursor-usage").startCursorUsagePoll(broadcast); } catch (err) { console.warn("cursor 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 list-sessions` per beat — each tmux call is // synchronous and stalls every terminal's keystrokes), 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 { liveSessions } = require("./lib/tmux"); const anyRun = require("./db").db.prepare( `SELECT EXISTS (SELECT 1 FROM lanes WHERE run_id IS NOT NULL) OR EXISTS (SELECT 1 FROM dashboard_runs WHERE status IN ('running', 'spawning')) AS any` ); const healTimer = setInterval(() => { try { // A Codex pool login a member's pane refreshed goes back to the pool // (also after its pane ended), so the next pane starts from it. require("./lib/codex-pool").syncBack(); } catch (err) { console.warn("codex pool sync failed:", err.message); } try { if (!anyRun.get().any) return; // nothing to heal: no tmux spawn at all const live = liveSessions(); if (!live) return; // tmux could not be asked: skip, never end every run healAllRunIds(live); // A pane that quit (Ctrl+C, `exit`) ends its run record too; one that // died under its agent (killed tmux, crash) is offered for resume. if (reconcileOrphans((id) => live.has(id)) > 0) broadcast("runs_interrupted", {}); } 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) → interrupted/abandoned`); } } catch (err) { console.warn("dashboard-runs reconciliation failed:", err.message); } // A proxy 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/proxy-apply").clearLaneProxies(); } catch (err) { console.warn("clearing workspace proxies 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, allProjectsDirs } = 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; // This account's projects dir, then every member's (S3: their sessions // write under their own home) — one after another, same mtime cache. // In the chain: a throw here (S3's member lookup) must not leave // `running` set and stop the sync for good. Promise.resolve() .then(allProjectsDirs) .then((dirs) => dirs.reduce( (prev, dir) => prev.then(async (acc) => { const { changed } = await syncDefaultProjects(dbModule, { mtimeCache, projectsDir: dir, }); return acc.concat(changed); }), Promise.resolve([]) ) ) .then((changed) => ({ changed })) .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(); // The status-area icon (server/lib/tray.js) — production servers only. require("./lib/tray").startTrayWatch(); }); }); // 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)) { // With the agenthub plugin enabled this only removed duplicate entries. 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 };