feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+11 -2
View File
@@ -5,7 +5,8 @@
* transcript, its events, its cost — is written to `audit_log` with who read
* it, whose it was and which path. `auditGuard` runs on `/api` after the
* scope guard; it never blocks a request, and it is a no-op in local mode and
* for a member (who can only read their own).
* for a member (who can only read their own). An admin's account change on
* the Users page is written here too (`recordAccountChange`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -57,4 +58,12 @@ function listAudit(limit = 100) {
).all(n);
}
module.exports = { auditGuard, listAudit };
/** An admin's account change from the Users page: who, on whom, what (never
* a password). `owner_id` is the account changed; `path` carries the action. */
function recordAccountChange(actorId, targetId, method, action) {
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
).run(actorId, targetId, method, String(action).slice(0, 500));
}
module.exports = { auditGuard, listAudit, recordAccountChange };
+20
View File
@@ -14,6 +14,9 @@
* its own bearer token), and everything else only with a valid login. An
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
* account's own configuration — are an `admin`'s.
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled).
*
* This is login, not isolation: every member still sees every workspace and
* session, and every pane runs as the one host account (phases S2/S3 of the
@@ -54,6 +57,7 @@ const ADMIN_WRITE_PREFIXES = [
"/remote-sources",
"/reports",
"/pets",
"/users",
];
/**
@@ -291,6 +295,21 @@ async function login(req, username, password) {
return { user: publicUser(u), token: startSession(u.id) };
}
/**
* Step-up check for an account change made in the dashboard: the acting
* user's OWN password again, so a login left open (or a page that rode it)
* cannot create an admin. Throttled like a login; throws EREAUTH/ETOOMANY.
*/
async function confirmPassword(user, password) {
const key = `reauth:${user.id}`;
if (blocked(key, FAIL_LIMIT)) throw err("ETOOMANY", "too many wrong passwords, try again later");
noteAttempt(key);
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(user.id);
const ok = await verifyPassword(String(password || ""), row?.password_hash || DUMMY_HASH);
if (!row || !ok) throw err("EREAUTH", "your password is wrong");
failures.delete(key);
}
// ── Pane credentials ────────────────────────────────────────────────────────
const PANE_HEADER = "x-agenthub-pane-token";
@@ -446,6 +465,7 @@ module.exports = {
hasAdmin,
userFromRequest,
login,
confirmPassword,
endSession,
cookieToken,
setLoginCookie,
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @file autostart.js
* @description Settings → About's "Start automatically" switch: whether the
* @description Settings → System's "Start automatically" switch: whether the
* dashboard starts at boot/login through the per-user service
* (`scripts/service-manager.js` — a systemd `--user` unit, or a LaunchAgent on
* macOS). The switch IS the unit's enabled state, nothing stored beside it.
+5
View File
@@ -152,6 +152,11 @@ const USAGE_COLS = [
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
"long_input_tokens",
"long_output_tokens",
"long_cache_read_tokens",
"long_cache_write_tokens",
"long_cache_write_1h_tokens",
];
/**
+54 -27
View File
@@ -1,7 +1,9 @@
/**
* @file commit-message.js
* @description Suggests a commit message for a lane's STAGED changes with the
* user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with
* @description Suggests a commit message for a lane's STAGED changes — or,
* given `paths` (the Changes panel's ticked files), for exactly those files'
* working-tree changes, untracked ones included — with the
* user's own `claude` CLI: `claude -p` on that diff, sent on stdin, with
* no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on
* disk (`--no-session-persistence`), so the call neither acts on the repo nor
* shows up as a session in the dashboard. Read-only on git; the diff is scoped
@@ -14,7 +16,7 @@ const { spawn } = require("node:child_process");
const worktree = require("./worktree");
const aiTools = require("./ai-tools");
const { isSecretOnDisk } = require("./git-diff");
const { isSecretOnDisk, laneStatus } = require("./git-diff");
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
@@ -24,7 +26,7 @@ const MAX_DIFF_CHARS = 60_000;
const TIMEOUT_MS = 120_000;
const PROMPT =
"Write a git commit message for the staged diff given on stdin. " +
"Write a git commit message for the diff given on stdin. " +
"Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " +
"matching the style of the recent subjects listed there. Add a short body after a blank line only " +
"when the change needs explaining. Output ONLY the commit message — no code fences, no commentary.";
@@ -33,28 +35,52 @@ function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/** The text the model reads: recent subjects, the staged file list, the diff. */
async function buildInput(dir) {
const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
.split("\0")
.filter(Boolean);
/** A diff's text. An oversized one keeps git's partial output — still the
* useful head — and so does `--no-index`, which exits 1 when files differ. */
async function readDiff(dir, args) {
try {
return (await git(dir, args, { maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 })).stdout;
} catch (err) {
if (!err.git?.stdout) throw err;
return String(err.git.stdout);
}
}
/**
* The text the model reads: recent subjects, the file list, the diff — of
* what is staged, or of `picked`: paths `git status` reports changed right
* now (EBADPATH otherwise), diffed against HEAD, an untracked one as new.
*/
async function buildInput(dir, picked) {
const status = Array.isArray(picked)
? new Map((await laneStatus(dir)).map((e) => [e.path, e.code]))
: null;
if (status) {
const unknown = picked.find((p) => typeof p !== "string" || !status.has(p));
if (unknown !== undefined) throw bad("EBADPATH", `not a changed path: ${unknown}`);
}
const names = status
? picked
: (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout
.split("\0")
.filter(Boolean);
const paths = [];
for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p);
if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace");
const diffArgs = ["--no-color", "--no-ext-diff", "--relative"];
let diff;
try {
diff = (
await git(
dir,
["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths],
{ maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 }
)
).stdout;
} catch (err) {
// An oversized diff: git's partial output is still the useful head.
if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err;
diff = String(err.git.stdout);
if (!status) {
diff = await readDiff(dir, ["diff", "--cached", ...diffArgs, "--", ...paths]);
} else {
const tracked = paths.filter((p) => status.get(p) !== "??");
diff = tracked.length
? await readDiff(dir, ["diff", "HEAD", ...diffArgs, "--", ...tracked]).catch(() => "")
: "";
for (const p of paths.filter((p) => status.get(p) === "??")) {
if (diff.length > MAX_DIFF_CHARS) break;
diff += await readDiff(dir, ["diff", "--no-index", "--no-color", "--", "/dev/null", p]);
}
}
if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`;
@@ -64,8 +90,8 @@ async function buildInput(dir) {
return [
log ? `Recent commit subjects:\n${log}` : "",
`Staged files:\n${paths.join("\n")}`,
`Staged diff:\n${diff}`,
`${status ? "Files" : "Staged files"}:\n${paths.join("\n")}`,
`${status ? "Diff" : "Staged diff"}:\n${diff}`,
]
.filter(Boolean)
.join("\n\n");
@@ -96,13 +122,14 @@ function run(bin, args, input) {
}
/**
* A suggested message for what `dir` has staged.
* A suggested message for what `dir` has staged, or for `paths`.
* @param {string} dir the lane's (or session worktree's) cwd
* @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests)
* @param {{bin?: string, paths?: string[]}} [opts] `bin` overrides the
* detected `claude` (tests); `paths` = the ticked files
* @returns {Promise<string>}
*/
async function suggestCommitMessage(dir, { bin } = {}) {
const input = await buildInput(dir);
async function suggestCommitMessage(dir, { bin, paths } = {}) {
const input = await buildInput(dir, paths);
const claude = bin || aiTools.detect("claude", { fresh: true }).path;
if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH");
const out = await run(
+89 -13
View File
@@ -657,7 +657,9 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
}
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
// `--parents` makes git REWRITE each parent to the file's own previous
// commit, so a file's history draws one joined-up line, not loose ends.
if (file) args.push("--parents", "--", lanePath(file));
const result = await git(dir, args);
const commits = [];
for (const record of result.stdout.split("\x1e")) {
@@ -682,6 +684,50 @@ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
return commits;
}
/**
* Annotate (JetBrains' blame gutter): who last changed each line of the
* working-tree file. `git blame --porcelain` on the file on disk, so a line
* nobody committed yet is `null`. Same guards as a whole-file read: the
* lexical lane guard, a secret refused (ESECRET), and the file's size capped.
* Answers `{commits: {sha: {short, author, date, summary}}, lines: [sha|null]}`.
*/
async function blameFile(dir, requested) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
const st = await fs.promises.stat(path.join(dir, file)).catch(() => null);
if (!st?.isFile()) throw badRequest("ENOFILE", "no such file");
if (st.size > MAX_FILE_VIEW_BYTES) throw badRequest("ETOOBIG", "file too large to annotate");
const out = (
await git(dir, ["blame", "--porcelain", "--", file], { maxBuffer: 64 * 1024 * 1024 })
).stdout;
const commits = {};
const lines = [];
let sha = null;
for (const line of out.split("\n")) {
const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line);
if (head) {
sha = head[1];
if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) };
continue;
}
if (line.startsWith("\t")) {
lines.push(/^0+$/.test(sha) ? null : sha);
continue;
}
const c = commits[sha];
if (!c) continue;
const sp = line.indexOf(" ");
const key = line.slice(0, sp);
const val = line.slice(sp + 1);
if (key === "author") c.author = val;
else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString();
else if (key === "summary") c.summary = val;
}
return { path: file, commits, lines };
}
/**
* One file's full content at a revision, for the "read the whole file" view
* that a diff's three lines of context cannot answer.
@@ -981,7 +1027,18 @@ async function listTree(dir, { subdir = "" } = {}) {
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
*/
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
if (!header || (await isSecretOnDisk(dir, file))) return null;
if (!header) return null;
const diff = await diffHunks(dir, file, context);
const hunk = diff?.hunks.find((h) => h.slice(0, h.indexOf("\n")) === header);
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? diff.head + (body.endsWith("\n") ? body : `${body}\n`) : null;
}
/** A tracked file's fresh `git diff HEAD` at `context`: its header lines and
* its hunks' texts, in order. Null for a secret, binary or too-large file. */
async function diffHunks(dir, file, context) {
if (await isSecretOnDisk(dir, file)) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
dir,
@@ -992,16 +1049,31 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
const at = raw.indexOf("\n@@ ");
if (at < 0) return null;
const head = raw.slice(0, at + 1);
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
const hunk = raw
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
return { head: raw.slice(0, at + 1), hunks: raw.slice(at + 1).split(/^(?=@@ )/m) };
}
/**
* The hunk-level commit's patch for one file (JetBrains' partial commit):
* the file's header plus each picked hunk cut down to its picked `blocks`,
* in file order, to be applied FORWARD onto an index holding HEAD. Every
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
* git wrote it — `git apply` locates a hunk by its old side.
*/
async function selectionPatch(dir, file, context, picks) {
const diff = await diffHunks(dir, file, context);
if (!diff || !picks.length) return null;
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
if (picks.some((p) => at(p.hunk) < 0)) return null;
const bodies = [];
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
if (!body) return null;
bodies.push(body);
}
return diff.head + bodies.join("");
}
/**
@@ -1020,7 +1092,9 @@ async function hunkPatch(dir, file, header, context, { block, reverse = false }
* checked side is unchanged. Null when the hunk has no such block.
*/
function onlyBlock(hunk, block, reverse) {
if (!Number.isInteger(block) || block < 0) return null;
// One block, or several (the hunk-level commit keeps every ticked one).
const keep = Array.isArray(block) ? block : [block];
if (!keep.length || keep.some((b) => !Number.isInteger(b) || b < 0)) return null;
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
if (!m) return null;
@@ -1040,7 +1114,7 @@ function onlyBlock(hunk, block, reverse) {
if (change && !inBlock) index += 1;
inBlock = change;
let kept = line;
if (change && index !== block) {
if (change && !keep.includes(index)) {
const fold = reverse ? "+" : "-";
kept = mark === fold ? ` ${line.slice(1)}` : null;
}
@@ -1050,7 +1124,7 @@ function onlyBlock(hunk, block, reverse) {
if (kept[0] !== "+") oldCount += 1;
if (kept[0] !== "-") newCount += 1;
}
if (block > index) return null;
if (Math.max(...keep) > index) return null;
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
}
@@ -1103,6 +1177,8 @@ module.exports = {
resolveInLane,
MAX_FILE_VIEW_BYTES,
hunkPatch,
selectionPatch,
blameFile,
laneStatus,
listCommits,
listPaths,
+179 -11
View File
@@ -1,6 +1,8 @@
/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, create or
* stage/unstage a file, commit (the index, everything, or exactly the ticked
* `paths`), discard a file, add an untracked file to the workspace's
* .gitignore (`ignore`), switch branch, create or
* delete a branch, publish a repo that has no remote yet, and merge a branch
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
@@ -59,7 +61,7 @@ const {
operationInProgress,
branchRef,
} = require("./worktree");
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const { hunkPatch, selectionPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -91,6 +93,7 @@ const ACTIONS = new Set([
"revert",
"undo-commit",
"amend",
"ignore",
]);
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
@@ -476,6 +479,11 @@ async function runGitAction(dir, action, body = {}) {
// would switch it to committing the working-tree copy (unstaged edits
// included) — so a subdirectory lane refuses rather than sweep another
// actor's staged work outside it into this lane's commit.
// `paths` (the panel's ticked files): exactly those, whatever the index
// holds — `--only` leaves every other change, staged or not, outside.
if (hasHunks(body)) return done(action, await commitSelection(dir, ["-m", message], body));
if (Array.isArray(body.paths))
return done(action, await commitPaths(dir, ["-m", message], body));
await assertNothingStagedOutside(dir);
if (body.only === "staged") {
// The panel's per-file staging only means something if a commit can
@@ -497,6 +505,8 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["commit", "-m", message]));
}
if (action === "ignore") return ignorePaths(dir, body);
if (action === "branch-create" || action === "branch-delete") {
return branchAction(dir, action, body);
}
@@ -627,7 +637,9 @@ async function commitSha(dir, sha) {
/** How many parents a commit has (0 = root, 2+ = a merge commit). */
async function parentCount(dir, sha) {
return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1;
return (
(await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1
);
}
/**
@@ -646,7 +658,13 @@ async function assertUnpushedHead(dir, sha) {
throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace");
}
const on = (
await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"])
await git(dir, [
"for-each-ref",
"--contains",
"HEAD",
"--format=%(refname:short)",
"refs/remotes",
])
).stdout.trim();
if (on) {
throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`);
@@ -670,7 +688,8 @@ async function historyAction(dir, action, body) {
const open = await operationInProgress(dir);
const conflicts = open ? await unmergedPaths(dir) : [];
// Conflicts: an operation that STARTED, finished from the banner.
if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
if (conflicts.length)
return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
// Nothing to resolve, so nothing may be left behind either: a pick
// whose changes are already here (EEMPTY), or a commit git could not
// make (no identity, a failing signer) — which can leave the changes
@@ -687,8 +706,14 @@ async function historyAction(dir, action, body) {
.split("\0")
.filter(Boolean);
if (touched.length) {
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched])
.catch(() => {});
await git(dir, [
"restore",
"--source=HEAD",
"--staged",
"--worktree",
"--",
...touched,
]).catch(() => {});
}
if (empty) throw bad("EEMPTY", "those changes are already on this branch");
throw e;
@@ -709,19 +734,162 @@ async function historyAction(dir, action, body) {
// amend: the index as it stands folds into the last commit, like `commit`
// with `only: "staged"`; no message keeps the old one.
await assertUnpushedHead(dir, body.sha);
await assertNothingStagedOutside(dir);
const message = typeof body.message === "string" ? body.message.trim() : "";
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
const msgArgs = message ? ["-m", message] : ["--no-edit"];
if (hasHunks(body)) {
return done(action, await commitSelection(dir, ["--amend", ...msgArgs], body));
}
if (Array.isArray(body.paths)) {
// No ticked file: `--only` with no path rewords and leaves the index out.
if (!body.paths.length) {
if (!message) throw bad("ENOCHANGES", "no file ticked and no new message to amend with");
return done(action, await git(dir, ["commit", "--amend", "--only", ...msgArgs]));
}
return done(action, await commitPaths(dir, ["--amend", ...msgArgs], body));
}
await assertNothingStagedOutside(dir);
if (!message) {
const staged = [...(await changedPaths(dir)).values()].some(
({ code }) => code[0] !== " " && code[0] !== "?"
);
if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with");
}
return done(
action,
await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])])
return done(action, await git(dir, ["commit", "--amend", ...msgArgs]));
}
/**
* `git commit --only -- <paths>`: the working-tree content of exactly the
* paths the request ticked (JetBrains' commit), each one `git status` reports
* changed. An untracked one is added first — `--only` takes only paths git
* knows — and taken back out of the index if the commit then fails.
*/
async function commitPaths(dir, args, body) {
const paths = await resolvePaths(dir, body);
const conflicted = paths.find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
// A rename's original path rides along, or its deletion stays behind.
const all = paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
if (untracked.length) await git(dir, ["add", "--", ...untracked]);
try {
return await git(dir, ["commit", ...args, "--only", "--", ...all]);
} catch (e) {
if (untracked.length)
await git(dir, ["rm", "--cached", "-q", "--", ...untracked]).catch(() => {});
throw e;
}
}
/** Whether the request names change blocks (`hunks`), not just whole files. */
function hasHunks(body) {
return !!body.hunks && typeof body.hunks === "object" && Object.keys(body.hunks).length > 0;
}
/** Most files one hunk-level commit may name partially. */
const MAX_PARTIAL_FILES = 300;
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
*/
async function commitSelection(dir, args, body) {
const picks = Object.entries(body.hunks);
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
for (const [, list] of picks) {
const ok =
Array.isArray(list) &&
list.length > 0 &&
list.every(
(x) =>
x &&
typeof x.hunk === "string" &&
Array.isArray(x.blocks) &&
x.blocks.length > 0 &&
x.blocks.every((b) => Number.isInteger(b) && b >= 0)
);
if (!ok) throw bad("EBADPATH", "hunks: each file needs [{hunk, blocks: [n, …]}]");
}
const whole = Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : [];
const part = await resolvePaths(dir, { paths: picks.map(([p]) => p) });
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
for (const p of part) {
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
if (whole.some((w) => w.path === p.path)) {
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
}
}
const wholePaths = whole.flatMap((p) =>
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
);
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-commit-"));
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
try {
await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], {
env,
});
if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env });
const file = path.join(tmp, "part.patch");
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
for (const [p, list] of picks) {
const patch = await selectionPatch(dir, p, body.context, list);
if (!patch)
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
await fs.promises.writeFile(file, patch);
// The patch names paths from the repo root, so it is applied there.
await git(top, ["apply", "--cached", ...zero, file], { env });
}
const out = await git(dir, ["commit", ...args], { env });
await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch(
() => {}
);
return out;
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
}
/** A path as one literal .gitignore line, anchored at the file's folder. */
function ignoreLine(p) {
return `/${p.replace(/[\\*?[]/g, "\\$&").replace(/ $/, "\\ ")}`;
}
/**
* Add to .gitignore: one anchored line per path in the WORKSPACE's own
* .gitignore (its cwd — a subdirectory workspace never writes above itself),
* only for a path `git status` lists as untracked, never a line it already
* has, and never through a symlink or onto anything but a regular file.
*/
async function ignorePaths(dir, body) {
const paths = await resolvePaths(dir, body);
const tracked = paths.find((p) => p.code !== "??");
if (tracked)
throw bad("ETRACKED", `${tracked.path} is tracked: only an untracked file can be ignored`);
// A line break in a name would write a second, arbitrary pattern.
if (paths.some((p) => /[\r\n]/.test(p.path)))
throw bad("EBADPATH", "a path with a line break cannot be ignored");
const file = path.join(dir, ".gitignore");
const st = await fs.promises.lstat(file).catch(() => null);
if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file");
const old = st ? await fs.promises.readFile(file, "utf8") : "";
const have = new Set(old.split(/\r?\n/));
const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l));
if (lines.length) {
const sep = old && !old.endsWith("\n") ? "\n" : "";
await fs.promises.appendFile(file, `${sep}${lines.join("\n")}\n`);
}
return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" });
}
/** Create or delete a branch WITHOUT touching the working tree. */
+4
View File
@@ -9,6 +9,7 @@
const { db, stmts } = require("../db");
const TranscriptCache = require("./transcript-cache");
const { longArgs } = require("./token-usage");
/** Write every hour of one transcript parse (MAX per hour, see the statement). */
function writeHourly(sessionId, tokensByHour) {
@@ -31,6 +32,9 @@ function writeHourly(sessionId, tokensByHour) {
t.webFetch,
t.codeExec
);
const long = longArgs(t);
if (long)
stmts.raiseLongHourly.run(...long, sessionId, t.hour, t.model, t.speed, t.geo, t.tier);
}
}
+9 -3
View File
@@ -3,7 +3,8 @@
* @description Custom pets for the dashboard mascot (Sagi). A pet is a folder
* `<data dir>/pets/<id>/` holding `meta.json` (written here) and either a
* Codex pet's `spritesheet.webp` — the `.codex-pet.zip` format: an 8×9 grid of
* 192×208 frames, one row per animation state — or one uploaded image. Pets
* 192×208 frames, one row per animation state (v2 adds two rows of gaze
* poses: 8×11) — or one uploaded image. Pets
* come from exactly three places: an uploaded `.codex-pet.zip`, an uploaded
* image, or one of the FEATURED pets, downloaded from codex-pets.net by a fixed
* id — never a URL or a path from a request. None is bundled with AgentHub:
@@ -28,6 +29,8 @@ const MAX_MANIFEST_BYTES = 16 * 1024;
const MAX_PETS = 50;
/** The Codex atlas: 8 columns × 9 rows of 192×208 frames. */
const SHEET = { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 };
/** A v2 atlas (`spriteVersionNumber: 2`): the same plus rows 9–10, 16 gaze poses. */
const SHEET_V2_HEIGHT = 2288;
const FEATURED_HOST = "https://codex-pets.net";
/** Offered in the pet menu, installed only on a click. `id` is codex-pets.net's. */
const FEATURED = [
@@ -105,10 +108,13 @@ function checkSheet(buf) {
if (buf.length > MAX_SHEET_BYTES) throw fail("ETOOBIG", "the spritesheet is larger than 6 MB");
const size = webpSize(buf);
if (!size) throw fail("EBADPET", "the spritesheet is not a WebP image");
if (size.width !== SHEET.width || size.height !== SHEET.height)
if (
size.width !== SHEET.width ||
(size.height !== SHEET.height && size.height !== SHEET_V2_HEIGHT)
)
throw fail(
"EBADPET",
`the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height}`
`the spritesheet is ${size.width}×${size.height}; a Codex pet is ${SHEET.width}×${SHEET.height} (v2: ${SHEET.width}×${SHEET_V2_HEIGHT})`
);
}
+6
View File
@@ -18,6 +18,11 @@ const CACHE_WRITE_1H_MULTIPLIER = 2.0; // 1-hour ephemeral write
// ── Cross-cutting rate modifiers ────────────────────────────────────────────
const DATA_RESIDENCY_US_MULTIPLIER = 1.1; // inference_geo === "us"
const BATCH_DISCOUNT_MULTIPLIER = 0.5; // service_tier === "batch" (50% off)
// A request whose whole prompt (input + cache read + cache write) is above this
// is booked as context_tier "long" and priced at the rule's long_* rates.
// ponytail: one threshold for every model (Haiku 5.5's 100k); a model with a
// different one needs the threshold stored per bucket, then a re-parse.
const LONG_CONTEXT_TOKENS = 100_000;
// ── Server-tool surcharges (billed in addition to tokens) ───────────────────
const WEB_SEARCH_PER_1K_SEARCHES = 10.0; // $10 per 1,000 web_search_requests
@@ -48,6 +53,7 @@ module.exports = {
CACHE_WRITE_1H_MULTIPLIER,
DATA_RESIDENCY_US_MULTIPLIER,
BATCH_DISCOUNT_MULTIPLIER,
LONG_CONTEXT_TOKENS,
WEB_SEARCH_PER_1K_SEARCHES,
WEB_FETCH_PER_REQUEST,
CODE_EXEC_PER_HOUR,
+30 -5
View File
@@ -19,10 +19,11 @@
* fast columns: Codex's fast/priority service tier is booked as `speed = fast`
* (`codex-usage.js`), and its flex tier as `service_tier = 'flex'` (half price,
* like batch, in `ratesForBucket`).
* A Claude model's `*_above_100k_tokens` input/output rates (Haiku 5.5) fill
* the long_* columns, priced against token_usage's long-context subsets.
* ponytail: OpenAI's long-context (>272k) and Gemini's >200k-token rates are
* not modelled — Codex's context window (258k) stays under 272k and no current
* Claude model has such a tier; it needs a context-size bucket in token_usage
* (a primary-key rebuild) if that changes.
* not modelled — Codex's context window (258k) stays under 272k, and a
* threshold other than LONG_CONTEXT_TOKENS (100k) needs its own subsets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -112,6 +113,14 @@ function toRow(fam, e) {
: [per(e.input_cost_per_token_priority), per(e.output_cost_per_token_priority)];
const prioOk =
prio.length > 0 && prio.every((v) => Number.isFinite(v) && v > 0 && v <= MAX_PER_MTOK);
const long = claude
? [
per(e.input_cost_per_token_above_100k_tokens),
per(e.output_cost_per_token_above_100k_tokens),
]
: [];
const longOk =
long.length > 0 && long.every((v) => Number.isFinite(v) && v > 0 && v <= MAX_PER_MTOK);
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
const r = (n) => Math.round(n * 1e6) / 1e6;
return {
@@ -124,6 +133,8 @@ function toRow(fam, e) {
w1h: r(w1h),
fastIn: fastOk ? r(input * fast) : prioOk ? r(prio[0]) : 0,
fastOut: fastOk ? r(output * fast) : prioOk ? r(prio[1]) : 0,
longIn: longOk ? r(long[0]) : 0,
longOut: longOk ? r(long[1]) : 0,
};
}
@@ -192,6 +203,8 @@ const RATE_COLS = [
["w1h", "cache_write_1h_per_mtok"],
["fastIn", "fast_input_per_mtok"],
["fastOut", "fast_output_per_mtok"],
["longIn", "long_input_per_mtok"],
["longOut", "long_output_per_mtok"],
];
const sameRates = (row, db) => RATE_COLS.every(([k, col]) => Math.abs(row[k] - db[col]) < 1e-9);
@@ -253,12 +266,14 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
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,
fast_input_per_mtok, fast_output_per_mtok, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
fast_input_per_mtok, fast_output_per_mtok, long_input_per_mtok, long_output_per_mtok, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
);
const update = db.prepare(
`UPDATE model_pricing SET display_name = ?, input_per_mtok = ?, output_per_mtok = ?,
cache_read_per_mtok = ?, cache_write_per_mtok = ?, cache_write_1h_per_mtok = ?,
fast_input_per_mtok = ?, fast_output_per_mtok = ?,
long_input_per_mtok = ?, long_output_per_mtok = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE model_pattern = ? AND source = 'sync'`
);
@@ -273,7 +288,17 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
if (dismissed.has(pattern)) continue;
const row = toRow(fam, list[fam]);
if (!row) continue;
const rates = [row.input, row.output, row.read, row.w5m, row.w1h, row.fastIn, row.fastOut];
const rates = [
row.input,
row.output,
row.read,
row.w5m,
row.w1h,
row.fastIn,
row.fastOut,
row.longIn,
row.longOut,
];
const have = rows.get(pattern);
if (!have) {
if (insert.run(pattern, row.name, ...rates).changes) added.push(pattern);
+58
View File
@@ -0,0 +1,58 @@
/**
* @file quit.js — Quit AgentHub (issue #65, 9router's tray "Quit"): stop the
* dashboard and every lane pane, and keep it stopped until the user starts it
* again. "Stopped" is the file `<data dir>/runtime/stopped`: the plugin
* bootstrap starts no server while it exists, and every server that starts
* clears it — so a boot or login through an enabled autostart unit still
* brings the dashboard back, and only Claude Code's SessionStart is held off.
* Kept free of anything but fs/path: the bootstrap reads it before the
* runtime's dependencies are installed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getDataDir } = require("./claude-home");
function flagPath(rt = path.join(getDataDir(), "runtime")) {
return path.join(rt, "stopped");
}
function isQuit(rt) {
return fs.existsSync(flagPath(rt));
}
function markQuit(rt) {
const file = flagPath(rt);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${new Date().toISOString()}\n`, "utf8");
}
function clearQuit(rt) {
fs.rmSync(flagPath(rt), { force: true });
}
/**
* Stops this server. A unit of ours that is running is stopped THROUGH its
* service manager: a SIGTERM'd server exits 0, which `Restart=always` /
* `KeepAlive` answer by starting it again. Async spawns, because launchd's
* `bootout` waits for the job — this process — to exit. If this process was
* not the unit's (a second dashboard beside it), it follows a moment later.
*/
function stopServer({
svc = require("../../scripts/service-manager"),
spawn = require("child_process").spawn,
kill = () => process.kill(process.pid, "SIGTERM"),
} = {}) {
const owned =
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
if (!owned) return kill();
const argv =
svc.kind === "launchd"
? ["launchctl", ["bootout", `gui/${process.getuid()}/io.smartgift.agenthub`]]
: ["systemctl", ["--user", "stop", "--no-block", "agenthub.service"]];
spawn(argv[0], argv[1], { detached: true, stdio: "ignore" }).unref();
setTimeout(kill, 5000).unref();
}
module.exports = { flagPath, isQuit, markQuit, clearQuit, stopServer };
+342 -12
View File
@@ -18,38 +18,89 @@
* never that account's usage.
* - **Codex**: no network. Every `token_count` event Codex writes into its
* rollout journal carries the account's `rate_limits`; the newest one wins.
* - **Cursor**: the Cursor CLI's (`cursor-agent`) own login —
* `$XDG_CONFIG_HOME/cursor/auth.json` — sent ONLY to cursor.com's
* `GET /api/usage-summary` (undocumented; what cursor.com/dashboard reads),
* as the `WorkosCursorSessionToken=<user id>::<token>` cookie it expects,
* while the token is valid and never refreshed. One `mo` window: the
* billing cycle's `totalPercentUsed`. No file (macOS keeps it in the
* keychain) means no row.
* - **Antigravity**: `agy --print /usage --output-format json` — the CLI's
* own `/usage` command (no model call: `num_turns` 0, no conversation),
* run by the catalog-detected binary in a private empty directory, only
* when the tool is enabled and `agy --version` is at least 1.1.11 (an
* older agy may bill `/usage` as a model request). One window per pool
* bucket, `group` naming the pool (Gemini / Claude and GPT). Its report
* 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.
*
* Each tool also carries its signed-in `account` (an email) and `expiresAt`,
* when its stored access token runs out — Claude's `claudeAiOauth.expiresAt`,
* Codex's `auth.json` access-token `exp` (claims read, never verified).
* Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name as the tool reports it: Claude's `subscriptionType`,
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
* `expiresAt`, when its stored access token runs out — Claude's
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
* read, never verified).
*
* Only percentages, window lengths, reset/expiry times and the account's
* email leave this module — never a token.
* Only percentages, window lengths, reset/expiry times, the plan's name and
* the account's email leave this module — never a token.
* @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 { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const aiTools = require("./ai-tools");
const { getCodexHome } = require("./codex-home");
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 CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
const EXPIRY_MARGIN_MS = 60_000;
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" };
const execFileAsync = promisify(execFile);
const cache = new Map(); // "live" | "profile:<name>" → { at, value }
const lastGood = new Map(); // profile name → { windows, at }
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click). */
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click);
* a call still in flight is shared, so two open tabs never run it twice. */
async function cached(key, force, fn) {
const hit = cache.get(key);
const age = hit ? Date.now() - hit.at : Infinity;
if (age < (force ? FORCE_MIN_MS : CLAUDE_TTL_MS)) return hit.value;
const value = await fn();
const value = fn();
cache.set(key, { at: Date.now(), value });
return value;
}
@@ -94,6 +145,7 @@ async function liveClaude() {
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = {
account: account?.emailAddress || account?.displayName || null,
plan: oauth.subscriptionType || null,
expiresAt: iso(oauth.expiresAt),
};
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
@@ -121,7 +173,11 @@ async function liveClaude() {
* the last figure this process read is shown with its `asOf`.
*/
async function profileClaude(item) {
const who = { account: item.email || item.displayName || null, expiresAt: iso(item.expiresAt) };
const who = {
account: item.email || item.displayName || null,
plan: item.subscriptionType || null,
expiresAt: iso(item.expiresAt),
};
let creds = null;
try {
creds = readProfile(item.name)?.credentials || null;
@@ -225,13 +281,15 @@ function jwtClaims(token) {
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
}
/** Codex's ChatGPT login from `auth.json`: email + access-token expiry. */
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
function codexAccount() {
try {
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
const { exp } = jwtClaims(tokens.access_token);
const id = jwtClaims(tokens.id_token);
return {
account: jwtClaims(tokens.id_token).email || null,
account: id.email || null,
plan: id["https://api.openai.com/auth"]?.chatgpt_plan_type || null,
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
};
} catch {
@@ -260,8 +318,273 @@ function codexQuota() {
return null;
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
}
/** The Cursor CLI's login and the account's plan + billing-cycle usage. */
async function cursorQuota() {
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const token = readJson(path.join(configHome, "cursor", "auth.json"))?.accessToken;
if (typeof token !== "string" || !token) return null; // not logged in
let claims = {};
try {
claims = jwtClaims(token);
} catch {
/* not a JWT: no expiry to check, cursor.com decides */
}
const info = readJson(path.join(getCursorDir(), "cli-config.json"))?.authInfo || {};
const who = {
id: "cursor",
active: true,
account: info.email || null,
plan: null,
expiresAt: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,
};
if (claims.exp && !fresh(claims.exp * 1000))
return { ...who, windows: [], error: "ETOKENEXPIRED" };
// The cookie names the user as the dashboard does: `user_…`, no `auth0|`.
const user = String(info.authId || info.userId || claims.sub || "")
.split("|")
.pop();
try {
const res = await fetch(CURSOR_USAGE_URL, {
headers: { Accept: "application/json", Cookie: `WorkosCursorSessionToken=${user}::${token}` },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
return { ...who, windows: [], error: res.status === 401 ? "ETOKENEXPIRED" : "EUPSTREAM" };
}
const u = await res.json();
const p = u?.individualUsage?.plan;
// Enterprise seats answer without `plan`: the plan name, no figure.
const used = typeof p?.totalPercentUsed === "number" ? p.totalPercentUsed : null;
return {
...who,
plan: u?.membershipType || null,
windows:
used === null
? []
: [{ label: "mo", usedPercent: used, resetsAt: u.billingCycleEnd || null }],
};
} catch {
return { ...who, windows: [], error: "EUPSTREAM" };
}
}
/** `agy --version` at least `AGY_MIN_VERSION`. */
function agyVersionOk(text) {
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
if (!m) return false;
for (let i = 0; i < 3; i++) {
const d = Number(m[i + 1]) - AGY_MIN_VERSION[i];
if (d) return d > 0;
}
return true;
}
/** `/usage`'s pools → windows: 5h before the week, in the pools' order. */
function agyWindows(report) {
const windows = [];
for (const g of report?.command?.data?.groups || []) {
const rows = (g.buckets || [])
.filter((b) => AGY_WINDOW[b.window] && typeof b.remaining_fraction === "number")
.map((b) => {
const resets = Date.parse(b.reset_time);
return {
group: String(g.name || ""),
label: AGY_WINDOW[b.window],
// A window past its reset is full again, as Codex's is.
usedPercent: resets < Date.now() ? 0 : (1 - b.remaining_fraction) * 100,
resetsAt: Number.isFinite(resets) ? new Date(resets).toISOString() : null,
};
});
rows.sort((a, b) => (a.label === "5h" ? -1 : 0) - (b.label === "5h" ? -1 : 0));
windows.push(...rows);
}
return windows;
}
async function antigravityQuota() {
if (process.env.AGENTHUB_AGY_USAGE === "0" || !aiTools.isEnabled("antigravity")) return null;
const { installed, path: bin } = aiTools.detect("antigravity");
if (!installed || !bin) return null;
const run = (args, cwd) =>
execFileAsync(bin, args, { cwd, timeout: AGY_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-usage-"));
try {
if (!agyVersionOk((await run(["--version"], dir)).stdout)) return null;
const { stdout } = await run(["--print", "/usage", "--output-format", "json"], dir);
const windows = agyWindows(JSON.parse(stdout));
if (!windows.length) throw new Error("no quota in /usage");
lastGood.set("antigravity", { windows, at: Date.now() });
return { id: "antigravity", active: true, windows };
} catch {
const prev = lastGood.get("antigravity");
if (!prev) return { id: "antigravity", active: true, windows: [], error: "EUPSTREAM" };
return {
id: "antigravity",
active: true,
windows: prev.windows.map(expireReset),
asOf: iso(prev.at),
};
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const isoOf = (v) => {
const ms = Date.parse(v);
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
};
/** A Bearer GET to a tool's own host: its windows, or the row's error. */
async function bearerWindows(who, url, token, headers, toWindows) {
try {
const res = await fetch(url, {
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...headers },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) {
const expired = res.status === 401 || res.status === 403;
return { ...who, windows: [], error: expired ? "ETOKENEXPIRED" : "EUPSTREAM" };
}
return { ...who, ...toWindows(await res.json()) };
} catch {
return { ...who, windows: [], error: "EUPSTREAM" };
}
}
/** Grok Build's `auth.x.ai` session and its billing view. */
async function grokQuota() {
const home = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
const sessions = readJson(path.join(home, "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() {
const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code");
const cred = readJson(path.join(home, "credentials", "kimi-code.json"));
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
const exp = Number(cred.expires_at) * 1000;
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
if (exp && !fresh(exp)) return { ...who, windows: [], error: "ETOKENEXPIRED" };
return bearerWindows(who, KIMI_USAGE_URL, cred.access_token, {}, (body) => ({
windows: [
["5h", body?.usages?.limit_5h],
["wk", body?.usages?.limit_7d],
["mo", body?.usages?.limit_month_total],
]
.filter(([, u]) => u && Number.isFinite(Number(u.used_ratio)))
.map(([label, u]) => ({
label,
usedPercent: Number(u.used_ratio) * 100,
resetsAt: isoOf(u.reset_time),
})),
}));
}
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
function openCodeGoKey() {
const dir = path.join(
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
"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;
try {
conn = new Database(path.join(dir, "opencode.db"), { readonly: true, 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();
}
return null;
}
async function openCodeGoQuota() {
const key = openCodeGoKey();
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) })),
}));
}
async function quotas({ force = false } = {}) {
const tools = [...(await claudeQuotas(force)), codexQuota()].filter(Boolean);
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
claudeQuotas(force),
cached("cursor", force, cursorQuota),
cached("antigravity", force, antigravityQuota),
cached("grok", force, grokQuota),
cached("kimi", force, kimiQuota),
cached("opencode", force, openCodeGoQuota),
]);
const tools = [...claude, codexQuota(), antigravity, cursor, grok, kimi, opencode].filter(
Boolean
);
return { tools, checkedAt: new Date().toISOString() };
}
@@ -270,4 +593,11 @@ function __resetCache() {
lastGood.clear();
}
module.exports = { quotas, codexWindow, codexAccount, windowLabel, __resetCache };
module.exports = {
quotas,
codexWindow,
codexAccount,
windowLabel,
agyVersionOk,
__resetCache,
};
+42 -1
View File
@@ -26,6 +26,30 @@ const { execFileSync } = require("node:child_process");
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
let execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
// Linux: the tmux server lives in the cgroup of whatever started it — the
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
// and dies when that cgroup is stopped (an update's restart under a unit
// without KillMode=process, systemd-oomd, closing that app). So the FIRST
// session, which starts the server, is created inside a transient scope of
// its own: panes end only on Quit (`quit.js`) or with the machine. `--scope`
// runs tmux as a child, so it keeps this process's environment.
const realScopeImpl = (args) =>
execFileSync(
"systemd-run",
[
"--user",
"--scope",
"--quiet",
"--collect",
"--description=AgentHub tmux server",
"--",
"tmux",
...args,
],
EXEC_OPTS
);
let scopeImpl = realScopeImpl;
// Whether this tmux takes `=name` targets: true for tmux itself, false for
// psmux (the WinGet `tmux` on Windows), null until a live session has shown
// which. A fake exec in a test is real tmux unless it says otherwise.
@@ -33,11 +57,17 @@ let exactTargets = null;
function __setExecImpl(fn, { exact = true } = {}) {
execImpl = fn;
scopeImpl = null; // a fake tmux never starts a real scope
exactTargets = exact;
listed = null;
}
function __setScopeImpl(fn) {
scopeImpl = fn;
}
function __reset() {
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
scopeImpl = realScopeImpl;
exactTargets = null;
listed = null;
}
@@ -143,7 +173,17 @@ function newSession({ name, cwd, argv, env }) {
// No argv = tmux's own default shell (a Windows Terminal pane).
const cmd = argv.length ? ["--", ...argv] : [];
listed = null;
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd]);
const args = ["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd];
if (scopeImpl && process.platform === "linux" && !serverPid()) {
try {
scopeImpl(args);
} catch {
// No user manager (SSH without linger, no systemd): the plain start.
if (!hasSession(name)) execImpl(args);
}
} else {
execImpl(args);
}
try {
enableMouse(name);
} catch (err) {
@@ -346,6 +386,7 @@ function serverVersion() {
}
module.exports = {
__setScopeImpl,
session,
pane,
hasSession,
+52 -3
View File
@@ -11,9 +11,16 @@
* price; anything unknown collapses to the standard/global default so old
* transcripts (which lack `speed` / `inference_geo` / `cache_creation`
* breakdown / `server_tool_use`) price exactly as they did before.
*
* A request whose whole prompt (input + cache read + cache write) is above
* LONG_CONTEXT_TOKENS is ALSO counted into the bucket's `long*` fields — a
* subset of the totals, like `cacheWrite1h` — so cost can price that part at
* the model's long-context rates.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { LONG_CONTEXT_TOKENS } = require("./pricing-constants");
// Separator for composite bucket keys — U+0001 (SOH) cannot occur in a model id.
const BUCKET_SEP = String.fromCharCode(1);
@@ -56,6 +63,13 @@ function emptyBucket(model, speed, geo, tier) {
webSearch: 0, // server_tool_use.web_search_requests (billed per 1k)
webFetch: 0, // server_tool_use.web_fetch_requests (free; tracked for visibility)
codeExec: 0, // server_tool_use.code_execution_requests (time-billed; estimated)
// Subsets of input/output/cacheRead/cacheWrite/cacheWrite1h spent by
// requests whose prompt was above LONG_CONTEXT_TOKENS.
longInput: 0,
longOutput: 0,
longCacheRead: 0,
longCacheWrite: 0,
longCacheWrite1h: 0,
};
}
@@ -76,6 +90,11 @@ function extractUsageFields(usage) {
webSearch: 0,
webFetch: 0,
codeExec: 0,
longInput: 0,
longOutput: 0,
longCacheRead: 0,
longCacheWrite: 0,
longCacheWrite1h: 0,
};
}
const cc =
@@ -93,18 +112,42 @@ function extractUsageFields(usage) {
usage.server_tool_use && typeof usage.server_tool_use === "object"
? usage.server_tool_use
: null;
const input = usage.input_tokens || 0;
const output = usage.output_tokens || 0;
const cacheRead = usage.cache_read_input_tokens || 0;
const long = input + cacheRead + cacheWrite > LONG_CONTEXT_TOKENS;
return {
input: usage.input_tokens || 0,
output: usage.output_tokens || 0,
cacheRead: usage.cache_read_input_tokens || 0,
input,
output,
cacheRead,
cacheWrite,
cacheWrite1h,
webSearch: stu ? stu.web_search_requests || 0 : 0,
webFetch: stu ? stu.web_fetch_requests || 0 : 0,
codeExec: stu ? stu.code_execution_requests || 0 : 0,
longInput: long ? input : 0,
longOutput: long ? output : 0,
longCacheRead: long ? cacheRead : 0,
longCacheWrite: long ? cacheWrite : 0,
longCacheWrite1h: long ? cacheWrite1h : 0,
};
}
/**
* A bucket's long-context subsets in `long_*_tokens` column order, or null
* when it has none (the caller then skips the extra write).
*/
function longArgs(b) {
const args = [
b.longInput,
b.longOutput,
b.longCacheRead,
b.longCacheWrite,
b.longCacheWrite1h,
].map((n) => n || 0);
return args.some((n) => n > 0) ? args : null;
}
/** Add the numeric fields of `src` into `target` in place. */
function accumulateBucket(target, src) {
target.input += src.input || 0;
@@ -115,6 +158,11 @@ function accumulateBucket(target, src) {
target.webSearch += src.webSearch || 0;
target.webFetch += src.webFetch || 0;
target.codeExec += src.codeExec || 0;
target.longInput = (target.longInput || 0) + (src.longInput || 0);
target.longOutput = (target.longOutput || 0) + (src.longOutput || 0);
target.longCacheRead = (target.longCacheRead || 0) + (src.longCacheRead || 0);
target.longCacheWrite = (target.longCacheWrite || 0) + (src.longCacheWrite || 0);
target.longCacheWrite1h = (target.longCacheWrite1h || 0) + (src.longCacheWrite1h || 0);
return target;
}
@@ -127,4 +175,5 @@ module.exports = {
emptyBucket,
extractUsageFields,
accumulateBucket,
longArgs,
};
+150
View File
@@ -0,0 +1,150 @@
/**
* @file tray.js — the AgentHub icon in the OS status area (GNOME top bar,
* macOS menu bar, Windows notification area), like 9router's. The icon is its
* own process (`scripts/tray.js`) so a server restart or a Quit never takes it
* away — after a Quit it is how the user starts AgentHub again. A production
* server keeps it alive (`startTrayWatch`): ON by default, OFF once the user
* hides it (the flag `<data dir>/runtime/tray-off`, written by Settings →
* System or the icon's own "Hide this icon"; nothing turns it back on but the
* user). On Linux it waits for a graphical session — a server started at boot
* has no DISPLAY until someone logs in. Never in server mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getDataDir } = require("./claude-home");
const ROOT = path.resolve(__dirname, "..", "..");
const CHECK_MS = 60_000;
const MAX_FAILED_STARTS = 3;
const HEARTBEAT_MS = 5_000;
const runtime = () => path.join(getDataDir(), "runtime");
const offPath = () => path.join(runtime(), "tray-off");
const pidPath = () => path.join(runtime(), "tray.json");
function isEnabled() {
return !fs.existsSync(offPath());
}
/**
* `{pid, root}` of the live tray process, or null. The icon touches its file
* on every poll (`HEARTBEAT_MS`), so a file left by a crash or a reboot goes
* stale and its pid — by then possibly another program's — is never signalled.
*/
function liveTray() {
try {
if (Date.now() - fs.statSync(pidPath()).mtimeMs > 6 * HEARTBEAT_MS) return null;
const rec = JSON.parse(fs.readFileSync(pidPath(), "utf8"));
process.kill(rec.pid, 0);
return rec;
} catch {
return null;
}
}
function writePid(pid, root = ROOT) {
fs.mkdirSync(runtime(), { recursive: true });
fs.writeFileSync(pidPath(), JSON.stringify({ pid, root }), "utf8");
}
function stopTray() {
const rec = liveTray();
if (!rec) return;
try {
process.kill(rec.pid, "SIGTERM");
} catch {
/* already gone */
}
}
/** The environment the icon needs to reach the desktop, or null when there is none. */
function guiEnv() {
if (process.platform !== "linux") return {};
const env = require("./desktop-env").desktopEnv();
return env.DISPLAY || env.WAYLAND_DISPLAY ? env : null;
}
function status() {
const { isServerMode } = require("./auth");
return {
available: !isServerMode() && guiEnv() !== null,
enabled: isEnabled(),
running: liveTray() !== null,
};
}
let failedStarts = 0;
/**
* Starts the icon when it should be up and is not. `replace` (once, at server
* start) retires an icon still running from another install root — the
* previous plugin build, whose cache directory is about to be pruned.
*/
function ensureTray({ replace = false, spawn = require("child_process").spawn } = {}) {
if (!isEnabled() || failedStarts >= MAX_FAILED_STARTS) return "skipped";
const live = liveTray();
if (live && !(replace && live.root !== ROOT)) return "running";
const env = guiEnv();
if (!env) return "no-gui";
if (live) stopTray();
const started = Date.now();
const child = spawn(process.execPath, [path.join(ROOT, "scripts", "tray.js")], {
cwd: ROOT,
detached: true,
stdio: "ignore",
env: { ...process.env, ...env },
});
child.on("error", () => failedStarts++);
// An icon that dies at once (no tray host, a binary the OS refuses) is not
// retried forever: three quick deaths and this server leaves it alone.
child.on("exit", () => {
if (Date.now() - started < 10_000) failedStarts++;
});
child.unref();
return "started";
}
function setEnabled(on, opts) {
if (on) {
fs.rmSync(offPath(), { force: true });
failedStarts = 0;
ensureTray(opts);
} else {
fs.mkdirSync(runtime(), { recursive: true });
fs.writeFileSync(offPath(), `${new Date().toISOString()}\n`, "utf8");
stopTray();
}
return status();
}
function startTrayWatch() {
if (process.env.NODE_ENV !== "production" || require("./auth").isServerMode()) return;
try {
ensureTray({ replace: true });
} catch {
/* an icon is never worth a server crash */
}
setInterval(() => {
try {
ensureTray();
} catch {
/* same */
}
}, CHECK_MS).unref();
}
module.exports = {
isEnabled,
liveTray,
writePid,
stopTray,
status,
ensureTray,
setEnabled,
startTrayWatch,
offPath,
pidPath,
HEARTBEAT_MS,
};