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:
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
Reference in New Issue
Block a user