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:
2026-09-29 16:09:19 +07:00
parent 7ec5a7cd85
commit 969b06253c
95 changed files with 9278 additions and 1696 deletions
+282 -3
View File
@@ -287,6 +287,11 @@ const schemas = {
"Absolute path to the active Claude Code home directory (CLAUDE_HOME, or `<homedir>/.claude` when unset).",
example: "/Users/son/.claude",
},
env_owned: {
type: "boolean",
description:
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
},
},
},
@@ -308,7 +313,7 @@ const schemas = {
SettingsClaudeHomeUpdateResponse: {
type: "object",
description:
"Confirmation that CLAUDE_HOME was updated. The new value is applied to process.env immediately and persisted to the project `.env` file.",
"Confirmation that CLAUDE_HOME was updated. The new value is applied to process.env immediately and persisted to the settings file (`$XDG_CONFIG_HOME/agenthub/settings.env`, default `~/.config/agenthub/`, or AGENTHUB_SETTINGS_FILE).",
required: ["ok", "claude_home"],
properties: {
ok: { type: "boolean", enum: [true] },
@@ -317,6 +322,22 @@ const schemas = {
description: "The resolved absolute path now in effect (after `~` expansion).",
example: "/Users/son/.codefuse/engine/cc",
},
env_owned: {
type: "boolean",
description:
"True when the real environment, or the checkout's own `.env`, set this variable at startup: either wins over the settings file, so a saved value is overridden again at the next start.",
},
routers: {
type: "object",
properties: {
missing: {
type: "array",
items: { type: "string" },
description:
"`<lane id> (<title>): <profile>` for every lane naming an AI Router profile the new home's store (`<CLAUDE_HOME>/agenthub-routers/`) does not have — such a lane starts on its base configuration.",
},
},
},
},
},
@@ -988,6 +1009,258 @@ const paths = {
},
},
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
content: {
"application/json": {
schema: {
type: "object",
required: ["supported", "readable", "path", "script", "installed", "trusted", "events"],
properties: {
supported: {
type: "boolean",
description: "False on Windows (the command is a POSIX script)",
},
readable: {
type: "boolean",
description: "False when hooks.json is not a JSON object",
},
error: { type: "string" },
path: { type: "string", description: "`$CODEX_HOME/hooks.json`" },
script: {
type: "string",
description: "`$CODEX_HOME/agenthub-hook.sh`, the forwarder",
},
installed: { type: "boolean" },
trusted: {
type: "boolean",
description:
"Codex's config.toml holds a trust entry for every one of AgentHub's hooks",
},
events: {
type: "object",
additionalProperties: {
type: "object",
properties: { installed: { type: "boolean" }, trusted: { type: "boolean" } },
},
},
},
},
},
},
};
const refused = {
description:
"EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)",
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
};
const failed = {
description: "ESAVE: the file system refused the write",
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
};
return {
get: {
tags: ["Settings"],
summary: "Status of AgentHub's hooks in Codex",
description:
"Per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd): whether AgentHub's group is in `$CODEX_HOME/hooks.json`, and whether Codex's `config.toml` holds a trust entry for it. Codex runs a hook only once the user trusts it; AgentHub only reads that trust.",
operationId: "getCodexHooks",
responses: { 200: status, 500: failed },
},
post: {
tags: ["Settings"],
summary: "Install (or reinstall) AgentHub's hooks in Codex",
description:
"Refuses a cross-origin request (403 EBADORIGIN). Writes the forwarder script and adds one hook of AgentHub's per event to `$CODEX_HOME/hooks.json`, keeping every other entry. The command is the script's stable path, so Codex's trust survives AgentHub updates; the server rewrites the script's content at every start. Codex asks the user to trust the new hooks the next time it starts.",
operationId: "installCodexHooks",
responses: { 200: status, 400: refused, 500: failed },
},
delete: {
tags: ["Settings"],
summary: "Remove AgentHub's hooks from Codex",
description:
"Refuses a cross-origin request (403 EBADORIGIN). Takes AgentHub's own hooks (matched by their exact command) out of `$CODEX_HOME/hooks.json` (deleting the file when nothing else is left, or emptying it through the link when it is a symlink) and deletes the forwarder script when it is AgentHub's. 400 EBADJSON when hooks.json is not a JSON object.",
operationId: "removeCodexHooks",
responses: { 200: status, 400: refused, 500: failed },
},
};
})(),
"/api/settings/antigravity-hooks": (() => {
const status = {
description: "Where AgentHub's Antigravity hook stands",
content: {
"application/json": {
schema: {
type: "object",
required: ["supported", "readable", "path", "script", "installed", "enabled", "events"],
properties: {
supported: {
type: "boolean",
description: "False on Windows (the command is a POSIX script)",
},
readable: {
type: "boolean",
description: "False when hooks.json is not a JSON object",
},
error: { type: "string" },
path: { type: "string", description: "`~/.gemini/config/hooks.json`" },
script: {
type: "string",
description: "`~/.gemini/config/agenthub-hook.sh`, the forwarder",
},
installed: { type: "boolean" },
enabled: {
type: "boolean",
description: 'Installed and not switched off (`"enabled": false`) in the file',
},
events: {
type: "object",
additionalProperties: {
type: "object",
properties: { installed: { type: "boolean" } },
},
},
},
},
},
},
};
const refused = {
description:
"EBADJSON (hooks.json is not a JSON object), EFOREIGN (the forwarder path holds a script that is not AgentHub's) or EUNSUPPORTED (Windows)",
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
};
const failed = {
description: "ESAVE: the file system refused the write",
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
};
return {
get: {
tags: ["Settings"],
summary: "Status of AgentHub's hook in Antigravity",
description:
"Per event (PreInvocation, PostToolUse, Stop): whether AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` runs its forwarder. PreToolUse is never installed: agy treats its answer as a permission decision.",
operationId: "getAntigravityHooks",
responses: { 200: status, 500: failed },
},
post: {
tags: ["Settings"],
summary: "Install (or reinstall) AgentHub's hook in Antigravity",
description:
"Refuses a cross-origin request (403 EBADORIGIN). Writes the forwarder script and sets the named hook `agenthub` in `~/.gemini/config/hooks.json`, keeping every other named hook. agy loads it at its next start, with no trust step.",
operationId: "installAntigravityHooks",
responses: { 200: status, 400: refused, 500: failed },
},
delete: {
tags: ["Settings"],
summary: "Remove AgentHub's hook from Antigravity",
description:
"Refuses a cross-origin request (403 EBADORIGIN). Deletes the named hook `agenthub` (and the file when nothing else is left, unless it is a symlink, which is emptied through the link) and the forwarder script when it is AgentHub's. 400 EBADJSON when hooks.json is not a JSON object.",
operationId: "removeAntigravityHooks",
responses: { 200: status, 400: refused, 500: failed },
},
};
})(),
"/api/settings/codex-home": {
get: {
tags: ["Settings"],
summary: "Get the active Codex home directory",
description:
"Returns the Codex home directory (CODEX_HOME, else `<homedir>/.codex`) the dashboard reads Codex config and rollout journals from, writes AI Router layers into, and hands every codex lane pane through tmux `-e`.",
operationId: "getCodexHome",
responses: {
200: {
description: "Current Codex home directory",
content: {
"application/json": {
schema: {
type: "object",
required: ["codex_home", "env_owned"],
properties: {
codex_home: { type: "string" },
env_owned: { type: "boolean" },
},
},
example: { codex_home: "/Users/son/.codex", env_owned: false },
},
},
},
},
},
put: {
tags: ["Settings"],
summary: "Update the Codex home directory",
description:
"Same validation and persistence as PUT /api/settings/claude-home (403 EBADORIGIN from another site, settings file, EBADHOME for a path that is or contains the OS home, INVALID_PATH otherwise). On success it re-writes the AI Router layer (`<name>.config.toml`) of every codex lane on a router into the new home, reported as `routers`, because `codex -p <name>` loads it from there. A codex pane already running keeps its old home until restarted.",
operationId: "updateCodexHome",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["path"],
properties: { path: { type: "string" } },
},
example: { path: "~/.codex-work" },
},
},
},
responses: {
200: {
description: "Codex home updated",
content: {
"application/json": {
schema: {
type: "object",
required: ["ok", "codex_home", "env_owned", "routers"],
properties: {
ok: { type: "boolean", enum: [true] },
codex_home: { type: "string" },
env_owned: { type: "boolean" },
routers: {
type: "object",
required: ["updated", "failed"],
properties: {
updated: { type: "integer" },
failed: { type: "array", items: { type: "string" } },
},
},
},
},
example: {
ok: true,
codex_home: "/Users/son/.codex-work",
env_owned: false,
routers: { updated: 1, failed: [] },
},
},
},
},
400: {
description: "Invalid path (INVALID_PATH) or too broad (EBADHOME).",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: {
code: "EBADHOME",
message: "Too broad for CODEX_HOME: / contains your home directory",
},
},
},
},
},
500: {
description: "The settings file could not be written (ESAVE).",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/settings/claude-home": {
get: {
tags: ["Settings"],
@@ -1011,7 +1284,7 @@ const paths = {
tags: ["Settings"],
summary: "Update the Claude Code home directory",
description:
"Changes the Claude Code home directory used for transcript/settings discovery. A leading `~/` in `path` is expanded to the OS home directory; the resolved value must be an absolute path that exists and is a directory. On success the new value is applied to process.env immediately (so subsequent reads use it) and persisted to the project `.env` file. Returns 400 INVALID_PATH when `path` is missing/not a string, or when the resolved path is not absolute, does not exist, or is not a directory.",
"Changes the Claude Code home directory used for transcript/settings discovery. A leading `~/` in `path` is expanded to the OS home directory; the resolved value must be an absolute path that exists and is a directory. On success the new value is applied to process.env immediately (so subsequent reads use it) and persisted to the settings file (`$XDG_CONFIG_HOME/agenthub/settings.env`, default `~/.config/agenthub/`, or AGENTHUB_SETTINGS_FILE — never the repo `.env`, which a plugin update replaces). The data dir (database, runtime) is derived from CLAUDE_HOME, so the first change also pins the one in use as DASHBOARD_DATA_DIR unless the environment already sets it. Refuses a cross-origin request (403 EBADORIGIN). Returns 400 EBADHOME when the path is, or contains (symlinks resolved), the OS user's home directory — `/`, `/home`, `~` — because everything under CLAUDE_HOME becomes readable through the config panel; 400 INVALID_PATH when `path` is missing/not a string, or the resolved path is not absolute, does not exist, or is not a directory.",
operationId: "updateClaudeHome",
requestBody: {
required: true,
@@ -1034,7 +1307,7 @@ const paths = {
},
400: {
description:
"Invalid path — `path` missing or not a string, or the resolved path is not absolute / does not exist / is not a directory (code INVALID_PATH).",
"Invalid path — `path` missing or not a string, or the resolved path is not absolute / does not exist / is not a directory (code INVALID_PATH), or it is or contains the OS home directory (code EBADHOME).",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
@@ -1047,6 +1320,12 @@ const paths = {
},
},
},
500: {
description: "The settings file could not be written (ESAVE).",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
+6
View File
@@ -401,6 +401,12 @@ const paths = {
"the normalized row that was just inserted (`session_id`, `agent_id`, `event_type`, " +
"`tool_name`, `summary`, `created_at`). Ingestion is designed to be fail-safe and " +
"non-blocking for the hook caller.\n\n" +
"The optional `tool` (`codex`, `antigravity`; anything else is `claude`) names the " +
"CLI that sent the hook. A Codex or agy hook is mapped onto the same hook types " +
"first (a Codex PermissionRequest becomes a Notification, an Interrupt a Stop; an " +
"agy turn's first PreInvocation a UserPromptSubmit), and one that maps to nothing " +
"is answered `{ ok: true, ignored: true }`. A hook delivered twice is answered " +
"`{ ok: true, duplicate: true }` and stored once.\n\n" +
"Validation failures return HTTP 400 with an `ErrorResponse` body " +
"(`{ error: { code, message } }`): `INVALID_INPUT` when `hook_type` or `data` is " +
"missing, and `MISSING_SESSION` when `data.session_id` is absent.",
+18 -1
View File
@@ -1081,8 +1081,25 @@ const paths = {
tags: ["Settings"],
summary: "Reset pricing table to defaults",
description:
"⚠ DESTRUCTIVE to pricing customizations. Deletes EVERY row in the model_pricing table and re-seeds it from the dashboard's built-in DEFAULT_PRICING list. Any custom rates or custom model patterns you added are permanently lost — there is no undo. Captured session/token data is untouched (only the pricing rules used to *compute* cost change). The response returns the full freshly-seeded pricing table.",
'⚠ DESTRUCTIVE to pricing customizations. Deletes EVERY row in the model_pricing table and re-seeds it from the dashboard\'s built-in DEFAULT_PRICING list. Any custom rates or custom model patterns you added are permanently lost — there is no undo. Captured session/token data is untouched (only the pricing rules used to *compute* cost change). The response returns the full freshly-seeded pricing table. With `{"scope": "claude"}` (the Claude Code tab\'s button) only `claude-…` rows are deleted and re-seeded; every other vendor\'s rows and custom patterns are kept.',
operationId: "resetPricing",
requestBody: {
required: false,
content: {
"application/json": {
schema: {
type: "object",
properties: {
scope: {
type: "string",
enum: ["claude"],
description: "Reset only `claude-…` rows. Omitted: every row.",
},
},
},
},
},
},
responses: {
200: {
description: "Pricing defaults restored",
@@ -138,6 +138,14 @@ const paths = {
"Case-insensitive search across `id` / `name` / `cwd`. Composes with the status filter when both are present.",
example: "pricing",
},
{
name: "tool",
in: "query",
schema: { type: "string", enum: ["claude", "codex", "antigravity"] },
description:
"Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.",
example: "claude",
},
{
$ref: "#/components/parameters/SourcesQuery",
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",