feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
@@ -0,0 +1,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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 : {}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user