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