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
+40 -5
View File
@@ -448,7 +448,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| Method | Path | Description |
| ------- | ------------------- | ------------------------------------------------ |
| `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) |
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`) |
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity` filters by CLI before the limit) |
| `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) |
| `POST` | `/api/sessions` | Create session (idempotent by `id`) |
| `PATCH` | `/api/sessions/:id` | Update session |
@@ -593,12 +593,37 @@ pipeline's last node, where the pipeline is finished and a later detection is
new work — and, the one rule that matters, never counts as evidence and never
renders a node as `done` (see `docs/LANES.md#stage-detection`).
A `codex` lane has no hooks to read and carries no pipeline, so nothing detects
or advances its stage. `server/lib/codex-ingest.js` only mirrors its `status`
A `codex` lane carries no pipeline, so nothing detects or advances its stage.
Codex's hooks are opt-in (`server/lib/codex-hooks.js`, installed from Settings →
Codex → AgentHub and trusted by the user inside Codex); a hook posted with
`tool: "codex"` reaches only the codex lane at its cwd and creates a
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
hooks are current (an event for its session in the last two minutes) —
between `idle` and `running` from the mtime of Codex's own rollout journals
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
`server/lib/codex-usage.js` reads the same journals for every Codex session
(lane or not) into `sessions` (`tool = 'codex'`) and `token_usage`, tracking
each file's progress in `codex_rollouts` (see ARCHITECTURE.md).
An `antigravity` lane carries no pipeline either.
- **Sessions and tokens:** `server/lib/antigravity-usage.js` reads agy's
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
`antigravity_conversations` holds the mtime read for each file.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its lane idle. That lane must be one the hooks left
running with no dashboard pane.
- **Hooks:** opt-in. `server/lib/antigravity-hooks.js` installs them from
Settings → Antigravity: the named hook `agenthub` in
`~/.gemini/config/hooks.json`, with PreInvocation, PostToolUse and Stop,
never PreToolUse. A hook posted with `tool: "antigravity"` reaches only the
antigravity lane at its workspace (see ARCHITECTURE.md, **Antigravity
Sessions and Hooks**).
### Remote Data Sources
@@ -647,10 +672,20 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
| Method | Path | Description |
| ------ | ------------------------------ | ------------------------------------------------ |
| `GET` | `/api/settings/info` | System info, DB stats, hooks status, cache stats. Also powers the Dashboard Health tab (server uptime, memory, CPU, DB record counts, WAL/journal mode, transcript cache hit/miss rates) |
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage |
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage, and empty `codex_rollouts`/`antigravity_conversations` so those sessions import again |
| `POST` | `/api/settings/reimport` | Re-import legacy sessions from `~/.claude/` |
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults |
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — lanes naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex lane's AI Router layer into the new home (`reapplyCodexLayers`) |
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
| `GET` | `/api/settings/antigravity-hooks` | Status of the named hook `agenthub` in `~/.gemini/config/hooks.json` |
| `POST` | `/api/settings/antigravity-hooks` | Install or reinstall it — PreInvocation, PostToolUse, Stop, never PreToolUse (`sameOriginGuard`; same 400 codes) |
| `DELETE` | `/api/settings/antigravity-hooks` | Remove the `agenthub` hook only (`sameOriginGuard`) |
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file`, or JSON `{ path }` (server reads it). Idempotent + non-destructive: sessions already present are skipped whole |
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data |
@@ -0,0 +1,231 @@
/**
* @file Tests POST /api/hooks/event for hooks marked `tool: "antigravity"`
* (sent by agy through server/lib/antigravity-hooks.js): the conversation
* becomes an Antigravity session with the workspace as its cwd, a run's first
* PreInvocation is the prompt (the lane goes running) and later ones are
* dropped, PostToolUse lands in the timeline, Stop sets the lane idle, a
* claude lane at the same directory is untouched, and a hook from the
* Antigravity IDE (transcript outside the CLI's directory) is ignored; a turn
* after a Stop, a Stop with background work (ignored), a failed run (error),
* and a symlinked Gemini dir. Plus the settings routes. The Gemini dir and the DB live in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
const http = require("node:http");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hook-route-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const lanes = require("../lib/lanes");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method,
headers: { "Content-Type": "application/json", ...headers },
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text || "{}") }));
}
);
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli");
/** A hook exactly as agy 1.2.13 sends it. */
function agyHook(hook_type, id, cwd, extra = {}, dir = CLI) {
return request("POST", "/api/hooks/event", {
hook_type,
tool: "antigravity",
data: {
conversationId: id,
workspacePaths: [cwd],
transcriptPath: path.join(dir, "brain", id, ".system_generated/logs/transcript_full.jsonl"),
artifactDirectoryPath: path.join(dir, "brain", id),
modelName: "gemini-3.8-flash-high",
...extra,
},
});
}
const eventsOf = (sid) =>
db.prepare("SELECT event_type, tool_name FROM events WHERE session_id = ? ORDER BY id").all(sid);
describe("antigravity hooks through /api/hooks/event", () => {
it("runs a turn on the antigravity lane only, and records its tool calls", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const agyLane = lanes.createLane({ title: "a", cwd, tool: "antigravity" });
const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" });
const id = "64389dfc-a91a-47d1-92f5-e153001026c7";
await agyHook("PreInvocation", id, cwd, { invocationNum: 0, initialNumSteps: 1 });
const s = stmts.getSession.get(id);
assert.equal(s.tool, "antigravity");
assert.equal(s.cwd, cwd);
assert.equal(s.name, `Antigravity ${id.slice(0, 8)}`);
assert.equal(s.transcript_path, null);
assert.equal(lanes.getLane(agyLane.id).status, "running");
assert.equal(lanes.getLane(agyLane.id).session_id, id);
const later = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 });
assert.equal(later.body.ignored, true);
const call = { name: "view_file", args: { AbsolutePath: `${cwd}/note.txt` } };
await agyHook("PostToolUse", id, cwd, { stepIdx: 2, error: "", toolCall: call });
await agyHook("PostToolUse", id, cwd, { stepIdx: 3, error: "", toolCall: call });
await agyHook("PostInvocation", id, cwd, { invocationNum: 0 });
await agyHook("Stop", id, cwd, {
executionNum: 0,
terminationReason: "NO_TOOL_CALL",
fullyIdle: true,
error: "",
});
assert.deepEqual(
eventsOf(id).map((e) => [e.event_type, e.tool_name]),
[
["UserPromptSubmit", null],
["PostToolUse", "view_file"],
["PostToolUse", "view_file"],
["Stop", null],
]
);
assert.equal(lanes.getLane(agyLane.id).status, "idle");
const c = lanes.getLane(claudeLane.id);
assert.equal(c.session_id, null);
assert.equal(c.status, claudeLane.status);
});
it("ignores the Antigravity IDE's hooks and a malformed conversation id", async () => {
const ide = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity");
const id = "11111111-2222-3333-4444-555555555555";
const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, ide);
assert.equal(r.body.ignored, true);
assert.equal(stmts.getSession.get(id), undefined);
const bad = await agyHook("Stop", "../../x", "/w");
assert.equal(bad.body.ignored, true);
const noData = await request("POST", "/api/hooks/event", {
hook_type: "Stop",
tool: "antigravity",
data: "x",
});
assert.equal(noData.body.ignored, true);
});
});
describe("antigravity turns, errors and a symlinked Gemini dir", () => {
it("starts a later turn after a Stop, whatever its invocationNum", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "t", cwd, tool: "antigravity" });
const id = "22222222-2222-4222-8222-222222222222";
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
await agyHook("Stop", id, cwd, { fullyIdle: true, error: "" });
assert.equal(lanes.getLane(lane.id).status, "idle");
await agyHook("PreInvocation", id, cwd, { invocationNum: 4 });
assert.equal(lanes.getLane(lane.id).status, "running");
assert.deepEqual(
eventsOf(id).map((e) => e.event_type),
["UserPromptSubmit", "Stop", "UserPromptSubmit"]
);
});
it("keeps a turn's own model calls out, but not the prompt after a turn with no Stop", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
lanes.createLane({ title: "n", cwd, tool: "antigravity" });
const id = "55555555-5555-4555-8555-555555555555";
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
const call = { name: "view_file", args: {} };
await agyHook("PostToolUse", id, cwd, { stepIdx: 1, error: "", toolCall: call });
const inTurn = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 });
assert.equal(inTurn.body.ignored, true);
// The run died on an API error (no Stop); the poll settled the session.
db.prepare("UPDATE sessions SET status = 'completed' WHERE id = ?").run(id);
const next = await agyHook("PreInvocation", id, cwd, { invocationNum: 2 });
assert.notEqual(next.body.ignored, true);
assert.deepEqual(
eventsOf(id).map((e) => e.event_type),
["UserPromptSubmit", "PostToolUse", "UserPromptSubmit"]
);
});
it("ignores a Stop while background work runs, and lands a failed run as an error", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "e", cwd, tool: "antigravity" });
const id = "33333333-3333-4333-8333-333333333333";
await agyHook("PreInvocation", id, cwd, { invocationNum: 0 });
const busy = await agyHook("Stop", id, cwd, { fullyIdle: false, error: "" });
assert.equal(busy.body.ignored, true);
assert.equal(lanes.getLane(lane.id).status, "running");
await agyHook("Stop", id, cwd, {
fullyIdle: true,
terminationReason: "ERROR",
error: "quota exceeded",
});
assert.equal(stmts.getSession.get(id).status, "error");
assert.equal(lanes.getLane(lane.id).status, "idle");
});
it("accepts a CLI hook reported under the real path of a symlinked Gemini dir", async () => {
const real = process.env.AGENTHUB_GEMINI_DIR;
const link = path.join(ROOT, "gemini-link");
fs.mkdirSync(real, { recursive: true });
fs.symlinkSync(real, link);
process.env.AGENTHUB_GEMINI_DIR = link;
try {
const id = "44444444-4444-4444-8444-444444444444";
const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, CLI);
assert.notEqual(r.body.ignored, true);
assert.equal(stmts.getSession.get(id).tool, "antigravity");
} finally {
process.env.AGENTHUB_GEMINI_DIR = real;
}
});
});
describe("GET/POST/DELETE /api/settings/antigravity-hooks", () => {
it("installs, reports and removes AgentHub's named hook, and refuses another site", async () => {
const file = path.join(process.env.AGENTHUB_GEMINI_DIR, "config", "hooks.json");
let r = await request("POST", "/api/settings/antigravity-hooks", null, {
Origin: "https://evil.example",
});
assert.equal(r.status, 403);
assert.equal(fs.existsSync(file), false);
r = await request("POST", "/api/settings/antigravity-hooks");
assert.equal(r.status, 200);
assert.equal(r.body.installed, true);
assert.ok(JSON.parse(fs.readFileSync(file, "utf8")).agenthub);
r = await request("GET", "/api/settings/antigravity-hooks");
assert.equal(r.body.installed, true);
r = await request("DELETE", "/api/settings/antigravity-hooks");
assert.equal(r.body.installed, false);
assert.equal(fs.existsSync(file), false);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* @file Tests server/lib/antigravity-hooks.js — AgentHub's named hook in
* `~/.gemini/config/hooks.json`: installed as `agenthub` with only
* PreInvocation/PostToolUse/Stop (never PreToolUse, whose `{}` answer makes
* agy deny the tool), every other named hook kept, remove taking out only
* ours, a non-JSON file refused, a hook switched off reported, the forwarder
* answering `{}` whatever the dashboard does, and refreshed only when it is
* ours. The Gemini dir is a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hooks-"));
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const hooks = require("../lib/antigravity-hooks");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const CONFIG = path.join(process.env.AGENTHUB_GEMINI_DIR, "config");
const FILE = path.join(CONFIG, "hooks.json");
const SCRIPT = path.join(CONFIG, "agenthub-hook.sh");
const read = () => JSON.parse(fs.readFileSync(FILE, "utf8"));
const theirs = {
enabled: true,
PreToolUse: [{ matcher: "run_command", hooks: [{ type: "command", command: "guard.sh" }] }],
};
beforeEach(() => {
fs.rmSync(process.env.AGENTHUB_GEMINI_DIR, { recursive: true, force: true });
fs.mkdirSync(CONFIG, { recursive: true });
});
describe("antigravity hooks", () => {
it("installs one named hook with the three observational events, never PreToolUse", () => {
const st = hooks.install();
assert.equal(st.installed, true);
assert.equal(st.enabled, true);
const ours = read().agenthub;
assert.deepEqual(Object.keys(ours).sort(), ["PostToolUse", "PreInvocation", "Stop", "enabled"]);
assert.equal(ours.PreToolUse, undefined);
const cmd = (e) => `'${SCRIPT}' ${e}`;
assert.deepEqual(ours.PreInvocation, [
{ type: "command", command: cmd("PreInvocation"), timeout: 5 },
]);
assert.deepEqual(ours.PostToolUse, [
{ matcher: ".*", hooks: [{ type: "command", command: cmd("PostToolUse"), timeout: 5 }] },
]);
assert.equal(fs.statSync(SCRIPT).mode & 0o111, 0o111);
});
it("answers {} on stdout and nothing else, with no dashboard to reach", () => {
hooks.install();
const out = execFileSync("sh", [SCRIPT, "Stop"], {
input: JSON.stringify({ conversationId: "x" }),
env: { PATH: process.env.PATH, CLAUDE_DASHBOARD_PORT: "1" },
timeout: 10_000,
}).toString();
assert.equal(out, "{}\n");
});
it("keeps every other named hook, and remove takes out only ours", () => {
fs.writeFileSync(FILE, JSON.stringify({ "my-guard": theirs }));
hooks.install();
hooks.install();
assert.deepEqual(Object.keys(read()).sort(), ["agenthub", "my-guard"]);
assert.deepEqual(read()["my-guard"], theirs);
const st = hooks.remove();
assert.equal(st.installed, false);
assert.deepEqual(read(), { "my-guard": theirs });
assert.equal(fs.existsSync(SCRIPT), false);
hooks.install();
hooks.remove();
assert.equal(fs.existsSync(FILE), true); // theirs is still in it
fs.rmSync(FILE);
hooks.install();
hooks.remove();
assert.equal(fs.existsSync(FILE), false);
});
it("refuses a hooks.json that is not a JSON object, and changes nothing", () => {
fs.writeFileSync(FILE, "[]");
assert.throws(() => hooks.install(), { code: "EBADJSON" });
assert.equal(fs.readFileSync(FILE, "utf8"), "[]");
assert.equal(hooks.status().readable, false);
});
it("reports a hook the user switched off", () => {
hooks.install();
const file = read();
file.agenthub.enabled = false;
fs.writeFileSync(FILE, JSON.stringify(file));
const st = hooks.status();
assert.equal(st.installed, true);
assert.equal(st.enabled, false);
});
it("refreshes a stale forwarder, and leaves a file that is not ours alone", () => {
assert.equal(hooks.refreshScript(), "absent");
hooks.install();
const stale = fs.readFileSync(SCRIPT, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'");
fs.writeFileSync(SCRIPT, stale);
assert.equal(hooks.refreshScript(), "written");
assert.equal(hooks.refreshScript(), "exists");
fs.writeFileSync(SCRIPT, "#!/bin/sh\necho mine\n");
assert.equal(hooks.refreshScript(), "foreign");
});
it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => {
fs.writeFileSync(SCRIPT, "#!/bin/sh\necho mine\n");
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
assert.equal(fs.existsSync(FILE), false);
hooks.remove();
assert.equal(fs.readFileSync(SCRIPT, "utf8"), "#!/bin/sh\necho mine\n");
fs.rmSync(SCRIPT);
fs.writeFileSync(FILE, JSON.stringify({ theirs }), { mode: 0o600 });
hooks.install();
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
hooks.remove();
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
});
it("treats a directory at the forwarder's path as not ours, and still removes the hook", () => {
hooks.install();
fs.rmSync(SCRIPT);
fs.mkdirSync(SCRIPT);
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
const st = hooks.remove();
assert.equal(st.events.Stop.installed, false);
assert.ok(fs.statSync(SCRIPT).isDirectory());
});
});
+355
View File
@@ -0,0 +1,355 @@
/**
* @file Tests server/lib/antigravity-usage.js — Antigravity CLI sessions and
* tokens from agy's per-conversation SQLite files: the protobuf reader, a
* session created as `tool = 'antigravity'` with its name, cwd, model and
* per-model tokens (input excluding cache reads, output including thinking),
* an unchanged file never re-read, a quiet one settled to completed (and the
* lane a run with no Stop left running set idle, unless a pane owns it), a
* session the user deleted staying deleted, a non-antigravity row with the
* same id never touched, the empty `-wal` of our own read-only open not being
* activity, a hook-reported turn kept running through a long quiet tool call,
* an error Stop's status kept, and a file that is not agy's read only once. The Gemini dir and the DB are temp dirs; the fixture
* files are built in the shape agy 1.2.13 writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const Database = require("better-sqlite3");
const { db } = require("../db");
const agy = require("../lib/antigravity-usage");
const lanes = require("../lib/lanes");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli");
const CONV = path.join(CLI, "conversations");
// --- a minimal protobuf writer, enough for the fixtures ---
function varint(n) {
const out = [];
while (n >= 0x80) {
out.push((n % 128) | 0x80);
n = Math.floor(n / 128);
}
out.push(n);
return Buffer.from(out);
}
const pb = (num, v) =>
typeof v === "number"
? Buffer.concat([varint(num * 8), varint(v)])
: Buffer.concat([varint(num * 8 + 2), varint(v.length), Buffer.from(v)]);
const msg = (...parts) => Buffer.concat(parts);
function generation({ model, input, output, cacheRead = 0, cacheWrite = 0 }) {
const stats = msg(
pb(1, 1318),
pb(2, input),
pb(3, output),
...(cacheWrite ? [pb(4, cacheWrite)] : []),
pb(5, cacheRead),
pb(7, "bot-1"),
pb(9, Math.floor(output / 2))
);
return msg(pb(2, ""), pb(4, "req"), pb(1, msg(pb(3, 1318), pb(4, stats), pb(19, model))));
}
function conversation(
id,
{ cwd = "/work/app", gens = [], prompt = "Fix the login bug\nplease", wal = false } = {}
) {
fs.mkdirSync(CONV, { recursive: true });
const file = path.join(CONV, `${id}.db`);
fs.rmSync(file, { force: true });
const c = new Database(file);
c.exec(`CREATE TABLE trajectory_meta (trajectory_id text, cascade_id text, trajectory_type integer, source integer, PRIMARY KEY (trajectory_id));
CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx));
CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id));`);
c.prepare("INSERT INTO trajectory_meta VALUES ('t1', ?, 4, 17)").run(id);
c.prepare("INSERT INTO trajectory_metadata_blob VALUES ('main', ?)").run(
msg(pb(1, msg(pb(2, `file://${cwd.replace(/ /g, "%20")}`))))
);
gens.forEach((g, i) =>
c.prepare("INSERT INTO gen_metadata VALUES (?, ?, 0)").run(i, generation(g))
);
// agy's files are WAL; a clean close deletes the -wal, as agy's exit does.
if (wal) c.pragma("journal_mode = WAL");
c.close();
const logs = path.join(CLI, "brain", id, ".system_generated", "logs");
fs.mkdirSync(logs, { recursive: true });
fs.writeFileSync(
path.join(logs, "transcript_full.jsonl"),
JSON.stringify({
step_index: 0,
source: "USER_EXPLICIT",
type: "USER_INPUT",
status: "DONE",
created_at: "2026-09-29T04:38:44Z",
content: `<USER_REQUEST>\n${prompt}\n</USER_REQUEST>\n<ADDITIONAL_METADATA>x</ADDITIONAL_METADATA>`,
}) + "\n"
);
return file;
}
function age(id, ms) {
const t = new Date(Date.now() - ms);
fs.utimesSync(path.join(CONV, `${id}.db`), t, t);
fs.utimesSync(
path.join(CLI, "brain", id, ".system_generated", "logs", "transcript_full.jsonl"),
t,
t
);
}
const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
const usage = (id) =>
db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(id);
const ID = "57f3e409-9608-422b-8040-503f1bc7b5b8";
beforeEach(() => {
fs.rmSync(process.env.AGENTHUB_GEMINI_DIR, { recursive: true, force: true });
db.exec(
"DELETE FROM events; DELETE FROM agents; DELETE FROM token_usage; DELETE FROM sessions; DELETE FROM antigravity_conversations;"
);
});
const hookEvent = (id, type, msAgo) =>
db
.prepare("INSERT INTO events (session_id, event_type, created_at) VALUES (?, ?, ?)")
.run(id, type, new Date(Date.now() - msAgo).toISOString());
describe("antigravity usage", () => {
it("reads a generation's usage and model, and refuses a malformed buffer", () => {
const u = agy.generationUsage(
generation({ model: "gemini-3.8-flash", input: 12271, output: 153, cacheRead: 40 })
);
assert.deepEqual(u, {
model: "gemini-3.8-flash",
input: 12271,
output: 153,
cacheWrite: 0,
cacheRead: 40,
});
assert.equal(agy.generationUsage(Buffer.from([0x0a, 0x7f, 1])), null);
assert.equal(agy.generationUsage(msg(pb(4, "x"))), null);
assert.equal(agy.firstFileUri(msg(pb(1, msg(pb(2, "file:///a%20b/c"))))), "/a b/c");
});
it("creates the session with its name, cwd, model and per-model tokens", async () => {
conversation(ID, {
cwd: "/work/my app",
gens: [
{ model: "gemini-3.8-flash", input: 100, output: 10, cacheRead: 1000 },
{ model: "gemini-3.8-flash", input: 50, output: 5, cacheWrite: 7 },
{ model: "gemini-3.1-pro", input: 1, output: 2 },
],
});
const r = await agy.ingestAntigravityUsage();
assert.deepEqual(r, { files: 1, sessions: 1 });
const s = session(ID);
assert.equal(s.tool, "antigravity");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, "/work/my app");
assert.equal(s.model, "gemini-3.1-pro");
assert.equal(s.started_at, "2026-09-29T04:38:44Z");
assert.equal(s.status, "active");
assert.equal(s.transcript_path, null);
assert.deepEqual(usage(ID), [
{
model: "gemini-3.1-pro",
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
{
model: "gemini-3.8-flash",
input_tokens: 150,
output_tokens: 15,
cache_read_tokens: 1000,
cache_write_tokens: 7,
},
]);
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
});
it("settles a session to completed once its file has gone quiet", async () => {
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
await agy.ingestAntigravityUsage();
assert.equal(session(ID).status, "active");
// Unchanged since the last read, just older: no re-read, only the status.
const seen = db.prepare("SELECT mtime_ms FROM antigravity_conversations").get().mtime_ms;
const r = await agy.ingestAntigravityUsage({ now: seen + 5 * 60_000 });
assert.deepEqual(r, { files: 0, sessions: 1 });
assert.equal(session(ID).status, "completed");
// Read fresh when already old: completed straight away, ended at its last write.
db.exec("DELETE FROM sessions; DELETE FROM antigravity_conversations;");
age(ID, 10 * 60_000);
await agy.ingestAntigravityUsage();
assert.equal(session(ID).status, "completed");
assert.ok(session(ID).ended_at);
});
it("sets idle the lane a run with no Stop left running, once the session is quiet", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "a", cwd, tool: "antigravity" });
lanes.updateLane(lane.id, { status: "running", session_id: ID });
const other = "11111111-2222-3333-4444-555555555555";
const cwd2 = fs.mkdtempSync(path.join(ROOT, "lane-"));
const paned = lanes.createLane({ title: "b", cwd: cwd2, tool: "antigravity" });
lanes.updateLane(paned.id, { status: "running", session_id: other, run_id: "agenthub-lane-9" });
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
conversation(other, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
const released = [];
const broadcastLane = (id) => released.push(id);
await agy.ingestAntigravityUsage({ broadcastLane });
assert.equal(lanes.getLane(lane.id).status, "running"); // still being written
const seen = db.prepare("SELECT max(mtime_ms) m FROM antigravity_conversations").get().m;
await agy.ingestAntigravityUsage({ broadcastLane, now: seen + 5 * 60_000 });
assert.equal(lanes.getLane(lane.id).status, "idle");
assert.equal(lanes.getLane(paned.id).status, "running");
assert.deepEqual(released, [lane.id]);
});
it("keeps a deleted session deleted until agy writes to it again", async () => {
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
age(ID, 10 * 60_000);
await agy.ingestAntigravityUsage();
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
await agy.ingestAntigravityUsage();
assert.equal(session(ID), undefined);
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 2, output: 1 }] });
await agy.ingestAntigravityUsage();
assert.equal(session(ID).tool, "antigravity");
});
it("never touches a session of another tool with the same id", async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES (?, 'mine', 'active', 'claude', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
).run(ID);
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
await agy.ingestAntigravityUsage();
assert.equal(session(ID).name, "mine");
assert.deepEqual(usage(ID), []);
});
it("keeps a hook-made session's cwd and a name the user saw, over a placeholder", async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, 'active', 'antigravity', '/from/hook', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
).run(ID, `Antigravity ${ID.slice(0, 8)}`);
conversation(ID, {
cwd: "/from/db",
gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }],
});
await agy.ingestAntigravityUsage();
assert.equal(session(ID).cwd, "/from/hook");
assert.equal(session(ID).name, "Fix the login bug");
});
it("skips a file that is not a conversation database, and carries on", async () => {
fs.mkdirSync(CONV, { recursive: true });
fs.writeFileSync(path.join(CONV, "00000000-0000-0000-0000-000000000000.db"), "not sqlite");
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
const r = await agy.ingestAntigravityUsage();
assert.equal(r.sessions, 1);
assert.ok(session(ID));
// Recorded, so it is not re-read (nor warned about) every pass.
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
});
it("does not take the empty -wal its own read-only open leaves for activity", async () => {
conversation(ID, { wal: true, gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
age(ID, 10 * 60_000);
await agy.ingestAntigravityUsage();
const first = session(ID);
assert.equal(first.status, "completed");
assert.deepEqual(await agy.ingestAntigravityUsage(), { files: 0, sessions: 0 });
assert.equal(session(ID).status, "completed");
assert.equal(session(ID).updated_at, first.updated_at);
});
it("keeps a turn the hooks report running through a long quiet tool call", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "h", cwd, tool: "antigravity" });
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
age(ID, 10 * 60_000);
await agy.ingestAntigravityUsage();
// A hook three minutes ago: agy is mid-turn, its file untouched for ten.
db.prepare("UPDATE sessions SET status = 'active', ended_at = NULL WHERE id = ?").run(ID);
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES (?, ?, 'Main', 'main', 'working', '2026-09-29T00:00:00Z')"
).run(`${ID}-main`, ID);
lanes.updateLane(lane.id, { status: "running", session_id: ID });
hookEvent(ID, "PostToolUse", 3 * 60_000);
await agy.ingestAntigravityUsage();
assert.equal(session(ID).status, "active");
assert.equal(lanes.getLane(lane.id).status, "running");
// Ten minutes after that hook, it is over.
await agy.ingestAntigravityUsage({ now: Date.now() + 8 * 60_000 });
assert.equal(session(ID).status, "completed");
assert.equal(lanes.getLane(lane.id).status, "idle");
assert.equal(
db.prepare("SELECT status FROM agents WHERE id = ?").get(`${ID}-main`).status,
"completed"
);
});
it("keeps a session waiting after a Stop active for ten quiet minutes", async () => {
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
age(ID, 10 * 60_000);
await agy.ingestAntigravityUsage();
db.prepare("UPDATE sessions SET status = 'active', ended_at = NULL WHERE id = ?").run(ID);
hookEvent(ID, "Stop", 3 * 60_000);
await agy.ingestAntigravityUsage();
assert.equal(session(ID).status, "active");
await agy.ingestAntigravityUsage({ now: Date.now() + 8 * 60_000 });
assert.equal(session(ID).status, "completed");
});
it("gives a file it could not read another try after a restart", async () => {
fs.mkdirSync(CONV, { recursive: true });
const bad = path.join(CONV, "00000000-0000-0000-0000-000000000000.db");
fs.writeFileSync(bad, "not sqlite");
await agy.ingestAntigravityUsage();
const seen = () => db.prepare("SELECT session_id FROM antigravity_conversations").all();
assert.deepEqual(seen(), [{ session_id: "" }]);
const saved = process.env.DASHBOARD_ANTIGRAVITY_POLL_MS;
process.env.DASHBOARD_ANTIGRAVITY_POLL_MS = "3600000";
try {
agy.startAntigravityUsagePoll(
() => {},
() => {}
);
// Cleared before the first pass reads it again.
assert.ok(seen().length <= 1);
await agy.ingestAntigravityUsage();
} finally {
if (saved === undefined) delete process.env.DASHBOARD_ANTIGRAVITY_POLL_MS;
else process.env.DASHBOARD_ANTIGRAVITY_POLL_MS = saved;
}
});
it("keeps the error a failed run's Stop gave the session", async () => {
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 1, output: 1 }] });
await agy.ingestAntigravityUsage();
db.prepare("UPDATE sessions SET status = 'error' WHERE id = ?").run(ID);
hookEvent(ID, "Stop", 1000);
conversation(ID, { gens: [{ model: "gemini-3.8-flash", input: 2, output: 1 }] });
await agy.ingestAntigravityUsage();
assert.equal(session(ID).status, "error");
assert.equal(usage(ID)[0].input_tokens, 2);
});
});
+15
View File
@@ -57,6 +57,7 @@ const EXPECTED_API_PATHS = [
"/api/settings/export",
"/api/settings/cleanup",
"/api/settings/claude-home",
"/api/settings/codex-home",
"/api/import/guide",
"/api/import/rescan",
"/api/import/scan-path",
@@ -277,6 +278,20 @@ describe("Sessions API", () => {
res.body.sessions.forEach((s) => assert.equal(s.status, "active"));
});
it("filters sessions by tool, before the limit", async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES ('sess-cx', 'cx', 'active', 'codex', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
).run();
const codex = await fetch("/api/sessions?tool=codex&limit=1");
assert.deepEqual(
codex.body.sessions.map((s) => s.id),
["sess-cx"]
);
const claude = await fetch("/api/sessions?tool=claude");
assert.ok(claude.body.sessions.length >= 1);
assert.ok(claude.body.sessions.every((s) => s.tool === "claude"));
});
it("should paginate sessions", async () => {
const res = await fetch("/api/sessions?limit=1&offset=0");
assert.equal(res.body.sessions.length, 1);
+81
View File
@@ -0,0 +1,81 @@
/**
* @file Tests setClaudeHome: the shared home-dir guard (a directory that is or
* contains the user's home is refused), persistence to the settings file
* (never the repo `.env`, which a plugin update wipes), and pinning the data
* dir, so a new CLAUDE_HOME can never open an empty database at the next start.
* The settings file is a temp path (AGENTHUB_SETTINGS_FILE).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-claude-home-set-"));
const SETTINGS = path.join(ROOT, "settings.env");
const REPO_ENV = path.resolve(__dirname, "..", "..", ".env");
const SAVED = {
AGENTHUB_SETTINGS_FILE: process.env.AGENTHUB_SETTINGS_FILE,
CLAUDE_HOME: process.env.CLAUDE_HOME,
DASHBOARD_DATA_DIR: process.env.DASHBOARD_DATA_DIR,
};
process.env.AGENTHUB_SETTINGS_FILE = SETTINGS;
const { setClaudeHome, getClaudeHome, getDataDir } = require("../lib/claude-home");
describe("setClaudeHome", () => {
let repoEnvBefore;
before(() => {
repoEnvBefore = fs.existsSync(REPO_ENV) ? fs.readFileSync(REPO_ENV, "utf8") : null;
});
beforeEach(() => {
fs.rmSync(SETTINGS, { force: true });
process.env.CLAUDE_HOME = fs.mkdtempSync(path.join(ROOT, "start-"));
delete process.env.DASHBOARD_DATA_DIR;
});
after(() => {
for (const [k, v] of Object.entries(SAVED)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fs.rmSync(ROOT, { recursive: true, force: true });
});
for (const bad of ["/", os.homedir(), path.dirname(os.homedir()), "~/"]) {
it(`refuses ${JSON.stringify(bad)} as too broad`, () => {
const current = getClaudeHome();
assert.throws(() => setClaudeHome(bad), { code: "EBADHOME" });
assert.equal(getClaudeHome(), current);
assert.ok(!fs.existsSync(SETTINGS));
});
}
it("accepts a real directory, persisting it to the settings file only", () => {
const next = fs.mkdtempSync(path.join(ROOT, "next-"));
assert.equal(setClaudeHome(next), next);
assert.equal(getClaudeHome(), next);
assert.match(fs.readFileSync(SETTINGS, "utf8"), new RegExp(`^CLAUDE_HOME=${next}$`, "m"));
const repoEnvNow = fs.existsSync(REPO_ENV) ? fs.readFileSync(REPO_ENV, "utf8") : null;
assert.equal(repoEnvNow, repoEnvBefore, "the repo .env is never written");
});
it("pins the data dir in use before CLAUDE_HOME moves, so the database stays put", () => {
const dataDirBefore = getDataDir();
setClaudeHome(fs.mkdtempSync(path.join(ROOT, "next-")));
assert.equal(getDataDir(), dataDirBefore);
assert.equal(process.env.DASHBOARD_DATA_DIR, dataDirBefore);
assert.match(
fs.readFileSync(SETTINGS, "utf8"),
new RegExp(`^DASHBOARD_DATA_DIR=${dataDirBefore}$`, "m")
);
});
it("leaves an explicit DASHBOARD_DATA_DIR alone", () => {
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "explicit-data");
setClaudeHome(fs.mkdtempSync(path.join(ROOT, "next-")));
assert.equal(process.env.DASHBOARD_DATA_DIR, path.join(ROOT, "explicit-data"));
assert.doesNotMatch(fs.readFileSync(SETTINGS, "utf8"), /DASHBOARD_DATA_DIR/);
});
});
+286
View File
@@ -0,0 +1,286 @@
/**
* @file Tests POST /api/hooks/event for hooks marked `tool: "codex"` (sent by
* Codex through server/lib/codex-hooks.js): the session is created as a Codex
* one with no transcript path, a PermissionRequest becomes the permission
* Notification (awaiting flag + the codex lane's needs_action, never the claude
* lane sharing the directory), no stage detection on a codex lane, and a
* sub-agent thread's lifecycle hooks dropped while its tool calls land on the
* root session, a main agent made for a session the journal poll imported first,
* and the install/remove routes refusing another site. CODEX_HOME and the DB
* live in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
const http = require("node:http");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-hook-route-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CODEX_HOME = path.join(ROOT, "codex");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const lanes = require("../lib/lanes");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method,
headers: { "Content-Type": "application/json", ...headers },
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text || "{}") }));
}
);
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const codexHook = (hook_type, data) =>
request("POST", "/api/hooks/event", { hook_type, data, tool: "codex" });
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const DAY = path.join(process.env.CODEX_HOME, "sessions", "2026", "09", "29");
function rollout(thread, root) {
fs.mkdirSync(DAY, { recursive: true });
const file = path.join(DAY, `rollout-2026-09-29T01-00-00-${thread}.jsonl`);
fs.writeFileSync(
file,
JSON.stringify({ type: "session_meta", payload: { id: thread, session_id: root, cwd: "/w" } }) +
"\n"
);
return file;
}
const eventsOf = (sid) =>
db.prepare("SELECT event_type, summary FROM events WHERE session_id = ? ORDER BY id").all(sid);
describe("codex hooks through /api/hooks/event", () => {
it("files a permission request as waiting even when its command says compress", async () => {
await codexHook("UserPromptSubmit", { session_id: "cx-zip", cwd: "/w", prompt: "pack" });
await codexHook("PermissionRequest", {
session_id: "cx-zip",
cwd: "/w",
tool_name: "Bash",
tool_input: { command: "tar --compress -cf out.tgz dist" },
});
assert.equal(eventsOf("cx-zip").at(-1).event_type, "Notification");
assert.ok(stmts.getSession.get("cx-zip").awaiting_input_since);
});
it("does not let a sub-agent's tool call after the root's Stop resume the root", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "k", cwd, tool: "codex" });
rollout("cx-root2", "cx-root2");
const kid = rollout("cx-kid2", "cx-root2");
await codexHook("UserPromptSubmit", { session_id: "cx-root2", cwd, prompt: "go" });
await codexHook("Stop", { session_id: "cx-root2", cwd });
assert.equal(lanes.getLane(lane.id).status, "idle");
const waiting = stmts.getSession.get("cx-root2").awaiting_input_since;
assert.ok(waiting);
const call = { session_id: "cx-kid2", transcript_path: kid, cwd, tool_name: "Bash" };
await codexHook("PreToolUse", { ...call, tool_input: { command: "ls" } });
await codexHook("PostToolUse", { ...call, tool_input: { command: "ls" } });
assert.equal(stmts.getSession.get("cx-root2").awaiting_input_since, waiting);
assert.notEqual(
db.prepare("SELECT status FROM agents WHERE id = ?").get("cx-root2-main").status,
"working"
);
assert.equal(lanes.getLane(lane.id).status, "idle");
assert.deepEqual(
eventsOf("cx-root2").map((e) => e.event_type),
["UserPromptSubmit", "Stop", "PreToolUse", "PostToolUse"]
);
});
it("creates a Codex session with no transcript path, and never reads the rollout as a Claude transcript", async () => {
const file = rollout("cx-1", "cx-1");
const r = await codexHook("SessionStart", {
session_id: "cx-1",
transcript_path: file,
cwd: "/w",
model: "gpt-5.6-sol",
source: "startup",
});
assert.equal(r.status, 200);
const row = stmts.getSession.get("cx-1");
assert.equal(row.tool, "codex");
assert.equal(row.name, "Codex cx-1");
assert.equal(row.transcript_path, null);
assert.equal(row.status, "active");
assert.ok(row.awaiting_input_since, "a fresh Codex session sits at its prompt");
});
it("treats an unknown tool value as Claude", async () => {
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "cc-odd", cwd: "/w" },
tool: "evil",
});
assert.equal(stmts.getSession.get("cc-odd").tool, "claude");
});
it("turns a PermissionRequest into the permission Notification, on the codex lane only", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" });
const codexLane = lanes.createLane({ title: "x", cwd, tool: "codex" });
await codexHook("UserPromptSubmit", { session_id: "cx-2", cwd, prompt: "go" });
await codexHook("PermissionRequest", {
session_id: "cx-2",
cwd,
tool_name: "Bash",
tool_input: { command: "touch /etc/x", description: "write outside the sandbox" },
});
assert.ok(stmts.getSession.get("cx-2").awaiting_input_since);
const last = eventsOf("cx-2").at(-1);
assert.equal(last.event_type, "Notification");
assert.equal(last.summary, "Codex needs your permission: write outside the sandbox");
assert.equal(lanes.getLane(codexLane.id).needs_action, last.summary);
assert.equal(lanes.getLane(codexLane.id).session_id, "cx-2");
assert.equal(lanes.getLane(claudeLane.id).needs_action, null);
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
await codexHook("PostToolUse", { session_id: "cx-2", cwd, tool_name: "Bash" });
assert.equal(lanes.getLane(codexLane.id).needs_action, null);
assert.equal(stmts.getSession.get("cx-2").awaiting_input_since, null);
});
it("ends the turn on an Interrupt (a declined permission, or Esc)", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "x", cwd, tool: "codex" });
await codexHook("UserPromptSubmit", { session_id: "cx-int", cwd, prompt: "go" });
await codexHook("PermissionRequest", {
session_id: "cx-int",
cwd,
tool_name: "Bash",
tool_input: { command: "rm -rf build" },
});
assert.equal(lanes.getLane(lane.id).status, "running");
await codexHook("Interrupt", { session_id: "cx-int", cwd });
const after = lanes.getLane(lane.id);
assert.equal(after.status, "idle");
assert.equal(after.needs_action, null);
assert.equal(stmts.getSession.get("cx-int").awaiting_reason, "stop");
assert.equal(eventsOf("cx-int").at(-1).event_type, "Stop");
});
it("never infers a stage on a codex lane", async () => {
const cwd = fs.mkdtempSync(path.join(ROOT, "lane-"));
const lane = lanes.createLane({ title: "x", cwd, tool: "codex" });
await codexHook("PreToolUse", {
session_id: "cx-3",
cwd,
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
});
const after = lanes.getLane(lane.id);
assert.equal(after.detected_stage, null);
assert.equal(after.status, "running");
});
it("drops a sub-agent thread's lifecycle hooks and puts its tool calls on the root session", async () => {
const child = rollout("cx-child", "cx-root");
await codexHook("SessionStart", { session_id: "cx-root", cwd: "/w" });
const stop = await codexHook("Stop", {
session_id: "cx-child",
transcript_path: child,
cwd: "/w",
});
assert.equal(stop.status, 200);
assert.equal(stop.body.ignored, true);
await codexHook("PreToolUse", {
session_id: "cx-child",
transcript_path: child,
cwd: "/w",
tool_name: "Bash",
tool_input: { command: "ls" },
});
assert.equal(stmts.getSession.get("cx-child"), undefined);
assert.equal(eventsOf("cx-root").at(-1).event_type, "PreToolUse");
assert.equal(
eventsOf("cx-root").some((e) => e.event_type === "Stop"),
false
);
});
it("gives a session the journal poll imported first its main agent", async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, tool, started_at, updated_at) VALUES ('cx-polled', 'p', 'completed', 'codex', '2026-09-29T00:00:00Z', '2026-09-29T00:00:00Z')"
).run();
await codexHook("PreToolUse", { session_id: "cx-polled", cwd: "/w", tool_name: "Bash" });
const agent = stmts.getAgent.get("cx-polled-main");
assert.ok(agent);
assert.equal(agent.type, "main");
});
it("does not trust a transcript_path outside CODEX_HOME to name a root session", async () => {
const outside = path.join(ROOT, "rollout-2026-09-29T01-00-00-cx-x.jsonl");
fs.writeFileSync(
outside,
JSON.stringify({ type: "session_meta", payload: { id: "cx-x", session_id: "victim" } }) + "\n"
);
await codexHook("PreToolUse", {
session_id: "cx-x",
transcript_path: outside,
cwd: "/w",
tool_name: "Bash",
});
assert.ok(stmts.getSession.get("cx-x"));
assert.equal(stmts.getSession.get("victim"), undefined);
});
});
describe("GET/POST/DELETE /api/settings/codex-hooks", () => {
it("installs, reports and removes AgentHub's Codex hooks", async () => {
let r = await request("GET", "/api/settings/codex-hooks");
assert.equal(r.status, 200);
assert.equal(r.body.installed, false);
r = await request("POST", "/api/settings/codex-hooks");
assert.equal(r.status, 200);
assert.equal(r.body.installed, true);
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, "hooks.json")));
r = await request("DELETE", "/api/settings/codex-hooks");
assert.equal(r.body.installed, false);
});
it("refuses an install or remove asked by another site", async () => {
const evil = { Origin: "https://evil.example" };
let r = await request("POST", "/api/settings/codex-hooks", null, evil);
assert.equal(r.status, 403);
assert.equal(fs.existsSync(path.join(process.env.CODEX_HOME, "hooks.json")), false);
r = await request("DELETE", "/api/settings/codex-hooks", null, evil);
assert.equal(r.status, 403);
});
it("answers 400 EBADJSON for a hooks.json it cannot merge into", async () => {
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(path.join(process.env.CODEX_HOME, "hooks.json"), "[1]");
const r = await request("POST", "/api/settings/codex-hooks");
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADJSON");
fs.rmSync(path.join(process.env.CODEX_HOME, "hooks.json"));
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* @file Tests server/lib/codex-hooks.js — installing AgentHub's hooks into
* Codex's `$CODEX_HOME/hooks.json`: a user's own hooks kept, a reinstall
* replacing only ours, remove leaving theirs, a non-JSON file refused, trust
* read from `config.toml` (also under a symlinked home), a user's hook in our
* group or with a similar name never removed with ours, and the forwarding
* script refreshed — only when it is ours — without its command (which Codex's
* trust is keyed on) changing. CODEX_HOME is a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-hooks-"));
process.env.CODEX_HOME = path.join(ROOT, "codex");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const hooks = require("../lib/codex-hooks");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const HOME = process.env.CODEX_HOME;
const FILE = path.join(HOME, "hooks.json");
const read = () => JSON.parse(fs.readFileSync(FILE, "utf8"));
const theirs = { hooks: [{ type: "command", command: "my-audit.sh", timeout: 10 }] };
beforeEach(() => {
fs.rmSync(HOME, { recursive: true, force: true });
fs.mkdirSync(HOME, { recursive: true });
});
describe("codex hooks", () => {
it("installs one group per event, all through the stable script", () => {
const st = hooks.install();
assert.equal(st.installed, true);
const file = read();
assert.deepEqual(Object.keys(file.hooks).sort(), [...hooks.HOOK_EVENTS].sort());
for (const e of hooks.HOOK_EVENTS) {
assert.deepEqual(file.hooks[e], [
{
hooks: [
{
type: "command",
command: `'${path.join(HOME, "agenthub-hook.sh")}' ${e}`,
timeout: 3,
},
],
},
]);
}
const script = fs.readFileSync(path.join(HOME, "agenthub-hook.sh"), "utf8");
assert.match(script, /^#!\/bin\/sh\n/);
assert.ok(script.includes(path.resolve(__dirname, "..", "..", "scripts", "hook-handler.js")));
assert.ok(script.includes(`CLAUDE_HOME='${process.env.CLAUDE_HOME}'`));
assert.ok(script.includes('"$1" codex'));
assert.equal(fs.statSync(path.join(HOME, "agenthub-hook.sh")).mode & 0o111, 0o111);
});
it("keeps the user's own hooks, and a reinstall replaces only ours", () => {
fs.writeFileSync(
FILE,
JSON.stringify({ description: "mine", hooks: { PreToolUse: [theirs], Notify: [theirs] } })
);
hooks.install();
hooks.install();
const file = read();
assert.equal(file.description, "mine");
assert.deepEqual(file.hooks.Notify, [theirs]);
assert.equal(file.hooks.PreToolUse.length, 2);
assert.deepEqual(file.hooks.PreToolUse[0], theirs);
assert.equal(file.hooks.Stop.length, 1);
});
it("remove takes out only ours, and deletes a file that held nothing else", () => {
fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
hooks.install();
let st = hooks.remove();
assert.equal(st.installed, false);
assert.deepEqual(read(), { hooks: { PreToolUse: [theirs] } });
assert.equal(fs.existsSync(path.join(HOME, "agenthub-hook.sh")), false);
fs.rmSync(FILE);
hooks.install();
st = hooks.remove();
assert.equal(fs.existsSync(FILE), false);
assert.equal(st.installed, false);
});
it("refuses a hooks.json that is not a JSON object, and changes nothing", () => {
fs.writeFileSync(FILE, "{ not json");
assert.throws(() => hooks.install(), { code: "EBADJSON" });
assert.equal(fs.readFileSync(FILE, "utf8"), "{ not json");
assert.equal(hooks.status().readable, false);
});
it("reports trust from Codex's own hooks.state entries", () => {
fs.writeFileSync(FILE, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
hooks.install();
let st = hooks.status();
assert.equal(st.events.PreToolUse.trusted, false);
// Ours is the second PreToolUse group (index 1); Stop's is the first.
fs.writeFileSync(
path.join(HOME, "config.toml"),
[
'model = "x"',
"",
`[hooks.state."${FILE}:pre_tool_use:1:0"]`,
'trusted_hash = "sha256:aa"',
`[hooks.state."${FILE}:stop:0:0"]`,
'trusted_hash = "sha256:bb"',
].join("\n")
);
st = hooks.status();
assert.equal(st.events.PreToolUse.trusted, true);
assert.equal(st.events.Stop.trusted, true);
assert.equal(st.events.SessionStart.trusted, false);
assert.equal(st.trusted, false);
});
it("refreshes a stale script, never touches an absent one, and keeps the command", () => {
assert.equal(hooks.refreshScript(), "absent");
hooks.install();
const before = read();
const script = path.join(HOME, "agenthub-hook.sh");
const stale = fs.readFileSync(script, "utf8").replace(/'[^']*hook-handler\.js'/, "'/old/h.js'");
fs.writeFileSync(script, stale);
assert.equal(hooks.refreshScript(), "written");
assert.ok(fs.readFileSync(script, "utf8").includes(path.resolve(__dirname, "..", "..")));
assert.equal(hooks.refreshScript(), "exists");
fs.chmodSync(script, 0o644);
assert.equal(hooks.refreshScript(), "written");
assert.equal(fs.statSync(script).mode & 0o111, 0o111);
assert.deepEqual(read(), before);
// A file by that name that is not ours is left alone.
fs.writeFileSync(script, "#!/bin/sh\necho mine\n");
assert.equal(hooks.refreshScript(), "foreign");
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
});
it("never removes a user's hook that shares our group or has a similar name", () => {
hooks.install();
const file = read();
const mine = { type: "command", command: "my-notify.sh" };
file.hooks.Stop[0].hooks.push(mine);
const lookalike = {
hooks: [{ type: "command", command: "/home/me/bin/not-agenthub-hook.sh" }],
};
file.hooks.PreToolUse.unshift(lookalike);
fs.writeFileSync(FILE, JSON.stringify(file));
assert.equal(hooks.status().events.PreToolUse.installed, true);
hooks.install();
let after = read();
assert.deepEqual(after.hooks.PreToolUse[0], lookalike);
assert.deepEqual(after.hooks.Stop[0].hooks, [mine]);
assert.equal(after.hooks.Stop.length, 2);
hooks.remove();
after = read();
assert.deepEqual(after.hooks, { PreToolUse: [lookalike], Stop: [{ hooks: [mine] }] });
});
it("reads trust keyed on the resolved home when CODEX_HOME is a symlink", () => {
const link = path.join(ROOT, "codex-link");
fs.rmSync(link, { force: true });
fs.symlinkSync(HOME, link);
process.env.CODEX_HOME = link;
try {
hooks.install();
fs.writeFileSync(
path.join(HOME, "config.toml"),
`[hooks.state."${FILE}:stop:0:0"]\ntrusted_hash = "sha256:bb"\n`
);
assert.equal(hooks.status().events.Stop.trusted, true);
} finally {
process.env.CODEX_HOME = HOME;
}
});
it("writes through a symlinked hooks.json instead of replacing the link", () => {
const real = path.join(ROOT, "dotfiles-hooks.json");
fs.writeFileSync(real, JSON.stringify({ hooks: { PreToolUse: [theirs] } }));
fs.symlinkSync(real, FILE);
hooks.install();
assert.equal(fs.lstatSync(FILE).isSymbolicLink(), true);
assert.equal(JSON.parse(fs.readFileSync(real, "utf8")).hooks.Stop.length, 1);
fs.rmSync(real);
});
it("single-quotes every value it writes into the script", () => {
assert.equal(hooks.shq("/a b/it's"), `'/a b/it'\\''s'`);
});
it("never overwrites or removes a script that is not ours, and keeps hooks.json's mode", () => {
const script = path.join(HOME, "agenthub-hook.sh");
fs.writeFileSync(script, "#!/bin/sh\necho mine\n");
assert.throws(() => hooks.install(), { code: "EFOREIGN" });
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
assert.equal(fs.existsSync(FILE), false);
hooks.remove();
assert.equal(fs.readFileSync(script, "utf8"), "#!/bin/sh\necho mine\n");
fs.rmSync(script);
fs.writeFileSync(FILE, JSON.stringify({ hooks: { Stop: [theirs] } }), { mode: 0o600 });
hooks.install();
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
hooks.remove();
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
});
it("empties a symlinked hooks.json through the link instead of unlinking it", () => {
const real = path.join(ROOT, "dotfiles-hooks.json");
fs.writeFileSync(real, "{}\n");
fs.symlinkSync(real, FILE);
hooks.install();
hooks.remove();
assert.ok(fs.lstatSync(FILE).isSymbolicLink());
assert.deepEqual(JSON.parse(fs.readFileSync(real, "utf8")), { hooks: {} });
});
});
+34
View File
@@ -95,6 +95,40 @@ describe("codex status mirror", () => {
lanes.deleteLane(lane.id);
});
it("keeps a lane running while an older journal of the same lane sits beside the live one", () => {
const lane = codexLane();
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).status, "running");
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).status, "running");
lanes.deleteLane(lane.id);
});
it("counts a journal whose mtime is a fraction of a second ahead as fresh", () => {
const lane = codexLane();
writeRollout(lane.cwd, { mtime: Date.now() + 400 });
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).status, "running");
lanes.deleteLane(lane.id);
});
it("leaves a lane alone while its session's hooks are current", () => {
const { stmts } = require("../db");
const lane = codexLane();
lanes.updateLane(lane.id, { session_id: "hooked" });
stmts.insertSession.run("hooked", "x", "active", lane.cwd, null, null);
stmts.insertEvent.run("hooked", null, "Stop", null, "ready", "{}");
writeRollout(lane.cwd); // fresh journal: the mirror alone would say running
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("a second pass with nothing new changes nothing", () => {
const lane = codexLane();
writeRollout(lane.cwd);
+430
View File
@@ -0,0 +1,430 @@
/**
* @file Tests server/lib/codex-usage.js — Codex sessions and token usage read
* from rollout journals: the token mapping (input includes cached, output
* includes reasoning), duplicate totals, a mid-session model switch, a fork's
* copied history skipped, a sub-agent thread folded into its root session,
* incremental reads, a deleted session not resurrected, and a Claude row never
* touched. CODEX_HOME and the DB live in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-usage-"));
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.CODEX_HOME = path.join(ROOT, "codex");
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const { db, stmts } = require("../db");
const { parseRolloutChunk, ingestCodexUsage, settleActive } = require("../lib/codex-usage");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const DAY = path.join(process.env.CODEX_HOME, "sessions", "2026", "09", "29");
function meta(id, extra = {}) {
return {
timestamp: "2026-09-29T01:00:00.000Z",
type: "session_meta",
payload: { id, session_id: id, timestamp: "2026-09-29T01:00:00.000Z", cwd: "/w/p", ...extra },
};
}
const ctx = (model) => ({
timestamp: "2026-09-29T01:00:01.000Z",
type: "turn_context",
payload: { model },
});
function tc(total, last, ts = "2026-09-29T01:00:02.000Z") {
const v = (t) => ({
input_tokens: t[0],
cached_input_tokens: t[1],
cache_write_input_tokens: 0,
output_tokens: t[2],
reasoning_output_tokens: 0,
total_tokens: t[0] + t[2],
});
return {
timestamp: ts,
type: "event_msg",
payload: {
type: "token_count",
info: { total_token_usage: v(total), last_token_usage: v(last) },
},
};
}
const jsonl = (lines) => lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
function writeRollout(id, lines, mtime = new Date("2026-09-29T02:00:00Z")) {
fs.mkdirSync(DAY, { recursive: true });
const file = path.join(DAY, `rollout-2026-09-29T01-00-00-${id}.jsonl`);
fs.writeFileSync(file, jsonl(lines));
fs.utimesSync(file, mtime, mtime);
return file;
}
const usage = (sid) =>
db
.prepare(
"SELECT model, input_tokens AS i, cache_read_tokens AS r, output_tokens AS o FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(sid);
beforeEach(() => {
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
db.exec(
"DELETE FROM codex_rollouts; DELETE FROM token_usage; DELETE FROM events; DELETE FROM sessions;"
);
});
describe("parseRolloutChunk", () => {
it("maps tokens, skips a repeated total and a null info, and follows the model", () => {
const text = jsonl([
meta("t1"),
ctx("gpt-a"),
tc([100, 60, 10], [100, 60, 10]),
tc([100, 60, 10], [100, 60, 10]), // emitted twice
{ type: "event_msg", payload: { type: "token_count", info: null } },
ctx("gpt-b"),
tc([150, 90, 15], [50, 30, 5], "2026-09-29T01:05:00.000Z"),
]);
const s = parseRolloutChunk(text, {});
assert.equal(s.threadId, "t1");
assert.equal(s.sessionId, "t1");
assert.equal(s.cwd, "/w/p");
assert.equal(s.model, "gpt-b");
assert.deepEqual(s.usage, {
"gpt-a": { input: 40, cacheRead: 60, cacheWrite: 0, output: 10 },
"gpt-b": { input: 20, cacheRead: 30, cacheWrite: 0, output: 5 },
});
});
it("continues from saved state across chunks", () => {
let s = parseRolloutChunk(jsonl([meta("t1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]), {});
s = parseRolloutChunk(jsonl([tc([10, 0, 1], [10, 0, 1]), tc([30, 0, 3], [20, 0, 2])]), s);
assert.deepEqual(s.usage["gpt-a"], { input: 30, cacheRead: 0, cacheWrite: 0, output: 3 });
});
});
describe("ingestCodexUsage", () => {
it("creates a codex session with its usage, named from session_index.jsonl", async () => {
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([100, 60, 10], [100, 60, 10])]);
fs.writeFileSync(
path.join(process.env.CODEX_HOME, "session_index.jsonl"),
JSON.stringify({ id: "s1", thread_name: "Fix the thing" }) + "\n"
);
const events = [];
await ingestCodexUsage({
broadcast: (t, d) => events.push([t, d.id]),
now: Date.parse("2026-09-29T03:00:00Z"),
});
const row = stmts.getSession.get("s1");
assert.equal(row.tool, "codex");
assert.equal(row.name, "Fix the thing");
assert.equal(row.cwd, "/w/p");
assert.equal(row.model, "gpt-a");
assert.equal(row.status, "completed");
assert.equal(row.started_at, "2026-09-29T01:00:00.000Z");
assert.equal(row.ended_at, "2026-09-29T02:00:00.000Z"); // the journal's mtime
assert.equal(row.updated_at, "2026-09-29T02:00:00.000Z"); // last activity, not ingestion
assert.equal(row.transcript_path, null);
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 40, r: 60, o: 10 }]);
assert.deepEqual(events, [["session_created", "s1"]]);
});
it("names a session with no thread name by its first prompt", async () => {
writeRollout("s2", [
meta("s2"),
{
type: "event_msg",
payload: { type: "user_message", message: " Fix the login bug\nthen ship" },
},
{ type: "event_msg", payload: { type: "user_message", message: "second prompt" } },
ctx("gpt-a"),
tc([1, 0, 1], [1, 0, 1]),
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.equal(stmts.getSession.get("s2").name, "Fix the login bug");
});
it("reads a prompt from the item_completed UserMessage shape too", async () => {
writeRollout("s3", [
meta("s3"),
{
type: "event_msg",
payload: {
type: "item_completed",
item: { type: "UserMessage", content: [{ type: "text", text: "Continue the task" }] },
},
},
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.equal(stmts.getSession.get("s3").name, "Continue the task");
});
it("is active while a journal was written in the last two minutes", async () => {
const now = Date.now();
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
new Date(now - 10_000)
);
await ingestCodexUsage({ now });
assert.equal(stmts.getSession.get("s1").status, "active");
assert.equal(stmts.getSession.get("s1").ended_at, null);
});
it("settles an active session to completed once its journals go quiet", async () => {
const now = Date.now();
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
new Date(now - 10_000)
);
await ingestCodexUsage({ now });
const events = [];
settleActive((t, d) => events.push([t, d.status]), now + 5 * 60_000);
assert.equal(stmts.getSession.get("s1").status, "completed");
assert.equal(
stmts.getSession.get("s1").ended_at,
new Date(Math.floor(now - 10_000)).toISOString()
);
assert.deepEqual(events, [["session_updated", "completed"]]);
});
it("skips the history a fork copied from its parent", async () => {
const parent = [
meta("p1"),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
tc([300, 0, 30], [200, 0, 20]),
];
writeRollout("p1", parent);
writeRollout("f1", [
meta("f1", { forked_from_id: "p1" }),
meta("p1"),
...parent.slice(1),
tc([350, 0, 35], [50, 0, 5]),
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("p1"), [{ model: "gpt-a", i: 300, r: 0, o: 30 }]);
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
});
it("folds a sub-agent thread into its root session", async () => {
writeRollout("r1", [meta("r1"), ctx("gpt-a"), tc([100, 0, 10], [100, 0, 10])]);
writeRollout("c1", [
meta("c1", { session_id: "r1", thread_source: "subagent" }),
ctx("gpt-b"),
tc([40, 0, 4], [40, 0, 4]),
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.equal(stmts.getSession.get("c1"), undefined);
assert.deepEqual(usage("r1"), [
{ model: "gpt-a", i: 100, r: 0, o: 10 },
{ model: "gpt-b", i: 40, r: 0, o: 4 },
]);
});
it("reads only what was appended, and re-reads a file that shrank", async () => {
const lines = [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])];
const file = writeRollout("s1", lines);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
fs.appendFileSync(file, jsonl([tc([30, 0, 3], [20, 0, 2])]) + '{"partial":');
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
fs.writeFileSync(file, jsonl(lines)); // rewritten shorter
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
});
it("does not bring back a deleted session while its journal is unchanged", async () => {
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
db.prepare("DELETE FROM sessions WHERE id = 's1'").run();
db.prepare("DELETE FROM token_usage WHERE session_id = 's1'").run();
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.equal(stmts.getSession.get("s1"), undefined);
});
it("never touches a Claude session with the same id", async () => {
stmts.insertSession.run("s1", "mine", "active", "/c", "claude-x", null);
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
const row = stmts.getSession.get("s1");
assert.equal(row.tool, "claude");
assert.equal(row.name, "mine");
assert.deepEqual(usage("s1"), []);
});
it("keeps skipping a fork's copied history that arrives after the first read", async () => {
const parent = [
meta("p1"),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
tc([300, 0, 30], [200, 0, 20]),
];
writeRollout("p1", parent);
const fork = writeRollout("f1", [meta("f1", { forked_from_id: "p1" }), meta("p1"), parent[1]]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
fs.appendFileSync(fork, jsonl([...parent.slice(2), tc([350, 0, 35], [50, 0, 5])]));
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
});
it("counts a fork whole, and says so, when its parent's journal is gone", async () => {
const warn = console.warn;
const warned = [];
console.warn = (m) => warned.push(String(m));
try {
writeRollout("f1", [
meta("f1", { forked_from_id: "gone" }),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
} finally {
console.warn = warn;
}
assert.deepEqual(usage("f1"), [{ model: "gpt-a", i: 100, r: 0, o: 10 }]);
assert.ok(warned.some((m) => m.includes("fork parent gone")));
});
it("does not count a journal twice when CODEX_HOME moves", async () => {
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
const home = process.env.CODEX_HOME;
fs.renameSync(home, `${home}-moved`);
process.env.CODEX_HOME = `${home}-moved`;
try {
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
} finally {
fs.renameSync(`${home}-moved`, home);
process.env.CODEX_HOME = home;
}
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM codex_rollouts").get().n, 1);
});
it("does not call a journal written in the future active", async () => {
const now = Date.now();
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
new Date(now + 60_000)
);
await ingestCodexUsage({ now });
assert.equal(stmts.getSession.get("s1").status, "completed");
});
it("drops the usage of a model a re-read journal no longer has", async () => {
const file = writeRollout("s1", [
meta("s1"),
ctx("gpt-a"),
tc([10, 0, 1], [10, 0, 1]),
ctx("gpt-b"),
tc([30, 0, 3], [20, 0, 2]),
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.equal(usage("s1").length, 2);
fs.writeFileSync(file, jsonl([meta("s1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]));
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
});
it("takes cwd, start and model from the root thread even when a sub-agent is read first", async () => {
writeRollout("c1", [
meta("c1", { session_id: "r1", cwd: "/w/child", timestamp: "2026-09-29T01:30:00.000Z" }),
ctx("gpt-b"),
tc([40, 0, 4], [40, 0, 4]),
]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.equal(stmts.getSession.get("r1").cwd, "/w/child");
writeRollout(
"r1",
[meta("r1"), ctx("gpt-a"), tc([100, 0, 10], [100, 0, 10])],
new Date("2026-09-29T01:40:00Z")
);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
const row = stmts.getSession.get("r1");
assert.equal(row.cwd, "/w/p");
assert.equal(row.started_at, "2026-09-29T01:00:00.000Z");
assert.equal(row.model, "gpt-a"); // the root's, though the child's file is newer
});
it("reads a journal with a line longer than one read in bounded steps", async () => {
const file = writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
fs.appendFileSync(file, `{"type":"response_item","x":"${"y".repeat(17 * 1024 * 1024)}"}\n`);
fs.appendFileSync(file, jsonl([tc([30, 0, 3], [20, 0, 2])]));
for (let i = 0; i < 4; i++) await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
const row = db.prepare("SELECT offset, size FROM codex_rollouts").get();
assert.equal(row.offset, row.size);
});
it("leaves a hook-fed session's status to its hooks, and still writes its usage", async () => {
stmts.insertSession.run("s1", "Codex s1", "active", "/w/p", null, null);
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = 's1'").run();
stmts.insertEvent.run("s1", null, "Stop", null, "ready", "{}");
const before = stmts.getSession.get("s1");
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
settleActive(() => {}, Date.parse("2026-09-29T09:00:00Z"));
const row = stmts.getSession.get("s1");
assert.equal(row.status, "active");
assert.equal(row.ended_at, null);
assert.equal(row.updated_at, before.updated_at);
assert.equal(row.model, "gpt-a");
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }]);
});
it("hands the status back to the journal once the hooks stop reporting", async () => {
// Hooked once (an hour before), then the hooks were removed; the journal
// kept being written.
stmts.insertSession.run("s1", "Codex s1", "abandoned", "/w/p", null, null);
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = 's1'").run();
db.prepare(
"INSERT INTO events (session_id, event_type, created_at) VALUES ('s1', 'Stop', '2026-09-29T01:00:00.000Z')"
).run();
const now = Date.parse("2026-09-29T02:00:30Z");
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([30, 0, 3], [30, 0, 3])],
new Date(now - 30_000)
);
await ingestCodexUsage({ now });
assert.equal(stmts.getSession.get("s1").status, "active");
settleActive(() => {}, now + 5 * 60_000);
assert.equal(stmts.getSession.get("s1").status, "completed");
});
it("imports its journals again after Clear data", async () => {
writeRollout("s1", [meta("s1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])]);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
try {
await fetch(`http://127.0.0.1:${server.address().port}/api/settings/clear-data`, {
method: "POST",
});
} finally {
server.close();
}
assert.equal(stmts.getSession.get("s1"), undefined);
await ingestCodexUsage({ now: Date.parse("2026-09-29T03:00:00Z") });
assert.deepEqual(usage("s1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
});
it("answers quietly when CODEX_HOME has no sessions", async () => {
const r = await ingestCodexUsage({ now: Date.now() });
assert.deepEqual(r, { files: 0, sessions: 0 });
});
});
+33 -3
View File
@@ -5,7 +5,9 @@
* local server to process the buffered request on its own schedule. These tests
* lock in that non-blocking contract so a future refactor can't reintroduce the
* "stuck running hooks" stall (handler waiting up to the per-request timeout for
* a slow/busy/wedged dashboard to reply).
* a slow/busy/wedged dashboard to reply). Also: a Codex hook (second argument
* `codex`) is marked `tool: "codex"`, an Antigravity one `tool: "antigravity"`,
* and any other second argument is not passed on.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -46,10 +48,10 @@ function startMockServer({ responseDelayMs }) {
// Spawn the real handler, pipe a hook payload to stdin, and time how long it
// takes to exit.
function runHandler({ port, hookType = "Stop", payload }) {
function runHandler({ port, hookType = "Stop", payload, extra = [] }) {
return new Promise((resolve, reject) => {
const start = process.hrtime.bigint();
const child = spawn(process.execPath, [HANDLER, hookType], {
const child = spawn(process.execPath, [HANDLER, hookType, ...extra], {
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) },
stdio: ["pipe", "ignore", "ignore"],
});
@@ -90,6 +92,34 @@ describe("hook-handler non-blocking delivery", () => {
}
});
it("marks a Codex hook with tool: codex, and a Claude hook with no tool at all", async () => {
const { server, port, received } = await startMockServer({ responseDelayMs: 0 });
try {
await runHandler({
port,
hookType: "PreToolUse",
payload: { session_id: "cx" },
extra: ["codex"],
});
await runHandler({ port, hookType: "PreToolUse", payload: { session_id: "cc" } });
await runHandler({
port,
hookType: "Stop",
payload: { session_id: "ag" },
extra: ["antigravity"],
});
await runHandler({ port, hookType: "Stop", payload: { session_id: "zz" }, extra: ["evil"] });
await new Promise((r) => setTimeout(r, 200));
const bodies = received.map((b) => JSON.parse(b));
assert.equal(bodies.find((b) => b.data.session_id === "cx").tool, "codex");
assert.equal(bodies.find((b) => b.data.session_id === "ag").tool, "antigravity");
assert.equal("tool" in bodies.find((b) => b.data.session_id === "zz"), false);
assert.equal("tool" in bodies.find((b) => b.data.session_id === "cc"), false);
} finally {
server.close();
}
});
it("exits promptly when no dashboard is listening (connection refused)", async () => {
// Grab a port then close it so nothing is listening there.
const { server, port } = await startMockServer({ responseDelayMs: 0 });
+154 -4
View File
@@ -1,7 +1,8 @@
/**
* @file Tests for the automatic pricing sync: a model seen in token_usage with
* no row of its own gets one from the price list, an edited row is never
* overwritten, and a bad list or a failed fetch changes nothing.
* overwritten, a bad list or a failed fetch changes nothing, and an OpenAI
* model (a Codex session's) is priced from its `openai` entry too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -15,7 +16,14 @@ process.env.DASHBOARD_DB_PATH = path.join(
`pricing-sync-${Date.now()}-${process.pid}.db`
);
const { db } = require("../db");
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
const {
syncPricing,
familyOf,
displayName,
toRow,
_resetCache,
_setCache,
} = require("../lib/pricing-sync");
// The real LiteLLM entry for Opus 5.5, trimmed.
const LIST = {
@@ -43,15 +51,49 @@ const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern =
beforeEach(() => {
_resetCache();
db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
db.prepare(
"DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%'"
).run();
});
describe("pricing sync", () => {
it("reduces an id to its family and names it", () => {
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
assert.equal(familyOf("gpt-4"), null);
assert.equal(familyOf("gpt-5.6-sol"), "gpt-5.6-sol");
assert.equal(familyOf("gpt-5.4-2026-03-05"), "gpt-5.4");
assert.equal(familyOf("o3-mini"), "o3-mini");
assert.equal(familyOf("clinepass/cline-pass/deepseek-v4-pro"), null);
assert.equal(familyOf("Highest"), null);
assert.equal(familyOf("claude-x%"), null);
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
assert.equal(displayName("gpt-5.6-sol"), "GPT-5.6 Sol");
assert.equal(displayName("o3-mini"), "o3 Mini");
});
it("prices a Gemini model from Vertex AI's entry, never another vendor's", async () => {
assert.equal(familyOf("gemini-3.8-flash"), "gemini-3.8-flash");
assert.equal(displayName("gemini-3.8-flash"), "Gemini 3.8 Flash");
useModel("gemini-3.8-flash");
const list = {
"gemini-3.8-flash": {
litellm_provider: "vertex_ai-language-models",
input_cost_per_token: 7.5e-7,
output_cost_per_token: 3.75e-6,
cache_read_input_token_cost: 7.5e-8,
},
};
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), ["gemini-3.8-flash%"]);
const r = row("gemini-3.8-flash%");
assert.equal(r.display_name, "Gemini 3.8 Flash");
assert.deepEqual(
[r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok],
[0.75, 3.75, 0.075]
);
assert.equal(r.cache_write_per_mtok, 0);
const other = {
"gemini-3.8-flash": { ...list["gemini-3.8-flash"], litellm_provider: "openrouter" },
};
assert.equal(toRow("gemini-3.8-flash", other["gemini-3.8-flash"]), null);
});
it("adds a row for a used model that only a broader pattern matched", async () => {
@@ -96,5 +138,113 @@ describe("pricing sync", () => {
});
assert.deepEqual(failed, []);
assert.equal(row("claude-opus-9-9%"), undefined);
// A failure is not retried by the next caller, only after the check interval.
let calls = 0;
const counting = async () => {
calls += 1;
return LIST;
};
await syncPricing(db, { fetchList: counting });
assert.equal(calls, 0);
await syncPricing(db, { fetchList: counting, now: Date.now() + 16 * 60_000 });
assert.equal(calls, 1);
});
it("prices an OpenAI model from its openai entry, with no cache-write charge", async () => {
useModel("gpt-9.1-sol");
const list = {
"gpt-9.1-sol": {
litellm_provider: "openai",
input_cost_per_token: 0.000004,
output_cost_per_token: 0.00002,
cache_read_input_token_cost: 4e-7,
},
};
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), ["gpt-9.1-sol%"]);
const r = row("gpt-9.1-sol%");
assert.equal(r.display_name, "GPT-9.1 Sol");
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.4]);
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [0, 0]);
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [0, 0]);
});
it("refuses an entry whose provider does not match the model's vendor", async () => {
useModel("gpt-9.2");
const list = {
"gpt-9.2": {
litellm_provider: "azure",
input_cost_per_token: 1e-6,
output_cost_per_token: 1e-6,
},
};
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), []);
});
it("POST /api/settings/reset-pricing brings a synced model's row straight back", async () => {
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const saved = process.env.AGENTHUB_PRICING_SYNC;
process.env.AGENTHUB_PRICING_SYNC = "1";
try {
useModel("gpt-9.3");
_setCache({
"gpt-9.3": {
litellm_provider: "openai",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
},
});
const res = await fetch(
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
{
method: "POST",
}
);
const body = await res.json();
assert.ok(body.pricing.some((p) => p.model_pattern === "gpt-9.3%"));
assert.ok(body.pricing.some((p) => p.model_pattern.startsWith("claude-")));
} finally {
if (saved === undefined) delete process.env.AGENTHUB_PRICING_SYNC;
else process.env.AGENTHUB_PRICING_SYNC = saved;
server.close();
}
});
it("POST /api/settings/reset-pricing with scope claude keeps every other row", async () => {
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
try {
const add = db.prepare(
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok) VALUES (?, ?, ?, ?)"
);
add.run("glm-4%", "GLM 4 (router)", 1, 2);
add.run("claude-opus-5-5%", "edited", 999, 999);
const res = await fetch(
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope: "claude" }),
}
);
const { pricing } = await res.json();
assert.equal(pricing.find((p) => p.model_pattern === "glm-4%")?.input_per_mtok, 1);
assert.notEqual(
pricing.find((p) => p.model_pattern === "claude-opus-5-5%")?.input_per_mtok,
999
);
} finally {
server.close();
}
});
});
+17
View File
@@ -398,6 +398,23 @@ describe("pty-run", () => {
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
});
it("hands a codex pane the dashboard's own CODEX_HOME, and a claude pane none", (t) => {
const saved = process.env.CODEX_HOME;
process.env.CODEX_HOME = "/tmp/custom-codex-home";
t.after(() => {
if (saved === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = saved;
});
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 40, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(envFlags(codex).includes("CODEX_HOME=/tmp/custom-codex-home"));
tmux.__reset();
const claude = captureNewSession(() => pty.spawnRun({ laneId: 41, cwd: "/tmp/repo" }));
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
});
describe("catalog tools", () => {
const aiTools = require("../lib/ai-tools");
const BIN = path.join(TMP, "bin");
+14
View File
@@ -351,6 +351,20 @@ describe("watchdog liveness reap", () => {
assert.equal(stmts.getSession.get(sid).status, "active", "remote session must not be reaped");
});
it("spares a Codex session, whose liveness codex-usage.js owns", async () => {
// A `claude` process probe proves nothing about a Codex session: it is
// settled from its own rollout journals' mtime instead.
const sid = "cdx00000-0000-0000-0000-00000000000f";
const tpath = await seedSession(sid, "/tmp/liveness-codex");
db.prepare("UPDATE sessions SET tool = 'codex' WHERE id = ?").run(sid);
backdate(sid, tpath);
liveness.probeLiveCwds = () => ({ available: true, cwds: new Set() });
hooksRouter.livenessReap();
assert.equal(stmts.getSession.get(sid).status, "active", "codex session must not be reaped");
});
it("does not touch error sessions", async () => {
const sid = "errr0000-0000-0000-0000-000000000006";
const cwd = "/tmp/liveness-error";
@@ -0,0 +1,148 @@
/**
* @file Tests GET/PUT /api/settings/codex-home and setCodexHome: the shared
* home-dir guard, persistence to the settings file, the `env_owned` flag, and
* the AI Router layer being re-written into the NEW home for every codex lane
* on a router (a `codex -p <name>` against a home without `<name>.config.toml`
* would fail at start); a home change from another site refused, and a
* CLAUDE_HOME change naming the lanes whose router profile the new home's
* store lacks. Everything lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codex-home-"));
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.CODEX_HOME = path.join(ROOT, "codex-a");
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const express = require("express");
const routers = require("../lib/ai-routers");
const lanesLib = require("../lib/lanes");
const { applyRouterToLane } = require("../lib/router-apply");
const { envOwned } = require("../lib/settings-env");
const settingsRouter = require("../routes/settings");
let server;
let base;
function request(method, url, body, extra = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
const req = http.request(
`${base}${url}`,
{
method,
headers: {
...(data ? { "content-type": "application/json", "content-length": data.length } : {}),
...extra,
},
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}
);
req.on("error", reject);
if (data) req.write(data);
req.end();
});
}
before(async () => {
const app = express();
app.use(express.json());
app.use("/api/settings", settingsRouter);
server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("/api/settings/codex-home", () => {
it("GET answers the current home and whether the environment owns it", async () => {
const res = await request("GET", "/api/settings/codex-home");
assert.equal(res.status, 200);
assert.equal(res.body.codex_home, process.env.CODEX_HOME);
assert.equal(res.body.env_owned, envOwned.has("CODEX_HOME"));
});
it("refuses a path that contains the user's home, changing nothing", async () => {
const before = process.env.CODEX_HOME;
const res = await request("PUT", "/api/settings/codex-home", { path: "/" });
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EBADHOME");
assert.equal(process.env.CODEX_HOME, before);
assert.ok(!fs.existsSync(process.env.AGENTHUB_SETTINGS_FILE));
});
it("refuses a missing path and a non-string body", async () => {
for (const body of [{ path: path.join(ROOT, "nope") }, { path: 7 }, {}]) {
const res = await request("PUT", "/api/settings/codex-home", body);
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "INVALID_PATH");
}
});
it("switches, persists, and re-writes every codex lane's router layer into the new home", async () => {
const cwd = path.join(ROOT, "lane");
fs.mkdirSync(cwd, { recursive: true });
const { name } = routers.createRouter({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
});
const lane = lanesLib.createLane({ title: "c", cwd, tool: "codex", router_profile: name });
applyRouterToLane(lane, name);
assert.ok(fs.existsSync(path.join(process.env.CODEX_HOME, `${name}.config.toml`)));
const next = path.join(ROOT, "codex-b");
fs.mkdirSync(next);
const res = await request("PUT", "/api/settings/codex-home", { path: next });
assert.equal(res.status, 200);
assert.equal(res.body.codex_home, next);
assert.deepEqual(res.body.routers, { updated: 1, failed: [] });
assert.equal(process.env.CODEX_HOME, next);
assert.ok(fs.existsSync(path.join(next, `${name}.config.toml`)));
assert.match(
fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"),
new RegExp(`^CODEX_HOME=${next}$`, "m")
);
});
it("refuses a home change asked by another site", async () => {
const next = path.join(ROOT, "codex-evil");
fs.mkdirSync(next);
const res = await request(
"PUT",
"/api/settings/codex-home",
{ path: next },
{ Origin: "https://evil.example" }
);
assert.equal(res.status, 403);
assert.notEqual(process.env.CODEX_HOME, next);
});
it("a CLAUDE_HOME change names the lanes whose router the new store lacks", async () => {
const next = path.join(ROOT, "claude-b");
fs.mkdirSync(next);
const res = await request("PUT", "/api/settings/claude-home", { path: next });
assert.equal(res.status, 200);
assert.equal(res.body.routers.missing.length, 1);
assert.match(res.body.routers.missing[0], /\(c\): 9router/i);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* @file Tests server/lib/settings-env.js — where a setting saved from the
* dashboard persists (a file outside the repo and outside CLAUDE_HOME, so a
* plugin update cannot wipe it), the startup load order (real env, then the
* checkout's `.env`, both owned, then the settings file), a save replacing
* every earlier line for its key and writing through a symlink, and the one
* path check both editable homes share. Every file lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-settings-env-"));
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const envSettings = require("../lib/settings-env");
describe("settings-env", () => {
let n = 0;
let file;
let repoEnv;
let env;
beforeEach(() => {
n += 1;
file = path.join(ROOT, `cfg${n}`, "agenthub", "settings.env");
repoEnv = path.join(ROOT, `repo${n}.env`);
env = {};
});
it("resolves its file from AGENTHUB_SETTINGS_FILE, else XDG_CONFIG_HOME, else ~/.config", () => {
assert.equal(envSettings.settingsFilePath({ AGENTHUB_SETTINGS_FILE: "/x/s.env" }), "/x/s.env");
assert.equal(
envSettings.settingsFilePath({ XDG_CONFIG_HOME: "/cfg" }),
path.join("/cfg", "agenthub", "settings.env")
);
assert.equal(
envSettings.settingsFilePath({}),
path.join(os.homedir(), ".config", "agenthub", "settings.env")
);
});
it("writes one line per key, replacing in place, with no blank-line growth, at 0600", () => {
envSettings.writeSetting("CLAUDE_HOME", "/a", file);
envSettings.writeSetting("CODEX_HOME", "/b", file);
envSettings.writeSetting("CLAUDE_HOME", "/c", file);
envSettings.writeSetting("CLAUDE_HOME", "/d", file);
assert.equal(fs.readFileSync(file, "utf8"), "CLAUDE_HOME=/d\nCODEX_HOME=/b\n");
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
});
it("loads the real env first, then the checkout's .env (owned), then the settings file", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
"CLAUDE_HOME=/from-settings\nCODEX_HOME=~/codex\nDASHBOARD_DATA_DIR=/live\n# note\n"
);
fs.writeFileSync(repoEnv, 'DASHBOARD_DATA_DIR=/dev-data\nOTHER="quoted"\nCODEX_HOME=/repo\n');
env.CODEX_HOME = "/from-env";
const owned = envSettings.loadEnvFiles({ env, owned: [repoEnv], files: [file] });
assert.equal(env.CODEX_HOME, "/from-env");
// A dev checkout's own data dir wins over the one the dashboard pinned.
assert.equal(env.DASHBOARD_DATA_DIR, "/dev-data");
assert.equal(env.CLAUDE_HOME, "/from-settings");
assert.equal(env.OTHER, "quoted");
assert.ok(owned.has("CODEX_HOME"));
assert.ok(owned.has("DASHBOARD_DATA_DIR"), "a .env key is the environment's");
assert.ok(!owned.has("CLAUDE_HOME"));
assert.equal(env.AGENTHUB_FILE_KEYS, "CLAUDE_HOME");
});
it("reads the settings file a .env names in AGENTHUB_SETTINGS_FILE", () => {
const other = path.join(ROOT, "other", "settings.env");
fs.mkdirSync(path.dirname(other), { recursive: true });
fs.writeFileSync(other, "CLAUDE_HOME=/from-other\n");
fs.writeFileSync(repoEnv, `AGENTHUB_SETTINGS_FILE=${other}\n`);
delete env.AGENTHUB_SETTINGS_FILE;
envSettings.loadEnvFiles({ env, owned: [repoEnv] });
assert.equal(env.CLAUDE_HOME, "/from-other");
});
it("replaces a hand-written `KEY = value` line, and writes through a symlink", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "# mine\nCODEX_HOME = /old\nCODEX_HOME=/older\nX=1\n");
envSettings.writeSetting("CODEX_HOME", "/new", file);
assert.equal(fs.readFileSync(file, "utf8"), "# mine\nCODEX_HOME=/new\nX=1\n");
const real = path.join(ROOT, `dotfiles${n}.env`);
fs.renameSync(file, real);
fs.symlinkSync(real, file);
envSettings.writeSetting("CODEX_HOME", "/newer", file);
assert.ok(fs.lstatSync(file).isSymbolicLink());
assert.match(fs.readFileSync(real, "utf8"), /CODEX_HOME=\/newer/);
});
it("expands ~/ in a loaded value and skips missing files", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "CODEX_HOME=~/codex\n");
envSettings.loadEnvFiles({ env, files: [path.join(ROOT, "missing.env"), file] });
assert.equal(env.CODEX_HOME, path.join(os.homedir(), "codex"));
});
it("names the file-loaded keys, and a successor inheriting them re-reads the file", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "CLAUDE_HOME=/saved\n");
env.CODEX_HOME = "/from-env";
envSettings.loadEnvFiles({ env, files: [file] });
assert.equal(env.AGENTHUB_FILE_KEYS, "CLAUDE_HOME");
// A self-restart hands this env on; the file changed meanwhile.
fs.writeFileSync(file, "CLAUDE_HOME=/saved-later\n");
const owned = envSettings.loadEnvFiles({ env, files: [file] });
assert.ok(!owned.has("CLAUDE_HOME"), "a file-loaded key is not the real env's");
assert.ok(owned.has("CODEX_HOME"));
assert.equal(env.CLAUDE_HOME, "/saved-later");
});
it("refuses a value with a line break, which would inject a second key", () => {
assert.throws(
() => envSettings.writeSetting("CLAUDE_HOME", "/a\nNODE_OPTIONS=x", file),
/line break/
);
assert.ok(!fs.existsSync(file));
});
describe("validateHomeDir", () => {
const dir = () => fs.mkdtempSync(path.join(ROOT, "home-"));
for (const bad of ["/", "/.", os.homedir(), `${os.homedir()}/`, "~/"]) {
it(`refuses ${JSON.stringify(bad)} as too broad`, () => {
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", bad), { code: "EBADHOME" });
});
}
it("refuses a parent of the home dir, and a symlink resolving to one", () => {
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", path.dirname(os.homedir())), {
code: "EBADHOME",
});
const link = path.join(ROOT, "link-to-root");
fs.symlinkSync("/", link);
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", link), { code: "EBADHOME" });
const homeLink = path.join(ROOT, "link-to-home");
fs.symlinkSync(os.homedir(), homeLink);
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", homeLink), {
code: "EBADHOME",
});
});
it("refuses a relative path, a missing path and a file", () => {
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", "rel"), /absolute path/);
assert.throws(
() => envSettings.validateHomeDir("CODEX_HOME", path.join(ROOT, "nope")),
/does not exist/
);
const f = path.join(ROOT, "a-file");
fs.writeFileSync(f, "");
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", f), /Not a directory/);
});
it("returns the normalized absolute path, trimmed", () => {
const d = dir();
assert.equal(envSettings.validateHomeDir("CODEX_HOME", ` ${d}/./ `), d);
});
it("refuses control characters with INVALID_PATH", () => {
assert.throws(() => envSettings.validateHomeDir("CODEX_HOME", `${dir()}\nX=1`), {
code: "INVALID_PATH",
});
});
});
});
+46
View File
@@ -1081,6 +1081,46 @@ try {
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source)`);
// Migrate: add `tool` to sessions — the AI coding CLI that produced it. Every
// hook-created and imported row is Claude Code's ('claude', the default); a
// Codex session is ingested from its rollout journals (server/lib/codex-usage.js)
// with 'codex'. Surfaces that act on a session through the `claude` CLI
// (attach, resume) list only 'claude' rows.
try {
db.prepare("SELECT tool FROM sessions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE sessions ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run();
}
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
// In the DB, not a side file, so a session the user deleted stays deleted
// until Codex writes to its journal again, while a wiped DB re-imports all.
db.exec(`
CREATE TABLE IF NOT EXISTS codex_rollouts (
path TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime_ms INTEGER NOT NULL DEFAULT 0,
offset INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_codex_rollouts_session ON codex_rollouts(session_id);
CREATE INDEX IF NOT EXISTS idx_codex_rollouts_thread ON codex_rollouts(thread_id);
`);
// Which Antigravity CLI conversation databases have been read, at what mtime
// (server/lib/antigravity-usage.js) — the same "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS antigravity_conversations (
path TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Remote data sources: other machines whose Claude Code history this dashboard
// pulls in over SSH. Config only — NO secrets are stored here: authentication
// always defers to the host's own SSH stack (~/.ssh/config, ssh-agent, keys,
@@ -1286,12 +1326,14 @@ try {
// Remote-source sessions (source != 'local') are exempt: their "last event" is bounded by
// the rsync cadence, not the remote CLI's actual activity, so a busy remote session could be
// wrongly completed here. server/lib/remote-sync.js owns their status via mirror reconciliation.
// Codex sessions are exempt too: server/lib/codex-usage.js derives theirs from the journal.
db.prepare(
`
UPDATE sessions SET
status = 'completed',
ended_at = COALESCE(ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE status = 'active'
AND tool = 'claude'
AND (source = 'local' OR source IS NULL)
AND started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
AND NOT EXISTS (
@@ -1493,6 +1535,10 @@ const stmts = {
AND updated_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' minutes')`
),
// A session's newest event (Antigravity: a turn after a Stop, a quiet session).
lastEvent: db.prepare(
"SELECT event_type, created_at FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1"
),
// A second delivery of one hook: the session's LATEST event, a few seconds
// old, with the same type and a byte-identical payload (the duplicate guard
// in routes/hooks.js). Latest only, never "any recent": two turns can end in
+42 -22
View File
@@ -20,28 +20,19 @@ for (const k of Object.keys(process.env)) {
// real editor setting is anything else and stays.
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
// Load .env file (simple key=value, no external dependency needed)
(function loadDotEnv() {
const fs = require("fs");
const os = require("os");
const envPath = require("path").resolve(__dirname, "..", ".env");
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
// Strip surrounding quotes (single or double)
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = val.replace(/^~(?=\/)/, os.homedir());
}
}
})();
// Load the repo `.env` (simple key=value), then persisted settings. The real
// environment always wins, then a checkout's own `.env`; a dashboard-saved
// value (CLAUDE_HOME, CODEX_HOME, the pinned DASHBOARD_DATA_DIR) lives in the
// settings file, outside the plugin cache an update replaces — see
// server/lib/settings-env.js. Only when this
// file IS the process: the test suite requires it dozens of times, and a
// developer's real settings file pins DASHBOARD_DATA_DIR at their live database.
if (require.main === module) {
const settingsEnv = require("./lib/settings-env");
settingsEnv.loadEnvFiles({
owned: [require("path").resolve(__dirname, "..", ".env")],
});
}
// Carry a pre-rebrand `~/.claude/agent-dashboard` over to `~/.claude/agenthub`.
//
@@ -513,6 +504,35 @@ function startBackgroundServices() {
} catch (err) {
console.warn("codex poll failed to start:", err.message);
}
// Codex sessions and token usage, from the same journals (codex-usage.js):
// a first pass over every rollout in the background, then the same beat.
try {
require("./lib/codex-usage").startCodexUsagePoll(broadcast);
} catch (err) {
console.warn("codex usage poll failed to start:", err.message);
}
// Antigravity CLI sessions and token usage, from agy's conversation files.
try {
const { broadcastLane } = require("./routes/lanes");
require("./lib/antigravity-usage").startAntigravityUsagePoll(broadcast, broadcastLane);
} catch (err) {
console.warn("antigravity usage poll failed to start:", err.message);
}
// Codex/Antigravity hooks the user installed forward through a script whose
// content names this build; point it here (the command in the tool's
// hooks.json, which Codex trusts, stays the same).
for (const [name, mod] of [
["codex-hooks", "./lib/codex-hooks"],
["antigravity-hooks", "./lib/antigravity-hooks"],
]) {
try {
const refreshed = require(mod).refreshScript();
if (refreshed === "written") console.log(`[${name}] forwarder updated to this build`);
if (refreshed === "failed") console.warn(`[${name}] could not update the forwarder`);
} catch (err) {
console.warn(`${name} forwarder refresh failed:`, err.message);
}
}
// A codex/antigravity pane that exits fires no hook, so release its lane on
// a beat instead (one `tmux has-session` per lane with a run). Off with
// DASHBOARD_RUN_HEAL_MS=0.
+42
View File
@@ -0,0 +1,42 @@
/**
* @file Where Antigravity's CLI (`agy`) keeps its data. `agy` has no home
* override of its own — everything is under `~/.gemini`: its conversations and
* transcripts in `antigravity-cli/`, and the user's global hooks in
* `config/hooks.json`, shared with the Antigravity IDE. `AGENTHUB_GEMINI_DIR`
* points AgentHub elsewhere (tests; a pane whose HOME differs). `agyCliDirs`
* also names the resolved path when a symlink leads elsewhere, since agy may
* report either in a hook.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
/** `~/.gemini`, or `AGENTHUB_GEMINI_DIR`; read at call time. */
function getGeminiDir() {
return process.env.AGENTHUB_GEMINI_DIR || path.join(os.homedir(), ".gemini");
}
/** The CLI's own data: `conversations/<id>.db`, `brain/<id>/…`. */
function getAgyCliDir() {
return path.join(getGeminiDir(), "antigravity-cli");
}
/** The CLI dir as configured and, when a symlink leads elsewhere, as resolved. */
function agyCliDirs() {
const raw = path.resolve(getAgyCliDir());
let real = raw;
try {
real = fs.realpathSync(raw);
} catch {
try {
real = path.join(fs.realpathSync(getGeminiDir()), "antigravity-cli");
} catch {
/* neither exists yet */
}
}
return real === raw ? [raw] : [raw, real];
}
module.exports = { getGeminiDir, getAgyCliDir, agyCliDirs };
+182
View File
@@ -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 };
+483
View File
@@ -0,0 +1,483 @@
/**
* @file Antigravity CLI sessions and token usage, read from `agy`'s own
* per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/
* <conversation id>.db`). This is how an Antigravity session reaches
* `sessions` (as `tool = 'antigravity'`) and its tokens reach `token_usage`;
* AgentHub's Antigravity hooks, when the user installs them, add its events and
* a lane's live status but no tokens.
*
* What a conversation file holds (agy 1.2.x, verified against the binary's own
* proto descriptors — nothing here is a documented format):
* - its file name is the conversation id the hooks report;
* - `trajectory_metadata_blob` — protobuf, whose first `file://` URI is the
* workspace the conversation ran in;
* - one `gen_metadata` row per model call — protobuf, field 1 a message whose
* field 19 is the model name and field 4 `ModelUsageStats`: 2 input (NOT
* including cache reads), 3 output (INCLUDING thinking), 4 cache write,
* 5 cache read.
* The session's name and start come from its transcript
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
* `-wal`'s) changes — they are small — and the mtime per file lives in
* `antigravity_conversations`, so a session the user deleted stays deleted
* until agy writes to it again. Opening read-only still lets SQLite leave an
* EMPTY `-wal` and a `-shm` beside a cleanly closed file, which is why an empty
* `-wal` is not activity. A file that is not agy's is recorded too (with no
* session), and tried again only once it changes or the server restarts.
*
* The status is the session's last activity — its file, its transcript and its
* newest hook event: `agy` fires no hook when it exits, nor when a run ends on
* an API error or a permission headless mode refused, so a session gone quiet
* settles to completed (its agents too) and sets its lane idle, when the hooks
* left it running and no dashboard pane owns its status. Quiet is 10 minutes
* for a session the hooks report on (a long tool call writes nothing until it
* ends; after a Stop agy may be waiting for the user, and it fires no hook on
* exit), else 2 minutes of file activity. An error Stop's `error` is kept.
* Only the CLI's directory is read, never the Antigravity IDE's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const Database = require("better-sqlite3");
const { db, stmts } = require("../db");
const { getAgyCliDir } = require("./antigravity-home");
const pricingSync = require("./pricing-sync");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const HOOK_QUIET_MS = 600_000; // with hooks: a tool call may run long, a prompt may wait
const FUTURE_SLACK_MS = 1000;
const FILE_NAME = /^([0-9a-f-]{36})\.db$/;
const NOT_AGYS = /SQLITE_NOTADB|SQLITE_CORRUPT|no such table/;
/**
* A protobuf message's fields as `[number, wireType, value]` — a varint as a
* Number (exact below 2^53, which every count is), a length-delimited field as
* a Buffer. Throws on a malformed buffer.
*/
function pbFields(buf) {
const out = [];
let i = 0;
const varint = () => {
let r = 0;
for (let s = 0; ; s += 7) {
if (i >= buf.length || s > 63) throw new Error("bad varint");
const b = buf[i++];
r += (b & 0x7f) * 2 ** s;
if (b < 0x80) return r;
}
};
while (i < buf.length) {
const key = varint();
const field = Math.floor(key / 8);
const wire = key % 8;
if (field === 0) throw new Error("field 0");
if (wire === 0) out.push([field, 0, varint()]);
else if (wire === 1) i += 8;
else if (wire === 5) i += 4;
else if (wire === 2) {
const n = varint();
if (i + n > buf.length) throw new Error("truncated");
out.push([field, 2, buf.subarray(i, i + n)]);
i += n;
} else throw new Error(`wire type ${wire}`);
}
if (i > buf.length) throw new Error("truncated");
return out;
}
const field = (fields, n, wire) => fields.find((f) => f[0] === n && f[1] === wire)?.[2];
/** One `gen_metadata` row → `{model, input, output, cacheRead, cacheWrite}`, or null. */
function generationUsage(buf) {
try {
const gen = field(pbFields(buf), 1, 2);
if (!gen) return null;
const g = pbFields(gen);
const stats = field(g, 4, 2);
if (!stats) return null;
const s = pbFields(stats);
const n = (k) => field(s, k, 0) || 0;
if (field(s, 2, 0) === undefined && field(s, 3, 0) === undefined) return null;
const name = field(g, 19, 2)?.toString("utf8");
return {
model: name && /^[\w.:/-]{1,100}$/.test(name) ? name : "unknown",
input: n(2),
output: n(3),
cacheWrite: n(4),
cacheRead: n(5),
};
} catch {
return null;
}
}
/** The first `file://` URI anywhere in a protobuf blob, as a path; null otherwise. */
function firstFileUri(buf, depth = 0) {
let fields;
try {
fields = pbFields(buf);
} catch {
return null;
}
for (const [, wire, v] of fields) {
if (wire !== 2) continue;
if (v.subarray(0, 7).toString("latin1") === "file://") {
try {
return decodeURIComponent(new URL(v.toString("utf8")).pathname) || null;
} catch {
continue;
}
}
if (depth < 6) {
const found = firstFileUri(v, depth + 1);
if (found) return found;
}
}
return null;
}
const transcriptFiles = (id) => {
const dir = path.join(getAgyCliDir(), "brain", id, ".system_generated", "logs");
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
};
/** The transcript's last write, 0 when it has none. */
function transcriptMtime(id) {
for (const file of transcriptFiles(id)) {
try {
return Math.floor(fs.statSync(file).mtimeMs);
} catch {
/* try the next name */
}
}
return 0;
}
/** Name (first prompt line, 60 chars) and start from the transcript's first user step. */
function transcriptFacts(id) {
for (const file of transcriptFiles(id)) {
let text;
try {
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 1024 * 1024));
fs.readSync(fd, buf, 0, buf.length, 0);
text = buf.toString("utf8");
} finally {
fs.closeSync(fd);
}
} catch {
continue;
}
const facts = { startedAt: null, firstPrompt: null };
for (const line of text.split("\n")) {
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
facts.startedAt ||= typeof o.created_at === "string" ? o.created_at : null;
if (o.type === "USER_INPUT" && typeof o.content === "string") {
const req = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/.exec(o.content);
const prompt = (req ? req[1] : o.content).trim();
if (prompt) {
facts.firstPrompt = prompt.split("\n")[0].slice(0, 60);
break;
}
}
}
return facts;
}
return { startedAt: null, firstPrompt: null };
}
/** Everything AgentHub reads from one conversation file. */
function readConversation(file) {
const conn = new Database(file, { readonly: true, fileMustExist: true });
try {
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
const usage = {};
let model = null;
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
const u = data && generationUsage(data);
if (!u) continue;
model = u.model;
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
for (const k of Object.keys(t)) t[k] += u[k];
}
return {
cwd: blob?.data ? firstFileUri(blob.data) : null,
usage,
model,
};
} finally {
conn.close();
}
}
/** The conversation files, each with the newer mtime of the file and its non-empty `-wal`. */
function conversationFiles() {
const dir = path.join(getAgyCliDir(), "conversations");
let names;
try {
names = fs.readdirSync(dir);
} catch {
return [];
}
const out = [];
for (const name of names) {
const m = FILE_NAME.exec(name);
if (!m) continue;
const file = path.join(dir, name);
try {
let mtime = fs.statSync(file).mtimeMs;
try {
// Our own read-only open leaves an empty one behind; it holds nothing.
const wal = fs.statSync(`${file}-wal`);
if (wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
} catch {
/* no WAL right now */
}
out.push({ path: file, id: m[1], mtime: Math.floor(mtime) });
} catch {
/* gone between readdir and stat */
}
}
return out;
}
const getSeen = db.prepare("SELECT * FROM antigravity_conversations WHERE path = ?");
const upsertSeen = db.prepare(
`INSERT INTO antigravity_conversations (path, session_id, mtime_ms, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id,
mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'antigravity')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
WHERE id = ? AND tool = 'antigravity'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'antigravity'"
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
const clearAwaiting = db.prepare(
"UPDATE sessions SET awaiting_input_since = NULL, awaiting_reason = NULL WHERE id = ? AND tool = 'antigravity'"
);
const stuckLanes = db.prepare(
`SELECT id FROM lanes WHERE tool = 'antigravity' AND session_id = ? AND status = 'running'
AND (run_id IS NULL OR run_id = '')`
);
/** The lanes a quiet session left `running` (no Stop came), set idle. */
function releaseLanes(sessionId, broadcastLane) {
for (const { id } of stuckLanes.all(sessionId)) {
try {
require("./lanes").updateLane(id, { status: "idle" });
broadcastLane(id);
} catch {
/* a lane is never worth failing the pass for */
}
}
}
/**
* The session's last activity (file, transcript, newest hook) and its quiet
* window: 10 minutes for a session the hooks report on — mid-turn a long tool
* call writes nothing, and after a Stop agy may be waiting for the user (it
* fires no hook on exit, so an open agy and a closed one look the same) — else
* 2 minutes of file activity.
*/
function activity(id, fileMtime) {
const ev = stmts.lastEvent.get(id);
return {
last: Math.max(fileMtime, transcriptMtime(id), ev ? Date.parse(ev.created_at) || 0 : 0),
window: ev ? HOOK_QUIET_MS : ACTIVE_MS,
stopped: ev?.event_type === "Stop",
};
}
// A write "in the future" (a clock that runs ahead) is not proof of activity.
const fresh = (a, now) => a.last - now < FUTURE_SLACK_MS && now - a.last < a.window;
/** A quiet session's agents, completed like a SessionEnd would. */
function completeAgents(sessionId, at, broadcast) {
for (const agent of stmts.listAgentsBySession.all(sessionId)) {
if (agent.status === "completed" || agent.status === "error") continue;
stmts.updateAgent.run(null, "completed", null, null, at, null, agent.id);
broadcast("agent_updated", stmts.getAgent.get(agent.id));
}
stmts.clearSessionAgentsAwaitingInput.run(sessionId);
clearAwaiting.run(sessionId); // not stmts' own: that one stamps updated_at = now
}
/** Write one conversation's session row and usage. @returns the broadcast type, or null. */
function writeSession(f, conv, now) {
const id = f.id;
const existing = stmts.getSession.get(id);
if (existing && existing.tool !== "antigravity") return null;
const facts = transcriptFacts(id);
const a = activity(id, f.mtime);
const lastWrite = new Date(a.last).toISOString();
const active = fresh(a, now);
let status = active ? "active" : "completed";
let endedAt = active ? null : lastWrite;
// A run the hooks saw fail stays an error until the next turn.
if (existing?.status === "error" && a.stopped) ({ status, ended_at: endedAt } = existing);
const name =
facts.firstPrompt ||
(existing && !/^Antigravity [0-9a-f]{8}$/.test(existing.name) ? existing.name : null) ||
`Antigravity ${id.slice(0, 8)}`;
db.transaction(() => {
if (existing)
updateSession.run(
name,
status,
conv.model,
endedAt,
lastWrite,
conv.cwd,
facts.startedAt,
id
);
else
insertSession.run(
id,
name,
status,
conv.cwd,
conv.model,
facts.startedAt || lastWrite,
endedAt,
lastWrite,
JSON.stringify({ source: "antigravity-conversation" })
);
// Every usage row of an Antigravity session is this module's: rewritten whole.
clearUsage.run(id);
for (const [m, t] of Object.entries(conv.usage)) {
stmts.setTokenUsage.run(
id,
m,
"standard",
"global",
"standard",
t.input,
t.output,
t.cacheRead,
t.cacheWrite,
0,
0,
0,
0
);
}
})();
return existing ? "session_updated" : "session_created";
}
let running = null;
/**
* One pass over every conversation file: a changed one is re-read, an
* unchanged one only has an `active` session settled once it went quiet.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestAntigravityUsage({
broadcast = () => {},
broadcastLane = () => {},
now = Date.now(),
} = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
for (const f of conversationFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
const a = activity(f.id, f.mtime);
if (fresh(a, now)) continue;
const s = stmts.getSession.get(f.id);
if (s && s.tool === "antigravity" && s.status === "active") {
const at = new Date(a.last).toISOString();
settle.run(at, s.id);
completeAgents(s.id, at, broadcast);
broadcast("session_updated", stmts.getSession.get(s.id));
written += 1;
}
releaseLanes(f.id, broadcastLane);
continue;
}
let conv;
try {
conv = readConversation(f.path);
} catch (err) {
// Not agy's (or not yet): tried again once it changes, or after a
// restart (a newer AgentHub may read it). Anything else — a busy
// file, our own bug — is retried on the next pass.
if (NOT_AGYS.test(`${err.code} ${err.message}`)) upsertSeen.run(f.path, "", f.mtime);
throw err;
}
read += 1;
const type = writeSession(f, conv, now);
upsertSeen.run(f.path, f.id, f.mtime);
if (type) {
written += 1;
const s = stmts.getSession.get(f.id);
if (s?.status === "completed") {
completeAgents(f.id, s.ended_at, broadcast);
releaseLanes(f.id, broadcastLane);
}
broadcast(type, stmts.getSession.get(f.id));
}
} catch (err) {
if (getSeen.get(f.path)?.mtime_ms !== f.mtime) {
console.warn(`[antigravity-usage] skipped ${f.path}: ${err.message}`);
}
}
await new Promise((r) => setImmediate(r));
}
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/**
* First pass at once (in the background), then every
* DASHBOARD_ANTIGRAVITY_POLL_MS (default 15 s; 0 disables).
*/
function startAntigravityUsagePoll(broadcast, broadcastLane) {
const POLL_MS = process.env.DASHBOARD_ANTIGRAVITY_POLL_MS
? Number(process.env.DASHBOARD_ANTIGRAVITY_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
// Files an older build could not read get another try.
db.prepare("DELETE FROM antigravity_conversations WHERE session_id = ''").run();
const tick = () => {
ingestAntigravityUsage({ broadcast, broadcastLane })
.then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
})
.catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
}
module.exports = {
pbFields,
generationUsage,
firstFileUri,
ingestAntigravityUsage,
startAntigravityUsagePoll,
};
+20 -45
View File
@@ -273,55 +273,31 @@ function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
}
/**
* Update CLAUDE_HOME at runtime. Updates process.env so getClaudeHome()
* immediately returns the new value, and persists to .env file.
* Returns the resolved absolute path.
* Update CLAUDE_HOME at runtime: `process.env` changes at once and the value
* persists to the settings file (`settings-env.js` — outside the repo, which a
* plugin update replaces, and outside CLAUDE_HOME). The path passes the shared
* `validateHomeDir` guard, which refuses anything that is or contains the
* user's home (`EBADHOME`).
*
* The data dir is derived from CLAUDE_HOME, so the first change pins the one
* in use as DASHBOARD_DATA_DIR (process + settings file) unless something
* already sets it: otherwise the next start would open an EMPTY database under
* the new home, and the plugin bootstrap and CLI — which never read the
* settings file — would disagree with the server about where its runtime is.
* The discovery file (`.agenthub.json`, server-info.js) is NOT pinned: it
* follows CLAUDE_HOME, so hooks and the CLI running under the default home
* find a dashboard on a non-default port only if they share its CLAUDE_HOME.
*
* @returns {string} the resolved absolute path.
*/
function setClaudeHome(newPath) {
const resolved = newPath.replace(/^~(?=\/)/, os.homedir());
if (!path.isAbsolute(resolved)) {
throw new Error("CLAUDE_HOME must be an absolute path");
}
if (!fs.existsSync(resolved)) {
throw new Error(`Directory does not exist: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
process.env.CLAUDE_HOME = resolved;
writeEnvFile("CLAUDE_HOME", resolved);
const { validateHomeDir, applySetting } = require("./settings-env");
const resolved = validateHomeDir("CLAUDE_HOME", newPath);
if (!process.env.DASHBOARD_DATA_DIR) applySetting("DASHBOARD_DATA_DIR", getDataDir());
applySetting("CLAUDE_HOME", resolved);
return resolved;
}
/**
* Write or update a key=value line in the .env file.
* Creates the file if it doesn't exist.
*/
function writeEnvFile(key, value) {
const envPath = path.resolve(__dirname, "..", "..", ".env");
let lines = [];
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, "utf8").split("\n");
}
let found = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith(`${key}=`)) {
lines[i] = `${key}=${value}`;
found = true;
break;
}
}
if (!found) {
lines.push(`${key}=${value}`);
}
// Write atomically: write to temp file then rename to prevent corruption
const tempPath = envPath + ".tmp";
fs.writeFileSync(tempPath, lines.join("\n") + "\n", "utf8");
fs.renameSync(tempPath, envPath);
}
module.exports = {
getClaudeHome,
getProjectsDir,
@@ -337,5 +313,4 @@ module.exports = {
findTranscriptPath,
findSubagentTranscriptPath,
setClaudeHome,
writeEnvFile,
};
+38 -1
View File
@@ -2,10 +2,12 @@
* @file Centralized Codex home directory path management. Resolves the Codex
* home directory, project-level Codex surfaces, and project roots. Supports a
* custom root via the CODEX_HOME environment variable so the dashboard can
* track non-standard Codex installations.
* track non-standard Codex installations, and changes it at runtime
* (`setCodexHome`, persisted to the settings file).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
@@ -19,6 +21,22 @@ function getCodexHome() {
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
}
/**
* CODEX_HOME as given and, when a symlink makes it differ, as resolved — so a
* path Codex reports (it may canonicalize) matches either.
*
* @returns {string[]} one or two absolute paths.
*/
function codexHomes() {
const raw = path.resolve(getCodexHome());
try {
const real = fs.realpathSync(raw);
return real === raw ? [raw] : [raw, real];
} catch {
return [raw];
}
}
/**
* The directory where Codex stores per-project surfaces (rollout journals,
* SQLite mirrors, etc.) for a project at the given working directory.
@@ -42,8 +60,27 @@ function getProjectRoot(cwd) {
return cwd;
}
/**
* Update CODEX_HOME at runtime: `process.env` changes at once and the value
* persists to the settings file (see `settings-env.js`). The path passes the
* shared guard that refuses anything that is or contains the user's home
* (`EBADHOME`). A codex pane gets this value through tmux `-e` at its next
* start; one already running keeps the home it started with.
*
* @param {string} newPath absolute path, `~/` expanded.
* @returns {string} the resolved absolute path.
*/
function setCodexHome(newPath) {
const { validateHomeDir, applySetting } = require("./settings-env");
const resolved = validateHomeDir("CODEX_HOME", newPath);
applySetting("CODEX_HOME", resolved);
return resolved;
}
module.exports = {
codexHomes,
getCodexHome,
setCodexHome,
getProjectCodexDir,
getProjectRoot,
};
+276
View File
@@ -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,
};
+44 -10
View File
@@ -1,7 +1,10 @@
/**
* @file Codex lane status mirror. Codex emits no hooks, so nothing else can
* tell the dashboard that a `codex` lane's agent is working. Its on-disk
* signal is the rollout journal it appends live, one file per thread:
* @file Codex lane status mirror. Without AgentHub's Codex hooks installed
* (codex-hooks.js, opt-in and trusted by the user in Codex) nothing else tells
* the dashboard that a `codex` lane's agent is working; while a lane's hooks
* are current (an event for its session in the last 2 minutes) this mirror
* leaves the lane to them. Its on-disk signal is the rollout journal Codex
* appends live, one file per thread:
*
* $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl
* line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}}
@@ -15,8 +18,9 @@
* Codex 0.149 also mirrors threads into version-suffixed SQLite files
* (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still
* points at a live rollout file, so reading the JSONL costs no coupling to a
* schema the next Codex release renames. `rolloutFiles()` is the one function
* to re-point if that ever stops being true.
* schema the next Codex release renames. `allRolloutFiles()` is the one
* function to re-point if that ever stops being true (codex-usage.js walks
* the same files for sessions and token usage).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,12 +29,13 @@ const path = require("node:path");
const os = require("node:os");
const { getCodexHome } = require("./codex-home");
const FUTURE_SLACK_MS = 1000;
const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the agent is working
const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass
const MAX_DEPTH = 4; // sessions/<YYYY>/<MM>/<DD>/<file>
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
function rolloutFiles() {
/** Every rollout journal under sessions/ ({path, mtime, size}), unsorted. */
function allRolloutFiles() {
const out = [];
const walk = (dir, depth) => {
let entries = [];
@@ -54,7 +59,14 @@ function rolloutFiles() {
}
};
walk(path.join(getCodexHome(), "sessions"), 0);
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
return out;
}
/** The newest MAX_FILES rollout journals, newest mtime first. */
function rolloutFiles() {
return allRolloutFiles()
.sort((a, b) => b.mtime - a.mtime)
.slice(0, MAX_FILES);
}
const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation)
@@ -114,6 +126,10 @@ function ingestCodexOnce({ broadcast } = {}) {
if (!codexLanes.length) return 0;
const touched = new Set();
// One verdict per lane — running when ANY of its journals is fresh. Judging
// file by file against the same status snapshot flipped a lane with an old
// journal beside the live one between running and idle on every pass.
const activeByLane = new Map();
const files = rolloutFiles();
pruneTracking(new Set(files.map((f) => f.path)));
for (const file of files) {
@@ -127,10 +143,26 @@ function ingestCodexOnce({ broadcast } = {}) {
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
// preserved timestamps) makes the raw difference negative, which reads as
// "written milliseconds ago". A file that claims to be from the future is
// not evidence of an agent working now, so treat it as idle.
// not evidence of an agent working now, so treat it as idle. The second of
// slack is for the file's nanosecond mtime against Date.now()'s truncated
// millisecond: a journal written this very millisecond reads as future.
const age = Date.now() - file.mtime;
const active = age >= 0 && age < CODEX_ACTIVE_MS;
const active = age > -FUTURE_SLACK_MS && age < CODEX_ACTIVE_MS;
const seen = activeByLane.get(lane.id);
activeByLane.set(lane.id, { lane, active: active || (seen ? seen.active : false) });
}
// Codex's hooks (codex-hooks.js) report a turn's start and end as they
// happen; while they are current, a journal written a minute ago would call
// a lane that just stopped "running".
const { db } = require("../db");
const hookedSince = new Date(Date.now() - CODEX_ACTIVE_MS).toISOString();
const recentHook = db.prepare(
"SELECT 1 FROM events WHERE session_id = ? AND created_at > ? LIMIT 1"
);
for (const { lane, active } of activeByLane.values()) {
if (lane.session_id && recentHook.get(lane.session_id, hookedSince)) continue;
// Status mirror, under the same guard hook ingestion uses: only idle↔running,
// and only for a lane the dashboard did not launch — provisioning and failed
// are lifecycle states this path must never stomp on. `updateLane` filters
@@ -181,7 +213,9 @@ function startCodexPoll(broadcast) {
}
module.exports = {
allRolloutFiles,
rolloutCwd,
FUTURE_SLACK_MS,
ingestCodexOnce,
startCodexPoll,
__resetCache,
+471
View File
@@ -0,0 +1,471 @@
/**
* @file Codex sessions and token usage, read from Codex's rollout journals
* (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). This is how
* a Codex session reaches `sessions` (as `tool = 'codex'`) and its tokens reach
* `token_usage` — Codex's hooks, when the user installs them, add its events
* and live status but no tokens.
*
* What a journal holds, and the two traps:
* - line 1 `session_meta`: `id` = the thread, `session_id` = the ROOT session
* (a sub-agent thread's parent; absent on old CLIs, then `id`), `cwd`,
* `timestamp`, `forked_from_id` on a fork;
* - `turn_context.model` is the model for the lines that follow;
* - `token_count` events carry a cumulative `total_token_usage` and its delta
* `last_token_usage` — `input_tokens` INCLUDES `cached_input_tokens`,
* `output_tokens` includes reasoning. The same total is often sent twice.
* - a FORK copies its parent's whole history into its own file, token events
* included, then carries on from the parent's total: every total vector the
* parent's file holds is skipped in the child, or the parent counts twice.
* - a sub-agent thread has its own file; its usage is summed into the root.
*
* Progress per file lives in `codex_rollouts` (offset + parse state), so an
* unchanged file is never re-read, a grown one is read from where it stopped,
* and a session the user deleted stays deleted until its journal changes.
* Totals are exact recomputes, written with `setTokenUsage`. A session is named
* by Codex's own thread name (`session_index.jsonl`), else its first prompt. Rows are never
* given a `transcript_path`, so the Claude transcript parsers skip them, and a
* session row whose tool is not `codex` is never touched. A session Codex's
* hooks report on (an event no older than its newest journal write, less two
* minutes) keeps the status, end and last-active time the hooks gave it; this
* module still writes its usage, model and name. Hooks that stopped reporting
* (removed, no longer trusted) hand the status back to the journal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline");
const { db, stmts } = require("../db");
const { getCodexHome, codexHomes } = require("./codex-home");
const { allRolloutFiles, FUTURE_SLACK_MS } = require("./codex-ingest");
const pricingSync = require("./pricing-sync");
const ACTIVE_MS = 120_000; // same window codex-ingest.js calls a lane running
// Read at most this much of one journal per pass: a sync read of a 100 MB
// journal would block the event loop for about a second.
const MAX_CHUNK = 16 * 1024 * 1024;
const NEEDLES = [
'"session_meta"',
'"turn_context"',
'"token_count"',
'"user_message"', // a prompt, older CLIs
'"UserMessage"', // a prompt, as an item_completed event (0.149+)
];
const FILE_NAME = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
function totalKey(t) {
return [
t.input_tokens,
t.cached_input_tokens,
t.cache_write_input_tokens,
t.output_tokens,
t.reasoning_output_tokens,
].join(",");
}
/** The text a user typed, from either prompt event shape; null otherwise. */
function promptOf(p) {
let text = null;
if (p.type === "user_message") text = p.message;
else if (p.type === "item_completed" && p.item?.type === "UserMessage") {
text = (p.item.content || [])
.filter((c) => c && c.type === "text")
.map((c) => c.text)
.join("\n");
}
return typeof text === "string" && text.trim() ? text.trim() : null;
}
/**
* Fold one chunk of journal text into `state` (mutated and returned). `skip`
* holds total vectors that belong to a fork's parent.
*/
function parseRolloutChunk(text, state = {}, skip = null) {
const s = state;
s.usage ||= {};
for (const line of text.split("\n")) {
if (!line || !NEEDLES.some((n) => line.includes(n))) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
const p = o.payload || {};
if (o.type === "session_meta") {
if (s.threadId) continue; // a fork repeats its ancestors' metas
s.threadId = p.id || null;
s.sessionId = p.session_id || p.id || null;
s.cwd = p.cwd || null;
s.startedAt = p.timestamp || o.timestamp || null;
s.forkedFrom = p.forked_from_id || null;
} else if (o.type === "turn_context") {
if (p.model) s.model = p.model;
} else if (o.type === "event_msg" && !s.firstPrompt && promptOf(p)) {
// The session's label when Codex has no thread name for it — the same
// first-line, 60-character rule hooks.js applies to a Claude prompt.
s.firstPrompt = promptOf(p).split("\n")[0].slice(0, 60);
} else if (o.type === "event_msg" && p.type === "token_count" && p.info?.total_token_usage) {
const key = totalKey(p.info.total_token_usage);
if (key === s.lastTotal) continue;
s.lastTotal = key;
// A fork's copied history comes first; its first total the parent never
// wrote ends it, so later totals are never matched against the parent.
if (s.forkedFrom && !s.pastFork) {
if (skip?.has(key)) continue;
if (skip) s.pastFork = true;
}
const l = p.info.last_token_usage || {};
const u = (s.usage[s.model || "unknown"] ||= {
input: 0,
cacheRead: 0,
cacheWrite: 0,
output: 0,
});
const cached = l.cached_input_tokens || 0;
u.input += Math.max(0, (l.input_tokens || 0) - cached);
u.cacheRead += cached;
u.cacheWrite += l.cache_write_input_tokens || 0;
u.output += l.output_tokens || 0;
}
}
return s;
}
/**
* Bytes [offset, size), at most MAX_CHUNK, up to the last newline, as text +
* bytes consumed + whether more is left. A window with no newline at all is
* one line longer than MAX_CHUNK — never a token event — and is skipped whole.
*/
function readChunk(file, offset, size) {
const buf = Buffer.alloc(Math.max(0, Math.min(size - offset, MAX_CHUNK)));
const fd = fs.openSync(file, "r");
try {
fs.readSync(fd, buf, 0, buf.length, offset);
} finally {
fs.closeSync(fd);
}
let end = buf.lastIndexOf(0x0a) + 1; // 0 when no full line yet
if (end === 0 && buf.length === MAX_CHUNK) end = MAX_CHUNK;
return { text: buf.toString("utf8", 0, end), bytes: end, more: offset + end < size && end > 0 };
}
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
async function parentTotals(file) {
const keys = new Set();
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
for await (const line of lines) {
if (!line.includes('"token_count"')) continue;
try {
const t = JSON.parse(line).payload?.info?.total_token_usage;
if (t) keys.add(totalKey(t));
} catch {
/* torn line */
}
}
return keys;
}
function firstLineMeta(file) {
try {
const { text } = readChunk(file, 0, Math.min(fs.statSync(file).size, 1024 * 1024));
const o = JSON.parse(text.split("\n")[0]);
return o.type === "session_meta" ? o.payload || {} : {};
} catch {
return {};
}
}
const threadCache = new Map(); // rollout path → {id, sessionId}; a first line never changes
/**
* The thread a rollout journal belongs to — `{id, sessionId}` from its
* session_meta, `sessionId` being the ROOT session — or null. Only a
* `rollout-*.jsonl` under `$CODEX_HOME/sessions` (as given or resolved) is read: the path comes from a
* hook payload, and a file elsewhere could name any session it likes.
*/
function threadOf(file) {
if (typeof file !== "string" || !path.isAbsolute(file)) return null;
const resolved = path.resolve(file);
const under = codexHomes().some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
if (!under || !FILE_NAME.test(path.basename(resolved))) return null;
if (threadCache.has(resolved)) return threadCache.get(resolved);
const meta = firstLineMeta(resolved);
const thread = meta.id ? { id: meta.id, sessionId: meta.session_id || meta.id } : null;
if (!thread) return null; // not written yet: ask again next time
if (threadCache.size >= 500) threadCache.clear();
threadCache.set(resolved, thread);
return thread;
}
/** `thread_name` per id from `$CODEX_HOME/session_index.jsonl` (last wins). */
function threadNames() {
const names = new Map();
let text = "";
try {
text = fs.readFileSync(path.join(getCodexHome(), "session_index.jsonl"), "utf8");
} catch {
return names;
}
for (const line of text.split("\n")) {
try {
const o = JSON.parse(line);
if (o.id && o.thread_name) names.set(o.id, String(o.thread_name));
} catch {
/* blank or torn */
}
}
return names;
}
const getRollout = db.prepare("SELECT * FROM codex_rollouts WHERE path = ?");
const upsertRollout = db.prepare(
`INSERT INTO codex_rollouts (path, session_id, thread_id, size, mtime_ms, offset, state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id, thread_id = excluded.thread_id,
size = excluded.size, mtime_ms = excluded.mtime_ms, offset = excluded.offset,
state = excluded.state, updated_at = excluded.updated_at`
);
// One row per thread: the same journal met under a new path (CODEX_HOME moved,
// or reached through a symlink) replaces its old row instead of counting twice.
const dropOtherPaths = db.prepare("DELETE FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
const rolloutsOf = db.prepare(
"SELECT thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
);
const insertCodexSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex')`
);
const updateCodexSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, model = ?, ended_at = ?, updated_at = ?,
cwd = COALESCE(?, cwd), started_at = COALESCE(?, started_at)
WHERE id = ? AND tool = 'codex'`
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
// A session Codex's hooks report on (server/lib/codex-hooks.js) has events;
// its status is theirs, as a Claude session's is — while they still report:
// an event no older than the journal's newest write (less ACTIVE_MS). Hooks
// removed, or no longer trusted, hand the status back to the journal.
const lastEventAt = db.prepare("SELECT MAX(created_at) AS t FROM events WHERE session_id = ?");
const newestRollout = db.prepare(
"SELECT MAX(mtime_ms) AS m FROM codex_rollouts WHERE session_id = ?"
);
function hooksCurrent(sessionId, newestMtime) {
const t = Date.parse(lastEventAt.get(sessionId)?.t || "");
return Number.isFinite(t) && t >= newestMtime - ACTIVE_MS;
}
// A journal written "in the future" (a clock that runs ahead) is not proof of
// activity — codex-ingest.js treats it the same way.
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
/** Re-derive one root session's row and usage from all its threads. */
function writeSession(sessionId, names, now) {
const rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
if (rows.length === 0) return null;
const existing = stmts.getSession.get(sessionId);
if (existing && existing.tool !== "codex") return null;
// The root thread's own file, when it has been read: a sub-agent file read
// first must not fix the session's cwd, start or model.
const ownRoot = rows.find((r) => r.thread_id === sessionId);
const root = ownRoot || rows[0];
const newest = rows.reduce((a, b) => (b.mtime_ms > a.mtime_ms ? b : a));
const active = rows.some((r) => fresh(r.mtime_ms, now));
const name =
names.get(sessionId) ||
root.state.firstPrompt ||
existing?.name ||
`Codex ${sessionId.slice(0, 8)}`;
const model = (ownRoot && ownRoot.state.model) || newest.state.model || null;
// A journal is appended to the end, so its mtime is when the session last
// did anything — later than its last token event after an abort.
let lastWrite = new Date(newest.mtime_ms).toISOString();
let status = active ? "active" : "completed";
let endedAt = active ? null : lastWrite;
if (existing && hooksCurrent(sessionId, newest.mtime_ms)) {
({ status, ended_at: endedAt, updated_at: lastWrite } = existing);
}
const totals = {};
for (const r of rows) {
for (const [m, u] of Object.entries(r.state.usage || {})) {
const t = (totals[m] ||= { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
for (const k of Object.keys(t)) t[k] += u[k] || 0;
}
}
db.transaction(() => {
// `updated_at` is the session's last activity (the list's "Last active"
// and its sort), so it is the journal's last write, not ingestion time.
if (existing)
updateCodexSession.run(
name,
status,
model,
endedAt,
lastWrite,
ownRoot ? ownRoot.state.cwd : null,
ownRoot ? ownRoot.state.startedAt : null,
sessionId
);
else
insertCodexSession.run(
sessionId,
name,
status,
root.state.cwd,
model,
root.state.startedAt || new Date(now).toISOString(),
endedAt,
lastWrite,
JSON.stringify({ source: "codex-rollout" })
);
// Every usage row of a Codex session is this module's, so it is rewritten
// whole: a model gone from a re-read journal leaves no stale row behind.
clearUsage.run(sessionId);
for (const [m, t] of Object.entries(totals)) {
stmts.setTokenUsage.run(
sessionId,
m,
"standard",
"global",
"standard",
t.input,
t.output,
t.cacheRead,
t.cacheWrite,
0,
0,
0,
0
);
}
})();
return existing ? "session_updated" : "session_created";
}
let running = null;
/**
* One pass over every journal. Yields to the event loop between files; a
* second call while one runs joins it.
* @returns {Promise<{files: number, sessions: number}>} files read, sessions written
*/
function ingestCodexUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
const files = allRolloutFiles();
const byThread = new Map();
for (const f of files) {
const m = FILE_NAME.exec(path.basename(f.path));
if (m) byThread.set(m[1], f.path);
}
const touched = new Set();
let read = 0;
for (const f of files) {
try {
const mtime = Math.floor(f.mtime);
const row = getRollout.get(f.path);
let offset = row ? row.offset : 0;
let state = row ? JSON.parse(row.state) : {};
if (row && row.size === f.size && row.mtime_ms === mtime && !state.more) continue;
if (f.size < offset) {
offset = 0;
state = {};
}
let skip = null;
const parent = offset === 0 ? firstLineMeta(f.path).forked_from_id : state.forkedFrom;
if (parent && !state.pastFork) {
if (byThread.has(parent)) skip = await parentTotals(byThread.get(parent));
else if (offset === 0)
console.warn(`[codex-usage] fork parent ${parent} not found; ${f.path} may overcount`);
}
const { text, bytes, more } = readChunk(f.path, offset, f.size);
state = parseRolloutChunk(text, state, skip);
state.more = more;
upsertRollout.run(
f.path,
state.sessionId || "",
state.threadId || "",
f.size,
mtime,
offset + bytes,
JSON.stringify(state)
);
if (state.threadId) dropOtherPaths.run(state.threadId, f.path);
read += 1;
if (state.sessionId) touched.add(state.sessionId);
} catch (err) {
console.warn(`[codex-usage] skipped ${f.path}: ${err.message}`);
}
await new Promise((r) => setImmediate(r));
}
let written = 0;
const names = touched.size ? threadNames() : new Map();
for (const sid of touched) {
try {
const type = writeSession(sid, names, now);
if (!type) continue;
written += 1;
broadcast(type, stmts.getSession.get(sid));
} catch (err) {
console.warn(`[codex-usage] session ${sid}: ${err.message}`);
}
}
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/**
* First pass at once (in the background), then every DASHBOARD_CODEX_POLL_MS
* (default 15 s; 0 disables, like the codex lane poll it shares the knob with).
* A pass also re-derives `active` → `completed`, so a session whose journal
* went quiet is re-written once its files are older than ACTIVE_MS.
*/
function startCodexUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CODEX_POLL_MS
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const tick = () => {
ingestCodexUsage({ broadcast })
.then((r) => {
settleActive(broadcast);
// A pass that wrote sessions may have brought a model with no rate
// row; price it now rather than at the sync's next 15-minute check.
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
})
.catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
}
/** Codex sessions still `active` whose every journal has gone quiet. */
function settleActive(broadcast, now = Date.now()) {
const quiet = db
.prepare(
`SELECT s.id FROM sessions s WHERE s.tool = 'codex' AND s.status = 'active'
AND NOT EXISTS (SELECT 1 FROM codex_rollouts r WHERE r.session_id = s.id
AND r.mtime_ms > ? AND r.mtime_ms < ?)`
)
.all(now - ACTIVE_MS, now + FUTURE_SLACK_MS);
if (quiet.length === 0) return;
const names = threadNames();
for (const { id } of quiet) {
if (hooksCurrent(id, newestRollout.get(id)?.m || 0)) continue;
if (writeSession(id, names, now)) broadcast("session_updated", stmts.getSession.get(id));
}
}
module.exports = {
parseRolloutChunk,
ingestCodexUsage,
settleActive,
startCodexUsagePoll,
threadOf,
};
+7 -3
View File
@@ -790,7 +790,9 @@ function subdirLikePattern(cwd) {
/**
* Find sessions that belong to a lane and may be purged: exact or subdirectory,
* excluding the lane's bound session and any active sessions. Shared between
* recorded by the lane's own tool (a Claude and a Codex lane can share one
* directory, and purging one must not take the other's history), excluding
* the lane's bound session and any active sessions. Shared between
* purgeLaneSessions (the deleter) and preflight counting, so the confirmation
* dialog's numbers match what actually gets deleted.
*/
@@ -799,10 +801,11 @@ function purgeCandidateSessions(lane) {
.prepare(
`SELECT id FROM sessions
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
AND tool = ?
AND id != ?
AND status != 'active'`
)
.all(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
.all(lane.cwd, subdirLikePattern(lane.cwd), lane.tool || "claude", lane.session_id || "");
}
/**
@@ -816,10 +819,11 @@ function hasActiveLaneSession(lane) {
.prepare(
`SELECT COUNT(*) AS count FROM sessions
WHERE (cwd = ? OR cwd LIKE ? ESCAPE '${SUBDIR_LIKE_ESCAPE}')
AND tool = ?
AND id != ?
AND status = 'active'`
)
.get(lane.cwd, subdirLikePattern(lane.cwd), lane.session_id || "");
.get(lane.cwd, subdirLikePattern(lane.cwd), lane.tool || "claude", lane.session_id || "");
return Boolean(row && row.count > 0);
}
+96 -17
View File
@@ -1,14 +1,24 @@
/**
* @file Automatic pricing for Claude models the dashboard has seen but has no
* rate row of its own for. Every model id in `token_usage` is reduced to its
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
* models the dashboard has seen but has no rate row of its own
* for. Every model id in `token_usage` is reduced to its family (a
* `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix dropped); a family with no
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
* never updated, so a rate the user edited in Settings always wins. "Its own
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
* Opus 5 by the broader `claude-opus-5%` pattern.
*
* The list is fetched only when something is missing, at most once per
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken
* only from its own vendor (`anthropic` for claude-…, `openai` for gpt-…,
* o<n>…, codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id (`clinepass/…`, `Highest`) is left to
* the user.
*
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
* modelled — Codex's context window stays under 272k and runs on the default
* tier; add a service_tier bucket if that changes. Gemini's >200k-token rate
* is not modelled either (the base rate is used).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,38 +30,78 @@ const CACHE_MS = 24 * 60 * 60_000;
const MAX_PER_MTOK = 1000;
let cached = null; // { at, list }
let failedAt = -Infinity; // last failed fetch
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
const OPENAI = /^(gpt-|o\d|codex-)[a-z0-9.-]*$/;
const GEMINI = /^gemini-[a-z0-9.-]+$/;
const PROVIDERS = {
claude: ["anthropic"],
gemini: ["vertex_ai-language-models", "gemini"],
openai: ["openai"],
};
const vendorOf = (fam) =>
fam.startsWith("claude-") ? "claude" : fam.startsWith("gemini-") ? "gemini" : "openai";
/**
* `claude-opus-5-5-20260101` → `claude-opus-5-5`, `gpt-5.4-2026-03-05` →
* `gpt-5.4`; null for anything else.
*/
function familyOf(model) {
if (typeof model !== "string") return null;
const fam = model.replace(/-\d{8}$/, "");
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
if (model.startsWith("claude-")) {
const fam = model.replace(/-\d{8}$/, "");
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
}
if (model.startsWith("gemini-")) return GEMINI.test(model) ? model : null;
const fam = model.replace(/-\d{4}-\d{2}-\d{2}$/, "");
return OPENAI.test(fam) ? fam : null;
}
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
const cap = (w) => w[0].toUpperCase() + w.slice(1);
/**
* `claude-opus-5-5` → `Claude Opus 5.5`, `gpt-5.6-sol` → `GPT-5.6 Sol`,
* `gemini-3.8-flash` → `Gemini 3.8 Flash`.
*/
function displayName(fam) {
if (fam.startsWith("gemini-")) return fam.split("-").map(cap).join(" ");
if (!fam.startsWith("claude-")) {
const [head, ...rest] = fam.split("-");
if (head === "gpt" && rest.length) {
const [ver, ...words] = rest;
return [`GPT-${ver}`, ...words.map(cap)].join(" ");
}
return [head === "codex" ? "Codex" : head, ...rest.map(cap)].join(" ");
}
const [, ...parts] = fam.split("-");
const words = [];
const nums = [];
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
const name = words.map(cap).join(" ");
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
}
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
function toRow(fam, e) {
if (!e || e.litellm_provider !== "anthropic") return null;
const claude = fam.startsWith("claude-");
if (!e || !PROVIDERS[vendorOf(fam)].includes(e.litellm_provider)) return null;
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
const input = per(e.input_cost_per_token);
const output = per(e.output_cost_per_token);
// Anthropic's published multipliers stand in for a field the list omits.
// Published multipliers stand in for a field the list omits: Anthropic
// charges cache writes (1.25x / 2x), OpenAI does not.
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
const w5m = per(
e.cache_creation_input_token_cost ?? (claude ? e.input_cost_per_token * 1.25 : 0)
);
const w1h = per(
e.cache_creation_input_token_cost_above_1hr ??
(claude ? e.input_cost_per_token * 2 : (e.cache_creation_input_token_cost ?? 0))
);
const rates = [input, output, read, w5m, w1h];
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
if (input === 0 && output === 0) return null;
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
const fast = claude && e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
const r = (n) => Math.round(n * 1e6) / 1e6;
@@ -98,7 +148,17 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
try {
const missing = missingFamilies(db);
if (missing.length === 0) return [];
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
if (!cached || now - cached.at > CACHE_MS) {
// A failed fetch is retried at the sync's own cadence, not on every
// caller (the Codex poll asks every 15 s while a session is active).
if (now - failedAt < CHECK_MS) return [];
try {
cached = { at: now, list: await fetchList() };
} catch (err) {
failedAt = now;
throw err;
}
}
const insert = 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,
@@ -129,14 +189,33 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
}
}
function syncEnabled() {
return !/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "");
}
function startPricingSync(db) {
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
if (!syncEnabled()) return;
void syncPricing(db);
setInterval(() => void syncPricing(db), CHECK_MS).unref();
}
function _resetCache() {
cached = null;
failedAt = -Infinity;
}
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
/** Test seam: pretend the price list was just fetched. */
function _setCache(list, at = Date.now()) {
cached = { at, list };
}
module.exports = {
syncPricing,
syncEnabled,
startPricingSync,
familyOf,
displayName,
toRow,
_resetCache,
_setCache,
};
+5
View File
@@ -32,6 +32,7 @@
const tmux = require("./tmux");
const { routerArgvForSpawn, routerEnvForSpawn } = require("./router-apply");
const { desktopEnv } = require("./desktop-env");
const { getCodexHome } = require("./codex-home");
const aiTools = require("./ai-tools");
let dashboardRuns = null;
@@ -329,6 +330,10 @@ function spawnRun(args) {
AGENTHUB_LANE_ID: String(laneId),
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
// A codex pane must read the same home the dashboard reads — its rollout
// journals and the router layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
...routerEnvForSpawn({ tool, router_profile: routerProfile || null }),
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
+40 -1
View File
@@ -36,7 +36,7 @@ const routers = require("./ai-routers");
const { patchSettings } = require("./cc-mutate");
const { excludeFromGit } = require("./git-exclude");
const { getCodexHome } = require("./codex-home");
const { lanesByRouter, setRouterProfile } = require("./lanes");
const { lanesByRouter, listLanes, setRouterProfile } = require("./lanes");
/**
* The env var Codex is told to read the router key from. One fixed name rather
@@ -255,6 +255,43 @@ function reapplyToLanes(profileName) {
return { updated, failed };
}
/**
* Re-write the router layer of every codex lane on a router, into the CURRENT
* `$CODEX_HOME`. Called after CODEX_HOME moves: the layer `codex -p <name>`
* loads lives there, so without this every such lane would fail to start.
* Per-lane best effort, like `reapplyToLanes`.
*
* @returns {{updated:number, failed:string[]}}
*/
function reapplyCodexLayers() {
const failed = [];
let updated = 0;
for (const lane of listLanes()) {
if (lane.tool !== "codex" || !lane.router_profile || lane.router_profile === "native") continue;
try {
applyRouterToLane(lane, lane.router_profile);
updated += 1;
} catch (e) {
failed.push(`${lane.id} (${lane.title || lane.cwd}): ${e.code || e.message}`);
}
}
return { updated, failed };
}
/**
* The lanes naming an AI Router profile the store no longer has — after
* CLAUDE_HOME moved (the store is `<CLAUDE_HOME>/agenthub-routers/`), such a
* lane starts on its base configuration (`readOrNull`), so the move says so.
* @returns {string[]} `"<id> (<title>): <profile>"` per lane.
*/
function lanesWithMissingRouter() {
return listLanes()
.filter(
(l) => l.router_profile && l.router_profile !== "native" && !readOrNull(l.router_profile)
)
.map((l) => `${l.id} (${l.title || l.cwd}): ${l.router_profile}`);
}
/**
* Detach `profileName` from every lane using it, and delete the Codex layer
* file it wrote.
@@ -291,8 +328,10 @@ function detachFromLanes(profileName) {
module.exports = {
applyRouterToLane,
reapplyToLanes,
reapplyCodexLayers,
detachFromLanes,
routerArgvForSpawn,
routerEnvForSpawn,
lanesWithMissingRouter,
CODEX_KEY_ENV,
};
+199
View File
@@ -0,0 +1,199 @@
/**
* @file Where a setting saved from the dashboard (CLAUDE_HOME, CODEX_HOME, the
* pinned DASHBOARD_DATA_DIR) persists, and the startup env load order.
*
* The file is `$XDG_CONFIG_HOME/agenthub/settings.env` (default
* `~/.config/agenthub/`, or `AGENTHUB_SETTINGS_FILE`) — outside the repo,
* because a plugin install runs from the plugin cache Claude Code replaces on
* every update, and outside CLAUDE_HOME, because the data dir is derived from
* CLAUDE_HOME and the setting that moves it cannot live under it.
*
* Load order: the real environment wins, then a checkout's own `<repo>/.env`
* (per checkout, so more specific than a per-user file: a dev checkout whose
* `.env` names its own DASHBOARD_DATA_DIR never opens the live database), then
* this file; a key is only ever set while still unset, and a key the real
* environment or `.env` set is `env_owned` — a value saved from the dashboard
* would be overridden again at the next start, and the UI says so. The keys
* that came from a file are listed in `AGENTHUB_FILE_KEYS`, so a server that
* inherits this process's env (a self-restart spawns its successor with it)
* does not mistake them for the real environment's and re-reads them. Deliberately dependency-free —
* `server/index.js` runs it before anything else is required.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/** Keys the real environment supplied at startup; filled by `loadEnvFiles`. */
const envOwned = new Set();
function settingsFilePath(env = process.env) {
if (env.AGENTHUB_SETTINGS_FILE) return env.AGENTHUB_SETTINGS_FILE;
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
return path.join(base, "agenthub", "settings.env");
}
function expandHome(value) {
return value.replace(/^~(?=\/|$)/, os.homedir());
}
function parseEnv(text) {
const out = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let val = trimmed.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
out.push([key, val]);
}
return out;
}
/**
* Fill `env` from `owned` then `files`, in order, never overwriting a key
* already set. A key from an `owned` file (the checkout's `.env`) counts as
* the environment's, like one the real environment held. `files` defaults to
* the settings file, located AFTER the owned files load, so an
* `AGENTHUB_SETTINGS_FILE` set in `.env` is the file read and written.
* @returns {Set<string>} the owned keys (inherited file-loaded keys, named in
* `AGENTHUB_FILE_KEYS`, excluded).
*/
function loadEnvFiles({ env = process.env, owned = [], files } = {}) {
for (const k of (env.AGENTHUB_FILE_KEYS || "").split(",")) if (k) delete env[k];
delete env.AGENTHUB_FILE_KEYS;
const before = new Set(Object.keys(env).filter((k) => env[k]));
const loaded = new Set();
const load = (file, into) => {
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
return;
}
for (const [key, val] of parseEnv(text)) {
if (!env[key]) {
env[key] = expandHome(val);
into.add(key);
}
}
};
for (const file of owned) load(file, before);
for (const file of files ?? [settingsFilePath(env)]) load(file, loaded);
if (loaded.size) env.AGENTHUB_FILE_KEYS = [...loaded].join(",");
if (env === process.env) for (const k of before) envOwned.add(k);
return before;
}
/**
* Persist a setting and apply it to this process. A key the real environment
* does not own joins `AGENTHUB_FILE_KEYS`, like one loaded from the file.
*/
function applySetting(key, value) {
writeSetting(key, value);
process.env[key] = value;
if (envOwned.has(key)) return;
const keys = new Set((process.env.AGENTHUB_FILE_KEYS || "").split(",").filter(Boolean));
keys.add(key);
process.env.AGENTHUB_FILE_KEYS = [...keys].join(",");
}
/**
* Set or replace one `KEY=value` line — every earlier line for that key,
* `KEY = value` included, is dropped, or the first one would still win at the
* next load. Atomic, dir 0700, file 0600, through a symlink to its target.
*/
// ponytail: read-modify-write without a lock — two servers saving at the same
// instant can drop one key; add a lock file if that ever happens for real.
function writeSetting(key, value, file = settingsFilePath()) {
if (/[\r\n]/.test(value)) throw new Error(`${key} may not contain a line break`);
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
let lines = [];
try {
lines = fs
.readFileSync(file, "utf8")
.split("\n")
.filter((l) => l.trim() !== "");
} catch {
/* first write */
}
const isKey = (l) => {
const t = l.trim();
return !t.startsWith("#") && t.includes("=") && t.slice(0, t.indexOf("=")).trim() === key;
};
let done = false;
lines = lines.flatMap((l) =>
!isKey(l) ? [l] : done ? [] : ((done = true), [`${key}=${value}`])
);
if (!done) lines.push(`${key}=${value}`);
let target = file;
try {
target = fs.realpathSync(file);
} catch {
/* first write */
}
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
fs.renameSync(tmp, target);
}
/**
* The one check an editable home directory passes. A directory that IS, or
* CONTAINS, the OS user's home — `/`, `/home`, `~` itself, or a symlink, a
* bind mount or (on a case-insensitive disk) a differently-cased path to any of
* them — is refused (`EBADHOME`): everything under such a home becomes
* readable, and partly writable, through its config panel. Compared by device
* and inode against the home and each of its ancestors.
* @returns {string} the normalized absolute path (not symlink-resolved).
*/
function validateHomeDir(label, input) {
const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_PATH" });
const trimmed = String(input).trim();
if (/[\x00-\x1f]/.test(trimmed)) throw invalid(`${label} may not contain control characters`);
const expanded = expandHome(trimmed);
if (!path.isAbsolute(expanded)) throw invalid(`${label} must be an absolute path`);
const resolved = path.resolve(expanded);
const tooBroad = () =>
Object.assign(new Error(`Too broad for ${label}: ${resolved} contains your home directory`), {
code: "EBADHOME",
});
const contains = (dir, home) =>
home === dir || home.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
if (contains(resolved, path.resolve(os.homedir()))) throw tooBroad();
if (!fs.existsSync(resolved)) throw invalid(`Directory does not exist: ${resolved}`);
if (!fs.statSync(resolved).isDirectory()) throw invalid(`Not a directory: ${resolved}`);
let realHome;
try {
realHome = fs.realpathSync(os.homedir());
} catch {
realHome = path.resolve(os.homedir());
}
const idOf = (p) => {
try {
const st = fs.statSync(p);
return `${st.dev}:${st.ino}`;
} catch {
return null;
}
};
const target = idOf(resolved);
for (let dir = realHome; ; dir = path.dirname(dir)) {
if (idOf(dir) === target) throw tooBroad();
if (dir === path.dirname(dir)) break;
}
return resolved;
}
module.exports = {
settingsFilePath,
loadEnvFiles,
writeSetting,
applySetting,
validateHomeDir,
envOwned,
};
+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",
+24 -3
View File
@@ -241,6 +241,11 @@ function createOpenApiSpec() {
description: "JSON-encoded session metadata",
},
updated_at: { type: "string", format: "date-time" },
tool: {
type: "string",
description:
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) or `antigravity` (agy's conversation files and its opt-in hooks).",
},
agent_count: { type: "integer", nullable: true },
last_activity: { type: "string", format: "date-time", nullable: true },
cost: { type: "number", nullable: true },
@@ -822,11 +827,18 @@ function createOpenApiSpec() {
hook_type: {
type: "string",
description:
"Hook type from Claude Code (common values: PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionStart, SessionEnd)",
"Hook type from the CLI that sent it (Claude Code: PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionStart, SessionEnd; Codex adds PermissionRequest and Interrupt; agy sends PreInvocation, PostToolUse and Stop)",
},
tool: {
type: "string",
enum: ["claude", "codex", "antigravity"],
description:
"Which CLI sent the hook (`scripts/hook-handler.js <Event> [codex|antigravity]`). An allowlist: anything else is `claude`.",
},
data: {
type: "object",
required: ["session_id"],
description:
"The raw hook payload. Claude Code and Codex name the session `session_id`; agy names it `conversationId` and is accepted only with a `transcriptPath` under its own CLI directory.",
properties: {
session_id: { type: "string" },
tool_name: { type: "string" },
@@ -838,10 +850,19 @@ function createOpenApiSpec() {
},
HookEventResponse: {
type: "object",
required: ["ok", "event"],
required: ["ok"],
properties: {
ok: { type: "boolean", enum: [true] },
event: { $ref: "#/components/schemas/DashboardEvent" },
ignored: {
type: "boolean",
description:
"The hook maps to nothing AgentHub records (a Codex sub-agent's lifecycle hook, an agy event other than a turn's first PreInvocation, a PostToolUse or a Stop, or a hook from the Antigravity IDE).",
},
duplicate: {
type: "boolean",
description: "The same hook delivered twice within 10 seconds; stored once.",
},
},
},
PricingRule: {
+238 -68
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for handling incoming hook events from Claude CLI. It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,6 +19,9 @@ const lanesLib = require("../lib/lanes");
const { broadcastLane } = require("./lanes");
const { detect } = require("../lib/stage-detect");
const { getPipeline } = require("../lib/pipelines");
const path = require("path");
const { threadOf } = require("../lib/codex-usage");
const { agyCliDirs } = require("../lib/antigravity-home");
const router = Router();
@@ -166,12 +169,13 @@ const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]);
* running/idle, so without this an adopted lane read "idle" for the entire
* time Claude was working in it.
*/
function touchLaneFromHook(hookType, data) {
function touchLaneFromHook(hookType, data, tool = "claude") {
try {
if (!data || !data.cwd) return;
// Hooks are only ever emitted by the Claude Code CLI, so pin to the
// `claude` lane at this cwd — never the `codex` lane that might share it.
const lane = lanesLib.resolveLaneByCwd(data.cwd, "claude");
// Pin to the lane of the CLI that sent the hook — a Claude hook never
// touches the codex lane sharing its directory, nor a Codex hook the
// claude one.
const lane = lanesLib.resolveLaneByCwd(data.cwd, tool);
if (!lane) return;
// Infer the lane's stage from this one tool event, best-effort. A real
@@ -188,30 +192,38 @@ function touchLaneFromHook(hookType, data) {
// of that bookkeeping because the block below returns early when the patch is
// empty — the common case — so detection placed after it would be unreachable
// on most hooks.
try {
// Credit the lane whose files/directory the tool actually touched, which
// is not always the session's lane - see resolveLaneForWork. Bookkeeping
// below still uses `lane`, the session's own.
const workLane = lanesLib.resolveLaneForWork(data.cwd, data.tool_input) || lane;
const detection = detect(getPipeline(workLane.pipeline), {
tool_name: data.tool_name,
tool_input: data.tool_input,
});
if (detection) {
const detectResult = lanesLib.recordDetection(workLane.id, detection);
if (detectResult.written) broadcastLane(workLane.id);
// Only a claude lane carries a pipeline; every other tool's lane has no
// stage to infer.
if (tool === "claude") {
try {
// Credit the lane whose files/directory the tool actually touched, which
// is not always the session's lane - see resolveLaneForWork. Bookkeeping
// below still uses `lane`, the session's own.
const workLane = lanesLib.resolveLaneForWork(data.cwd, data.tool_input) || lane;
const detection = detect(getPipeline(workLane.pipeline), {
tool_name: data.tool_name,
tool_input: data.tool_input,
});
if (detection) {
const detectResult = lanesLib.recordDetection(workLane.id, detection);
if (detectResult.written) broadcastLane(workLane.id);
}
} catch {
/* an inference is never worth dropping real bookkeeping for */
}
} catch {
/* an inference is never worth dropping real bookkeeping for */
}
const patch = {};
// A Codex sub-agent's tool call (see processEvent): not the user's session
// resuming, so it neither clears needs_action nor moves the lane.
const childEvent = tool === "codex" && data._codexChild === true;
const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || "");
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
if (hookType === "Notification" && !idleNudge) {
patch.needs_action = data.message || "needs you";
patch.needs_action_session = data.session_id || null;
} else if (
!childEvent &&
lane.needs_action &&
data.session_id &&
data.session_id === (lane.needs_action_session || lane.session_id)
@@ -226,7 +238,7 @@ function touchLaneFromHook(hookType, data) {
// Only idle↔running is mirrored: provisioning and failed are lifecycle
// states this path must never stomp on.
if (!lane.run_id && (lane.status === "idle" || lane.status === "running")) {
if (!childEvent && !lane.run_id && (lane.status === "idle" || lane.status === "running")) {
const next = LANE_WORKING_HOOKS.has(hookType)
? "running"
: LANE_DONE_HOOKS.has(hookType) || idleNudge
@@ -243,40 +255,51 @@ function touchLaneFromHook(hookType, data) {
}
}
function ensureSession(sessionId, data) {
const setSessionTool = db.prepare("UPDATE sessions SET tool = ? WHERE id = ?");
/** The session's `<id>-main` agent, created and broadcast. */
function createMainAgent(session) {
const mainAgentId = `${session.id}-main`;
const sessionLabel = session.name || `Session ${session.id.slice(0, 8)}`;
stmts.insertAgent.run(
mainAgentId,
session.id,
`Main Agent - ${sessionLabel}`,
"main",
null,
"working",
null,
null,
null
);
const mainAgent = stmts.getAgent.get(mainAgentId);
if (mainAgent) broadcast("agent_created", mainAgent);
}
const SESSION_LABEL = { codex: "Codex", antigravity: "Antigravity" };
function ensureSession(sessionId, data, tool = "claude") {
let session = stmts.getSession.get(sessionId);
if (!session) {
stmts.insertSession.run(
sessionId,
data.session_name || `Session ${sessionId.slice(0, 8)}`,
data.session_name || `${SESSION_LABEL[tool] || "Session"} ${sessionId.slice(0, 8)}`,
"active",
data.cwd || null,
data.model || null,
null
);
if (tool !== "claude") setSessionTool.run(tool, sessionId);
session = stmts.getSession.get(sessionId);
if (!session) {
console.error(`[HOOKS] Failed to create session ${sessionId} — insert returned no row`);
return null;
}
broadcast("session_created", session);
// Create main agent for new session
const mainAgentId = `${sessionId}-main`;
const sessionLabel = session.name || `Session ${sessionId.slice(0, 8)}`;
stmts.insertAgent.run(
mainAgentId,
sessionId,
`Main Agent - ${sessionLabel}`,
"main",
null,
"working",
null,
null,
null
);
const mainAgent = stmts.getAgent.get(mainAgentId);
if (mainAgent) broadcast("agent_created", mainAgent);
createMainAgent(session);
} else if (tool !== "claude" && !stmts.getAgent.get(`${sessionId}-main`)) {
// A Codex session the journal poll imported first has no main agent yet.
createMainAgent(session);
}
// First-seen transcript_path → write to session row so the periodic sweep
@@ -284,7 +307,9 @@ function ensureSession(sessionId, data) {
// (NULL/'' check), so subsequent hooks for the same session are no-ops.
// Type guard: hook payloads are unvalidated JSON — a non-string value would
// make better-sqlite3 throw inside the surrounding processEvent transaction.
if (typeof data.transcript_path === "string" && data.transcript_path) {
// A Codex rollout is not a Claude transcript, and every sweep that reads
// transcript_path parses Claude's format — it is never stored for one.
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
stmts.setSessionTranscriptPath.run(data.transcript_path, sessionId);
}
return session;
@@ -419,11 +444,14 @@ function applyFirstUserDescriptor(sessionId, result) {
if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
}
const processEvent = db.transaction((hookType, data) => {
const processEvent = db.transaction((hookType, data, tool = "claude") => {
const sessionId = data.session_id;
if (!sessionId) return null;
const session = ensureSession(sessionId, data);
const session = ensureSession(sessionId, data, tool);
// Only a Claude transcript is parsed here; a Codex session's tokens, model
// and name come from its rollout journal (server/lib/codex-usage.js).
const transcriptPath = tool === "claude" ? data.transcript_path : null;
// Remote household hooks (aideck-hook.js on other machines) cannot rely on
// the transcript being readable on THIS host (the JSONL lives on the remote
@@ -442,6 +470,11 @@ const processEvent = db.transaction((hookType, data) => {
let mainAgent = getMainAgent(sessionId);
const mainAgentId = mainAgent?.id ?? null;
// A Codex sub-agent's tool call folded onto its root session (codexHookInput):
// work, but not the user resuming — it neither reactivates the session, nor
// clears its waiting flag, nor puts the main agent to work. Its own Stop is
// dropped, so anything it set here would never be undone.
const childEvent = tool === "codex" && data._codexChild === true;
// Reactivate non-active sessions when we receive hook events proving the session is alive.
// - UserPromptSubmit and PreToolUse always reactivate (user actively retried, even from error).
@@ -455,6 +488,7 @@ const processEvent = db.transaction((hookType, data) => {
const isStopLike = hookType === "Stop" || hookType === "SubagentStop";
const isImportedOrAbandoned = session.status === "completed" || session.status === "abandoned";
const needsReactivation =
!childEvent &&
session.status !== "active" &&
isNonTerminalEvent &&
(isUserAction ||
@@ -495,12 +529,14 @@ const processEvent = db.transaction((hookType, data) => {
// (reason='notification'), in which case the flag is preserved
// (clearAwaitingInputRespectingActor). Computed before the clear because
// the Agent-spawn branch below mutates agent rows.
clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActorNow(sessionId, mainAgent),
true
);
if (!childEvent) {
clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActorNow(sessionId, mainAgent),
true
);
}
// If the tool is Agent, a subagent is being created
if (toolName === "Agent") {
@@ -562,6 +598,7 @@ const processEvent = db.transaction((hookType, data) => {
}
if (
mainAgent &&
!childEvent &&
!subagentIsActor &&
(mainAgent.status === "working" || mainAgent.status === "waiting")
) {
@@ -581,12 +618,14 @@ const processEvent = db.transaction((hookType, data) => {
// a clear here, we'd be stuck in waiting until the next PreToolUse.
// Same actor-guard as PreToolUse: a background subagent's PostToolUse
// must not clear a genuine 'notification' wait held by the main agent.
clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActorNow(sessionId, mainAgent),
true
);
if (!childEvent) {
clearAwaitingInputRespectingActor(
sessionId,
mainAgentId,
subagentIsActorNow(sessionId, mainAgent),
true
);
}
// NOTE: PostToolUse for "Agent" tool fires immediately when a subagent is
// backgrounded — it does NOT mean the subagent finished its work.
@@ -787,7 +826,7 @@ const processEvent = db.transaction((hookType, data) => {
// tail. A transient API error that the CLI retried past (successful turns
// after it) has recovered, so exiting after a long healthy run must land
// as `completed`, not a stale `error` frozen from days earlier.
const endResult = data.transcript_path ? transcriptCache.extract(data.transcript_path) : null;
const endResult = transcriptPath ? transcriptCache.extract(transcriptPath) : null;
const finalSessionStatus =
endSession?.status === "error" && isErrorAtTail(endResult) ? "error" : "completed";
const allAgents = stmts.listAgentsBySession.all(sessionId);
@@ -824,8 +863,10 @@ const processEvent = db.transaction((hookType, data) => {
case "Notification": {
const msg = data.message || "Notification received";
// Tag compaction-related notifications so they show as Compaction events
if (/compact|compress|context.*(reduc|truncat|summar)/i.test(msg)) {
// Tag compaction-related notifications so they show as Compaction events.
// Claude's only: another CLI's Notification is a permission prompt, whose
// message quotes the command ("tar --compress …").
if (tool === "claude" && /compact|compress|context.*(reduc|truncat|summar)/i.test(msg)) {
eventType = "Compaction";
summary = msg;
} else if (isWaitingForUserMessage(msg)) {
@@ -862,8 +903,8 @@ const processEvent = db.transaction((hookType, data) => {
//
// Also detects compaction events (isCompactSummary in JSONL) and creates a
// Compaction agent + event so the dashboard shows when context was compressed.
if (data.transcript_path) {
const result = transcriptCache.extract(data.transcript_path);
if (transcriptPath) {
const result = transcriptCache.extract(transcriptPath);
if (result) {
const { tokensByModel, compaction, latestModel } = result;
@@ -1115,8 +1156,8 @@ const processEvent = db.transaction((hookType, data) => {
// Evict transcript from cache on SessionEnd — session is done, no more reads expected.
// Must happen after token extraction above to avoid re-populating the cache.
if (hookType === "SessionEnd" && data.transcript_path) {
transcriptCache.invalidate(data.transcript_path);
if (hookType === "SessionEnd" && transcriptPath) {
transcriptCache.invalidate(transcriptPath);
}
// Bump session updated_at on every event
@@ -1144,13 +1185,131 @@ const processEvent = db.transaction((hookType, data) => {
return event;
});
// A Codex sub-agent thread's own lifecycle is not the user's session's: its
// Stop is not the end of the user's turn, nor its SessionStart a new session.
const CODEX_CHILD_DROPPED = new Set([
"SessionStart",
"SessionEnd",
"Stop",
"Interrupt",
"UserPromptSubmit",
]);
/**
* A Codex hook, in the terms the rest of this route speaks. A hook from a
* sub-agent thread (its rollout's session_meta names another root session)
* is put on the root session, or dropped for a lifecycle event; a
* PermissionRequest becomes the Notification Claude Code sends for a
* permission prompt, so the waiting flag, needs_action, alerts and the
* browser notification follow unchanged; an Interrupt (declined permission,
* Esc) ends the turn like a Stop. Null means "ignore this hook".
*/
function codexHookInput(hookType, data) {
let d = data;
const thread = threadOf(data.transcript_path);
if (thread && thread.sessionId !== thread.id) {
if (CODEX_CHILD_DROPPED.has(hookType)) return null;
// Marked: a sub-agent's tool call is not the user resuming the root.
d = { ...d, session_id: thread.sessionId, _codexChild: true };
}
if (hookType === "PermissionRequest") {
const input = d.tool_input && typeof d.tool_input === "object" ? d.tool_input : {};
const what = [input.description, input.command, d.tool_name].find(
(v) => typeof v === "string" && v.trim()
);
const message = `Codex needs your permission: ${String(what || "a tool call").slice(0, 200)}`;
return { hookType: "Notification", data: { ...d, message } };
}
// A declined permission or Esc ends the turn with Interrupt, not Stop, and
// leaves Codex at its prompt just as a Stop does.
if (hookType === "Interrupt") return { hookType: "Stop", data: d };
return { hookType, data: d };
}
/**
* An Antigravity (`agy`) hook, in the terms the rest of this route speaks.
* Its payload names the conversation `conversationId` and the workspace
* `workspacePaths[0]`; a run's first `PreInvocation` (or the first after a
* Stop) is the user's prompt, a `PostToolUse` carries `toolCall.{name,args}`,
* a `Stop` ends the turn — as an error when it carries one, and not at all
* while `fullyIdle` is false (background work still running). A hook
* whose transcript is not under the CLI's own directory is the Antigravity
* IDE's (it loads the same global hooks.json) and is ignored, as is every
* other event. Null means "ignore this hook".
*/
const AGY_TURN_GAP_MS = 120_000;
function antigravityHookInput(hookType, data) {
const id = data.conversationId;
if (typeof id !== "string" || !/^[0-9a-f-]{36}$/.test(id)) return null;
if (typeof data.transcriptPath !== "string") return null;
const transcript = path.resolve(data.transcriptPath);
if (!agyCliDirs().some((dir) => transcript.startsWith(dir + path.sep))) return null;
const cwd = Array.isArray(data.workspacePaths) ? data.workspacePaths[0] : null;
const d = {
session_id: id,
cwd: typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : undefined,
model: typeof data.modelName === "string" ? data.modelName : undefined,
};
if (hookType === "PreInvocation") {
// A run's first model call is the user's prompt; so is the first one after
// a Stop, after a turn that ended with none (an API error, a refused
// permission), or on a session the poll already settled — whatever agy
// numbers it. Inside a turn a model call follows a hook within seconds.
const last = stmts.lastEvent.get(id);
const turn =
data.invocationNum === 0 ||
!last ||
last.event_type === "Stop" ||
stmts.getSession.get(id)?.status !== "active" ||
Date.now() - Date.parse(last.created_at) > AGY_TURN_GAP_MS;
return turn ? { hookType: "UserPromptSubmit", data: d } : null;
}
if (hookType === "PostToolUse") {
const call = data.toolCall && typeof data.toolCall === "object" ? data.toolCall : {};
return {
hookType,
data: {
...d,
tool_name: typeof call.name === "string" ? call.name : "unknown",
// Two identical calls in a row stay two events (the duplicate guard).
step: Number.isInteger(data.stepIdx) ? data.stepIdx : undefined,
tool_input: call.args && typeof call.args === "object" ? call.args : {},
...(typeof data.error === "string" &&
data.error && { tool_response: { error: data.error } }),
},
};
}
if (hookType === "Stop") {
// Background work still running: the turn is not over yet.
if (data.fullyIdle === false) return null;
const failed =
(typeof data.error === "string" && data.error !== "") ||
(typeof data.terminationReason === "string" && /error|fail/i.test(data.terminationReason));
return { hookType, data: failed ? { ...d, stop_reason: "error" } : d };
}
return null;
}
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
router.post("/event", (req, res) => {
const { hook_type, data } = req.body;
let { hook_type, data } = req.body;
if (!hook_type || !data) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
});
}
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
// Code — an allowlist, never a value stored as sent.
const tool = Object.hasOwn(HOOK_TOOLS, req.body.tool) ? req.body.tool : "claude";
if (tool !== "claude") {
if (!data || typeof data !== "object") return res.json({ ok: true, ignored: true });
const mapped = HOOK_TOOLS[tool](hook_type, data);
if (!mapped) return res.json({ ok: true, ignored: true });
({ hookType: hook_type, data } = mapped);
}
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
// + a leftover checkout install, two marketplaces), or two servers on one DB
@@ -1166,7 +1325,7 @@ router.post("/event", (req, res) => {
return res.json({ ok: true, duplicate: true });
}
const result = processEvent(hook_type, data);
const result = processEvent(hook_type, data, tool);
if (!result) {
return res.status(400).json({
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
@@ -1174,7 +1333,7 @@ router.post("/event", (req, res) => {
}
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data);
touchLaneFromHook(hook_type, data, tool);
res.json({ ok: true, event: result });
@@ -1191,7 +1350,12 @@ router.post("/event", (req, res) => {
// tool calls that aren't yet in the events table. Subagent tool_use blocks
// never fire hooks on the parent session — this scan is the only path that
// attributes them to the subagent's agent_id.
if (hook_type === "SubagentStop" && data.session_id && data.transcript_path) {
if (
tool === "claude" &&
hook_type === "SubagentStop" &&
data.session_id &&
data.transcript_path
) {
// Models the MAIN transcript wrote. The subagent scan must SKIP these
// buckets — the main-transcript writer above owns them, and writing one
// from two sources of different magnitude would trip replaceTokenUsage's
@@ -1245,6 +1409,7 @@ router.post("/event", (req, res) => {
// Run on the lifecycle hooks that bracket a workflow finishing, off the
// response path and fail-safe — a partial/absent journal is expected.
if (
tool === "claude" &&
["Stop", "SubagentStop", "SessionEnd"].includes(hook_type) &&
data.session_id &&
data.transcript_path
@@ -1329,6 +1494,8 @@ function watchdogCheck() {
// is a delayed rsync mirror on this host and their activity clock is the
// remote machine's, so the transcript-tail error/interrupt heuristics below
// would misfire. Their lifecycle is owned by server/lib/remote-sync.js.
// Codex sessions (tool = 'codex') have no Claude transcript to tail; their
// lifecycle is owned by server/lib/codex-usage.js.
const staleSessions = db
.prepare(
`SELECT s.id, s.status, s.cwd,
@@ -1338,7 +1505,7 @@ function watchdogCheck() {
ORDER BY e.created_at DESC LIMIT 1) as last_data
FROM sessions s
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
AND (s.source = 'local' OR s.source IS NULL)`
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
)
.all(cutoff);
@@ -1560,12 +1727,15 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
// absolute (e.g. /home/ubuntu/matroid), so the posix-cwd guard below does not
// catch them. Their liveness is owned entirely by the sync/import path
// (server/lib/remote-sync.js reconciles their status from the mirrored
// transcript), so the local process probe must leave them alone.
// transcript), so the local process probe must leave them alone. A Codex
// session (`tool = 'codex'`) is spared for the same reason: a `claude`
// process probe proves nothing about it, and codex-usage.js settles it from
// its own journals.
const activeSessions = db
.prepare(
`SELECT id, name, cwd, transcript_path, updated_at FROM sessions
WHERE status = 'active' AND cwd IS NOT NULL AND cwd <> ''
AND (source = 'local' OR source IS NULL)`
AND (source = 'local' OR source IS NULL) AND tool = 'claude'`
)
.all();
if (activeSessions.length === 0) return; // nothing to check — skip the ps/lsof cost
+7
View File
@@ -127,6 +127,9 @@ router.get("/", (req, res) => {
const status = req.query.status;
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const cwd = req.query.cwd;
// Which CLI's sessions (claude / codex / antigravity). Applied here, before
// the limit, so a busy Codex lane cannot push Claude rows out of a page.
const tool = typeof req.query.tool === "string" ? req.query.tool : "";
const sortBy = req.query.sort_by || "time"; // "time", "duration", "price"
const sortDesc = req.query.sort_desc !== "false";
@@ -146,6 +149,10 @@ router.get("/", (req, res) => {
where.push("s.cwd = ?");
params.push(cwd);
}
if (tool) {
where.push("s.tool = ?");
params.push(tool);
}
// Data-scope filter: restrict to sessions collected from a chosen set of
// machines (local + any configured remote sources). Absent = all sources.
const sourceFilter = sourceColumnClause(parseSources(req));
+102 -30
View File
@@ -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) => {