8d48a038c7
Three defects that all showed up as "the dashboard's numbers are wrong", plus the notification and brand work asked for alongside them. Tokens: Claude Code writes ONE JSONL line per content block of an assistant message, and every one of those lines repeats the same message-level `usage`. Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran 1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId` counts a message once, carried across the transcript cache's incremental chunk boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser cannot correct what is already stored, so `setTokenUsage` (exact, baselines cleared) is added for the deliberate re-parse path and `reconcileTokens` now covers every session with a transcript on disk rather than imported ones only. Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on the transcript's timestamp, so each hook re-inserted the session's whole turn history — 587k rows, 75% of a real events table, one session holding 68,747 copies of the same turns. The row now carries the transcript timestamp, which fixes the guard and the feed's ordering together. Same class of bug in the session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms` are assigned from the full parse instead of `+=`, since `extract()` returns whole-transcript totals on every call. Events: leftover checkout hook entries in `~/.claude/settings.json` double every event, every subagent row and every token figure. `stripLegacyHooks` has always removed them — but only inside `bootstrap()`, which `fastPathOk` skips on exactly the healthy installs where nothing else forces a full pass, so they survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path. A one-time repair at the first start after upgrading (`user_version` 3) deletes the duplicated `TurnDuration` rows and re-parses every transcript with exact writes. Marker 3 and not 2: an install that ran the earlier pass had its corrected rows re-inflated by whatever old build was still ingesting hooks. Router: switching a lane's AI Router while a pane is live only half-applies — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails with "model not found". The apply route reports `restart_required` / `run_id`, and the picker offers a Restart that respawns the pane from the lane id alone (replaying the old `--model` would re-create the mismatch). Notifications (#29): the two moments Claude is BLOCKED on the user now have their own switches and default to on — `onAwaitingInput` (a permission prompt or a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable and the second hung off a preference that defaults to off, so the one thing a user walks away from went unannounced. Prefs shape/defaults/storage move to `client/src/lib/notifPrefs.ts`; Settings and the hook had private copies. Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under it, the collapsed rail shows the mark, and the favicon is vendored to match.
916 lines
34 KiB
JavaScript
916 lines
34 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;
|
|
// Leftover checkout hooks are the one defect that corrupts the DATA rather
|
|
// than the install: events carry no id, so a second handler doubles every
|
|
// event, every subagent row and every turn. `stripLegacyHooks` removes them —
|
|
// but it runs inside `bootstrap()`, which this fast path skips, so on a
|
|
// machine that is otherwise healthy they survived every session forever.
|
|
// Reading one JSON file is cheap enough here; the strip still happens in the
|
|
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
|
|
// Task spawning two subagent rows.)
|
|
if (hasLegacyHooks()) return false;
|
|
return serverIsLive();
|
|
}
|
|
|
|
/* ------------------------------------------------------- node version gate */
|
|
|
|
function nodeVersionOk(version = process.versions.node) {
|
|
const parts = String(version)
|
|
.split(".")
|
|
.map((n) => parseInt(n, 10) || 0);
|
|
for (let i = 0; i < MIN_NODE.length; i++) {
|
|
if (parts[i] > MIN_NODE[i]) return true;
|
|
if (parts[i] < MIN_NODE[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------- lock */
|
|
|
|
function lockPath(rt = runtimeDir()) {
|
|
return path.join(rt, ".bootstrap.lock");
|
|
}
|
|
|
|
function isPidAlive(pid) {
|
|
if (!pid) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err) {
|
|
return err.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function lockIsStale(dir, now = Date.now()) {
|
|
let stat;
|
|
try {
|
|
stat = fs.statSync(dir);
|
|
} catch {
|
|
return false; // gone already
|
|
}
|
|
if (now - stat.mtimeMs > LOCK_STALE_MS) return true;
|
|
let pid = 0;
|
|
try {
|
|
pid = parseInt(fs.readFileSync(path.join(dir, "pid"), "utf8").trim(), 10);
|
|
} catch {
|
|
return true; // lock dir without a readable pid is debris
|
|
}
|
|
return !isPidAlive(pid);
|
|
}
|
|
|
|
/**
|
|
* Atomic `mkdir` lock so two sessions starting at once cannot race the install
|
|
* or spawn two servers (the second would die on EADDRINUSE). A lock whose owner
|
|
* is dead, or older than LOCK_STALE_MS, is reclaimed.
|
|
*
|
|
* @returns {boolean} true when this process owns the lock
|
|
*/
|
|
function acquireLock(rt = runtimeDir()) {
|
|
const dir = lockPath(rt);
|
|
fs.mkdirSync(rt, { recursive: true });
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
fs.mkdirSync(dir);
|
|
fs.writeFileSync(path.join(dir, "pid"), String(process.pid), "utf8");
|
|
return true;
|
|
} catch (err) {
|
|
if (err.code !== "EEXIST") return false;
|
|
if (!lockIsStale(dir)) return false;
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function releaseLock(rt = runtimeDir()) {
|
|
try {
|
|
fs.rmSync(lockPath(rt), { recursive: true, force: true });
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
/* --------------------------------------------------------- legacy cleanup */
|
|
|
|
/**
|
|
* `claude plugin install` materializes the plugin's inline hooks into
|
|
* `~/.claude/settings.json` itself, with `${CLAUDE_PLUGIN_ROOT}` already
|
|
* resolved to the cache path — confirmed by installing this plugin for real
|
|
* and inspecting the file. Those entries also contain `hook-handler.js`, so
|
|
* `isOurEntry()` alone (checkout vs. plugin, both match the same substring)
|
|
* cannot tell a legitimate plugin-installed hook from a leftover
|
|
* `npm run install-hooks` entry. Only entries whose command does NOT resolve
|
|
* under the plugin cache are the legacy, checkout-installed kind.
|
|
*
|
|
* @returns {(entry: object) => boolean}
|
|
*/
|
|
function isCheckoutHookEntry(claudeHome = getClaudeHome()) {
|
|
const cacheRoot = path.join(claudeHome, "plugins", "cache");
|
|
return (entry) => {
|
|
if (!isOurEntry(entry)) return false;
|
|
const commands = [
|
|
entry.command,
|
|
...(Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []),
|
|
];
|
|
return !commands.some((c) => typeof c === "string" && c.includes(cacheRoot));
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove hook entries a previous `npm run install-hooks` wrote into
|
|
* ~/.claude/settings.json. The plugin installs its own hooks, and events carry
|
|
* no id — two handlers mean every token and cost figure is counted twice.
|
|
* Never touches the plugin's own cache-resolved entries (see
|
|
* `isCheckoutHookEntry`) — only genuine leftover checkout paths. The original
|
|
* file is copied aside before any write.
|
|
*
|
|
* @returns {number} how many entries were removed
|
|
*/
|
|
/**
|
|
* Does `~/.claude/settings.json` still carry hook entries from a checkout
|
|
* install? Pure fs, no writes — the fast path calls it on every SessionStart.
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
function hasLegacyHooks(settingsPath = getSettingsPath()) {
|
|
let settings;
|
|
try {
|
|
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!settings.hooks || typeof settings.hooks !== "object") return false;
|
|
const isLegacy = isCheckoutHookEntry();
|
|
return Object.values(settings.hooks).some((e) => Array.isArray(e) && e.some(isLegacy));
|
|
}
|
|
|
|
function stripLegacyHooks(settingsPath = getSettingsPath()) {
|
|
let settings;
|
|
try {
|
|
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
} catch {
|
|
return 0; // no settings file, or not ours to touch
|
|
}
|
|
if (!settings.hooks || typeof settings.hooks !== "object") return 0;
|
|
|
|
const isLegacy = isCheckoutHookEntry();
|
|
let removed = 0;
|
|
for (const [type, entries] of Object.entries(settings.hooks)) {
|
|
if (!Array.isArray(entries)) continue;
|
|
const kept = entries.filter((e) => !isLegacy(e));
|
|
removed += entries.length - kept.length;
|
|
if (kept.length) settings.hooks[type] = kept;
|
|
else delete settings.hooks[type];
|
|
}
|
|
if (!removed) return 0;
|
|
|
|
fs.copyFileSync(settingsPath, `${settingsPath}.agenthub-bak`);
|
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
return removed;
|
|
}
|
|
|
|
/* ------------------------------------------------------------ CLI on PATH */
|
|
|
|
function cliDir() {
|
|
return path.join(os.homedir(), ".local", "bin");
|
|
}
|
|
|
|
/**
|
|
* Write a launcher for `agenthub` into ~/.local/bin. A launcher rather than a
|
|
* symlink into the plugin cache: that directory is replaced on every plugin
|
|
* update, which would leave a dangling link until the next session.
|
|
*
|
|
* Never overwrites a `agenthub` this bootstrap did not write — a developer's
|
|
* `npm link`ed checkout CLI must keep winning.
|
|
*
|
|
* @returns {"written"|"exists"|"foreign"|"failed"}
|
|
*/
|
|
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
|
|
const marker = "# agenthub-plugin-launcher";
|
|
const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
|
const body =
|
|
process.platform === "win32"
|
|
? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n`
|
|
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
|
try {
|
|
if (fs.existsSync(target)) {
|
|
const current = fs.readFileSync(target, "utf8");
|
|
if (!current.includes("agenthub-plugin-launcher")) return "foreign";
|
|
if (current === body) return "exists";
|
|
}
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(target, body, "utf8");
|
|
fs.chmodSync(target, 0o755);
|
|
return "written";
|
|
} catch {
|
|
return "failed";
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------- dependencies */
|
|
|
|
/**
|
|
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
|
* the root postinstall from pulling the whole Vite client toolchain; the
|
|
* manifests are copied out of the (read-only) plugin cache so npm has a project
|
|
* to install for.
|
|
*/
|
|
function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
|
fs.mkdirSync(rt, { recursive: true });
|
|
for (const f of ["package.json", "package-lock.json"]) {
|
|
const src = path.join(root, f);
|
|
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
|
}
|
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
const res = spawnSync(
|
|
npm,
|
|
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
|
);
|
|
return res.status === 0;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ server */
|
|
|
|
/**
|
|
* Discovery-file entries whose PID is actually alive right now. Reads the file
|
|
* directly rather than server-info's `resolveAllDashboardPorts()`, which falls
|
|
* back to `[DEFAULT_PORT]` when nothing is live — a reasonable guess for the
|
|
* CLI/hook handler, but wrong here: it would make the bootstrap believe a
|
|
* server is already running when none is.
|
|
*
|
|
* @returns {{port:number, pid:number}[]}
|
|
*/
|
|
function liveServers() {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
|
|
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
|
|
return servers.filter((s) => s && isPidAlive(s.pid));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function livePids() {
|
|
return liveServers().map((s) => s.pid);
|
|
}
|
|
|
|
/**
|
|
* Stop running dashboard servers. Needed after a plugin update: the old server
|
|
* runs from a cache directory Claude Code has already replaced, so it must be
|
|
* retired before the new one can take the port.
|
|
*
|
|
* @returns {number} how many processes were signalled
|
|
*/
|
|
function stopDashboard() {
|
|
let stopped = 0;
|
|
for (const pid of livePids()) {
|
|
try {
|
|
process.kill(pid, "SIGTERM");
|
|
stopped++;
|
|
} catch {
|
|
/* already gone, or not ours to signal */
|
|
}
|
|
}
|
|
return stopped;
|
|
}
|
|
|
|
/**
|
|
* Block until no dashboard PID is alive, so the replacement server does not
|
|
* race the old one for the port. Bounded — the worker is detached, but it must
|
|
* not hang forever if a process refuses to die.
|
|
*/
|
|
function waitForPortsFree(timeoutMs = 10000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
while (livePids().length && Date.now() < deadline) {
|
|
Atomics.wait(sleeper, 0, 0, 200);
|
|
}
|
|
return livePids().length === 0;
|
|
}
|
|
|
|
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
|
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
|
|
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
|
|
cwd: root,
|
|
detached: true,
|
|
stdio: ["ignore", logFile, logFile],
|
|
env: {
|
|
...process.env,
|
|
NODE_PATH: path.join(rt, "node_modules"),
|
|
// The plugin cache is read-only; /agenthub-open builds the bundle here.
|
|
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
|
},
|
|
});
|
|
child.unref();
|
|
return child.pid;
|
|
}
|
|
|
|
/* --------------------------------------------------------------- worker run */
|
|
|
|
function log(rt, line) {
|
|
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
|
try {
|
|
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install the plugins AgentHub's pipelines drive but this machine cannot resolve —
|
|
* today `superpowers`, which `/agenthub:ship-feature(-lane)` calls step by step. A
|
|
* fresh `agenthub` install otherwise runs those skills partway and fails at the
|
|
* first call with no warning beforehand.
|
|
*
|
|
* Off by setting `AGENTHUB_AUTO_INSTALL_PLUGINS` to `0`, `false` or `off`; that
|
|
* leaves `/agenthub-doctor`'s WARN row as the only notice, which is the right
|
|
* behaviour for anyone who wants to choose their own plugins.
|
|
*
|
|
* One-shot: the outcome is recorded in the runtime state and the fast path
|
|
* takes it into account, so a machine with no network does not re-attempt a
|
|
* clone on every session. `/agenthub-update` (`--force`) retries.
|
|
*
|
|
* Best-effort throughout. Nothing here may throw or block: the dashboard,
|
|
* hooks and CLI all work without these plugins.
|
|
*
|
|
* @returns {"off"|"nothing-missing"|"installed"|"failed"|"error"} or the previously
|
|
* recorded outcome when this run had nothing to settle.
|
|
*/
|
|
function autoInstallSkillPlugins(rt, { force = false } = {}) {
|
|
const flag = process.env.AGENTHUB_AUTO_INSTALL_PLUGINS;
|
|
if (flag === "0" || flag === "false" || flag === "off") return "off";
|
|
// Return the PREVIOUS outcome, not a marker: writeState records whatever this
|
|
// returns, and overwriting "installed" with "already-settled" would lose the
|
|
// only record of what actually happened.
|
|
const settled = readState(rt)?.skillPluginsSettled;
|
|
if (!force && settled) return settled;
|
|
|
|
try {
|
|
const { autoInstallSkillPlugins: install } = require("../server/lib/skill-plugins");
|
|
const summary = install((line) => log(rt, line));
|
|
if (!summary.attempted.length) return "nothing-missing";
|
|
return summary.failed.length ? "failed" : "installed";
|
|
} catch (err) {
|
|
log(rt, `skill-plugin auto-install skipped: ${err.message}`);
|
|
return "error";
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------ plugin-cache pruning */
|
|
|
|
/** How many retired builds to leave behind, newest first, as a rollback cushion. */
|
|
const KEEP_RETIRED_ROOTS = 1;
|
|
|
|
/**
|
|
* Delete the cache directories earlier updates left behind.
|
|
*
|
|
* Claude Code unpacks every plugin update into a NEW directory beside the old
|
|
* one and does not reliably remove the old. A machine that tracks releases
|
|
* closely ends up with a dozen full copies of this repo — node_modules and all
|
|
* — which is what this clears.
|
|
*
|
|
* This is the one place anything here touches the plugin cache, and the file
|
|
* header's "never write into the cache" rule is about the RUNTIME living
|
|
* outside it, not about leaving retired copies forever. Everything still in use
|
|
* is protected, by four independent readings of "in use":
|
|
*
|
|
* - this build;
|
|
* - every build Claude Code records as installed, at any scope;
|
|
* - the directory our systemd unit runs from, foreign unit included;
|
|
* - any directory still named in the user's `settings.json` hooks — a Claude
|
|
* Code process that has not been restarted is still executing from there,
|
|
* and deleting it would break that session's hooks mid-flight. (This is
|
|
* exactly the directory `supersededBy` describes, seen from the other side.)
|
|
*
|
|
* …plus the newest `KEEP_RETIRED_ROOTS` of whatever is left, so a rollback has
|
|
* something to roll back to. Best effort throughout: a directory that will not
|
|
* delete is left alone, never an error.
|
|
*
|
|
* @returns {number} how many directories were removed
|
|
*/
|
|
function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
|
|
// Only inside Claude Code's plugin cache. For a git checkout the parent
|
|
// directory holds the user's own work and is never ours to delete from.
|
|
const cacheMarker = `${path.sep}plugins${path.sep}cache${path.sep}`;
|
|
if (!root.includes(cacheMarker)) return 0;
|
|
const cacheDir = path.dirname(root);
|
|
|
|
const keep = new Set([root]);
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8"));
|
|
for (const entries of Object.values((data && data.plugins) || {})) {
|
|
for (const entry of entries || []) {
|
|
if (entry && typeof entry.installPath === "string")
|
|
keep.add(path.resolve(entry.installPath));
|
|
}
|
|
}
|
|
} catch {
|
|
/* no record to read — the other three protections still apply */
|
|
}
|
|
try {
|
|
const unitRoot = require("./systemd-service").unitRoot("agenthub");
|
|
if (unitRoot) keep.add(path.resolve(unitRoot));
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
let settings = "";
|
|
try {
|
|
settings = fs.readFileSync(getSettingsPath(), "utf8");
|
|
} catch {
|
|
/* no settings file — nothing pinned by a hook */
|
|
}
|
|
|
|
let dirs;
|
|
try {
|
|
dirs = fs.readdirSync(cacheDir, { withFileTypes: true });
|
|
} catch {
|
|
return 0;
|
|
}
|
|
|
|
const retired = dirs
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => path.join(cacheDir, d.name))
|
|
.filter((dir) => !keep.has(dir) && !settings.includes(dir))
|
|
.map((dir) => {
|
|
let at = 0;
|
|
try {
|
|
at = fs.statSync(dir).mtimeMs;
|
|
} catch {
|
|
/* unreadable sorts oldest, so it goes first */
|
|
}
|
|
return { dir, at };
|
|
})
|
|
.sort((a, b) => b.at - a.at)
|
|
.slice(KEEP_RETIRED_ROOTS);
|
|
|
|
let removed = 0;
|
|
for (const { dir } of retired) {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
removed += 1;
|
|
} catch {
|
|
/* in use, read-only, whatever — leaving it is always safe */
|
|
}
|
|
}
|
|
if (removed) {
|
|
log(rt, `removed ${removed} retired plugin cache ${removed === 1 ? "copy" : "copies"}`);
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* The real work. Runs detached from the SessionStart hook, so its only channel
|
|
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
|
|
* reinstalls dependencies and restarts the server unconditionally.
|
|
*
|
|
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"}
|
|
*/
|
|
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
|
fs.mkdirSync(rt, { recursive: true });
|
|
|
|
// First, and ahead of the lock: a retired build has nothing to do and must
|
|
// not take the lock to discover it. `force` does NOT override this — the
|
|
// plugin-update chain runs `--force` from the NEW cache directory, which is
|
|
// never superseded, so the only `--force` this can refuse is one that would
|
|
// downgrade the user on purpose.
|
|
const newer = supersededBy();
|
|
if (newer) {
|
|
log(
|
|
rt,
|
|
`this build (${pluginVersion()}) has been replaced by ${pluginVersion(newer)} — doing nothing. ` +
|
|
`Restart Claude Code so it loads the new one; until then this session keeps running the old plugin.`
|
|
);
|
|
return "superseded";
|
|
}
|
|
|
|
if (!nodeVersionOk()) {
|
|
log(
|
|
rt,
|
|
`Node ${process.versions.node} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
|
|
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
|
|
);
|
|
return "node-too-old";
|
|
}
|
|
|
|
if (!acquireLock(rt)) {
|
|
log(rt, "another bootstrap holds the lock — nothing to do");
|
|
return "locked";
|
|
}
|
|
|
|
// Declared out here because the restart it guards has to happen in the
|
|
// `finally`, after the lock is released and the state is on disk: it is the
|
|
// step that may cost this process its own life.
|
|
let pendingSystemdRestart = false;
|
|
|
|
try {
|
|
const wantDeps = depsHash();
|
|
const state = readState(rt) || {};
|
|
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
|
|
log(rt, "installing runtime dependencies (first run takes a few minutes)");
|
|
if (!installDeps(rt)) {
|
|
log(rt, "npm install failed — see the output above; run /agenthub-doctor after fixing it");
|
|
return "install-failed";
|
|
}
|
|
}
|
|
|
|
const removed = stripLegacyHooks();
|
|
if (removed) {
|
|
log(
|
|
rt,
|
|
`removed ${removed} hook entr${removed === 1 ? "y" : "ies"} left by npm run install-hooks ` +
|
|
`(backup: ${getSettingsPath()}.agenthub-bak) — the plugin installs its own`
|
|
);
|
|
}
|
|
|
|
log(rt, `agenthub CLI launcher: ${linkCli()}`);
|
|
|
|
// Built eagerly (not lazily behind /agenthub-open) so the dashboard, including
|
|
// client-only routes like /run, works the instant `claude` is started —
|
|
// same trigger as the dependency install above: missing, or this plugin
|
|
// version has not built one yet.
|
|
const { buildClient } = require("./plugin-open");
|
|
const uiResult = buildClient({
|
|
rt,
|
|
root: PLUGIN_ROOT,
|
|
force: force || state.depsHash !== wantDeps,
|
|
logPath: path.join(rt, "client-build.log"),
|
|
});
|
|
if (uiResult === "failed") {
|
|
log(rt, "dashboard UI build failed — the API still works; run /agenthub-open to retry");
|
|
} else if (uiResult === "built") {
|
|
log(rt, "built the dashboard UI bundle");
|
|
}
|
|
|
|
const svc = require("./systemd-service");
|
|
// Who owns the running server decides how it may be retired. A unit of
|
|
// ours means systemd owns it — and this process is a descendant of that
|
|
// server, so it sits in the unit's cgroup. SIGTERM-ing the main process
|
|
// there does two fatal things at once: the clean exit(0) tells systemd
|
|
// "meant to stop", and `KillMode=control-group` reaps this bootstrap
|
|
// before it can write state or point the unit at the new cache dir. That
|
|
// is the bug that left a dashboard dead after every self-update until the
|
|
// next Claude Code session happened to bootstrap again.
|
|
// ACTIVE matters, not merely present: a unit of ours that is installed but
|
|
// stopped owns nothing right now. If a detached server is holding the port
|
|
// in that state, treating systemd as the owner would skip the stop and then
|
|
// ask systemd to start a second server onto a bound port — a crash loop,
|
|
// courtesy of Restart=always.
|
|
const systemdOwned =
|
|
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
|
|
|
|
// After a plugin update the running server executes code from a cache
|
|
// directory Claude Code has already discarded — retire it first. Only
|
|
// when nothing else owns its lifecycle; otherwise systemd retires it, in
|
|
// one restart job, once everything below is durable.
|
|
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
|
|
if (movedRoot && !systemdOwned && stopDashboard()) {
|
|
log(rt, "stopped the server started from the previous plugin version");
|
|
waitForPortsFree();
|
|
}
|
|
|
|
// Two different needs, deliberately not collapsed. `needsServer` is "the
|
|
// running process must be replaced"; `unitStale` is "the unit names a cache
|
|
// directory that is no longer this one", which is invisible until a reboot
|
|
// and must be repaired even while a perfectly healthy server is up.
|
|
const needsServer = movedRoot || !serverIsLive();
|
|
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
|
|
if (needsServer || unitStale) {
|
|
const unit = {
|
|
name: "agenthub",
|
|
description: "AgentHub dashboard server (Claude Code plugin)",
|
|
workingDir: PLUGIN_ROOT,
|
|
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
|
|
env: {
|
|
NODE_ENV: "production",
|
|
NODE_PATH: path.join(rt, "node_modules"),
|
|
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
|
|
// systemd --user units get systemd's own minimal PATH, not the
|
|
// shell's — forward it so the server can find `claude`/`codex`.
|
|
PATH: process.env.PATH || "",
|
|
},
|
|
};
|
|
// Two shapes of the same step. When systemd already owns the server the
|
|
// unit is only REWRITTEN here — pointing at the new plugin root — and
|
|
// the restart is deferred to `pendingSystemdRestart` below, because
|
|
// starting it now would kill this process mid-bootstrap. Otherwise
|
|
// there is no cgroup to be reaped by, and install-and-start is fine.
|
|
const result = systemdOwned
|
|
? svc.writeUserService(unit)
|
|
: svc.systemctlAvailable()
|
|
? svc.installUserService(unit)
|
|
: "unavailable";
|
|
if (result === "written" || result === "unchanged") {
|
|
if (result === "written") svc.enableLinger();
|
|
// Only when the RUNNING process has to change. A stale unit beside a
|
|
// healthy server is now corrected on disk and left to take effect at
|
|
// the next start — restarting a working dashboard to fix a file that
|
|
// only matters at boot would be churn the user feels for no reason.
|
|
pendingSystemdRestart = systemdOwned && needsServer;
|
|
log(
|
|
rt,
|
|
result === "unchanged"
|
|
? "systemd --user service already up to date"
|
|
: unitStale && !needsServer
|
|
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
|
|
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
|
|
);
|
|
} else if (needsServer) {
|
|
// No systemd (macOS/Windows/WSL without it), or the unit exists but
|
|
// wasn't ours to touch — same detached-spawn fallback as before,
|
|
// scoped to this session only. Guarded by `needsServer`: entering this
|
|
// block for a stale unit alone must never spawn a second server onto
|
|
// the port the live one already holds.
|
|
const pid = startDashboard(rt);
|
|
log(
|
|
rt,
|
|
`started the dashboard server (pid ${pid})${result === "foreign" ? " — a foreign systemd unit named 'agenthub' already exists" : ""}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Last, and deliberately so: it clones from the network, and nothing above
|
|
// may wait on it. Runs here rather than in the foreground pass because this
|
|
// whole function is already detached from the 10 s SessionStart hook.
|
|
const skillPlugins = autoInstallSkillPlugins(rt, { force });
|
|
|
|
writeState(
|
|
{
|
|
pluginVersion: pluginVersion(),
|
|
depsHash: wantDeps,
|
|
pluginRoot: PLUGIN_ROOT,
|
|
claudeHome: getClaudeHome(),
|
|
// Settles the fast path: whatever the outcome, the next SessionStart
|
|
// must not spawn a worker just to retry a network clone. Cleared by
|
|
// `/agenthub-update` (force), which is the retry.
|
|
skillPluginsSettled: skillPlugins,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
rt
|
|
);
|
|
|
|
// After the state is durable, so a prune that goes wrong cannot also cost
|
|
// us the record of a bootstrap that went right.
|
|
pruneRetiredPluginRoots(rt);
|
|
return "ok";
|
|
} finally {
|
|
releaseLock(rt);
|
|
// LAST, and after the lock: systemd stops the old main process to start
|
|
// the new one, and this process shares its cgroup, so it may not outlive
|
|
// this call. Everything that had to be durable already is — the state
|
|
// file is written and the lock is gone, so even being killed here leaves
|
|
// no debris and no half-recorded bootstrap.
|
|
if (pendingSystemdRestart) {
|
|
log(rt, "handing the server over to systemd (restarting the unit)");
|
|
require("./systemd-service").restartUserService("agenthub");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Foreground pass, invoked by the SessionStart hook. Returns within
|
|
* milliseconds in the steady state, and otherwise hands off to a detached
|
|
* worker so the session never waits on an install.
|
|
*/
|
|
function main() {
|
|
if (fastPathOk()) return;
|
|
const child = spawn(process.execPath, [__filename, "--worker"], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
child.unref();
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const force = process.argv.includes("--force");
|
|
if (force || process.argv.includes("--worker")) {
|
|
// `--force` (from /agenthub-update) runs in the foreground so the user sees the
|
|
// npm output and the result.
|
|
const result = bootstrap(runtimeDir(), { force });
|
|
if (force) {
|
|
console.log(
|
|
result === "ok"
|
|
? "AgentHub runtime refreshed."
|
|
: result === "superseded"
|
|
? "A newer AgentHub plugin build is already installed — this one did nothing on purpose.\n" +
|
|
"Restart Claude Code so it loads the new build, then try again."
|
|
: `bootstrap: ${result}`
|
|
);
|
|
if (result !== "ok") process.exitCode = 1;
|
|
}
|
|
} else {
|
|
try {
|
|
main();
|
|
} catch {
|
|
// A bootstrap failure must never break a Claude Code session.
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
MIN_NODE,
|
|
LOCK_STALE_MS,
|
|
KEEP_RETIRED_ROOTS,
|
|
supersededBy,
|
|
pruneRetiredPluginRoots,
|
|
installedPluginsPath,
|
|
pluginVersion,
|
|
runtimeDir,
|
|
statePath,
|
|
readState,
|
|
writeState,
|
|
depsHash,
|
|
fastPathOk,
|
|
nodeVersionOk,
|
|
lockPath,
|
|
lockIsStale,
|
|
acquireLock,
|
|
releaseLock,
|
|
isCheckoutHookEntry,
|
|
stripLegacyHooks,
|
|
hasLegacyHooks,
|
|
linkCli,
|
|
installDeps,
|
|
serverIsLive,
|
|
liveServers,
|
|
livePids,
|
|
stopDashboard,
|
|
startDashboard,
|
|
autoInstallSkillPlugins,
|
|
bootstrap,
|
|
};
|