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
+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,
};