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
View File
@@ -22,6 +22,7 @@ const {
normalizeGeo,
normalizeTier,
accumulateBucket,
longArgs,
} = require("../server/lib/token-usage");
const {
@@ -822,6 +823,11 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel, opts = {}) {
tokens.webFetch || 0,
tokens.codeExec || 0
);
// The exact path also clears a stale subset; the default one only raises.
const long = longArgs(tokens);
const key = [sessionId, tokens.model, tokens.speed, tokens.geo, tokens.tier];
if (opts.exact) stmts.setLongTokens.run(...(long || [0, 0, 0, 0, 0]), ...key);
else if (long) stmts.raiseLongTokens.run(...long, ...key);
written++;
}
}
@@ -852,6 +858,11 @@ function subagentTokenRows(tokensByModel) {
web_search_requests: b.webSearch || 0,
web_fetch_requests: b.webFetch || 0,
code_execution_requests: b.codeExec || 0,
long_input_tokens: b.longInput || 0,
long_output_tokens: b.longOutput || 0,
long_cache_read_tokens: b.longCacheRead || 0,
long_cache_write_tokens: b.longCacheWrite || 0,
long_cache_write_1h_tokens: b.longCacheWrite1h || 0,
};
const hasUsage =
row.input_tokens ||
+1 -1
View File
@@ -247,4 +247,4 @@ if (require.main === module) {
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
// a previous checkout install left behind (they would double-count events).
module.exports = { installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
+8 -2
View File
@@ -35,6 +35,7 @@ const crypto = require("crypto");
const { spawn, spawnSync } = require("child_process");
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
const { isQuit } = require("../server/lib/quit");
const { getServerInfoPath } = require("../server/lib/server-info");
const { isOurEntry } = require("./install-hooks");
@@ -178,7 +179,8 @@ function fastPathOk(rt = runtimeDir()) {
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
// Task spawning two subagent rows.)
if (hasLegacyHooks()) return false;
return serverIsLive();
// Quit (server/lib/quit.js): nothing to start until the user starts it.
return serverIsLive() || isQuit(rt);
}
/* ------------------------------------------------------- node version gate */
@@ -740,7 +742,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// running process must be replaced"; `unitStale` is "the unit names a cache
// directory that is no longer this one", which is invisible until a reboot
// and must be repaired even while a perfectly healthy server is up.
const needsServer = movedRoot || !serverIsLive();
// A Quit holds every start off (an explicit `--force` from
// /agenthub-update overrides it); the unit is still repaired below.
const quit = !force && isQuit(rt);
if (quit) log(rt, "AgentHub was quit — not starting it (agenthub start brings it back)");
const needsServer = !quit && (movedRoot || !serverIsLive());
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
if (needsServer || unitStale) {
const unit = {
+7
View File
@@ -127,6 +127,12 @@ function unitIsActive(name) {
return String(res.stdout || "").trim() === "active";
}
/** `KillMode=process`: a stop or an update's restart ends the server alone.
* tmux forks its server from whichever process first asks for a session —
* the dashboard itself, after a boot with autostart on — so it can sit in
* this unit's cgroup, and the default `control-group` would kill it and every
* lane pane with it. Panes close only on Quit (`server/lib/quit.js`), which
* kills them itself. The launchd twin is `AbandonProcessGroup`. */
function unitContent({ description, workingDir, execStart, env }) {
const envLines = Object.entries(env)
.map(([k, v]) => `Environment=${k}=${v}`)
@@ -142,6 +148,7 @@ WorkingDirectory=${workingDir}
ExecStart=${execStart}
Restart=always
RestartSec=3
KillMode=process
${envLines}
[Install]
+7
View File
@@ -42,6 +42,13 @@ const env = {
AGENTHUB_SETTINGS_FILE: path.join(tmuxDir, "settings.env"),
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
AGENTHUB_PRICING_SYNC: "0",
// Nor run the developer's real agy (it may update itself) or send their
// real Cursor, Grok, Kimi or OpenCode login for the quota.
AGENTHUB_AGY_USAGE: "0",
XDG_CONFIG_HOME: tmuxDir,
XDG_DATA_HOME: tmuxDir,
GROK_HOME: tmuxDir,
KIMI_CODE_HOME: tmuxDir,
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
};
delete env.TMUX;
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* @file tray.js — the AgentHub icon in the OS status area, as its own process
* (started and kept alive by `server/lib/tray.js`; `agenthub tray` runs it in
* the foreground). Draws through `systray2`'s helper binary, polls the
* dashboard's /api/health, and offers Open, Quit/Start (the CLI's own
* `agenthub stop|start`, so the icon stops and starts AgentHub exactly as the
* terminal does) and Hide this icon (the same off flag as Settings → System).
* One per data dir: `<data dir>/runtime/tray.json`, touched as a heartbeat.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const path = require("path");
const http = require("http");
const { spawn } = require("child_process");
const { dashboardUrl } = require("../server/lib/dashboard-url");
const { getDataDir } = require("../server/lib/claude-home");
const tray = require("../server/lib/tray");
const ROOT = path.resolve(__dirname, "..");
function loadSysTray() {
try {
return require("systray2").default;
} catch {
// A plugin install keeps its dependencies in the runtime dir; the CLI
// launcher does not put that on NODE_PATH the way the server's unit does.
return require(path.join(getDataDir(), "runtime", "node_modules", "systray2")).default;
}
}
/** npm drops the executable bit on systray2's bundled helper binaries. */
function chmodHelper() {
if (process.platform === "win32") return;
const name = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
for (const id of ["systray2", path.join(getDataDir(), "runtime", "node_modules", "systray2")]) {
try {
fs.chmodSync(path.join(path.dirname(require.resolve(id)), "traybin", name), 0o755);
return;
} catch {
/* try the next location */
}
}
}
function isUp(base) {
return new Promise((resolve) => {
const req = http.get(`${base}/api/health`, { timeout: 2000 }, (res) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on("timeout", () => req.destroy());
req.on("error", () => resolve(false));
});
}
function openUrl(url) {
const [cmd, args] =
process.platform === "darwin"
? ["open", [url]]
: process.platform === "win32"
? ["cmd", ["/c", "start", "", url]]
: ["xdg-open", [url]];
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
}
function cli(command) {
spawn(process.execPath, [path.join(ROOT, "bin", "agenthub.js"), command], {
detached: true,
stdio: "ignore",
}).unref();
}
function main() {
const other = tray.liveTray();
if (other && other.pid !== process.pid) {
console.log(`AgentHub's icon is already running (pid ${other.pid})`);
return;
}
tray.writePid(process.pid, ROOT);
chmodHelper();
const SysTray = loadSysTray();
const item = (title, extra = {}) => ({ title, tooltip: "", enabled: true, ...extra });
const status = item("AgentHub", { enabled: false });
const open = item("Open AgentHub");
// Quit closes every terminal session, so it takes a second click (the web
// UI asks in a dialog; a tray menu has none).
const quitYes = item("Yes, quit — closes every terminal session");
const quit = item("Quit AgentHub", { items: [quitYes] });
const start = item("Start AgentHub", { hidden: true });
const hide = item("Hide this icon");
const systray = new SysTray({
menu: {
icon: fs.readFileSync(path.join(ROOT, "client", "public", "favicon.ico")).toString("base64"),
isTemplateIcon: false,
title: "",
tooltip: "AgentHub",
items: [status, open, quit, start, hide],
},
});
let up = null;
// After a Quit/Start click, "Stopping…"/"Starting…" stays until the server
// reaches the asked-for state (or 30 s pass) — not undone by the next poll.
let want = null;
let waitUntil = 0;
let base = dashboardUrl();
// systray2 finds an item by the id it stamped on OUR object — so update in place.
const show = (it, changes) => {
Object.assign(it, changes);
systray.sendAction({ type: "update-item", item: it });
};
const host = () => base.replace(/^https?:\/\//, "");
const poll = async () => {
try {
const now = new Date();
fs.utimesSync(tray.pidPath(), now, now);
} catch {
tray.writePid(process.pid, ROOT); // the file went missing: claim it again
}
base = dashboardUrl();
const next = await isUp(base);
if (Date.now() < waitUntil && next !== want) return;
waitUntil = 0;
if (next === up) return;
up = next;
show(status, { title: up ? `AgentHub — running (${host()})` : "AgentHub — stopped" });
show(open, { enabled: up });
show(quit, { hidden: !up });
show(start, { hidden: up, enabled: true, title: "Start AgentHub" });
};
const exit = () => {
try {
if (tray.liveTray()?.pid === process.pid) fs.rmSync(tray.pidPath(), { force: true });
} catch {
/* best effort */
}
systray.kill(true);
setTimeout(() => process.exit(0), 2000); // a helper that never started cannot be killed
};
process.on("SIGTERM", exit);
process.on("SIGINT", exit);
systray.onClick(({ item: clicked }) => {
if (clicked.__id === open.__id) openUrl(base);
else if (clicked.__id === quitYes.__id) {
cli("stop");
show(quit, { hidden: true });
show(start, { hidden: false, enabled: false, title: "Stopping…" });
[up, want, waitUntil] = [null, false, Date.now() + 30_000];
} else if (clicked.__id === start.__id) {
cli("start");
show(start, { enabled: false, title: "Starting…" });
[up, want, waitUntil] = [null, true, Date.now() + 30_000];
} else if (clicked.__id === hide.__id) {
tray.setEnabled(false); // writes the off flag and signals this process
}
});
systray
.ready()
.then(() => {
systray.onExit(() => process.exit(0)); // the helper exists only once ready
poll();
setInterval(poll, tray.HEARTBEAT_MS);
})
.catch((err) => {
console.error(`AgentHub icon could not start: ${err?.message || err}`);
exit();
});
}
if (require.main === module) main();
module.exports = { main };