feat(ai-tools): drop OpenCode and Grok Build

- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
This commit is contained in:
2026-10-23 13:44:24 +07:00
parent 9ea7758abb
commit c0832182f2
31 changed files with 116 additions and 1277 deletions
+6 -17
View File
@@ -655,23 +655,12 @@ An `antigravity` workspace carries no pipeline either.
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
session's journals from there. Under S3 each member's `~/.omp` is read only
through `openSafeAsync`.
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
disables), when its file or WAL changed: each root `session` row becomes a
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
per `modelID` become `token_usage` (reasoning counted as output), and each
child session (the `task` tool's) an `opencode-<id>` subagent row.
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
session's newest `time_updated`; the transcript routes read the conversation
(messages + parts) from the store itself. A member's store and WAL are copied
through the guard before they are opened.
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
and drops its `transcript_path` (Grok's file, never parsed as a Claude
transcript). No tokens: Grok's session files are not read.
- **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
`~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
tells it apart by the camelCase keys only Grok sends (`hookEventName` +
`workspaceRoot`) and drops it, rather than record a Claude session whose
`transcript_path` is Grok's file.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its workspace idle. That workspace must be one the hooks left
+2 -2
View File
@@ -45,7 +45,7 @@ before(async () => {
builtin,
tool("gem", [
{ path: "~/.gem/settings.json" },
{ path: "~/.gem/opencode.jsonc" },
{ path: "~/.gem/tool.jsonc" },
{ path: "~/.gem/GEMINI.md" },
{ path: "~/.gem/missing.json" },
])
@@ -57,7 +57,7 @@ before(async () => {
fs.mkdirSync(path.join(home, ".gem"));
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/tool.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
const express = require("express");
+31 -11
View File
@@ -26,8 +26,28 @@ for (const name of ["claude", "agy", "npm"]) {
const origPath = process.env.PATH;
process.env.PATH = BIN;
// An npm-installed tool, in the release-synced tier (only a release catalog may
// name an installer): no built-in tool updates through npm.
const REMOTE = path.join(ROOT, "remote-catalog");
fs.mkdirSync(REMOTE);
fs.writeFileSync(
path.join(REMOTE, "npmtool.json"),
JSON.stringify({
id: "npmtool",
label: "npm tool",
detect: ["npmtool"],
prompt: { mode: "flag", flag: "--prompt" },
install: { kind: "npm", package: "npmtool" },
update: { kind: "npm" },
})
);
const { createApp } = require("../index");
require("../lib/ai-tools").refresh();
require("../lib/ai-tools").__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
REMOTE,
path.join(ROOT, "custom-catalog"),
]);
let app;
let base;
@@ -59,7 +79,7 @@ describe("/api/ai-tools", () => {
assert.equal(agy.path, path.join(BIN, "agy"));
assert.equal(agy.resume, true);
assert.equal(agy.bypass, true);
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
assert.equal(body.tools.find((t) => t.id === "npmtool").installed, false);
assert.equal("args" in agy, false);
});
@@ -76,7 +96,7 @@ describe("/api/ai-tools", () => {
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
const bad = await request("PUT", "/api/ai-tools/npmtool", { default: true });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADDEFAULT");
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
@@ -112,10 +132,10 @@ describe("/api/ai-tools", () => {
});
it("refresh re-detects", async () => {
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
fs.writeFileSync(path.join(BIN, "npmtool"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
assert.equal(r.body.tools.find((t) => t.id === "npmtool").installed, true);
});
it("/api/run/binary resolves through the catalog", async () => {
@@ -132,9 +152,9 @@ describe("lanes on catalog tools", () => {
fs.mkdirSync(d, { recursive: true });
return d;
};
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "npmtool" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "opencode");
assert.equal(r.body.lane.tool, "npmtool");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
@@ -212,8 +232,8 @@ describe("install / update / icons", () => {
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
assert.equal(notInstalled.status, 409);
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
// opencode was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
// npmtool was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
@@ -241,7 +261,7 @@ describe("install / update / icons", () => {
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
const v = await request("GET", "/api/ai-tools/versions");
assert.equal(v.status, 200);
assert.equal(v.body.opencode.npmManaged, false);
assert.equal(v.body.npmtool.npmManaged, false);
});
it("update-all answers ENOTHING when nothing has an update", async () => {
@@ -1,7 +1,7 @@
{
"id": "grok",
"label": "Grok",
"detect": ["grok"],
"id": "septool",
"label": "Separator tool",
"detect": ["septool"],
"prompt": {
"mode": "argv",
"separator": "--"
+9 -14
View File
@@ -2,10 +2,10 @@
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
* on the grok workspace — never the claude one sharing the directory — with
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
* in a temp dir.
* (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
* tool, and taken as Claude's it would bind the claude workspace and name a
* Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
* lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -58,23 +58,18 @@ const grokData = (id, extra = {}) => ({
});
describe("Grok hooks through the Claude settings", () => {
it("records a grok session, on the grok workspace, with no transcript path", async () => {
it("drops a Grok hook: no session, no event, no workspace bound", async () => {
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
const id = "0199f0aa-1111-7222-8333-444455556666";
await post({ hook_type: "SessionStart", data: grokData(id) });
const r = await post({ hook_type: "SessionStart", data: grokData(id) });
assert.equal(r.ignored, true);
await post({
hook_type: "UserPromptSubmit",
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
});
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
assert.equal(s.tool, "grok");
assert.equal(s.transcript_path, null);
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
assert.ok(events.length >= 1);
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
assert.equal(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
assert.equal(lanes.getLane(grokLane.id).session_id, id);
});
it("leaves a Claude hook Claude's", async () => {
-188
View File
@@ -1,188 +0,0 @@
/**
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
* transcript routes — OpenCode sessions from its SQLite store: a root session
* created as `tool = 'opencode'` with its title, cwd, model and per-model
* tokens (its subagent session's included, reasoning counted as output), a
* subagent row per child session, an unchanged store never re-read, a
* session the user deleted staying deleted until it is updated, and the
* conversation (text, reasoning, tool call + result) in the Claude
* transcript shape. The store is a temp file with the tables and columns
* opencode 1.19 writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { db, Database } = require("../db");
const oc = require("../lib/opencode-usage");
const { createApp, startServer } = require("../index");
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
const CHILD = "ses_eac8393b4fffChildSession01";
const CWD = path.join(fs.realpathSync(ROOT), "work");
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
let server;
let BASE;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
function withStore(fn) {
const conn = new Database(STORE);
try {
fn(conn);
} finally {
conn.close();
}
}
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
input,
output,
reasoning,
cache: { read, write },
});
before(async () => {
fs.mkdirSync(path.dirname(STORE), { recursive: true });
withStore((c) => {
c.exec(`
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
`);
const s = c.prepare(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
);
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
p.run(
"prt_3",
"msg_2",
ID,
T0 + 22,
T0 + 22,
JSON.stringify({
type: "tool",
tool: "read",
callID: "call_1",
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
})
);
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
describe("opencode usage ingest", () => {
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 1);
const s = sessionRow();
assert.equal(s.tool, "opencode");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, CWD);
assert.equal(s.model, "claude-sonnet-4-5");
assert.equal(s.status, "completed");
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
const usage = db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(ID);
assert.deepEqual(usage, [
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
]);
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
assert.equal(agent.session_id, ID);
assert.equal(agent.type, "subagent");
assert.equal(agent.name, "Explore auth (@explore subagent)");
assert.equal(agent.status, "completed");
});
it("leaves an unchanged store unread", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 0);
});
it("maps the store to the transcript shape, and lists the subagent's", async () => {
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
[
["main", "main", null],
[CHILD, "subagent", `opencode-${CHILD}`],
]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
[
["user", "user", "text"],
["assistant", "assistant", "thinking+tool_use"],
["user", "tool", "tool_result"],
]
);
assert.equal(body.messages[2].content[0].output, "export {}");
assert.equal(body.last_line, 3);
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
assert.equal(tail.messages.length, 1);
const sub = await (
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
).json();
assert.deepEqual(
sub.messages.map((m) => [m.sender, m.content[0].text]),
[
["orchestrator", "Explore auth"],
["assistant", "Found it"],
]
);
});
it("keeps a deleted session deleted until OpenCode updates it", async () => {
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
const bump = (t) => {
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
const at = new Date(t);
fs.utimesSync(STORE, at, at);
};
bump(Date.now() + 5000);
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow(), undefined);
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow()?.tool, "opencode");
});
});
+2 -2
View File
@@ -733,7 +733,7 @@ describe("pty-run", () => {
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "grok", "goose", "command-code", "omp"]) {
for (const name of ["agy", "septool", "goose", "command-code", "omp"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
@@ -854,7 +854,7 @@ describe("pty-run", () => {
);
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("septool"), [at("septool"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
});
+4 -118
View File
@@ -7,8 +7,8 @@
* with the usage of a token that belongs to a different account; each
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
* while valid, never refreshed.
* the Kimi Code login, sent only to its own host while valid, never
* refreshed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,8 +22,8 @@ process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
);
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
process.env.AGENTHUB_AGY_USAGE = "0";
// Nor its real Grok, Kimi or OpenCode logins.
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
// Nor its real Kimi login.
for (const k of ["KIMI_CODE_HOME"]) {
process.env[k] = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
);
@@ -416,68 +416,6 @@ function stubFetch(answers) {
return { asked, restore: () => (global.fetch = prev) };
}
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const auth = path.join(process.env.GROK_HOME, "auth.json");
const f = stubFetch({
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
200,
{
config: {
creditUsagePercent: 12,
currentPeriod: { end: "2099-01-08T00:00:00Z" },
subscriptionTier: "SuperGrok",
monthlyLimit: { val: "200" },
used: { val: "50" },
billingPeriodEnd: "2099-02-01T00:00:00Z",
},
},
],
});
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
try {
__resetCache();
assert.strictEqual(await grok(), undefined);
const later = new Date(Date.now() + 3600_000).toISOString();
fs.writeFileSync(
auth,
JSON.stringify({
"https://other.example": { key: "tok-other", expires_at: later },
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
})
);
__resetCache();
assert.deepStrictEqual(await grok(), {
id: "grok",
active: true,
account: "g@x.ai",
plan: "SuperGrok",
expiresAt: later,
windows: [
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("grok.com")),
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
);
fs.writeFileSync(
auth,
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
);
__resetCache();
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
} finally {
f.restore();
fs.rmSync(auth, { force: true });
__resetCache();
}
});
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
const fs = require("node:fs");
const path = require("node:path");
@@ -532,55 +470,3 @@ test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and m
__resetCache();
}
});
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "auth.json");
const f = stubFetch({
"https://opencode.ai/zen/go/v1/usage": [
200,
{
usage: {
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
},
},
],
});
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
try {
__resetCache();
// A login with no Go key (Copilot through opencode, say) is no row.
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
assert.strictEqual(await go(), undefined);
fs.writeFileSync(
file,
JSON.stringify({
"opencode-go": { type: "api", key: "sk-go" },
other: { type: "api", key: "x" },
})
);
__resetCache();
assert.deepStrictEqual(await go(), {
id: "opencode",
active: true,
plan: "Go",
windows: [
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("opencode")),
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
+7 -39
View File
@@ -1,7 +1,7 @@
/**
* @file server-s3-quota.test.js
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
* reads Claude, Codex, Cursor and Kimi Code from that
* Linux account's home only — never the host's logins, never agy — and only
* files the member owns (review fix F3: a FIFO or another account's file is
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
@@ -32,16 +32,13 @@ process.env.CLAUDE_HOME = path.join(HOST, ".claude");
process.env.CODEX_HOME = path.join(HOST, ".codex");
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
process.env.GROK_HOME = path.join(HOST, ".grok");
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { quotas, __resetCache } = require("../lib/quota");
const { Database } = require("../db");
const LATER = Date.now() + 3600_000;
const write = (file, body) => {
@@ -76,28 +73,12 @@ function seedHome(home, who, { codexPercent }) {
write(path.join(home, ".cursor", "cli-config.json"), {
authInfo: { email: `${who}@cursor`, userId: "u1" },
});
write(path.join(home, ".grok", "auth.json"), {
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
});
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
access_token: `${who}-kimi`,
expires_at: Math.floor(LATER / 1000),
});
}
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
function seedOpenCodeDb(home, key) {
const dir = path.join(home, ".local", "share", "opencode");
fs.mkdirSync(dir, { recursive: true });
const db = new Database(path.join(dir, "opencode.db"));
db.pragma?.("journal_mode = WAL");
db.exec(
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
);
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
db.close();
}
let asked = [];
const realFetch = global.fetch;
global.fetch = async (url, opts = {}) => {
@@ -125,11 +106,7 @@ const setUid = (uid) => {
before(async () => {
seedHome(HOST, "host", { codexPercent: 77 });
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
"opencode-go": { type: "api", key: "host-opencode" },
});
seedHome(MIA, "mia", { codexPercent: 21 });
seedOpenCodeDb(MIA, "mia-opencode");
setUid(process.getuid());
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
@@ -159,27 +136,16 @@ describe("quotas({home}) reads a member's own logins", () => {
__resetCache();
asked = [];
const { tools } = await quotas({ home: MIA });
assert.deepEqual(tools.map((t) => t.id).sort(), [
"claude",
"codex",
"cursor",
"grok",
"kimi",
"opencode",
]);
assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor", "kimi"]);
const codex = tools.find((t) => t.id === "codex");
assert.equal(codex.account, "mia@codex");
assert.equal(codex.windows[0].usedPercent, 21);
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
assert.ok(asked.length >= 6, asked.join(","));
assert.ok(asked.length >= 4, asked.join(","));
assert.ok(
asked.every((a) => a.includes("mia-")),
`only mia's tokens leave: ${asked.join(",")}`
);
assert.ok(
asked.some((a) => a.includes("mia-opencode")),
"OpenCode 2's db is read"
);
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
});
@@ -195,8 +161,10 @@ describe("quotas({home}) reads a member's own logins", () => {
});
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
fs.mkdirSync(path.join(BOB, ".kimi-code", "credentials"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [
path.join(BOB, ".kimi-code", "credentials", "kimi-code.json"),
]);
__resetCache();
assert.deepEqual((await quotas({ home: BOB })).tools, []);
// mia's files now belong to another uid: none is read.
+2 -2
View File
@@ -37,11 +37,11 @@ describe("what a publish copies", () => {
it("a file in an npm package: the package folder (scoped too)", () => {
const plain = write(
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
path.join(TMP, "npm", "lib", "node_modules", "some-cli", "bin", "oc.js"),
"#!/usr/bin/env node\n"
);
assert.deepEqual(shared.planFor(plain), {
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
src: path.join(TMP, "npm", "lib", "node_modules", "some-cli"),
entry: path.join("bin", "oc.js"),
});
const scoped = write(
+1 -1
View File
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
`resume` when a start carries `resumeSessionId`, and only for an id that starts
with a letter or digit. A tool without it (and without a `launcher`) refuses a
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
## Install / update (A2)
-41
View File
@@ -1,41 +0,0 @@
{
"id": "grok",
"label": "Grok Build",
"detect": [
"grok"
],
"prompt": {
"mode": "argv",
"separator": "--"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--resume"
],
"bypass": [
"--permission-mode",
"bypassPermissions"
],
"homepageUrl": "https://x.ai/cli",
"faviconDomain": "x.ai",
"install": {
"kind": "script",
"url": "https://x.ai/cli/install.sh"
},
"update": {
"kind": "self",
"args": [
"update"
]
},
"config": [
{
"path": "~/.grok/config.toml"
}
]
}
-37
View File
@@ -1,37 +0,0 @@
{
"id": "opencode",
"label": "OpenCode",
"detect": [
"opencode"
],
"prompt": {
"mode": "flag",
"flag": "--prompt"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--session"
],
"homepageUrl": "https://opencode.ai/docs/cli/",
"install": {
"kind": "npm",
"package": "opencode-ai"
},
"update": {
"kind": "npm"
},
"config": [
{
"path": "~/.config/opencode/opencode.json",
"format": "jsonc"
},
{
"path": "~/.config/opencode/opencode.jsonc"
}
]
}
-17
View File
@@ -1486,23 +1486,6 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
`);
// Which OpenCode stores have been read at what mtime, and per root session the
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
// "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS opencode_stores (
path TEXT PRIMARY KEY,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS opencode_sessions (
session_id TEXT PRIMARY KEY,
store TEXT NOT NULL,
time_updated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(`
+5 -11
View File
@@ -793,17 +793,11 @@ function startBackgroundServices() {
} catch (err) {
console.warn("cursor usage poll failed to start:", err.message);
}
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's
// own session store (omp-usage.js, opencode-usage.js).
for (const [name, mod, start] of [
["omp", "./lib/omp-usage", "startOmpUsagePoll"],
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"],
]) {
try {
require(mod)[start](broadcast);
} catch (err) {
console.warn(`${name} usage poll failed to start:`, err.message);
}
// Oh My Pi sessions, subagents and token usage, from its own session journals.
try {
require("./lib/omp-usage").startOmpUsagePoll(broadcast);
} catch (err) {
console.warn("omp usage poll failed to start:", err.message);
}
// Codex/Antigravity hooks the user installed forward through a script whose
// content names this build; point it here (the command in the tool's
-504
View File
@@ -1,504 +0,0 @@
/**
* @file OpenCode (`opencode`) sessions, subagents and token usage, read from
* OpenCode's own SQLite store (`~/.local/share/opencode/opencode.db`, under
* `XDG_DATA_HOME` when set). This is how an OpenCode session reaches
* `sessions` (as `tool = 'opencode'`), its tokens `token_usage` and its
* subagents `agents` — OpenCode has no hooks AgentHub installs.
*
* What the store holds (opencode 1.19, verified against a file it wrote — not
* a documented format):
* - `session(id, parent_id, directory, title, time_created, time_updated,
* time_archived)` — a session the `task` tool spawned has `parent_id` set,
* and is shown as a subagent of its root session;
* - `message(session_id, data)` — an assistant message's JSON carries
* `modelID` and `tokens: {input, output, reasoning, cache: {read, write}}`
* (input NOT including cache reads; reasoning is counted as output here);
* - `part(message_id, session_id, data)` — the message's text, reasoning and
* tool parts, read only for a transcript.
* The file is re-read when its mtime (or its non-empty `-wal`'s) changes; per
* root session the newest `time_updated` seen lives in `opencode_sessions`,
* so a session the user deleted stays deleted until OpenCode writes to it
* again. Status: active while its tree was updated in the last 2 minutes or a
* live dashboard pane holds it, else completed. S3: a member's store (and its
* WAL) is read through the guard into a private copy, never opened in place.
* @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");
const { db, stmts, Database } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const pricingSync = require("./pricing-sync");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const SESSION_ID = /^[A-Za-z0-9_-]{8,120}$/;
const MAX_COPY = 256 << 20;
/** OpenCode's data dir: `AGENTHUB_OPENCODE_DIR`, `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`. */
function getOpencodeDir() {
if (process.env.AGENTHUB_OPENCODE_DIR) return process.env.AGENTHUB_OPENCODE_DIR;
const xdg = process.env.XDG_DATA_HOME;
return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share"), "opencode");
}
/** Every store: `{path, mtime}` — this account's, then every member's. */
function storeFiles() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".local", "share", "opencode"));
const out = [];
for (const dir of [getOpencodeDir(), ...members]) {
const file = path.join(dir, "opencode.db");
try {
const st = fs.lstatSync(file);
if (!st.isFile()) continue;
let mtime = st.mtimeMs;
try {
const wal = fs.lstatSync(`${file}-wal`);
if (wal.isFile() && wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
} catch {
/* no WAL right now */
}
out.push({ path: file, mtime: Math.floor(mtime) });
} catch {
/* no store */
}
}
return out;
}
/**
* Run `fn(conn)` on a store, read-only. A member's store (S3) and its WAL are
* copied through the guard first: SQLite opens by NAME, and a member's FIFO
* standing in for the `-wal` would block this thread.
*/
function withStore(file, fn) {
const { ownerForRead, readFileSafe } = require("./run-as");
let tmp = null;
try {
let open = file;
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-"));
open = path.join(tmp, "opencode.db");
fs.writeFileSync(open, readFileSafe(file, null, { max: MAX_COPY }));
try {
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: MAX_COPY }));
} catch {
/* no WAL (or not theirs): what is in the main file */
}
}
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
try {
return fn(conn);
} finally {
conn.close();
}
} finally {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
}
}
/** A session's model column (`{"id": …}` JSON, or a plain id), or null. */
function modelOf(raw) {
if (typeof raw !== "string" || !raw) return null;
try {
const o = JSON.parse(raw);
return typeof o?.id === "string" ? o.id.slice(0, 128) : null;
} catch {
return raw.slice(0, 128);
}
}
/**
* Everything a pass needs from one store: `[{root, children, usage, updated}]`
* per root session — `usage` per model over the root and every descendant.
*/
function readStore(file) {
return withStore(file, (conn) => {
const rows = conn
.prepare(
`SELECT id, parent_id, directory, title, time_created, time_updated, time_archived, model
FROM session`
)
.all();
const byId = new Map(rows.map((r) => [r.id, r]));
const rootOf = (r) => {
let cur = r;
for (let i = 0; i < 32 && cur.parent_id && byId.has(cur.parent_id); i++) {
cur = byId.get(cur.parent_id);
}
return cur;
};
const trees = new Map();
for (const r of rows) {
if (!SESSION_ID.test(r.id)) continue;
const root = rootOf(r);
if (!SESSION_ID.test(root.id)) continue;
const t = trees.get(root.id) || { root, children: [], usage: {}, updated: 0, model: null };
if (r !== root) t.children.push(r);
t.updated = Math.max(t.updated, Number(r.time_updated) || 0);
trees.set(root.id, t);
}
const sums = conn
.prepare(
`SELECT session_id,
json_extract(data, '$.modelID') AS model,
SUM(COALESCE(json_extract(data, '$.tokens.input'), 0)) AS input,
SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)
+ COALESCE(json_extract(data, '$.tokens.reasoning'), 0)) AS output,
SUM(COALESCE(json_extract(data, '$.tokens.cache.read'), 0)) AS cache_read,
SUM(COALESCE(json_extract(data, '$.tokens.cache.write'), 0)) AS cache_write,
MAX(time_created) AS last
FROM message
WHERE json_extract(data, '$.role') = 'assistant'
GROUP BY session_id, model`
)
.all();
for (const s of sums) {
const r = byId.get(s.session_id);
const t = r && trees.get(rootOf(r).id);
if (!t) continue;
const model = typeof s.model === "string" && s.model ? s.model.slice(0, 128) : "unknown";
const u = (t.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
u.input += Number(s.input) || 0;
u.output += Number(s.output) || 0;
u.cacheRead += Number(s.cache_read) || 0;
u.cacheWrite += Number(s.cache_write) || 0;
if (r === t.root && (!t.lastAt || s.last > t.lastAt)) {
t.lastAt = s.last;
t.model = model;
}
}
return [...trees.values()];
});
}
const getStore = db.prepare("SELECT * FROM opencode_stores WHERE path = ?");
const upsertStore = db.prepare(
`INSERT INTO opencode_stores (path, mtime_ms, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const getTracked = db.prepare("SELECT * FROM opencode_sessions WHERE session_id = ?");
const upsertTracked = db.prepare(
`INSERT INTO opencode_sessions (session_id, store, time_updated, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(session_id) DO UPDATE SET store = excluded.store,
time_updated = excluded.time_updated, updated_at = excluded.updated_at`
);
const activeOpencode = db.prepare(
"SELECT s.id, t.time_updated FROM sessions s JOIN opencode_sessions t ON t.session_id = s.id WHERE s.tool = 'opencode' AND s.status = 'active'"
);
const heldByPane = db.prepare(
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'opencode')`
);
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 = 'opencode'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'opencode'"
);
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
const agentTimes = db.prepare(
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
);
const fresh = (t, now) => t - now < FUTURE_SLACK_MS && now - t < ACTIVE_MS;
const iso = (t) => (Number.isFinite(Number(t)) && Number(t) > 0 ? new Date(Number(t)).toISOString() : null);
/** Write one root session's row, usage and subagents. @returns its broadcast type, or null. */
function writeSession(store, t, now, broadcast) {
const r = t.root;
const existing = stmts.getSession.get(r.id);
if (existing && existing.tool !== "opencode") return null;
const cwd = typeof r.directory === "string" && path.isAbsolute(r.directory) ? r.directory : null;
const runAs = require("./run-as");
// S3: a member's store names only their own folder and session.
if (!runAs.sameOwner(store, cwd, existing?.cwd)) return null;
const lastWrite = iso(t.updated) || new Date(now).toISOString();
const active = !r.time_archived && (fresh(t.updated, now) || !!heldByPane.get(r.id));
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const name =
(typeof r.title === "string" && r.title.trim().slice(0, 200)) ||
existing?.name ||
`OpenCode ${r.id.slice(4, 12)}`;
const model = t.model || modelOf(r.model);
const agentEvents = [];
db.transaction(() => {
if (existing) {
updateSession.run(name, status, model, endedAt, lastWrite, cwd, iso(r.time_created), r.id);
} else {
insertSession.run(
r.id,
name,
status,
cwd,
model,
iso(r.time_created) || lastWrite,
endedAt,
lastWrite,
JSON.stringify({ source: "opencode-store" })
);
}
claimToolSessions(r.id);
clearUsage.run(r.id);
for (const [m, u] of Object.entries(t.usage)) {
stmts.setTokenUsage.run(
r.id,
m,
"standard",
"global",
"standard",
u.input,
u.output,
u.cacheRead,
u.cacheWrite,
0,
0,
0,
0
);
}
for (const c of t.children) {
const id = `opencode-${c.id}`;
const done = !active || !fresh(Number(c.time_updated) || 0, now);
const agentStatus = done ? "completed" : "working";
const ended = done ? iso(c.time_updated) : null;
const label = (typeof c.title === "string" && c.title.trim().slice(0, 120)) || c.id;
const prior = stmts.getAgent.get(id);
if (prior && prior.session_id !== r.id) continue;
if (!prior) {
stmts.insertAgent.run(
id,
r.id,
label,
"subagent",
"task",
agentStatus,
label,
null,
JSON.stringify({ source: "opencode-store", transcript: c.id })
);
} else if (prior.status !== agentStatus || prior.name !== label) {
stmts.updateAgent.run(label, agentStatus, label, null, ended, null, id);
} else continue;
agentTimes.run(iso(c.time_created), ended, id);
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
}
upsertTracked.run(r.id, store, t.updated);
})();
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
return existing ? "session_updated" : "session_created";
}
let running = null;
let lastPending = 0;
/**
* One pass: every store whose file changed is read, and each root session
* updated since it was last written is rewritten; an `active` one that went
* quiet is settled.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestOpencodeUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
const done = new Set();
for (const f of storeFiles()) {
const seen = getStore.get(f.path);
if (seen && seen.mtime_ms === f.mtime) continue;
let trees;
try {
trees = readStore(f.path);
} catch (err) {
if (getStore.get(f.path)?.mtime_ms !== f.mtime) {
console.warn(`[opencode-usage] skipped ${f.path}: ${err.message}`);
}
upsertStore.run(f.path, f.mtime);
continue;
}
read += 1;
for (const t of trees) {
const tracked = getTracked.get(t.root.id);
const changed = !tracked || tracked.time_updated !== t.updated;
const s = stmts.getSession.get(t.root.id);
// Unchanged and deleted by the user: stays deleted.
if (!changed && (!s || s.status !== "active")) continue;
try {
const type = writeSession(f.path, t, now, broadcast);
if (!type) continue;
done.add(t.root.id);
written += 1;
broadcast(type, stmts.getSession.get(t.root.id));
} catch (err) {
console.warn(`[opencode-usage] session ${t.root.id}: ${err.message}`);
}
}
upsertStore.run(f.path, f.mtime);
await new Promise((r) => setImmediate(r));
}
// An active session in a store that did not change: settled once quiet.
let pending = 0;
for (const { id, time_updated: updated } of activeOpencode.all()) {
if (done.has(id)) {
pending += 1;
continue;
}
if (fresh(updated, now) || heldByPane.get(id)) {
pending += 1;
continue;
}
settle.run(iso(updated) || new Date(now).toISOString(), id);
for (const a of stmts.listAgentsBySession.all(id)) {
if (a.status === "completed" || a.status === "error") continue;
stmts.updateAgent.run(null, "completed", null, null, iso(updated), null, a.id);
broadcast("agent_updated", stmts.getAgent.get(a.id));
}
written += 1;
broadcast("session_updated", stmts.getSession.get(id));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/** A tool part's input/output, kept small. */
const short = (v, max = 10240) => {
const s = typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
return s.length > max ? `${s.slice(0, max)}[truncated]` : s;
};
const TEXT_MAX = 1024 * 1024;
/**
* One OpenCode session's conversation (`sessionId`, or one of its subagent
* sessions by `childId`), in the Claude transcript shape the client renders:
* `[{type, sender, timestamp, content}]`, oldest first. Null when unknown.
*/
function transcriptMessages(sessionId, childId = null) {
const tracked = getTracked.get(sessionId);
if (!tracked) return null;
const target = childId || sessionId;
if (!SESSION_ID.test(target)) return null;
return withStore(tracked.store, (conn) => {
if (childId) {
// Only a session of this tree.
let cur = conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(childId);
for (let i = 0; i < 32 && cur && cur.id !== sessionId; i++) {
cur = cur.parent_id
? conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(cur.parent_id)
: null;
}
if (!cur) return null;
}
const messages = conn
.prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const parts = conn
.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
.all(target);
const byMessage = new Map();
for (const p of parts) {
let d;
try {
d = JSON.parse(p.data);
} catch {
continue;
}
(byMessage.get(p.message_id) || byMessage.set(p.message_id, []).get(p.message_id)).push(d);
}
const out = [];
for (const m of messages) {
let info;
try {
info = JSON.parse(m.data);
} catch {
continue;
}
const at = iso(m.time_created);
const role = info.role === "user" ? "user" : info.role === "assistant" ? "assistant" : null;
if (!role) continue;
const content = [];
const results = [];
for (const p of byMessage.get(m.id) || []) {
if (p.type === "text" && typeof p.text === "string" && p.text.trim() && !p.synthetic) {
content.push({ type: "text", text: short(p.text, TEXT_MAX) });
} else if (p.type === "reasoning" && typeof p.text === "string" && p.text.trim()) {
content.push({ type: "thinking", text: short(p.text) });
} else if (p.type === "tool") {
const st = p.state || {};
content.push({
type: "tool_use",
name: p.tool || "unknown",
id: p.callID || null,
input: st.input && typeof st.input === "object" ? st.input : {},
});
if (st.status === "completed" || st.status === "error") {
results.push({
type: "tool_result",
id: p.callID || null,
output: short(st.status === "error" ? st.error : st.output),
is_error: st.status === "error",
});
}
}
}
if (content.length) {
const sender = role === "user" ? (childId && !out.length ? "orchestrator" : "user") : "assistant";
const msg = { type: role, sender, timestamp: at, content };
if (role === "assistant" && info.modelID) msg.model = info.modelID;
out.push(msg);
}
if (results.length) out.push({ type: "user", sender: "tool", timestamp: at, content: results });
}
return out;
});
}
/** A root session's subagent sessions: `[{id, name, rowId}]`. */
function subagentSessions(sessionId) {
return stmts
.listAgentsBySession.all(sessionId)
.filter((a) => a.id.startsWith("opencode-"))
.map((a) => ({ id: a.id.slice("opencode-".length), name: a.name, rowId: a.id }));
}
/**
* First pass at once, then every DASHBOARD_OPENCODE_POLL_MS (default 15 s; 0
* disables) — once a minute after a pass that found nothing to do.
*/
function startOpencodeUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_OPENCODE_POLL_MS
? Number(process.env.DASHBOARD_OPENCODE_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
require("./backoff-poll").startBackoffPoll(
() =>
ingestOpencodeUsage({ broadcast }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
getOpencodeDir,
ingestOpencodeUsage,
transcriptMessages,
subagentSessions,
startOpencodeUsagePoll,
};
+7 -143
View File
@@ -34,21 +34,12 @@
* names no account and no plan. Cached like Claude; a failed run shows
* the last figure with its `asOf`. Running agy may let it update itself;
* `AGENTHUB_AGY_USAGE=0` never runs it.
* - **Grok Build**: the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json`
* (default `~/.grok`), sent only to `cli-chat-proxy.grok.com`'s
* `GET /v1/billing?format=credits`: `wk` from `creditUsagePercent`, `mo`
* from `used / monthlyLimit`, plan `subscriptionTier`.
* - **Kimi Code**: `$KIMI_CODE_HOME/credentials/kimi-code.json` (default
* `~/.kimi-code`), sent only to `api.kimi.com`'s `GET /coding/v1/usages`:
* `5h`, `wk`, `mo` from `limit_5h`/`limit_7d`/`limit_month_total`.
* - **OpenCode Go**: opencode's own `opencode-go` API key — its `auth.json`
* (OpenCode 1) else its `opencode.db` `credential` table (OpenCode 2),
* under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — sent only to
* `opencode.ai`'s `GET /zen/go/v1/usage`: `5h`, `wk`, `mo` meters. No Go
* key (another provider's login) means no row.
* Grok's and Kimi's tokens are used only while valid and never refreshed
* (Kimi rotates its refresh token: a refresh here would sign the CLI out).
* These three are Orca's readers (github.com/stablyai/orca, MIT), trimmed.
* Kimi's token is used only while valid and never refreshed (Kimi rotates
* its refresh token: a refresh here would sign the CLI out). This reader is
* Orca's (github.com/stablyai/orca, MIT), trimmed.
*
* Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name as the tool reports it: Claude's `subscriptionType`,
@@ -59,8 +50,7 @@
*
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
* `.cursor`, `.kimi-code`, never the host's env overrides — through `run-as`'s owner-checked reads (F3). No
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
* `/usage` needs the self-updating binary, so it stays host-only.
*
@@ -80,9 +70,7 @@ const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage");
const { Database } = require("../db");
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
const { s3Enabled } = require("./linux-accounts");
const { readFileSafe, openSafe } = require("./run-as");
const CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
@@ -91,9 +79,7 @@ const TAIL_BYTES = 512 * 1024;
const CODEX_FILES = 5;
const CURSOR_USAGE_URL = "https://cursor.com/api/usage-summary";
const TIMEOUT_MS = 10_000;
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
const OPENCODE_GO_URL = "https://opencode.ai/zen/go/v1/usage";
const AGY_TIMEOUT_MS = 60_000;
const AGY_MIN_VERSION = [1, 1, 11];
const AGY_WINDOW = { "5h": "5h", weekly: "wk" };
@@ -364,9 +350,7 @@ function toolDirs(home) {
codex: home ? path.join(home, ".codex") : getCodexHome(),
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
grok: env("GROK_HOME") || path.join(h, ".grok"),
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
};
}
@@ -502,53 +486,6 @@ async function bearerWindows(who, url, token, headers, toWindows) {
}
}
/** Grok Build's `auth.x.ai` session and its billing view. */
async function grokQuota(dirs = toolDirs()) {
const sessions = readJson(path.join(dirs.grok, "auth.json"));
if (!sessions || typeof sessions !== "object") return null;
const xai = Object.entries(sessions)
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
.map(([, s]) => s);
const live = (s) => !s.expires_at || fresh(Date.parse(s.expires_at));
const s = xai.find(live) || xai[0];
if (!s) return null;
const who = {
id: "grok",
active: true,
account: s.email || null,
plan: null,
expiresAt: s.expires_at ? isoOf(s.expires_at) : null,
};
if (!live(s)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
const headers = {
"X-XAI-Token-Auth": "xai-grok-cli",
...(s.user_id && { "x-userid": String(s.user_id) }),
};
return bearerWindows(who, GROK_BILLING_URL, s.key, headers, (body) => {
const c = body?.config || body || {};
const windows = [];
// ponytail: a reply with no percent shows no week; Orca infers 0 from
// the period fields — add that if a real account ever answers so.
if (typeof c.creditUsagePercent === "number") {
windows.push({
label: "wk",
usedPercent: c.creditUsagePercent,
resetsAt: isoOf(c.currentPeriod?.end ?? c.billingPeriodEnd),
});
}
const limit = Number(c.monthlyLimit?.val);
const used = Number(c.used?.val);
if (limit > 0 && Number.isFinite(used)) {
windows.push({
label: "mo",
usedPercent: (used / limit) * 100,
resetsAt: isoOf(c.billingPeriodEnd),
});
}
return { plan: c.subscriptionTier || null, windows };
});
}
/** Kimi Code's OAuth token and its managed-plan usage. */
async function kimiQuota(dirs = toolDirs()) {
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
@@ -571,69 +508,6 @@ async function kimiQuota(dirs = toolDirs()) {
}));
}
/**
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
* into a private temp dir — SQLite opens by NAME, which the member could swap
* between a check and the open. The host's own is opened in place.
*/
function openCodeDbPath(dir, tmp) {
const db = path.join(dir, "opencode.db");
if (!s3Enabled() || !ownerForRead(db)) return db;
const copy = path.join(tmp(), "opencode.db");
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
try {
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
} catch {
/* no WAL: everything is in the main file */
}
return copy;
}
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
function openCodeGoKey(dirs = toolDirs()) {
const dir = dirs.opencode;
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
let conn;
let tmpDir = null;
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
try {
const file = openCodeDbPath(dir, tmp);
// The private copy is writable so SQLite can replay its WAL.
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
const rows = conn
.prepare(
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
)
.all();
for (const r of rows) {
const v = JSON.parse(r.value);
if (typeof v?.key === "string" && v.key) return v.key;
}
} catch {
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
} finally {
conn?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
}
return null;
}
async function openCodeGoQuota(dirs = toolDirs()) {
const key = openCodeGoKey(dirs);
if (!key) return null;
const who = { id: "opencode", active: true, plan: "Go" };
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
windows: [
["5h", body?.usage?.rolling],
["wk", body?.usage?.weekly],
["mo", body?.usage?.monthly],
]
.filter(([, m]) => typeof m?.percent === "number")
.map(([label, m]) => ({ label, usedPercent: m.percent, resetsAt: isoOf(m.resetsAt) })),
}));
}
/**
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
* logins only, and no agy; omitted, the host's.
@@ -641,23 +515,13 @@ async function openCodeGoQuota(dirs = toolDirs()) {
async function quotas({ force = false, home = null } = {}) {
const dirs = toolDirs(home);
const key = (k) => (home ? `${home}|${k}` : k);
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
const [claude, cursor, antigravity, kimi] = await Promise.all([
claudeQuotas(force, home),
cached(key("cursor"), force, () => cursorQuota(dirs)),
home ? null : cached("antigravity", force, antigravityQuota),
cached(key("grok"), force, () => grokQuota(dirs)),
cached(key("kimi"), force, () => kimiQuota(dirs)),
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
]);
const tools = [
...claude,
codexQuota(dirs.codex),
antigravity,
cursor,
grok,
kimi,
opencode,
].filter(Boolean);
const tools = [...claude, codexQuota(dirs.codex), antigravity, cursor, kimi].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() };
}
+12 -23
View File
@@ -1473,12 +1473,13 @@ function antigravityHookInput(hookType, data) {
}
/**
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
* hook reaches here with no `tool` — told apart by the camelCase keys only
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
* transcript, so it is dropped before anything could parse it.
* A Grok Build hook. Grok is not an AgentHub tool, but it runs the hooks in
* `~/.claude/settings.json` itself (its Claude compatibility, on by default),
* so AgentHub's own Claude hook still reaches here from a Grok the user
* installed, with no `tool` — told apart by the camelCase keys only Grok sends
* beside Claude's snake_case aliases (`hookEventName`, `workspaceRoot`) and
* dropped, or it would land as a Claude session whose `transcript_path` is
* Grok's file.
*/
const isGrokPayload = (data) =>
!!data &&
@@ -1486,18 +1487,9 @@ const isGrokPayload = (data) =>
typeof data.hookEventName === "string" &&
typeof data.workspaceRoot === "string";
function grokHookInput(hookType, data) {
if (typeof data.session_id !== "string" || !data.session_id) return null;
const d = { ...data };
delete d.transcript_path;
delete d.agent_transcript_path;
return { hookType, data: d };
}
const HOOK_TOOLS = {
codex: codexHookInput,
antigravity: antigravityHookInput,
grok: grokHookInput,
};
/**
@@ -1564,14 +1556,11 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
}
const ignored = { status: 200, body: { ok: true, ignored: true } };
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
// the Claude hooks (by its payload's own keys), anything else is Claude
// Code — an allowlist, never a value stored as sent.
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
? body.tool
: isGrokPayload(data)
? "grok"
: "claude";
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
// Code — an allowlist, never a value stored as sent. Grok running the
// Claude hooks is no AgentHub tool: dropped.
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
if (tool === "claude" && isGrokPayload(data)) return ignored;
if (tool !== "claude") {
if (!data || typeof data !== "object") return ignored;
require("../lib/hook-auth").dropForeignPaths(peer, data);
-69
View File
@@ -843,25 +843,6 @@ router.get("/:id/transcripts", async (req, res) => {
: [];
return res.json({ transcripts: list });
}
// OpenCode: a subagent is a child session of the store, tied to the agent
// row its poller made.
if (session.tool === "opencode") {
const list = [
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
];
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
list.push({
id: sub.id,
name: sub.name,
type: "subagent",
subagent_type: "task",
has_transcript: true,
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
});
}
return res.json({ transcripts: list });
}
const result = [];
// Query database agent list for db_agent_id association
@@ -1101,20 +1082,6 @@ router.get("/:id/transcript", async (req, res) => {
const isCursor = session.tool === "cursor";
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
const isAgy = session.tool === "antigravity";
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
// whole (one session's rows), then paged like the files below.
if (session.tool === "opencode") {
let all = null;
try {
all = require("../lib/opencode-usage").transcriptMessages(
req.params.id,
isSubagentFile ? agentId : null
);
} catch {
all = null;
}
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
}
if (isOmp) {
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
} else if (isCursor) {
@@ -1592,42 +1559,6 @@ function agyMessage(entry, num) {
}
}
/**
* The transcript route's paging over a conversation already in memory (an
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
* default latest-`limit` window answer exactly as they do over a file.
*/
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
let picked;
let hasMore;
if (afterLine !== null) {
const rest = lined.filter((m) => m.line > afterLine);
picked = rest.slice(0, limit);
hasMore = rest.length > limit;
} else if (beforeLine !== null) {
const older = lined.filter((m) => m.line < beforeLine);
picked = older.slice(-limit);
hasMore = older.length > limit;
} else if (offset > 0) {
picked = lined.slice(offset, offset + limit);
hasMore = lined.length > offset + limit;
} else {
picked = lined.slice(-limit);
hasMore = lined.length > limit;
}
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
const firstLine = picked.length ? picked[0].line : 0;
for (const m of picked) delete m.line;
return {
messages: picked,
total: lined.length,
has_more: hasMore,
last_line: lastLine,
first_line: firstLine,
};
}
module.exports = router;
module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.