2b8f83d2dd
The plugin stops shipping its workflow layer: - skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash commands (plugin.json keeps agenthub-doctor/update/open), and all four agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer). - CLI `agenthub skills install`, `agenthub lanes agents install`, `agenthub ship-full|ship-quick`; routes POST /api/skills/install and POST /api/lanes/:id/agents/install; the Settings button, the lane Tools menu item and the Add-lane setup step that called them. - The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row. Pipelines are kept but dormant: server/data/pipelines/ is deleted, so no template ships. `default` resolves to EMPTY_PIPELINE (no nodes, progress 0), createLane still accepts it, POST /api/lanes/:id/stage answers 400 ENOPIPELINE, and the Workspace hides the stage timer, pickers, PipelineMap and strip stage chip for a lane with no nodes. A template dropped into DASHBOARD_PIPELINES_DIR turns it all back on. The server suite loads its templates from server/__tests__/fixtures/pipelines/. Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE, docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
882 lines
33 KiB
JavaScript
882 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 or a macOS LaunchAgent when available (see
|
|
* `service-manager.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 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 */
|
|
|
|
/**
|
|
* 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];
|
|
// 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) {
|
|
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 */
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------ 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.
|
|
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"
|
|
? `${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,
|
|
installDeps,
|
|
serverIsLive,
|
|
liveServers,
|
|
livePids,
|
|
stopDashboard,
|
|
startDashboard,
|
|
bootstrap,
|
|
};
|