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,182 @@
|
||||
/**
|
||||
* @file AgentHub's hooks in Antigravity (`~/.gemini/config/hooks.json`, the
|
||||
* global file `agy` and the Antigravity IDE both load), installed and removed
|
||||
* only on the user's click. The file maps hook NAMES to their events; ours is
|
||||
* the one named `agenthub`, and every other name is left as it is.
|
||||
*
|
||||
* Only three events, all observational: `PreInvocation` (a model call starts
|
||||
* — the first of a run is the user's prompt), `PostToolUse` and `Stop`.
|
||||
* NEVER `PreToolUse`: its answer is a permission decision, and agy DENIES the
|
||||
* tool when a PreToolUse hook answers `{}` (verified on 1.2.13) — `allow`
|
||||
* would skip the user's own permission rules and `ask` would force a prompt,
|
||||
* so there is no answer that leaves agy's behaviour alone. agy has no hook
|
||||
* for a permission prompt either, so AgentHub cannot show one.
|
||||
*
|
||||
* The command is a stable path, `'<gemini>/config/agenthub-hook.sh' <Event>`;
|
||||
* the script runs this build's `scripts/hook-handler.js <Event> antigravity`
|
||||
* with the server's node and CLAUDE_HOME, prints the `{}` agy reads, and every
|
||||
* server start rewrites it when stale — same scheme as codex-hooks.js, though
|
||||
* agy asks for no trust. A script there that is not ours is never overwritten
|
||||
* or removed (`EFOREIGN`), and hooks.json keeps its mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getGeminiDir } = require("./antigravity-home");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const { shq, writeAtomic, scriptIsOurs, isLink } = require("./codex-hooks");
|
||||
|
||||
const HOOK_NAME = "agenthub";
|
||||
const HOOK_EVENTS = ["PreInvocation", "PostToolUse", "Stop"];
|
||||
const TOOL_EVENTS = new Set(["PostToolUse"]); // take {matcher, hooks} groups
|
||||
const TIMEOUT_S = 5; // the handler gives up after 2.5 s
|
||||
const HANDLER = path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js");
|
||||
const MARKER = "# AgentHub's Antigravity hook forwarder.";
|
||||
|
||||
const configDir = () => path.join(getGeminiDir(), "config");
|
||||
const hooksPath = () => path.join(configDir(), "hooks.json");
|
||||
const scriptPath = () => path.join(configDir(), "agenthub-hook.sh");
|
||||
const commandFor = (event) => `${shq(scriptPath())} ${event}`;
|
||||
|
||||
function scriptBody() {
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
`${MARKER} Rewritten by AgentHub on every start.`,
|
||||
`N=${shq(process.execPath)}; [ -x "$N" ] || N=node`,
|
||||
`CLAUDE_HOME=${shq(getClaudeHome())} "$N" ${shq(HANDLER)} "$1" antigravity >/dev/null 2>&1`,
|
||||
"# agy reads a JSON answer; an empty one changes nothing for these events.",
|
||||
"echo '{}'",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function fail(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/** hooks.json as an object, `{}` when absent; throws EBADJSON otherwise. */
|
||||
function readHooks() {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(hooksPath(), "utf8");
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return {};
|
||||
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`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function assertSupported() {
|
||||
if (process.platform === "win32") {
|
||||
throw fail("EUNSUPPORTED", "Antigravity hooks from AgentHub need a POSIX shell");
|
||||
}
|
||||
}
|
||||
|
||||
function ourDefinition() {
|
||||
const def = { enabled: true };
|
||||
for (const event of HOOK_EVENTS) {
|
||||
const handler = { type: "command", command: commandFor(event), timeout: TIMEOUT_S };
|
||||
def[event] = TOOL_EVENTS.has(event) ? [{ matcher: ".*", hooks: [handler] }] : [handler];
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/** Install (or reinstall) AgentHub's named hook. @returns the new status. */
|
||||
function install() {
|
||||
assertSupported();
|
||||
const file = readHooks();
|
||||
if (!scriptIsOurs(scriptPath(), MARKER)) {
|
||||
throw fail("EFOREIGN", `${scriptPath()} is not AgentHub's; move it first`);
|
||||
}
|
||||
file[HOOK_NAME] = ourDefinition();
|
||||
fs.mkdirSync(configDir(), { recursive: true });
|
||||
writeAtomic(scriptPath(), scriptBody(), 0o755);
|
||||
writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||
return status();
|
||||
}
|
||||
|
||||
/** Take AgentHub's named hook out; every other one stays. @returns the new status. */
|
||||
function remove() {
|
||||
const file = readHooks();
|
||||
if (HOOK_NAME in file) {
|
||||
delete file[HOOK_NAME];
|
||||
if (Object.keys(file).length === 0 && !isLink(hooksPath()))
|
||||
fs.rmSync(hooksPath(), { force: true });
|
||||
else writeAtomic(hooksPath(), JSON.stringify(file, null, 2) + "\n", 0o644, { keepMode: true });
|
||||
}
|
||||
if (scriptIsOurs(scriptPath(), MARKER)) fs.rmSync(scriptPath(), { force: true });
|
||||
return status();
|
||||
}
|
||||
|
||||
/** Whether `list` (a flat handler list, or {matcher, hooks} groups) runs our command. */
|
||||
function runsOurs(list, event) {
|
||||
if (!Array.isArray(list)) return false;
|
||||
return list.some((h) =>
|
||||
Array.isArray(h?.hooks)
|
||||
? h.hooks.some((x) => x?.command === commandFor(event))
|
||||
: h?.command === commandFor(event)
|
||||
);
|
||||
}
|
||||
|
||||
/** Per event: is our handler there; plus whether our hook is switched off. */
|
||||
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,
|
||||
enabled: false,
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
const ours = file[HOOK_NAME] && typeof file[HOOK_NAME] === "object" ? file[HOOK_NAME] : {};
|
||||
const events = {};
|
||||
for (const event of HOOK_EVENTS) events[event] = { installed: runsOurs(ours[event], event) };
|
||||
const installed = HOOK_EVENTS.every((e) => events[e].installed) && fs.existsSync(scriptPath());
|
||||
return {
|
||||
...base,
|
||||
readable: true,
|
||||
installed,
|
||||
enabled: installed && ours.enabled !== false,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the forwarder when it is installed and stale or lost its exec bit.
|
||||
* Never creates it, 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_NAME, HOOK_EVENTS, commandFor, install, remove, status, refreshScript };
|
||||
Reference in New Issue
Block a user