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