#!/usr/bin/env node /** * @file plugin-bootstrap.js * @description Makes AgentHub usable straight from a Claude Code plugin install. * Runs from the plugin's `SessionStart` hook and must NEVER block a session: * the foreground pass only checks a fast path, then hands the real work to a * detached worker copy of itself. * * The worker installs runtime dependencies, removes hook entries left behind by * an older `npm run install-hooks` (they would double-count every event), puts * the `agenthub` CLI on PATH, and starts the dashboard server — as a systemd * `--user` service when available (see `systemd-service.js`), so it survives * reboots without Claude Code being opened first; otherwise a detached spawn * scoped to this session, same as before. * * Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in * the plugin cache, which Claude Code replaces wholesale on every plugin * update. The one thing this file does reach into the cache for is deleting * the copies those updates leave behind (`pruneRetiredPluginRoots`). * * A bootstrap that is no longer the installed build does nothing at all * (`supersededBy`): an old cache directory, still loaded by a Claude Code * process that has not been restarted, would otherwise repoint systemd at * itself and downgrade the dashboard once per session, forever. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const os = require("os"); const path = require("path"); const crypto = require("crypto"); const { spawn, spawnSync } = require("child_process"); const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home"); const { getServerInfoPath } = require("../server/lib/server-info"); const { isOurEntry } = require("./install-hooks"); const PLUGIN_ROOT = path.resolve(__dirname, ".."); // node:sqlite — the only SQLite driver a `--omit=dev --ignore-scripts` install // leaves us with, since better-sqlite3 is not a runtime dependency and its // native addon would need a build step. const MIN_NODE = [22, 5, 0]; const LOCK_STALE_MS = 10 * 60 * 1000; function runtimeDir() { return path.join(getDataDir(), "runtime"); } function pluginVersion(root = PLUGIN_ROOT) { try { return require(path.join(root, "package.json")).version; } catch { return "unknown"; } } /* -------------------------------------------------- superseded plugin build */ /** Claude Code's own record of which build of each plugin is installed. */ function installedPluginsPath() { return path.join(getClaudeHome(), "plugins", "installed_plugins.json"); } /** * The build Claude Code has installed IN PLACE OF this one, or null when this * build is the installed one (or when there is no such record to read). * * `claude plugin update` unpacks the new version into a new cache directory and * says "Restart to apply changes" — but a Claude Code process that was already * running keeps loading the OLD directory, hooks and all. Every session it * opens from then on runs the OLD bootstrap, which happily repoints the systemd * unit at itself and restarts the dashboard onto the version the user just * upgraded away from. The dashboard then reports the old version and offers the * update again — forever, once per session, until Claude Code is restarted. * * So a bootstrap that is no longer the installed build does NOTHING. It cannot * hand over either: the process it would have to replace is the one loading it. * * Matched by SIBLING directory rather than by `@` key: the * builds of one plugin are exactly the directories beside this one, and that * holds without parsing a cache path or trusting a name. An entry naming a * directory that no longer exists is ignored — a phantom must never wedge a * bootstrap shut. */ function supersededBy(root = PLUGIN_ROOT) { let data; try { data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8")); } catch { return null; // no record, unreadable, or not a plugin install at all } const siblings = path.dirname(root); const found = []; for (const entries of Object.values((data && data.plugins) || {})) { for (const entry of entries || []) { const installed = entry && typeof entry.installPath === "string" ? path.resolve(entry.installPath) : null; if (!installed) continue; // This build IS installed (possibly at one scope among several) — the // whole question is settled, whatever any other entry says. if (installed === root) return null; if (path.dirname(installed) === siblings && fs.existsSync(installed)) found.push(installed); } } return found[0] || null; } /* ------------------------------------------------------------------ state */ function statePath(rt = runtimeDir()) { return path.join(rt, "state.json"); } function readState(rt = runtimeDir()) { try { return JSON.parse(fs.readFileSync(statePath(rt), "utf8")); } catch { return null; } } function writeState(state, rt = runtimeDir()) { fs.mkdirSync(rt, { recursive: true }); fs.writeFileSync(statePath(rt), JSON.stringify(state, null, 2) + "\n", "utf8"); } /** A dashboard server that is actually listening (the discovery file is PID-checked). */ function serverIsLive() { // NOT resolveAllDashboardPorts(): it falls back to [DEFAULT_PORT] when the // discovery file has no live entry (a reasonable guess for the CLI/hook // handler), which would make bootstrap believe a server is already running // when none is. livePids() only counts entries with a PID that is actually // alive. return livePids().length > 0; } /** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */ function depsHash(root = PLUGIN_ROOT) { const h = crypto.createHash("sha256"); for (const f of ["package.json", "package-lock.json"]) { try { h.update(fs.readFileSync(path.join(root, f))); } catch { h.update(f); // absent counts as its own state } } return h.digest("hex").slice(0, 16); } /** * Nothing to do when the recorded state matches this plugin build AND a server * is already up. Deliberately cheap: this runs on every single SessionStart. */ function fastPathOk(rt = runtimeDir()) { const state = readState(rt); if (!state) return false; if (state.pluginVersion !== pluginVersion()) return false; // A plugin update lands in a NEW cache directory; the server still running // from the old one has to be replaced, so this is not a fast path. if (state.pluginRoot !== PLUGIN_ROOT) return false; if (state.depsHash !== depsHash()) return false; if (!fs.existsSync(path.join(rt, "node_modules"))) return false; // A machine can be fully set up and still be missing the plugins the // pipelines drive. Reading one recorded flag keeps this hot path cheap — the // actual check (manifest + every user SKILL.md) runs once, in the worker. if (!state.skillPluginsSettled) return false; // A live server says nothing about whether it will come back. When our unit // still names a previous plugin cache directory, everything looks healthy // until the next reboot — and then the dashboard is simply gone, from a path // Claude Code deleted long ago. Reading one file is cheap enough for a hot // path; `unitIsStale` deliberately spawns nothing. if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false; // Leftover checkout hooks are the one defect that corrupts the DATA rather // than the install: events carry no id, so a second handler doubles every // event, every subagent row and every turn. `stripLegacyHooks` removes them — // but it runs inside `bootstrap()`, which this fast path skips, so on a // machine that is otherwise healthy they survived every session forever. // Reading one JSON file is cheap enough here; the strip still happens in the // worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one // Task spawning two subagent rows.) if (hasLegacyHooks()) return false; return serverIsLive(); } /* ------------------------------------------------------- node version gate */ function nodeVersionOk(version = process.versions.node) { const parts = String(version) .split(".") .map((n) => parseInt(n, 10) || 0); for (let i = 0; i < MIN_NODE.length; i++) { if (parts[i] > MIN_NODE[i]) return true; if (parts[i] < MIN_NODE[i]) return false; } return true; } /* -------------------------------------------------------------------- lock */ function lockPath(rt = runtimeDir()) { return path.join(rt, ".bootstrap.lock"); } function isPidAlive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (err) { return err.code === "EPERM"; } } function lockIsStale(dir, now = Date.now()) { let stat; try { stat = fs.statSync(dir); } catch { return false; // gone already } if (now - stat.mtimeMs > LOCK_STALE_MS) return true; let pid = 0; try { pid = parseInt(fs.readFileSync(path.join(dir, "pid"), "utf8").trim(), 10); } catch { return true; // lock dir without a readable pid is debris } return !isPidAlive(pid); } /** * Atomic `mkdir` lock so two sessions starting at once cannot race the install * or spawn two servers (the second would die on EADDRINUSE). A lock whose owner * is dead, or older than LOCK_STALE_MS, is reclaimed. * * @returns {boolean} true when this process owns the lock */ function acquireLock(rt = runtimeDir()) { const dir = lockPath(rt); fs.mkdirSync(rt, { recursive: true }); for (let attempt = 0; attempt < 2; attempt++) { try { fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, "pid"), String(process.pid), "utf8"); return true; } catch (err) { if (err.code !== "EEXIST") return false; if (!lockIsStale(dir)) return false; try { fs.rmSync(dir, { recursive: true, force: true }); } catch { return false; } } } return false; } function releaseLock(rt = runtimeDir()) { try { fs.rmSync(lockPath(rt), { recursive: true, force: true }); } catch { /* best effort */ } } /* --------------------------------------------------------- legacy cleanup */ /** * `claude plugin install` materializes the plugin's inline hooks into * `~/.claude/settings.json` itself, with `${CLAUDE_PLUGIN_ROOT}` already * resolved to the cache path — confirmed by installing this plugin for real * and inspecting the file. Those entries also contain `hook-handler.js`, so * `isOurEntry()` alone (checkout vs. plugin, both match the same substring) * cannot tell a legitimate plugin-installed hook from a leftover * `npm run install-hooks` entry. Only entries whose command does NOT resolve * under the plugin cache are the legacy, checkout-installed kind. * * @returns {(entry: object) => boolean} */ function isCheckoutHookEntry(claudeHome = getClaudeHome()) { const cacheRoot = path.join(claudeHome, "plugins", "cache"); return (entry) => { if (!isOurEntry(entry)) return false; const commands = [ entry.command, ...(Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []), ]; return !commands.some((c) => typeof c === "string" && c.includes(cacheRoot)); }; } /** * Remove hook entries a previous `npm run install-hooks` wrote into * ~/.claude/settings.json. The plugin installs its own hooks, and events carry * no id — two handlers mean every token and cost figure is counted twice. * Never touches the plugin's own cache-resolved entries (see * `isCheckoutHookEntry`) — only genuine leftover checkout paths. The original * file is copied aside before any write. * * @returns {number} how many entries were removed */ /** * Does `~/.claude/settings.json` still carry hook entries from a checkout * install? Pure fs, no writes — the fast path calls it on every SessionStart. * * @returns {boolean} */ function hasLegacyHooks(settingsPath = getSettingsPath()) { let settings; try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { return false; } if (!settings.hooks || typeof settings.hooks !== "object") return false; const isLegacy = isCheckoutHookEntry(); return Object.values(settings.hooks).some((e) => Array.isArray(e) && e.some(isLegacy)); } function stripLegacyHooks(settingsPath = getSettingsPath()) { let settings; try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { return 0; // no settings file, or not ours to touch } if (!settings.hooks || typeof settings.hooks !== "object") return 0; const isLegacy = isCheckoutHookEntry(); let removed = 0; for (const [type, entries] of Object.entries(settings.hooks)) { if (!Array.isArray(entries)) continue; const kept = entries.filter((e) => !isLegacy(e)); removed += entries.length - kept.length; if (kept.length) settings.hooks[type] = kept; else delete settings.hooks[type]; } if (!removed) return 0; fs.copyFileSync(settingsPath, `${settingsPath}.agenthub-bak`); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8"); return removed; } /* ------------------------------------------------------------ CLI on PATH */ function cliDir() { return path.join(os.homedir(), ".local", "bin"); } /** * Write a launcher for `agenthub` into ~/.local/bin. A launcher rather than a * symlink into the plugin cache: that directory is replaced on every plugin * update, which would leave a dangling link until the next session. * * Never overwrites a `agenthub` this bootstrap did not write — a developer's * `npm link`ed checkout CLI must keep winning. * * @returns {"written"|"exists"|"foreign"|"failed"} */ function linkCli(dir = cliDir(), root = PLUGIN_ROOT) { const marker = "# agenthub-plugin-launcher"; const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub"); const body = process.platform === "win32" ? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n` : `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`; try { if (fs.existsSync(target)) { const current = fs.readFileSync(target, "utf8"); if (!current.includes("agenthub-plugin-launcher")) return "foreign"; if (current === body) return "exists"; } fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(target, body, "utf8"); fs.chmodSync(target, 0o755); return "written"; } catch { return "failed"; } } /* ------------------------------------------------------------- dependencies */ /** * Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps * the root postinstall from pulling the whole Vite client toolchain; the * manifests are copied out of the (read-only) plugin cache so npm has a project * to install for. */ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) { fs.mkdirSync(rt, { recursive: true }); for (const f of ["package.json", "package-lock.json"]) { const src = path.join(root, f); if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f)); } const npm = process.platform === "win32" ? "npm.cmd" : "npm"; const res = spawnSync( npm, ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: rt, stdio: "inherit", shell: process.platform === "win32" } ); return res.status === 0; } /* ------------------------------------------------------------------ server */ /** * Discovery-file entries whose PID is actually alive right now. Reads the file * directly rather than server-info's `resolveAllDashboardPorts()`, which falls * back to `[DEFAULT_PORT]` when nothing is live — a reasonable guess for the * CLI/hook handler, but wrong here: it would make the bootstrap believe a * server is already running when none is. * * @returns {{port:number, pid:number}[]} */ function liveServers() { try { const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8")); const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed]; return servers.filter((s) => s && isPidAlive(s.pid)); } catch { return []; } } function livePids() { return liveServers().map((s) => s.pid); } /** * Stop running dashboard servers. Needed after a plugin update: the old server * runs from a cache directory Claude Code has already replaced, so it must be * retired before the new one can take the port. * * @returns {number} how many processes were signalled */ function stopDashboard() { let stopped = 0; for (const pid of livePids()) { try { process.kill(pid, "SIGTERM"); stopped++; } catch { /* already gone, or not ours to signal */ } } return stopped; } /** * Block until no dashboard PID is alive, so the replacement server does not * race the old one for the port. Bounded — the worker is detached, but it must * not hang forever if a process refuses to die. */ function waitForPortsFree(timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; const sleeper = new Int32Array(new SharedArrayBuffer(4)); while (livePids().length && Date.now() < deadline) { Atomics.wait(sleeper, 0, 0, 200); } return livePids().length === 0; } function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) { const logFile = fs.openSync(path.join(rt, "server.log"), "a"); const child = spawn(process.execPath, [path.join(root, "server", "index.js")], { cwd: root, detached: true, stdio: ["ignore", logFile, logFile], env: { ...process.env, NODE_PATH: path.join(rt, "node_modules"), // The plugin cache is read-only; /agenthub-open builds the bundle here. DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"), }, }); child.unref(); return child.pid; } /* --------------------------------------------------------------- worker run */ function log(rt, line) { const stamped = `[${new Date().toISOString()}] ${line}\n`; try { fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped); } catch { /* best effort */ } } /** * Install the plugins AgentHub's pipelines drive but this machine cannot resolve — * today `superpowers`, which `/agenthub:ship-feature(-lane)` calls step by step. A * fresh `agenthub` install otherwise runs those skills partway and fails at the * first call with no warning beforehand. * * Off by setting `AGENTHUB_AUTO_INSTALL_PLUGINS` to `0`, `false` or `off`; that * leaves `/agenthub-doctor`'s WARN row as the only notice, which is the right * behaviour for anyone who wants to choose their own plugins. * * One-shot: the outcome is recorded in the runtime state and the fast path * takes it into account, so a machine with no network does not re-attempt a * clone on every session. `/agenthub-update` (`--force`) retries. * * Best-effort throughout. Nothing here may throw or block: the dashboard, * hooks and CLI all work without these plugins. * * @returns {"off"|"nothing-missing"|"installed"|"failed"|"error"} or the previously * recorded outcome when this run had nothing to settle. */ function autoInstallSkillPlugins(rt, { force = false } = {}) { const flag = process.env.AGENTHUB_AUTO_INSTALL_PLUGINS; if (flag === "0" || flag === "false" || flag === "off") return "off"; // Return the PREVIOUS outcome, not a marker: writeState records whatever this // returns, and overwriting "installed" with "already-settled" would lose the // only record of what actually happened. const settled = readState(rt)?.skillPluginsSettled; if (!force && settled) return settled; try { const { autoInstallSkillPlugins: install } = require("../server/lib/skill-plugins"); const summary = install((line) => log(rt, line)); if (!summary.attempted.length) return "nothing-missing"; return summary.failed.length ? "failed" : "installed"; } catch (err) { log(rt, `skill-plugin auto-install skipped: ${err.message}`); return "error"; } } /* ------------------------------------------------------ plugin-cache pruning */ /** How many retired builds to leave behind, newest first, as a rollback cushion. */ const KEEP_RETIRED_ROOTS = 1; /** * Delete the cache directories earlier updates left behind. * * Claude Code unpacks every plugin update into a NEW directory beside the old * one and does not reliably remove the old. A machine that tracks releases * closely ends up with a dozen full copies of this repo — node_modules and all * — which is what this clears. * * This is the one place anything here touches the plugin cache, and the file * header's "never write into the cache" rule is about the RUNTIME living * outside it, not about leaving retired copies forever. Everything still in use * is protected, by four independent readings of "in use": * * - this build; * - every build Claude Code records as installed, at any scope; * - the directory our systemd unit runs from, foreign unit included; * - any directory still named in the user's `settings.json` hooks — a Claude * Code process that has not been restarted is still executing from there, * and deleting it would break that session's hooks mid-flight. (This is * exactly the directory `supersededBy` describes, seen from the other side.) * * …plus the newest `KEEP_RETIRED_ROOTS` of whatever is left, so a rollback has * something to roll back to. Best effort throughout: a directory that will not * delete is left alone, never an error. * * @returns {number} how many directories were removed */ function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) { // Only inside Claude Code's plugin cache. For a git checkout the parent // directory holds the user's own work and is never ours to delete from. const cacheMarker = `${path.sep}plugins${path.sep}cache${path.sep}`; if (!root.includes(cacheMarker)) return 0; const cacheDir = path.dirname(root); const keep = new Set([root]); try { const data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8")); for (const entries of Object.values((data && data.plugins) || {})) { for (const entry of entries || []) { if (entry && typeof entry.installPath === "string") keep.add(path.resolve(entry.installPath)); } } } catch { /* no record to read — the other three protections still apply */ } try { const unitRoot = require("./systemd-service").unitRoot("agenthub"); if (unitRoot) keep.add(path.resolve(unitRoot)); } catch { /* best effort */ } let settings = ""; try { settings = fs.readFileSync(getSettingsPath(), "utf8"); } catch { /* no settings file — nothing pinned by a hook */ } let dirs; try { dirs = fs.readdirSync(cacheDir, { withFileTypes: true }); } catch { return 0; } const retired = dirs .filter((d) => d.isDirectory()) .map((d) => path.join(cacheDir, d.name)) .filter((dir) => !keep.has(dir) && !settings.includes(dir)) .map((dir) => { let at = 0; try { at = fs.statSync(dir).mtimeMs; } catch { /* unreadable sorts oldest, so it goes first */ } return { dir, at }; }) .sort((a, b) => b.at - a.at) .slice(KEEP_RETIRED_ROOTS); let removed = 0; for (const { dir } of retired) { try { fs.rmSync(dir, { recursive: true, force: true }); removed += 1; } catch { /* in use, read-only, whatever — leaving it is always safe */ } } if (removed) { log(rt, `removed ${removed} retired plugin cache ${removed === 1 ? "copy" : "copies"}`); } return removed; } /** * The real work. Runs detached from the SessionStart hook, so its only channel * is the log file; `/agenthub-update` runs it in the foreground with `force`, which * reinstalls dependencies and restarts the server unconditionally. * * @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"} */ function bootstrap(rt = runtimeDir(), { force = false } = {}) { fs.mkdirSync(rt, { recursive: true }); // First, and ahead of the lock: a retired build has nothing to do and must // not take the lock to discover it. `force` does NOT override this — the // plugin-update chain runs `--force` from the NEW cache directory, which is // never superseded, so the only `--force` this can refuse is one that would // downgrade the user on purpose. const newer = supersededBy(); if (newer) { log( rt, `this build (${pluginVersion()}) has been replaced by ${pluginVersion(newer)} — doing nothing. ` + `Restart Claude Code so it loads the new one; until then this session keeps running the old plugin.` ); return "superseded"; } if (!nodeVersionOk()) { log( rt, `Node ${process.versions.node} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` + `(the server stores data through node:sqlite). Upgrade Node, then start a new session.` ); return "node-too-old"; } if (!acquireLock(rt)) { log(rt, "another bootstrap holds the lock — nothing to do"); return "locked"; } // Declared out here because the restart it guards has to happen in the // `finally`, after the lock is released and the state is on disk: it is the // step that may cost this process its own life. let pendingSystemdRestart = false; try { const wantDeps = depsHash(); const state = readState(rt) || {}; if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) { log(rt, "installing runtime dependencies (first run takes a few minutes)"); if (!installDeps(rt)) { log(rt, "npm install failed — see the output above; run /agenthub-doctor after fixing it"); return "install-failed"; } } const removed = stripLegacyHooks(); if (removed) { log( rt, `removed ${removed} hook entr${removed === 1 ? "y" : "ies"} left by npm run install-hooks ` + `(backup: ${getSettingsPath()}.agenthub-bak) — the plugin installs its own` ); } log(rt, `agenthub CLI launcher: ${linkCli()}`); // Built eagerly (not lazily behind /agenthub-open) so the dashboard, including // client-only routes like /run, works the instant `claude` is started — // same trigger as the dependency install above: missing, or this plugin // version has not built one yet. const { buildClient } = require("./plugin-open"); const uiResult = buildClient({ rt, root: PLUGIN_ROOT, force: force || state.depsHash !== wantDeps, logPath: path.join(rt, "client-build.log"), }); if (uiResult === "failed") { log(rt, "dashboard UI build failed — the API still works; run /agenthub-open to retry"); } else if (uiResult === "built") { log(rt, "built the dashboard UI bundle"); } const svc = require("./systemd-service"); // Who owns the running server decides how it may be retired. A unit of // ours means systemd owns it — and this process is a descendant of that // server, so it sits in the unit's cgroup. SIGTERM-ing the main process // there does two fatal things at once: the clean exit(0) tells systemd // "meant to stop", and `KillMode=control-group` reaps this bootstrap // before it can write state or point the unit at the new cache dir. That // is the bug that left a dashboard dead after every self-update until the // next Claude Code session happened to bootstrap again. // ACTIVE matters, not merely present: a unit of ours that is installed but // stopped owns nothing right now. If a detached server is holding the port // in that state, treating systemd as the owner would skip the stop and then // ask systemd to start a second server onto a bound port — a crash loop, // courtesy of Restart=always. const systemdOwned = svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub"); // After a plugin update the running server executes code from a cache // directory Claude Code has already discarded — retire it first. Only // when nothing else owns its lifecycle; otherwise systemd retires it, in // one restart job, once everything below is durable. const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT); if (movedRoot && !systemdOwned && stopDashboard()) { log(rt, "stopped the server started from the previous plugin version"); waitForPortsFree(); } // Two different needs, deliberately not collapsed. `needsServer` is "the // running process must be replaced"; `unitStale` is "the unit names a cache // directory that is no longer this one", which is invisible until a reboot // and must be repaired even while a perfectly healthy server is up. const needsServer = movedRoot || !serverIsLive(); const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT); if (needsServer || unitStale) { const unit = { name: "agenthub", description: "AgentHub dashboard server (Claude Code plugin)", workingDir: PLUGIN_ROOT, execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`, env: { NODE_ENV: "production", NODE_PATH: path.join(rt, "node_modules"), DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"), // systemd --user units get systemd's own minimal PATH, not the // shell's — forward it so the server can find `claude`/`codex`. PATH: process.env.PATH || "", }, }; // Two shapes of the same step. When systemd already owns the server the // unit is only REWRITTEN here — pointing at the new plugin root — and // the restart is deferred to `pendingSystemdRestart` below, because // starting it now would kill this process mid-bootstrap. Otherwise // there is no cgroup to be reaped by, and install-and-start is fine. const result = systemdOwned ? svc.writeUserService(unit) : svc.systemctlAvailable() ? svc.installUserService(unit) : "unavailable"; if (result === "written" || result === "unchanged") { if (result === "written") svc.enableLinger(); // Only when the RUNNING process has to change. A stale unit beside a // healthy server is now corrected on disk and left to take effect at // the next start — restarting a working dashboard to fix a file that // only matters at boot would be churn the user feels for no reason. pendingSystemdRestart = systemdOwned && needsServer; log( rt, result === "unchanged" ? "systemd --user service already up to date" : unitStale && !needsServer ? "repointed the systemd --user service at the current plugin version (it named a retired one)" : "installed a systemd --user service — the dashboard now survives reboots without Claude Code" ); } else if (needsServer) { // No systemd (macOS/Windows/WSL without it), or the unit exists but // wasn't ours to touch — same detached-spawn fallback as before, // scoped to this session only. Guarded by `needsServer`: entering this // block for a stale unit alone must never spawn a second server onto // the port the live one already holds. const pid = startDashboard(rt); log( rt, `started the dashboard server (pid ${pid})${result === "foreign" ? " — a foreign systemd unit named 'agenthub' already exists" : ""}` ); } } // Last, and deliberately so: it clones from the network, and nothing above // may wait on it. Runs here rather than in the foreground pass because this // whole function is already detached from the 10 s SessionStart hook. const skillPlugins = autoInstallSkillPlugins(rt, { force }); writeState( { pluginVersion: pluginVersion(), depsHash: wantDeps, pluginRoot: PLUGIN_ROOT, claudeHome: getClaudeHome(), // Settles the fast path: whatever the outcome, the next SessionStart // must not spawn a worker just to retry a network clone. Cleared by // `/agenthub-update` (force), which is the retry. skillPluginsSettled: skillPlugins, updatedAt: new Date().toISOString(), }, rt ); // After the state is durable, so a prune that goes wrong cannot also cost // us the record of a bootstrap that went right. pruneRetiredPluginRoots(rt); return "ok"; } finally { releaseLock(rt); // LAST, and after the lock: systemd stops the old main process to start // the new one, and this process shares its cgroup, so it may not outlive // this call. Everything that had to be durable already is — the state // file is written and the lock is gone, so even being killed here leaves // no debris and no half-recorded bootstrap. if (pendingSystemdRestart) { log(rt, "handing the server over to systemd (restarting the unit)"); require("./systemd-service").restartUserService("agenthub"); } } } /** * Foreground pass, invoked by the SessionStart hook. Returns within * milliseconds in the steady state, and otherwise hands off to a detached * worker so the session never waits on an install. */ function main() { if (fastPathOk()) return; const child = spawn(process.execPath, [__filename, "--worker"], { detached: true, stdio: "ignore", }); child.unref(); } if (require.main === module) { const force = process.argv.includes("--force"); if (force || process.argv.includes("--worker")) { // `--force` (from /agenthub-update) runs in the foreground so the user sees the // npm output and the result. const result = bootstrap(runtimeDir(), { force }); if (force) { console.log( result === "ok" ? "AgentHub runtime refreshed." : result === "superseded" ? "A newer AgentHub plugin build is already installed — this one did nothing on purpose.\n" + "Restart Claude Code so it loads the new build, then try again." : `bootstrap: ${result}` ); if (result !== "ok") process.exitCode = 1; } } else { try { main(); } catch { // A bootstrap failure must never break a Claude Code session. } } } module.exports = { MIN_NODE, LOCK_STALE_MS, KEEP_RETIRED_ROOTS, supersededBy, pruneRetiredPluginRoots, installedPluginsPath, pluginVersion, runtimeDir, statePath, readState, writeState, depsHash, fastPathOk, nodeVersionOk, lockPath, lockIsStale, acquireLock, releaseLock, isCheckoutHookEntry, stripLegacyHooks, hasLegacyHooks, linkCli, installDeps, serverIsLive, liveServers, livePids, stopDashboard, startDashboard, autoInstallSkillPlugins, bootstrap, };