Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb3796f064 | |||
| c3f8dec104 | |||
| 6e685ce1c3 | |||
| 29a5da3e4e |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- 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
|
## 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.
|
- **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.
|
- **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.
|
- Preserve existing behavior unless explicitly asked to change it.
|
||||||
- Prefer minimal, reversible diffs.
|
- Prefer minimal, reversible diffs.
|
||||||
|
|||||||
@@ -475,22 +475,31 @@ export function AddLaneModal({
|
|||||||
] as const
|
] as const
|
||||||
).map((option) => {
|
).map((option) => {
|
||||||
const active = option.value === "native" ? router === "native" : router !== "native";
|
const active = option.value === "native" ? router === "native" : router !== "native";
|
||||||
|
// Clicking "AI Router" before the list lands would resolve back to
|
||||||
|
// Native and read as a dead control.
|
||||||
|
const disabled = option.value === "router" && !routersLoaded;
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border p-2.5 text-xs shadow-sm transition-colors ${
|
// No radio dot: the card itself lights up when selected. That
|
||||||
active
|
// leaves the DISABLED state with nothing to show it, since the
|
||||||
? "border-accent bg-accent/10 text-accent"
|
// greyed-out dot was carrying it — hence the explicit branch.
|
||||||
: "border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
// The input only goes visually hidden, so keyboard and screen
|
||||||
|
// readers still get a real radio group, and the card carries
|
||||||
|
// its focus ring.
|
||||||
|
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||||||
|
: active
|
||||||
|
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||||||
|
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="add-lane-router"
|
name="add-lane-router"
|
||||||
className="h-3.5 w-3.5 accent-accent"
|
className="sr-only"
|
||||||
// Clicking "AI Router" before the list lands would resolve
|
disabled={disabled}
|
||||||
// back to Native and read as a dead control.
|
|
||||||
disabled={option.value === "router" && !routersLoaded}
|
|
||||||
checked={active}
|
checked={active}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
// Switching to "AI Router" lands on the first saved
|
// Switching to "AI Router" lands on the first saved
|
||||||
@@ -536,9 +545,12 @@ export function AddLaneModal({
|
|||||||
|
|
||||||
{tool === "claude" && (
|
{tool === "claude" && (
|
||||||
<label
|
<label
|
||||||
// Card that lights up when ticked, matching LaneStripCard's
|
// No tick box: the card itself lights up when selected, the same
|
||||||
// selected/unselected treatment.
|
// treatment LaneStripCard uses, which makes a second indicator of
|
||||||
className={`flex cursor-pointer items-start gap-2.5 rounded-lg border p-3 shadow-sm transition-colors ${
|
// the same state redundant. The input stays and only goes visually
|
||||||
|
// hidden — it is still what a keyboard toggles and what a screen
|
||||||
|
// reader announces — so the card carries the focus ring for it.
|
||||||
|
className={`block cursor-pointer rounded-lg border p-3 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||||
writeDocs
|
writeDocs
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||||
@@ -551,17 +563,15 @@ export function AddLaneModal({
|
|||||||
aria-label={t("addLaneWriteDocsLabel")}
|
aria-label={t("addLaneWriteDocsLabel")}
|
||||||
checked={writeDocs}
|
checked={writeDocs}
|
||||||
onChange={(e) => setWriteDocs(e.target.checked)}
|
onChange={(e) => setWriteDocs(e.target.checked)}
|
||||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-accent"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span
|
||||||
<span
|
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
|
||||||
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
|
>
|
||||||
>
|
{t("addLaneWriteDocsLabel")}
|
||||||
{t("addLaneWriteDocsLabel")}
|
</span>
|
||||||
</span>
|
<span className="mt-1 block text-[10px] text-fg-muted">
|
||||||
<span className="mt-1 block text-[10px] text-fg-muted">
|
{t("addLaneWriteDocsHint")}
|
||||||
{t("addLaneWriteDocsHint")}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9537,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-[10px] text-status-warning/90 mt-1"
|
class="text-[10px] text-status-warning/90 mt-1"
|
||||||
>
|
>
|
||||||
UI build v1.6.1 (rebuild client to match)
|
UI build v1.6.3 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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:
|
waits on an install:
|
||||||
|
|
||||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
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).
|
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. **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.
|
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||||
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.
|
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. **Legacy hook cleanup** — see above.
|
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. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
6. **Legacy hook cleanup** — see above.
|
||||||
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.
|
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||||
8. **Server** — on Linux with `systemd --user` available, install/refresh a
|
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
|
`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
|
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
|
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
|
runtime `node_modules` and `DASHBOARD_CLIENT_DIST` at the runtime
|
||||||
`client-dist` — scoped to this session, same as before this fell back to
|
`client-dist` — scoped to this session, same as before this fell back to
|
||||||
systemd.
|
systemd.
|
||||||
9. **Record state** — `runtime/state.json`.
|
10. **Record state** — `runtime/state.json`.
|
||||||
10. **Hand over to systemd** — only when a unit of ours already owns the server
|
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
|
(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
|
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)
|
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
|
output goes to `client-build.log` next to it; the server's own output goes to
|
||||||
`server.log`.
|
`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
|
### Auto-start on boot
|
||||||
|
|
||||||
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
|
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 1.6.1
|
version: 1.6.3
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.1",
|
"version": "1.6.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.1",
|
"version": "1.6.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1943,7 +1943,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-expression-matcher": {
|
"node_modules/path-expression-matcher": {
|
||||||
"version": "1.6.1",
|
"version": "1.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
|
||||||
"integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
|
"integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.1",
|
"version": "1.6.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+196
-6
@@ -14,8 +14,14 @@
|
|||||||
* scoped to this session, same as before.
|
* scoped to this session, same as before.
|
||||||
*
|
*
|
||||||
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
|
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
|
||||||
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
|
* the plugin cache, which Claude Code replaces wholesale on every plugin
|
||||||
* on every plugin update.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -40,14 +46,65 @@ function runtimeDir() {
|
|||||||
return path.join(getDataDir(), "runtime");
|
return path.join(getDataDir(), "runtime");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluginVersion() {
|
function pluginVersion(root = PLUGIN_ROOT) {
|
||||||
try {
|
try {
|
||||||
return require(path.join(PLUGIN_ROOT, "package.json")).version;
|
return require(path.join(root, "package.json")).version;
|
||||||
} catch {
|
} catch {
|
||||||
return "unknown";
|
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 */
|
/* ------------------------------------------------------------------ state */
|
||||||
|
|
||||||
function statePath(rt = runtimeDir()) {
|
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
|
* 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
|
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
|
||||||
* reinstalls dependencies and restarts the server unconditionally.
|
* 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 } = {}) {
|
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||||
fs.mkdirSync(rt, { recursive: true });
|
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()) {
|
if (!nodeVersionOk()) {
|
||||||
log(
|
log(
|
||||||
rt,
|
rt,
|
||||||
@@ -621,6 +795,10 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
|||||||
},
|
},
|
||||||
rt
|
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";
|
return "ok";
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock(rt);
|
releaseLock(rt);
|
||||||
@@ -657,7 +835,14 @@ if (require.main === module) {
|
|||||||
// npm output and the result.
|
// npm output and the result.
|
||||||
const result = bootstrap(runtimeDir(), { force });
|
const result = bootstrap(runtimeDir(), { force });
|
||||||
if (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;
|
if (result !== "ok") process.exitCode = 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -672,6 +857,11 @@ if (require.main === module) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
MIN_NODE,
|
MIN_NODE,
|
||||||
LOCK_STALE_MS,
|
LOCK_STALE_MS,
|
||||||
|
KEEP_RETIRED_ROOTS,
|
||||||
|
supersededBy,
|
||||||
|
pruneRetiredPluginRoots,
|
||||||
|
installedPluginsPath,
|
||||||
|
pluginVersion,
|
||||||
runtimeDir,
|
runtimeDir,
|
||||||
statePath,
|
statePath,
|
||||||
readState,
|
readState,
|
||||||
|
|||||||
@@ -85,6 +85,24 @@ function unitIsStale(name, root) {
|
|||||||
return !body.includes(`\nWorkingDirectory=${root}\n`);
|
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
|
/** Is the unit currently running? Costs a `systemctl` call — never call it
|
||||||
* from the fast path. */
|
* from the fast path. */
|
||||||
function unitIsActive(name) {
|
function unitIsActive(name) {
|
||||||
@@ -209,6 +227,7 @@ module.exports = {
|
|||||||
systemctlAvailable,
|
systemctlAvailable,
|
||||||
unitIsOurs,
|
unitIsOurs,
|
||||||
unitIsStale,
|
unitIsStale,
|
||||||
|
unitRoot,
|
||||||
unitIsActive,
|
unitIsActive,
|
||||||
writeUserService,
|
writeUserService,
|
||||||
restartUserService,
|
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, () => {
|
describe("systemd-service — unitIsActive", linuxOnly, () => {
|
||||||
it("is true only for `active`", () => {
|
it("is true only for `active`", () => {
|
||||||
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
|
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user