diff --git a/CLAUDE.md b/CLAUDE.md index a794cc39..d6b93b47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: v1.6.0 (2026-09-14) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -45,6 +45,8 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti ## Non-negotiable engineering rules - **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port. +- **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps. +- **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the 2 newest survivors as a rollback cushion, and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here. - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index d2333fc4..363acd55 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -129,13 +129,14 @@ milliseconds — the real work happens in a detached worker, so a session never waits on an install: 1. **Fast path** — recorded state matches this plugin build and a server is live → exit. -2. **Node gate** — refuse below 22.5 with one line (see Prerequisites). -3. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers. -4. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain. -5. **Legacy hook cleanup** — see above. -6. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. -7. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line. -8. **Server** — on Linux with `systemd --user` available, install/refresh a +2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)). +3. **Node gate** — refuse below 22.5 with one line (see Prerequisites). +4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers. +5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain. +6. **Legacy hook cleanup** — see above. +7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. +8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line. +9. **Server** — on Linux with `systemd --user` available, install/refresh a `agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead of a bare spawn, so the server is supervised and comes back after a reboot with no Claude Code session involved. Everywhere else (macOS, Windows, or @@ -143,10 +144,11 @@ waits on an install: runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime `client-dist` — scoped to this session, same as before this fell back to systemd. -9. **Record state** — `runtime/state.json`. -10. **Hand over to systemd** — only when a unit of ours already owns the server +10. **Record state** — `runtime/state.json`. +11. **Prune retired cache copies** — delete the plugin-cache directories earlier updates left behind (see [Pruning the plugin cache](#pruning-the-plugin-cache)). After step 10, so a prune that goes wrong cannot also cost the record of a bootstrap that went right. +12. **Hand over to systemd** — only when a unit of ours already owns the server (see [Who may stop the server](#who-may-stop-the-server)). Deliberately - after step 9. + after step 10. The first run takes a few minutes — most of it is the UI build, which is what makes client-only routes (`http://localhost:4820/run`, and every other page) @@ -155,6 +157,66 @@ goes to `~/.claude/agenthub/runtime/bootstrap.log`; `npm run build`'s own output goes to `client-build.log` next to it; the server's own output goes to `server.log`. +### A retired build never acts + +`claude plugin update` unpacks the new version into a **new** cache directory +and prints "Restart to apply changes". A Claude Code process that was already +running does not: it keeps loading the OLD directory — skills, commands and +hooks included — until it is restarted. + +That used to be self-inflicting. Every session such a process opened ran the +OLD `plugin-bootstrap.js`, 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 then 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. + +So the bootstrap now asks first (`supersededBy`): if Claude Code's +`~/.claude/plugins/installed_plugins.json` records a **sibling** directory as +the installed build of this plugin, this build logs one line and returns +`superseded` without touching dependencies, the unit, or the server. `--force` +does not override it — the self-update chain runs `--force` from the NEW +directory, which is never superseded, so the only `--force` this can refuse is +one that would deliberately downgrade. + +It cannot hand over to the new build either: the process it would have to +replace is the one loading it. Restarting Claude Code is the fix, and the log +line says so. + +Matching is by sibling directory rather than by the `@` +key, which holds without parsing a cache path or trusting a name. An entry +pointing at a directory that no longer exists is ignored — a phantom must never +wedge a bootstrap shut. + +### Pruning the plugin cache + +Claude Code unpacks every update beside the last one and does not reliably +remove the old. A machine that tracks releases closely accumulates a full copy +of this repo — `node_modules` and all — per release; a dozen of them is +gigabytes. + +Step 11 of the bootstrap (`pruneRetiredPluginRoots`) deletes them. This is the +only place anything in the plugin touches the cache, and the rule it appears to +break ("never write into the plugin cache") is about the *runtime* living +outside it, not about keeping retired copies forever. + +Four independent readings of "still in use" are protected, and nothing matching +any of them is ever deleted: + +| Protected | Why | +|---|---| +| This build | It is running. | +| Every `installPath` in `installed_plugins.json`, at any scope | Claude Code says it is installed. | +| The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. | +| Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. | + +Plus the newest `KEEP_RETIRED_ROOTS` (2) of whatever is left, as a rollback +cushion. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for +a git checkout the parent directory holds the user's own work. Best effort +throughout: a directory that will not delete is left alone, never an error. + ### Auto-start on boot On Linux, every plugin bootstrap (not just the first) installs/refreshes a diff --git a/scripts/plugin-bootstrap.js b/scripts/plugin-bootstrap.js index 4de5256b..857bca1a 100644 --- a/scripts/plugin-bootstrap.js +++ b/scripts/plugin-bootstrap.js @@ -14,8 +14,14 @@ * scoped to this session, same as before. * * Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in - * the plugin cache, which Claude Code garbage-collects and replaces wholesale - * on every plugin update. + * 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ĩ */ @@ -40,14 +46,65 @@ function runtimeDir() { return path.join(getDataDir(), "runtime"); } -function pluginVersion() { +function pluginVersion(root = PLUGIN_ROOT) { try { - return require(path.join(PLUGIN_ROOT, "package.json")).version; + return require(path.join(root, "package.json")).version; } catch { return "unknown"; } } +/* -------------------------------------------------- superseded plugin build */ + +/** Claude Code's own record of which build of each plugin is installed. */ +function installedPluginsPath() { + return path.join(getClaudeHome(), "plugins", "installed_plugins.json"); +} + +/** + * The build Claude Code has installed IN PLACE OF this one, or null when this + * build is the installed one (or when there is no such record to read). + * + * `claude plugin update` unpacks the new version into a new cache directory and + * says "Restart to apply changes" — but a Claude Code process that was already + * running keeps loading the OLD directory, hooks and all. Every session it + * opens from then on runs the OLD bootstrap, which happily repoints the systemd + * unit at itself and restarts the dashboard onto the version the user just + * upgraded away from. The dashboard then reports the old version and offers the + * update again — forever, once per session, until Claude Code is restarted. + * + * So a bootstrap that is no longer the installed build does NOTHING. It cannot + * hand over either: the process it would have to replace is the one loading it. + * + * Matched by SIBLING directory rather than by `@` key: the + * builds of one plugin are exactly the directories beside this one, and that + * holds without parsing a cache path or trusting a name. An entry naming a + * directory that no longer exists is ignored — a phantom must never wedge a + * bootstrap shut. + */ +function supersededBy(root = PLUGIN_ROOT) { + let data; + try { + data = JSON.parse(fs.readFileSync(installedPluginsPath(), "utf8")); + } catch { + return null; // no record, unreadable, or not a plugin install at all + } + const siblings = path.dirname(root); + const found = []; + for (const entries of Object.values((data && data.plugins) || {})) { + for (const entry of entries || []) { + const installed = + entry && typeof entry.installPath === "string" ? path.resolve(entry.installPath) : null; + if (!installed) continue; + // This build IS installed (possibly at one scope among several) — the + // whole question is settled, whatever any other entry says. + if (installed === root) return null; + if (path.dirname(installed) === siblings && fs.existsSync(installed)) found.push(installed); + } + } + return found[0] || null; +} + /* ------------------------------------------------------------------ state */ function statePath(rt = runtimeDir()) { @@ -447,16 +504,133 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) { } } +/* ------------------------------------------------------ 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"} + * @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, @@ -621,6 +795,10 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { }, 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); @@ -657,7 +835,14 @@ if (require.main === module) { // npm output and the result. const result = bootstrap(runtimeDir(), { force }); if (force) { - console.log(result === "ok" ? "AgentHub runtime refreshed." : `bootstrap: ${result}`); + 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 { @@ -672,6 +857,11 @@ if (require.main === module) { module.exports = { MIN_NODE, LOCK_STALE_MS, + KEEP_RETIRED_ROOTS, + supersededBy, + pruneRetiredPluginRoots, + installedPluginsPath, + pluginVersion, runtimeDir, statePath, readState, diff --git a/scripts/systemd-service.js b/scripts/systemd-service.js index a7263cb3..c3cc5f0a 100644 --- a/scripts/systemd-service.js +++ b/scripts/systemd-service.js @@ -85,6 +85,24 @@ function unitIsStale(name, root) { return !body.includes(`\nWorkingDirectory=${root}\n`); } +/** + * The directory our unit actually runs from, or null when there is no unit. + * + * Deliberately does NOT require the marker: a FOREIGN unit named `agenthub` is + * not ours to rewrite, but whatever directory it runs from is still a directory + * nothing here may delete. The one caller is the plugin-cache pruner. + */ +function unitRoot(name) { + let body; + try { + body = fs.readFileSync(unitPath(name), "utf8"); + } catch { + return null; + } + const match = body.match(/^WorkingDirectory=(.*)$/m); + return match ? match[1].trim() : null; +} + /** Is the unit currently running? Costs a `systemctl` call — never call it * from the fast path. */ function unitIsActive(name) { @@ -209,6 +227,7 @@ module.exports = { systemctlAvailable, unitIsOurs, unitIsStale, + unitRoot, unitIsActive, writeUserService, restartUserService, diff --git a/server/__tests__/plugin-bootstrap.test.js b/server/__tests__/plugin-bootstrap.test.js index 7b7d0434..b0396838 100644 --- a/server/__tests__/plugin-bootstrap.test.js +++ b/server/__tests__/plugin-bootstrap.test.js @@ -488,3 +488,127 @@ describe("buildClient dependency install", () => { ); }); }); + +describe("supersededBy — an old cache directory must not act", () => { + const pluginsDir = path.join(TMP_HOME, "plugins"); + const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub"); + const oldRoot = path.join(cacheDir, "aaaaaaaaaaaa"); + const newRoot = path.join(cacheDir, "bbbbbbbbbbbb"); + + function writeInstalled(installPath) { + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { "agenthub@sg": [{ scope: "user", installPath }] }, + }) + ); + } + + beforeEach(() => { + fs.rmSync(pluginsDir, { recursive: true, force: true }); + fs.mkdirSync(oldRoot, { recursive: true }); + fs.mkdirSync(newRoot, { recursive: true }); + }); + + it("names the installed build when this one is not it", () => { + writeInstalled(newRoot); + assert.equal(boot.supersededBy(oldRoot), newRoot); + }); + + it("clears the build that IS installed", () => { + writeInstalled(newRoot); + assert.equal(boot.supersededBy(newRoot), null); + }); + + it("ignores an installed path that no longer exists", () => { + // A phantom entry must never wedge a bootstrap shut. + writeInstalled(path.join(cacheDir, "cccccccccccc")); + assert.equal(boot.supersededBy(oldRoot), null); + }); + + it("ignores a plugin that is not a sibling of this one", () => { + fs.mkdirSync(pluginsDir, { recursive: true }); + const other = path.join(pluginsDir, "cache", "sg", "sg-all", "0.1.0"); + fs.mkdirSync(other, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ plugins: { "sg-all@sg": [{ installPath: other }] } }) + ); + assert.equal(boot.supersededBy(oldRoot), null); + }); + + it("says nothing when there is no record to read (a git checkout)", () => { + assert.equal(boot.supersededBy(oldRoot), null); + }); +}); + +describe("pruneRetiredPluginRoots", () => { + const pluginsDir = path.join(TMP_HOME, "plugins"); + const cacheDir = path.join(pluginsDir, "cache", "sg", "agenthub"); + const settingsPath = path.join(TMP_HOME, "settings.json"); + + /** Six builds, oldest first, so mtime order is deterministic. */ + function seed(names) { + fs.rmSync(pluginsDir, { recursive: true, force: true }); + let t = Date.now() - names.length * 60_000; + for (const n of names) { + const dir = path.join(cacheDir, n); + fs.mkdirSync(dir, { recursive: true }); + fs.utimesSync(dir, new Date(t), new Date(t)); + t += 60_000; + } + } + + const NAMES = ["v1", "v2", "v3", "v4", "v5", "current"]; + const dir = (n) => path.join(cacheDir, n); + + beforeEach(() => { + resetRuntime(); + seed(NAMES); + fs.rmSync(settingsPath, { force: true }); + fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}"); + }); + + it("keeps the current build and the two newest retired ones", () => { + const removed = boot.pruneRetiredPluginRoots(RT, dir("current")); + assert.equal(removed, 3); // v1 v2 v3 go; v4 v5 are the cushion + assert.ok(fs.existsSync(dir("current"))); + assert.ok(fs.existsSync(dir("v5"))); + assert.ok(fs.existsSync(dir("v4"))); + assert.ok(!fs.existsSync(dir("v1"))); + }); + + it("never deletes a build Claude Code records as installed", () => { + fs.writeFileSync( + path.join(pluginsDir, "installed_plugins.json"), + JSON.stringify({ plugins: { "agenthub@sg": [{ installPath: dir("v1") }] } }) + ); + boot.pruneRetiredPluginRoots(RT, dir("current")); + assert.ok(fs.existsSync(dir("v1"))); + }); + + it("never deletes a build the user's hooks still point at", () => { + // A Claude Code process that has not been restarted is still executing + // from there — deleting it breaks that session's hooks mid-flight. + fs.writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command: `node "${dir("v2")}/scripts/x.js"` }] }] }, + }) + ); + boot.pruneRetiredPluginRoots(RT, dir("current")); + assert.ok(fs.existsSync(dir("v2"))); + assert.ok(!fs.existsSync(dir("v1"))); + }); + + it("refuses to touch anything outside the plugin cache", () => { + const checkout = path.join(TMP_HOME, "checkout", "agenthub"); + const sibling = path.join(TMP_HOME, "checkout", "something-precious"); + fs.mkdirSync(checkout, { recursive: true }); + fs.mkdirSync(sibling, { recursive: true }); + assert.equal(boot.pruneRetiredPluginRoots(RT, checkout), 0); + assert.ok(fs.existsSync(sibling)); + }); +}); diff --git a/server/__tests__/systemd-service.test.js b/server/__tests__/systemd-service.test.js index 94039a06..99e782bc 100644 --- a/server/__tests__/systemd-service.test.js +++ b/server/__tests__/systemd-service.test.js @@ -168,6 +168,25 @@ describe("systemd-service — unitIsStale", linuxOnly, () => { }); }); +describe("systemd-service — unitRoot", linuxOnly, () => { + it("reports the directory the unit runs from", () => { + svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" }); + assert.equal(svc.unitRoot("agenthub"), "/plugin/root/old"); + }); + + it("reports a FOREIGN unit's directory too", () => { + // Not ours to rewrite, but still a directory the cache pruner may not + // delete out from under a running service. + fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true }); + fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8"); + assert.equal(svc.unitRoot("agenthub"), "/opt/mine"); + }); + + it("is null when there is no unit", () => { + assert.equal(svc.unitRoot("agenthub"), null); + }); +}); + describe("systemd-service — unitIsActive", linuxOnly, () => { it("is true only for `active`", () => { svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));