feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
+102
-30
@@ -23,7 +23,10 @@ const APP_VERSION = (() => {
|
||||
})();
|
||||
|
||||
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
||||
const CLAUDE_SETTINGS_PATH = getSettingsPath();
|
||||
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 {
|
||||
@@ -48,10 +51,10 @@ function getTableCounts() {
|
||||
|
||||
function getHookStatus() {
|
||||
try {
|
||||
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
||||
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
||||
if (!fs.existsSync(getSettingsPath())) {
|
||||
return { installed: false, path: getSettingsPath(), hooks: {} };
|
||||
}
|
||||
const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
|
||||
const raw = fs.readFileSync(getSettingsPath(), "utf8");
|
||||
const settings = JSON.parse(raw);
|
||||
const hookTypes = [
|
||||
"PreToolUse",
|
||||
@@ -73,9 +76,9 @@ function getHookStatus() {
|
||||
);
|
||||
}
|
||||
const installed = Object.values(hooks).every(Boolean);
|
||||
return { installed, path: CLAUDE_SETTINGS_PATH, hooks };
|
||||
return { installed, path: getSettingsPath(), hooks };
|
||||
} catch {
|
||||
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
||||
return { installed: false, path: getSettingsPath(), hooks: {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +147,10 @@ router.post("/clear-data", (_req, res) => {
|
||||
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();
|
||||
@@ -182,9 +189,48 @@ router.post("/reinstall-hooks", (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/reset-pricing — reset pricing to defaults
|
||||
router.post("/reset-pricing", (_req, res) => {
|
||||
db.prepare("DELETE FROM model_pricing").run();
|
||||
// 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();
|
||||
} else {
|
||||
db.prepare("DELETE FROM model_pricing").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 (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -195,6 +241,12 @@ router.post("/reset-pricing", (_req, res) => {
|
||||
// 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()) {
|
||||
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 });
|
||||
@@ -223,28 +275,48 @@ router.get("/export", (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/settings/claude-home — get current CLAUDE_HOME path
|
||||
router.get("/claude-home", (_req, res) => {
|
||||
res.json({ claude_home: getClaudeHome() });
|
||||
});
|
||||
// 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() });
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/settings/claude-home — update CLAUDE_HOME path
|
||||
router.put("/claude-home", (req, res) => {
|
||||
const { path: newPath } = req.body;
|
||||
if (!newPath || typeof newPath !== "string") {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_PATH", message: "path is required and must be a string" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resolved = setClaudeHome(newPath);
|
||||
res.json({ ok: true, claude_home: resolved });
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
error: { code: "INVALID_PATH", message: err.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
// 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) => {
|
||||
|
||||
Reference in New Issue
Block a user