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:
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file AgentHub's hooks in Codex (`$CODEX_HOME/hooks.json`), installed and
|
||||
* removed only on the user's click. Codex runs a hook only once the user
|
||||
* trusts its exact definition (Codex asks at startup, or `/hooks`), keyed on
|
||||
* that definition's hash — so the command is a STABLE path,
|
||||
* `'<CODEX_HOME>/agenthub-hook.sh' <Event>`, and what changes between AgentHub
|
||||
* builds is only that script's content: it runs this build's
|
||||
* `scripts/hook-handler.js <Event> codex` with the server's node and
|
||||
* CLAUDE_HOME, and every server start rewrites it when stale. AgentHub never
|
||||
* writes Codex's trust (`[hooks.state]` in `config.toml`) — it only reads it
|
||||
* to report — and never passes `--dangerously-bypass-hook-trust`. A script
|
||||
* at that path that is not AgentHub's is never overwritten or removed
|
||||
* (`EFOREIGN`), and a rewritten hooks.json keeps the mode the user gave it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { atomicWriteFile } = require("./config-write");
|
||||
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"Interrupt", // a declined permission or Esc ends the turn without a Stop
|
||||
"SessionEnd",
|
||||
];
|
||||
const SCRIPT = "agenthub-hook.sh";
|
||||
// Codex clamps SessionEnd/Interrupt to 3 s with a startup warning; the handler
|
||||
// gives up after 2.5 s anyway.
|
||||
const TIMEOUT_S = 3;
|
||||
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||
|
||||
const scriptPath = () => path.join(getCodexHome(), SCRIPT);
|
||||
const hooksPath = () => path.join(getCodexHome(), "hooks.json");
|
||||
const MARKER = "# AgentHub's Codex hook forwarder.";
|
||||
|
||||
/** POSIX single-quoting: safe inside a shell command whatever the value. */
|
||||
function shq(s) {
|
||||
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
const commandFor = (event) => `${shq(scriptPath())} ${event}`;
|
||||
|
||||
function scriptBody() {
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
`${MARKER} Rewritten by AgentHub on every start; the`,
|
||||
"# hooks.json command pointing here stays the same, so Codex's trust holds.",
|
||||
`N=${shq(process.execPath)}; [ -x "$N" ] || N=node`,
|
||||
`CLAUDE_HOME=${shq(getClaudeHome())} exec "$N" ${shq(HANDLER)} "$1" codex`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Ours: exactly `'<dir>/agenthub-hook.sh' <Event>` as commandFor quotes it
|
||||
// (any CODEX_HOME, earlier ones included) — never a user's hook that merely
|
||||
// mentions the name, and never a user's hook sharing a group with ours.
|
||||
const OURS = /^'(?:[^']|'\\'')*\/agenthub-hook\.sh' [A-Za-z]+$/;
|
||||
const isOurHook = (h) => typeof h?.command === "string" && OURS.test(h.command);
|
||||
|
||||
function fail(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/** hooks.json as an object, `{hooks:{}}` when absent; throws EBADJSON otherwise. */
|
||||
function readHooks() {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(hooksPath(), "utf8");
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return { hooks: {} };
|
||||
throw err;
|
||||
}
|
||||
let file;
|
||||
try {
|
||||
file = JSON.parse(text);
|
||||
} catch {
|
||||
file = null;
|
||||
}
|
||||
if (!file || typeof file !== "object" || Array.isArray(file)) {
|
||||
throw fail("EBADJSON", `${hooksPath()} is not a JSON object; fix or move it first`);
|
||||
}
|
||||
if (file.hooks === undefined) file.hooks = {};
|
||||
if (!file.hooks || typeof file.hooks !== "object" || Array.isArray(file.hooks)) {
|
||||
throw fail("EBADJSON", `${hooksPath()}: "hooks" is not an object`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Temp + rename, through a symlinked file to its target, keeping `mode`. */
|
||||
function writeAtomic(file, text, mode, { keepMode = false } = {}) {
|
||||
let target = file;
|
||||
let existing = null;
|
||||
try {
|
||||
target = fs.realpathSync(file);
|
||||
existing = fs.statSync(target).mode & 0o777;
|
||||
} catch {
|
||||
/* not there yet */
|
||||
}
|
||||
atomicWriteFile(target, text);
|
||||
// A user's own file keeps its mode (a 0600 hooks.json stays private).
|
||||
fs.chmodSync(target, keepMode && existing !== null ? existing : mode);
|
||||
}
|
||||
|
||||
/** A symlink (a dotfiles repo's file): emptied through the link, never unlinked. */
|
||||
function isLink(file) {
|
||||
try {
|
||||
return fs.lstatSync(file).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `file` is absent or is AgentHub's forwarder (line 2 starts with
|
||||
* `marker`). Unreadable, a directory, anything else: not ours, so it is left
|
||||
* alone (install answers EFOREIGN, remove skips it).
|
||||
*/
|
||||
function scriptIsOurs(file, marker) {
|
||||
try {
|
||||
return fs.readFileSync(file, "utf8").split("\n", 2)[1]?.startsWith(marker) ?? false;
|
||||
} catch (err) {
|
||||
return err.code === "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The file with every hook of ours taken out; a group left empty by that is
|
||||
* dropped, and so is an event left with no group.
|
||||
*/
|
||||
function withoutOurs(file) {
|
||||
for (const [event, groups] of Object.entries(file.hooks)) {
|
||||
if (!Array.isArray(groups)) continue;
|
||||
const kept = [];
|
||||
for (const g of groups) {
|
||||
if (!Array.isArray(g?.hooks) || !g.hooks.some(isOurHook)) {
|
||||
kept.push(g);
|
||||
continue;
|
||||
}
|
||||
const hooks = g.hooks.filter((h) => !isOurHook(h));
|
||||
if (hooks.length) kept.push({ ...g, hooks });
|
||||
}
|
||||
if (kept.length) file.hooks[event] = kept;
|
||||
else delete file.hooks[event];
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function assertSupported() {
|
||||
if (process.platform === "win32") {
|
||||
throw fail("EUNSUPPORTED", "Codex hooks from AgentHub need a POSIX shell");
|
||||
}
|
||||
}
|
||||
|
||||
/** Install (or reinstall) AgentHub's hooks. @returns the new status. */
|
||||
function install() {
|
||||
assertSupported();
|
||||
const file = withoutOurs(readHooks());
|
||||
for (const event of HOOK_EVENTS) {
|
||||
const groups = Array.isArray(file.hooks[event]) ? file.hooks[event] : [];
|
||||
groups.push({ hooks: [{ type: "command", command: commandFor(event), timeout: TIMEOUT_S }] });
|
||||
file.hooks[event] = groups;
|
||||
}
|
||||
if (!scriptIsOurs(scriptPath(), MARKER)) {
|
||||
throw fail("EFOREIGN", `${scriptPath()} is not AgentHub's; move it first`);
|
||||
}
|
||||
fs.mkdirSync(getCodexHome(), { recursive: true });
|
||||
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||
writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||
return status();
|
||||
}
|
||||
|
||||
/** Take AgentHub's hooks out; the user's own stay. @returns the new status. */
|
||||
function remove() {
|
||||
const file = withoutOurs(readHooks());
|
||||
const others = Object.keys(file).filter((k) => k !== "hooks");
|
||||
if (Object.keys(file.hooks).length === 0 && others.length === 0 && !isLink(hooksPath())) {
|
||||
fs.rmSync(hooksPath(), { force: true });
|
||||
} else if (fs.existsSync(hooksPath())) {
|
||||
writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||
}
|
||||
if (scriptIsOurs(scriptPath(), MARKER)) fs.rmSync(scriptPath(), { force: true });
|
||||
return status();
|
||||
}
|
||||
|
||||
const snake = (event) => event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
|
||||
/**
|
||||
* Per event: is our hook there, and does Codex's `config.toml` hold a trust
|
||||
* entry for its position (`"<hooks.json>:<event>:<group>:<hook>"`, under the
|
||||
* home as given or resolved). A trust entry for an older definition would read
|
||||
* as trusted too; ours never changes.
|
||||
*/
|
||||
function status() {
|
||||
const base = {
|
||||
supported: process.platform !== "win32",
|
||||
path: hooksPath(),
|
||||
script: scriptPath(),
|
||||
};
|
||||
let file;
|
||||
try {
|
||||
file = readHooks();
|
||||
} catch (err) {
|
||||
return {
|
||||
...base,
|
||||
readable: false,
|
||||
error: err.message,
|
||||
installed: false,
|
||||
trusted: false,
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
let toml = "";
|
||||
try {
|
||||
toml = fs.readFileSync(path.join(getCodexHome(), "config.toml"), "utf8");
|
||||
} catch {
|
||||
/* no config.toml: nothing trusted */
|
||||
}
|
||||
const files = codexHomes().map((h) => path.join(h, "hooks.json"));
|
||||
const events = {};
|
||||
for (const event of HOOK_EVENTS) {
|
||||
const groups = Array.isArray(file.hooks[event]) ? file.hooks[event] : [];
|
||||
let at = null;
|
||||
groups.forEach((g, i) => {
|
||||
const j = Array.isArray(g?.hooks)
|
||||
? g.hooks.findIndex((h) => h?.command === commandFor(event))
|
||||
: -1;
|
||||
if (j >= 0 && !at) at = `${snake(event)}:${i}:${j}`;
|
||||
});
|
||||
events[event] = {
|
||||
installed: at !== null,
|
||||
trusted: at !== null && files.some((f) => toml.includes(`"${f}:${at}"`)),
|
||||
};
|
||||
}
|
||||
const all = (k) => HOOK_EVENTS.every((e) => events[e][k]);
|
||||
const installed = all("installed") && fs.existsSync(scriptPath());
|
||||
return { ...base, readable: true, installed, trusted: installed && all("trusted"), events };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the forwarder when it is installed and stale (a new build, node or
|
||||
* CLAUDE_HOME) or lost its exec bit. Never creates it — installing is the
|
||||
* user's call — and never touches a file by that name that is not ours.
|
||||
* @returns {"absent"|"foreign"|"exists"|"written"|"failed"}
|
||||
*/
|
||||
function refreshScript() {
|
||||
try {
|
||||
const current = fs.readFileSync(scriptPath(), "utf8");
|
||||
if (!current.split("\n", 2)[1]?.startsWith(MARKER)) return "foreign";
|
||||
const executable = (fs.statSync(scriptPath()).mode & 0o111) !== 0;
|
||||
if (current === scriptBody() && executable) return "exists";
|
||||
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||
return "written";
|
||||
} catch (err) {
|
||||
return err.code === "ENOENT" ? "absent" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_EVENTS,
|
||||
shq,
|
||||
writeAtomic,
|
||||
scriptIsOurs,
|
||||
isLink,
|
||||
commandFor,
|
||||
install,
|
||||
remove,
|
||||
status,
|
||||
refreshScript,
|
||||
};
|
||||
Reference in New Issue
Block a user