a9c71cd905
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.
151 lines
4.4 KiB
JavaScript
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,
|
|
};
|