#!/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 — through the * systemd `--user` service or macOS LaunchAgent (see `service-manager.js`) * only while the user has turned start-at-login on (Settings → About, OFF by * default), so it survives reboots without Claude Code being opened first; * otherwise a detached spawn scoped to this session. * * 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 { rotateIfLarge } = require("./log-rotate"); 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 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("./service-manager").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, nodePath = process.execPath) { 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` : // The pinned node is a versioned path (Homebrew Cellar, nvm) that an // upgrade deletes; fall back to whatever `node` PATH has then. `#!/bin/sh\n${marker}\nN="${nodePath}"; [ -x "$N" ] || N=node\nexec "$N" "${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 */ /** * npm, run by the node executing this file: `{ file, args, env, shell }` for * `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so * nothing depends on PATH — the self-update chain runs with the dashboard * server's environment, and on Windows that PATH need not hold the directory * of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs. * PATH is still prefixed with that node's directory so npm's scripts (the UI * build's `tsc`/`vite` shims) find the same node. A node with no bundled npm * (a distro's split nodejs/npm packages) falls back to `npm` on PATH. */ function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) { const dir = path.dirname(execPath); const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH"; const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) }; const cli = [ path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout ].find((p) => fs.existsSync(p)); if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false }; const win = process.platform === "win32"; return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win }; } /** * 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 = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]); const res = spawnSync(npm.file, npm.args, { cwd: rt, stdio: "inherit", env: npm.env, shell: npm.shell, }); 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]; // An entry older than this boot is a leftover whose pid may now belong to // an unrelated process (macOS has no systemd to clean up after a reboot). // 60 s margin for clock skew; an unparseable startedAt is kept. // ponytail: an NTP step larger than 60 s after boot misreads a live entry // as stale; compare against a monotonic boot id if that ever bites. const bootedAt = Date.now() - os.uptime() * 1000 - 60_000; return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt)); } 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) { rotateIfLarge(path.join(rt, "server.log")); 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 { rotateIfLarge(path.join(rt, "bootstrap.log")); fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped); } catch { /* best effort */ } } /* ------------------------------------------------------ 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("./service-manager").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("./service-manager"); // 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")}`, argv: [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. // Start-at-login is OPT-IN (Settings → About, `/api/settings/autostart`): // the unit is installed or started only while the user has it enabled, // and its enabled state is never changed here. const autostart = svc.unitIsEnabled("agenthub"); let result = "off"; if (systemdOwned) result = svc.writeUserService({ ...unit, enable: autostart }); else if (autostart) result = needsServer ? svc.installUserService(unit) : svc.writeUserService(unit); else if (unitStale) svc.writeUserService({ ...unit, enable: false }); if (result === "written" || result === "unchanged") { if (result === "written" && autostart) 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" ? `${svc.label} already up to date` : unitStale && !needsServer ? `repointed the ${svc.label} at the current plugin version (it named a retired one)` : `installed a ${svc.label} — the dashboard now survives reboots without Claude Code` ); } else if (needsServer) { // No service manager (Windows, WSL without systemd, a Mac over SSH with // no GUI login), 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 ${svc.label} named 'agenthub' already exists` : ""}` ); } } writeState( { pluginVersion: pluginVersion(), depsHash: wantDeps, pluginRoot: PLUGIN_ROOT, claudeHome: getClaudeHome(), 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) { const svc = require("./service-manager"); log(rt, `handing the server over to ${svc.kind} (restarting the unit)`); // On launchd the restart is bootout + bootstrap, and a failed bootstrap // leaves NOTHING running (systemd's restart either happens or leaves the // old server up). This process survives it there, so fall back. if (svc.restartUserService("agenthub") === "failed" && !serverIsLive()) { log( rt, `${svc.kind} could not start the server — started it detached (pid ${startDashboard(rt)})` ); } } } } /** * 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, npmCommand, installDeps, serverIsLive, liveServers, livePids, stopDashboard, startDashboard, bootstrap, };