Files
AgentHub/server/routes/settings.js
T
nntrivi2001 f887c16309 feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)
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.
2026-10-01 13:39:17 +07:00

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;