Files
AgentHub/server/lib/tray.js
T
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

151 lines
4.4 KiB
JavaScript

/**
* @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,
};