Files
nntrivi2001 a9c71cd905 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.
2026-10-08 12:22:14 +07:00

181 lines
6.1 KiB
JavaScript

#!/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 };