/** * @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 { reapplyCodexLayers, lanesWithMissingRouter } = require("../lib/router-apply"); 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(); db.pragma("foreign_keys = OFF"); db.prepare("DELETE FROM token_usage").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(); db.pragma("foreign_keys = ON"); res.json({ ok: true, cleared: counts }); }); // 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 router.get("/export", (_req, res) => { const sessions = db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all(); const agents = db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all(); const events = db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(); const tokenUsage = db.prepare("SELECT * FROM token_usage").all(); const pricing = stmts.listPricing.all(); res.setHeader("Content-Type", "application/json"); res.setHeader( "Content-Disposition", `attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"` ); res.json({ exported_at: new Date().toISOString(), sessions, agents, events, token_usage: tokenUsage, model_pricing: pricing, }); }); // 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, after = () => ({})) { 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), ...after() }); }); } // GET/PUT /api/settings/claude-home — a PUT names the lanes whose AI Router // profile the new home's store does not have (they would start on native). homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaudeHome, () => ({ routers: { missing: lanesWithMissingRouter() }, })); // GET/PUT /api/settings/codex-home — a PUT re-writes every codex lane's AI // Router layer into the new home, or `codex -p ` fails at the next start. homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome, () => ({ routers: reapplyCodexLayers(), })); // POST /api/settings/cleanup — abandon stale sessions, purge old data 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); for (const row of stale) { db.prepare( "UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" ).run(row.id); // Also complete any lingering agents 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')" ).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); if (toDelete.length > 0) { const ids = toDelete.map((r) => r.id); const placeholders = ids.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(...ids).changes; result.purged_agents = db .prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`) .run(...ids).changes; db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids); db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids); result.purged_sessions = toDelete.length; } } res.json({ ok: true, ...result }); }); module.exports = router;