Files
AgentHub/server/routes/settings.js
T
nntrivi2001 65fe680d38 fix(pricing): book daily cost to the day tokens were spent
token_usage holds a session's cumulative totals with no time, so
/api/pricing/cost booked a multi-day session's whole cost to its start
date and the status bar's Today read ~$0. TranscriptCache now also splits
usage by each message's UTC hour (tokensByHour); the hook writer stores it
in the new token_usage_hourly table (MAX per hour) and the server
back-fills existing sessions at start. The daily split books each total
over its hourly rows' local days and the rest to the start date, so the
days still add up to the total.
2026-10-05 08:03:05 +07:00

384 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 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 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();
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) {
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
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 token_usage_hourly 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;