Files
AgentHub/scripts/plugin-bootstrap.js
T
nntrivi2001 c90dd7b147 fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
  instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
  path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
  server keeps running and the corrected unit takes effect at the next
  start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
  detached-spawn fallback is guarded by needsServer so a stale unit alone
  cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
  stopped owns nothing: skipping the stop there and asking systemd to start
  would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
  come back after a reboot") and the fix. WARN, never FAIL — the dashboard
  in front of you works, and having no unit at all is a normal setup.

The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
2026-09-11 16:45:47 +07:00

698 lines
26 KiB
JavaScript

#!/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 garbage-collects and replaces wholesale
* on every plugin update.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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() {
try {
return require(path.join(PLUGIN_ROOT, "package.json")).version;
} catch {
return "unknown";
}
}
/* ------------------------------------------------------------------ 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;
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
*/
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";
}
}
/**
* 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"}
*/
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
fs.mkdirSync(rt, { recursive: true });
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
);
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." : `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,
runtimeDir,
statePath,
readState,
writeState,
depsHash,
fastPathOk,
nodeVersionOk,
lockPath,
lockIsStale,
acquireLock,
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
linkCli,
installDeps,
serverIsLive,
liveServers,
livePids,
stopDashboard,
startDashboard,
autoInstallSkillPlugins,
bootstrap,
};