#!/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: `/runtime/tray.json`, touched as a heartbeat. * @author Nguyễn Ngọc Trí Vĩ */ 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 };