f887c16309
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.
Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.
Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.
Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.
DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
386 lines
15 KiB
JavaScript
386 lines
15 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 <name>` 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;
|