Files
AgentHub/scripts/systemd-service.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

269 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
/**
* @file systemd-service.js
* @description Shared helper for installing/removing a systemd `--user`
* service that keeps the AgentHub dashboard server running across reboots,
* without needing Claude Code (or any other tool) to be started first.
* Used by both `agenthub service install` (checkout installs) and
* `plugin-bootstrap.js` (plugin installs) so the two paths stay in sync.
* Linux/systemd only — callers fall back to their existing manual-start
* path when `systemctlAvailable()` is false.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const MARKER = "# agenthub-managed-unit — regenerated on install, do not edit by hand";
function unitDir() {
return path.join(os.homedir(), ".config", "systemd", "user");
}
function unitPath(name) {
return path.join(unitDir(), `${name}.service`);
}
// Test seam: swap the systemctl implementation so unit tests never talk to a
// real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
let execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
function __setExecImpl(fn) {
execImpl = fn;
}
function __reset() {
execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
}
function systemctlAvailable() {
if (process.platform !== "linux") return false;
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
}
/**
* Is there a unit by this name, and did WE write it? The distinction decides
* whether the dashboard may be stopped by hand: a unit of ours means systemd
* owns the process, and SIGTERM-ing it out of band is the one thing that must
* never happen (see `restartUserService`).
*/
function unitIsOurs(name) {
try {
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
} catch {
return false;
}
}
/**
* `Restart=always`, not `on-failure`: a graceful SIGTERM exits 0, which
* `on-failure` reads as "meant to stop". One stray kill — or an out-of-band
* stop during a plugin self-update — then left the dashboard down until some
* later Claude Code session happened to run the bootstrap. An explicit
* `systemctl stop` (what uninstall does) still stops it for good.
*/
/**
* Does our unit still point at `root`? A plugin update moves the install to a
* new cache directory, and a unit left naming the old one is a failure that
* shows NOTHING until the next reboot — the dashboard simply does not come
* back, from a path Claude Code garbage-collected weeks earlier.
*
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
* runs on every single SessionStart and may not spawn a process.
*
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
*/
function unitIsStale(name, root) {
let body;
try {
body = fs.readFileSync(unitPath(name), "utf8");
} catch {
return false; // no unit at all: nothing to be stale
}
if (!body.includes(MARKER)) return false;
return !body.includes(`\nWorkingDirectory=${root}\n`);
}
/**
* The directory our unit actually runs from, or null when there is no unit.
*
* Deliberately does NOT require the marker: a FOREIGN unit named `agenthub` is
* not ours to rewrite, but whatever directory it runs from is still a directory
* nothing here may delete. The one caller is the plugin-cache pruner.
*/
function unitRoot(name) {
let body;
try {
body = fs.readFileSync(unitPath(name), "utf8");
} catch {
return null;
}
const match = body.match(/^WorkingDirectory=(.*)$/m);
return match ? match[1].trim() : null;
}
/** Is our unit enabled to start at boot/login — the Settings "start
* automatically" switch. Costs a `systemctl` call. */
function unitIsEnabled(name) {
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
const res = execImpl(["is-enabled", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "enabled";
}
/** Enable or disable our unit for boot — never starts or stops it, so the
* server answering the switch stays up either way. */
function setUnitEnabled(name, on) {
if (!systemctlAvailable()) return "unavailable";
if (!unitIsOurs(name)) return "foreign";
return systemctl([on ? "enable" : "disable", `${name}.service`]).status === 0 ? "ok" : "failed";
}
/** Is the unit currently running? Costs a `systemctl` call — never call it
* from the fast path. */
function unitIsActive(name) {
if (!systemctlAvailable()) return false;
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "active";
}
/** `KillMode=process`: a stop or an update's restart ends the server alone.
* tmux forks its server from whichever process first asks for a session —
* the dashboard itself, after a boot with autostart on — so it can sit in
* this unit's cgroup, and the default `control-group` would kill it and every
* lane pane with it. Panes close only on Quit (`server/lib/quit.js`), which
* kills them itself. The launchd twin is `AbandonProcessGroup`. */
function unitContent({ description, workingDir, execStart, env }) {
const envLines = Object.entries(env)
.map(([k, v]) => `Environment=${k}=${v}`)
.join("\n");
return `[Unit]
${MARKER}
Description=${description}
After=network.target
[Service]
Type=simple
WorkingDirectory=${workingDir}
ExecStart=${execStart}
Restart=always
RestartSec=3
KillMode=process
${envLines}
[Install]
WantedBy=default.target
`;
}
function systemctl(args) {
return execImpl(args, { stdio: "inherit" });
}
/**
* Writes (or refreshes) the unit file and enables it for boot (unless
* `enable: false`, which leaves the enabled state as it is) — but starts
* nothing. Split out of `installUserService` for the plugin self-update, which
* must be able to point the unit at the new cache directory *before* anything
* stops the running server, and defer the restart to its very last act.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function writeUserService({ name, description, workingDir, execStart, env = {}, enable = true }) {
if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ description, workingDir, execStart, env });
const target = unitPath(name);
try {
if (fs.existsSync(target)) {
const current = fs.readFileSync(target, "utf8");
if (!current.includes(MARKER)) return "foreign";
if (current === body) return "unchanged";
}
fs.mkdirSync(unitDir(), { recursive: true });
fs.writeFileSync(target, body, "utf8");
systemctl(["daemon-reload"]);
if (enable) systemctl(["enable", `${name}.service`]);
return "written";
} catch {
return "failed";
}
}
/**
* Ask systemd to restart the unit and do NOT wait for it.
*
* `--no-block` is the whole point, not a nicety. The caller is the bootstrap
* spawned by the running server, so it lives in that server's cgroup; the
* moment systemd stops the old main process, `KillMode=control-group` reaps
* the caller too. Blocking here means being killed mid-call, which is why the
* restart must be the last thing the bootstrap does — by then there is nothing
* left to lose.
*/
function restartUserService(name) {
if (!systemctlAvailable()) return "unavailable";
const res = systemctl(["restart", "--no-block", `${name}.service`]);
return res.status === 0 ? "restarted" : "failed";
}
/**
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
* cache path after an update) is reloaded and restarted.
*
* Safe for `agenthub service install`, which is invoked from a user's own
* shell and is therefore not inside the unit's cgroup. The plugin bootstrap
* is, and uses `writeUserService` + `restartUserService` instead.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function installUserService(opts) {
const result = writeUserService(opts);
if (result === "unchanged") {
systemctl(["enable", "--now", `${opts.name}.service`]);
} else if (result === "written") {
systemctl(["enable", "--now", `${opts.name}.service`]);
systemctl(["restart", `${opts.name}.service`]);
}
return result;
}
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
function uninstallUserService(name) {
if (!systemctlAvailable()) return "unavailable";
const target = unitPath(name);
if (!fs.existsSync(target)) return "absent";
if (!fs.readFileSync(target, "utf8").includes(MARKER)) return "foreign";
systemctl(["disable", "--now", `${name}.service`]);
fs.rmSync(target, { force: true });
systemctl(["daemon-reload"]);
return "removed";
}
/** Lets the user service start on cold boot, not just after login. Best
* effort — some setups require an admin to grant this, so a failure here
* is reported, never thrown. */
function enableLinger() {
const user = os.userInfo().username;
return spawnSync("loginctl", ["enable-linger", user], { stdio: "ignore" }).status === 0;
}
module.exports = {
kind: "systemd",
label: "systemd --user service",
systemctlAvailable,
unitIsOurs,
unitIsStale,
unitRoot,
unitIsActive,
unitIsEnabled,
setUnitEnabled,
writeUserService,
restartUserService,
installUserService,
uninstallUserService,
enableLinger,
unitPath,
__setExecImpl,
__reset,
};