29a5da3e4e
`claude plugin update` unpacks the new version into a new cache directory and says "Restart to apply changes". A Claude Code process that was already running does not: it keeps loading the OLD directory, hooks and all. Every session such a process opened then ran the OLD plugin-bootstrap, which saw a state.json naming a different root, concluded the server had to be replaced, repointed agenthub.service at itself and restarted the dashboard onto the version the user had just upgraded away from. The dashboard reported the old version and offered the update again — once per session, forever, while installed_plugins.json and the cache both said the new version was installed. A /agenthub-update from that session "succeeded" and changed nothing. - `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING cache directory is the recorded install, the bootstrap logs one line and returns "superseded" — no deps, no unit, no server. `--force` does not override it: the self-update chain forces from the NEW directory, which is never superseded, so the only --force refused is a deliberate downgrade. It cannot hand over either — the process it would replace is the one loading it — so the log line says to restart Claude Code. - Matched by sibling directory, not by the <plugin>@<marketplace> key: correct without parsing a cache path or trusting a name. A phantom entry (installPath that no longer exists) is ignored rather than wedging the bootstrap shut. Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy of the repo — node_modules and all — per release; twelve of them measured 1.7 GB on one machine. It runs after writeState, so a bad prune cannot cost the record of a good bootstrap, and it protects four independent readings of "in use": this build, every installPath Claude Code records, the directory agenthub.service names (`unitRoot`, foreign unit included), and any directory still named in the user's settings.json hooks — that last one is the un-restarted process above, seen from the other side. Plus the 2 newest survivors as a rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
888 lines
33 KiB
JavaScript
888 lines
33 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 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ĩ <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(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 `<plugin>@<marketplace>` 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;
|
|
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";
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------ plugin-cache pruning */
|
|
|
|
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
|
|
const KEEP_RETIRED_ROOTS = 2;
|
|
|
|
/**
|
|
* 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,
|
|
linkCli,
|
|
installDeps,
|
|
serverIsLive,
|
|
liveServers,
|
|
livePids,
|
|
stopDashboard,
|
|
startDashboard,
|
|
autoInstallSkillPlugins,
|
|
bootstrap,
|
|
};
|