/** * @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions. This allows the frontend to manage and maintain the agent monitoring system effectively. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const fs = require("fs"); const path = require("path"); const os = require("os"); const v8 = require("v8"); const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db"); const { getConnectionCount } = require("../websocket"); const { transcriptCache } = require("./hooks"); const router = Router(); const APP_VERSION = (() => { try { return require("../../package.json").version || "0.0.0"; } catch { return "0.0.0"; } })(); const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home"); const { getCodexHome, setCodexHome } = require("../lib/codex-home"); const { envOwned } = require("../lib/settings-env"); const pricingSync = require("../lib/pricing-sync"); function getDbSize() { try { const stat = fs.statSync(DB_PATH); return stat.size; } catch { return 0; } } function getTableCounts() { const tables = ["sessions", "agents", "events", "model_pricing"]; const counts = {}; for (const t of tables) { counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c; } counts.token_usage = db .prepare("SELECT COUNT(DISTINCT session_id) as c FROM token_usage") .get().c; return counts; } function getHookStatus() { try { if (!fs.existsSync(getSettingsPath())) { return { installed: false, path: getSettingsPath(), hooks: {} }; } const raw = fs.readFileSync(getSettingsPath(), "utf8"); const settings = JSON.parse(raw); const hookTypes = [ "PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification", "SessionStart", "SessionEnd", ]; const hooks = {}; for (const ht of hookTypes) { const entries = settings.hooks?.[ht] || []; hooks[ht] = entries.some( (e) => (e.command && e.command.includes("hook-handler.js")) || (Array.isArray(e.hooks) && e.hooks.some((h) => h.command && h.command.includes("hook-handler.js"))) ); } const installed = Object.values(hooks).every(Boolean); return { installed, path: getSettingsPath(), hooks }; } catch { return { installed: false, path: getSettingsPath(), hooks: {} }; } } // GET /api/settings/info — system info, db stats, hook status router.get("/info", (req, res) => { const dbSize = getDbSize(); const counts = getTableCounts(); const hookStatus = getHookStatus(); // Advanced SQLite info const pragmas = { journal_mode: db.pragma("journal_mode", { simple: true }), synchronous: db.pragma("synchronous", { simple: true }), auto_vacuum: db.pragma("auto_vacuum", { simple: true }), encoding: db.pragma("encoding", { simple: true }), foreign_keys: db.pragma("foreign_keys", { simple: true }), busy_timeout: db.pragma("busy_timeout", { simple: true }), }; // Recent activity load (events in last 5, 15, 60 minutes) const getCount = (ms) => { const d = new Date(Date.now() - ms).toISOString(); return db.prepare("SELECT COUNT(*) as c FROM events WHERE created_at > ?").get(d).c; }; const load_stats = { m5: getCount(5 * 60 * 1000), m15: getCount(15 * 60 * 1000), h1: getCount(60 * 60 * 1000), }; res.json({ db: { path: DB_PATH, size: dbSize, counts, pragmas, load_stats, }, hooks: hookStatus, server: { version: APP_VERSION, uptime: process.uptime(), node_version: process.version, platform: process.platform, ws_connections: getConnectionCount(), // heapTotal is only what V8 has grown the heap to SO FAR, so used/total // sits at 70-90% on a perfectly healthy process. heapLimit is the ceiling // the process actually dies at — the denominator a "heap used %" needs. memory: { ...process.memoryUsage(), heapLimit: v8.getHeapStatistics().heap_size_limit }, cpu_load: os.loadavg(), arch: os.arch(), total_mem: os.totalmem(), free_mem: os.freemem(), cpus: os.cpus().length, }, transcript_cache: transcriptCache.stats(), }); }); // POST /api/settings/clear-data — delete all sessions, agents, events, tokens router.post("/clear-data", (_req, res) => { const counts = getTableCounts(); // foreign_keys cannot change inside a transaction, so it is turned off // around one: all of it lands, or none of it (a crash midway used to leave // events without sessions). db.pragma("foreign_keys = OFF"); try { db.transaction(clearAll)(); } finally { db.pragma("foreign_keys = ON"); } res.json({ ok: true, cleared: counts }); }); function clearAll() { db.prepare("DELETE FROM token_usage").run(); db.prepare("DELETE FROM token_usage_hourly").run(); db.prepare("DELETE FROM events").run(); db.prepare("DELETE FROM agents").run(); db.prepare("DELETE FROM sessions").run(); // What the Codex/Antigravity polls have read: kept, it would stop them ever // importing those sessions again (and bring one back half, on its next write). db.prepare("DELETE FROM codex_rollouts").run(); db.prepare("DELETE FROM antigravity_conversations").run(); // Fired alerts reference the cleared sessions — wipe the feed too. Alert // *rules* survive: they're user configuration, like model_pricing. db.prepare("DELETE FROM alert_events").run(); // Webhook delivery log is an audit trail of those fired alerts — wipe it too. // Webhook *targets* survive, like alert rules and pricing. db.prepare("DELETE FROM webhook_deliveries").run(); // Workflow runs belong to the sessions; with foreign keys off they would // not cascade and stay as orphans. db.prepare("DELETE FROM workflows").run(); } // POST /api/settings/reimport — re-import legacy sessions from ~/.claude/ router.post("/reimport", async (_req, res) => { try { const { importAllSessions } = require("../../scripts/import-history"); const dbModule = require("../db"); const result = await importAllSessions(dbModule); res.json({ ok: true, ...result }); } catch (err) { res.status(500).json({ error: { code: "IMPORT_FAILED", message: err.message }, }); } }); // POST /api/settings/reinstall-hooks — reinstall Claude Code hooks router.post("/reinstall-hooks", (_req, res) => { try { const { installHooks } = require("../../scripts/install-hooks"); const success = installHooks(true); const hookStatus = getHookStatus(); res.json({ ok: success, hooks: hookStatus }); } catch (err) { res.status(500).json({ error: { code: "HOOK_INSTALL_FAILED", message: err.message }, }); } }); // GET/POST/DELETE /api/settings/codex-hooks — AgentHub's hooks in Codex's // `$CODEX_HOME/hooks.json`: status, install (or reinstall), remove. Only ever // on a click; Codex itself asks the user to trust them (server/lib/codex-hooks.js). const codexHooks = require("../lib/codex-hooks"); const { sameOriginGuard } = require("../lib/same-origin"); function codexHooksRoute(fn) { return (_req, res) => { try { res.json(fn()); } catch (err) { const client = ["EBADJSON", "EUNSUPPORTED", "EFOREIGN"].includes(err.code); res.status(client ? 400 : 500).json({ error: { code: client ? err.code : "ESAVE", message: err.message }, }); } }; } router.get("/codex-hooks", codexHooksRoute(codexHooks.status)); // Writing makes Codex run a command (once trusted), so no other site may ask. router.post("/codex-hooks", sameOriginGuard, codexHooksRoute(codexHooks.install)); router.delete("/codex-hooks", sameOriginGuard, codexHooksRoute(codexHooks.remove)); // GET/POST/DELETE /api/settings/antigravity-hooks — AgentHub's named hook in // `~/.gemini/config/hooks.json` (server/lib/antigravity-hooks.js). agy runs it // with no trust step, so the write routes refuse another site the same way. const antigravityHooks = require("../lib/antigravity-hooks"); router.get("/antigravity-hooks", codexHooksRoute(antigravityHooks.status)); router.post("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.install)); router.delete("/antigravity-hooks", sameOriginGuard, codexHooksRoute(antigravityHooks.remove)); // POST /api/settings/reset-pricing — reset pricing to defaults. The seed is // Claude's; a model priced by the automatic sync (an OpenAI one, from Codex // sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it. // `{scope: "claude"}` (the Claude tab's button) resets only `claude-…` rows, // so another vendor's edits and a custom router row survive it. const RESET_SYNC_MS = 3000; router.post("/reset-pricing", async (req, res) => { if (req.body?.scope === "claude") { db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-%'").run(); db.prepare("DELETE FROM model_pricing_dismissed WHERE model_pattern LIKE 'claude-%'").run(); } else { db.prepare("DELETE FROM model_pricing").run(); db.prepare("DELETE FROM model_pricing_dismissed").run(); } const seedPricing = db.prepare( "INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ); for (const [pattern, name, inp, out, cr, cw, cw1h, fin, fout] of DEFAULT_PRICING) { seedPricing.run(pattern, name, inp, out, cr, cw, cw1h, fin, fout); } // Re-apply time-limited intro rates (e.g. Sonnet 5) — the seed above only // carries standard rates, so without this a reset silently drops the promo. applyIntroPricing(db); if (pricingSync.syncEnabled()) { // The rows just deleted include current models nobody has used yet; the // cached list counts as applied, so without this they wait for tomorrow. pricingSync.invalidate(); await Promise.race([ pricingSync.syncPricing(db), new Promise((r) => setTimeout(r, RESET_SYNC_MS).unref()), ]); } const pricing = stmts.listPricing.all(); res.json({ ok: true, pricing }); }); // GET /api/settings/export — export all data as JSON. Same document as ever, // but written as it is read: `events` alone is GBs on a long-lived install, // and one `.all()` + `res.json` held it in memory twice (past V8's string // limit). Events go out in keyset pages, waiting for the socket to drain. const EXPORT_PAGE = 500; router.get("/export", async (req, res) => { res.setHeader("Content-Type", "application/json"); res.setHeader( "Content-Disposition", `attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"` ); let gone = false; req.on("close", () => (gone = true)); const write = async (chunk) => { if (!res.write(chunk)) await new Promise((r) => res.once("drain", r)); }; const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`; try { await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`); await write( field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all()) ); await write(field("agents", db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all())); await write(`,"events":[`); const first = db.prepare( `SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}` ); const next = db.prepare( `SELECT * FROM events WHERE created_at < ? OR (created_at = ? AND id < ?) ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}` ); let page = first.all(); let sep = ""; while (page.length && !gone) { await write(sep + page.map((e) => JSON.stringify(e)).join(",")); sep = ","; const last = page[page.length - 1]; page = next.all(last.created_at, last.created_at, last.id); } await write("]"); await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all())); await write(field("model_pricing", stmts.listPricing.all()) + "}"); res.end(); } catch (err) { console.warn("export failed:", err.message); res.destroy(err); } }); // The two editable home directories share one shape: GET answers the value and // whether the real environment set it (`env_owned` — then a saved value is // overridden again at the next start), PUT validates, applies and persists // (server/lib/settings-env.js). EBADHOME = too broad, INVALID_PATH = any other // bad path, 500 ESAVE = the settings file could not be written. function homeRoutes(route, key, field, get, set) { router.get(route, (_req, res) => { res.json({ [field]: get(), env_owned: envOwned.has(key) }); }); // A home decides which config a pane runs (CODEX_HOME: MCP servers, notify // commands), so no other site may set one. router.put(route, sameOriginGuard, (req, res) => { const newPath = req.body && req.body.path; if (!newPath || typeof newPath !== "string") { return res.status(400).json({ error: { code: "INVALID_PATH", message: "path is required and must be a string" }, }); } let resolved; try { resolved = set(newPath); } catch (err) { // A bad path is 400; anything else (the settings file unwritable) is ours. const bad = err.code === "EBADHOME" || err.code === "INVALID_PATH"; return res.status(bad ? 400 : 500).json({ error: { code: bad ? err.code : "ESAVE", message: err.message }, }); } res.json({ ok: true, [field]: resolved, env_owned: envOwned.has(key) }); }); } // GET/PUT /api/settings/claude-home homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome); // GET/PUT /api/settings/codex-home — a session's AI Router layer is written // into the current home at its spawn, so a move needs no repair here. homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome); // POST /api/settings/cleanup — abandon stale sessions, purge old data const PURGE_CHUNK = 500; router.post("/cleanup", (req, res) => { const { abandon_hours, purge_days } = req.body; const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 }; if (abandon_hours && typeof abandon_hours === "number" && abandon_hours > 0) { // Mark active sessions with no recent events as abandoned const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString(); const stale = db .prepare( `SELECT s.id FROM sessions s WHERE s.status = 'active' AND s.started_at < ? AND NOT EXISTS ( SELECT 1 FROM events e WHERE e.session_id = s.id AND e.created_at > ? )` ) .all(cutoff, cutoff); const abandon = db.prepare( "UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" ); // Also complete any lingering agents const complete = db.prepare( "UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')" ); db.transaction(() => { for (const row of stale) { abandon.run(row.id); complete.run(row.id); } })(); result.abandoned = stale.length; } if (purge_days && typeof purge_days === "number" && purge_days > 0) { const cutoff = new Date(Date.now() - purge_days * 86400 * 1000).toISOString(); // Only purge completed/error/abandoned sessions, never active const toDelete = db .prepare( "SELECT id FROM sessions WHERE status IN ('completed','error','abandoned') AND started_at < ?" ) .all(cutoff); // One transaction, in chunks: a single IN (...) list of every id would // pass SQLite's bound-variable limit on a large history. db.transaction(() => { const ids = toDelete.map((r) => r.id); for (let i = 0; i < ids.length; i += PURGE_CHUNK) { const chunk = ids.slice(i, i + PURGE_CHUNK); const placeholders = chunk.map(() => "?").join(","); // Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups result.purged_events += db .prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`) .run(...chunk).changes; result.purged_agents += db .prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`) .run(...chunk).changes; db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk); db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run( ...chunk ); db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk); } result.purged_sessions = ids.length; })(); } res.json({ ok: true, ...result }); }); module.exports = router;