Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e685ce1c3 | |||
| 29a5da3e4e | |||
| 713e9fe58b | |||
| 13bc7154d6 |
@@ -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.
|
||||
|
||||
@@ -306,7 +306,9 @@ export function UpdateNotifier() {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -432,7 +434,10 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
{/* `whitespace-nowrap` inherits, so one class keeps every label on its
|
||||
own single line; `flex-wrap` then breaks the ROW instead of the
|
||||
words if a translation still outgrows the modal. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 whitespace-nowrap px-5 py-3 border-t border-border bg-surface-2/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
@@ -473,7 +478,10 @@ export function UpdateNotifier() {
|
||||
) : (
|
||||
<Zap className="w-4 h-4" aria-hidden />
|
||||
)}
|
||||
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
|
||||
{/* `restartingShort`, not `restarting`: the full sentence is
|
||||
already in the banner above, and a whole sentence on a button
|
||||
is what forced this footer onto two lines. */}
|
||||
{applying ? t("updating") : restarting ? t("restartingShort") : t("updateNow")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover."
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||
"restartingShort": "Restarting…"
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục."
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
|
||||
"restartingShort": "Đang khởi động lại…"
|
||||
}
|
||||
|
||||
@@ -9537,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.6.0 (rebuild client to match)
|
||||
UI build v1.6.2 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+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
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.6.0
|
||||
version: 1.6.2
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
@@ -1943,7 +1943,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
|
||||
"integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
|
||||
"funding": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+196
-6
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -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 `<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()) {
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" : "" }));
|
||||
|
||||
Reference in New Issue
Block a user