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