Compare commits

...

12 Commits

Author SHA1 Message Date
nntrivi2001 fc7b58f15b chore(release): bump version to 1.5.10 2026-09-14 13:10:21 +07:00
nntrivi2001 c4025cd2f7 style(lanes): render the create-readme option as a selectable card
Matches LaneStripCard's selected/unselected treatment and moves the hint
inside the card. Drops the "codex has no slash commands" clause from the
hint — the option is already hidden for a codex lane.
2026-09-14 13:10:16 +07:00
nntrivi2001 ee1e07bb5e fix(update-check): resolve the release URL for a relative plugin source
This project's marketplace entry is `"source": "./"` — the plugin IS the
marketplace repo — but pluginMarketplaceUrl only accepted a URL string or
`{url}`, so it returned null and every plugin-cache install answered
`no_remote`: no install of this plugin could ever see a published release.

A relative source now falls back to the manifest's own `repository`. An
`owner/repo` shorthand still returns null — it names a different repo, so
borrowing the marketplace's URL would be a confidently wrong answer.
2026-09-14 13:10:16 +07:00
nntrivi2001 91af3dca86 chore(release): bump version to 1.5.9 2026-09-14 11:22:11 +07:00
nntrivi2001 baa355bee4 feat(lanes): offer a create/update readme run when adding a lane
An optional checkbox in the Add-lane modal starts the new lane's terminal
run right after creation with `/sg-all:sg-md-writing` as its first message,
so the lane opens already writing or refreshing its CLAUDE.md/AGENTS.md set.
Claude lanes only — codex has no slash commands.
2026-09-14 11:20:37 +07:00
nntrivi2001 b7e81af0b6 chore(release): bump version to 1.5.8 2026-09-11 16:48:00 +07:00
nntrivi2001 c90dd7b147 fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.

- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
  instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
  path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
  server keeps running and the corrected unit takes effect at the next
  start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
  detached-spawn fallback is guarded by needsServer so a stale unit alone
  cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
  stopped owns nothing: skipping the stop there and asking systemd to start
  would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
  come back after a reboot") and the fix. WARN, never FAIL — the dashboard
  in front of you works, and having no unit at all is a normal setup.

The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
2026-09-11 16:45:47 +07:00
nntrivi2001 49a041b1dc chore(release): bump version to 1.5.7 2026-09-11 16:32:50 +07:00
nntrivi2001 de8d1079e0 fix(bootstrap): never stop the dashboard from inside its own systemd cgroup
A plugin self-update took the dashboard down and left it down. The chain
ran correctly up to the point where the bootstrap SIGTERMs the server it is
replacing — and that server was systemd-managed, with the bootstrap running
as its descendant, i.e. inside its cgroup. Two failures fired at once:

  * the server exits 0, and `Restart=on-failure` reads a clean exit as
    "meant to stop", so systemd did not bring it back;
  * `KillMode=control-group` reaped the bootstrap itself, mid-run, before it
    could repoint the unit at the new plugin root or write state.json.

Evidence from the incident: bootstrap.log ends at "stopped the server
started from the previous plugin version" with no systemd line after it,
.bootstrap.lock survived with a dead pid, state.json still named the
previous version, and the unit's ExecStart still pointed at the old cache
dir. The same truncation is visible on three earlier updates — those only
recovered because a later SessionStart bootstrapped again.

systemd-service.js now splits install into writeUserService (repoints the
unit, starts nothing) and restartUserService (--no-block). The bootstrap
skips the manual stop whenever a unit of ours owns the server, and defers
the restart past writeState and releaseLock, so being killed by its own
request costs nothing. The unit is Restart=always: a graceful SIGTERM from
anything must no longer read as "leave it stopped".

installUserService keeps its old semantics for `agenthub service install`,
which runs from a user's shell and is not in the cgroup.
2026-09-11 16:30:10 +07:00
nntrivi2001 0335e0e6bc chore(release): bump version to 1.5.6 2026-09-11 15:59:33 +07:00
nntrivi2001 18614f378d fix(updates): give a plugin-cache install a real message, not null
`agenthub update-check` prints `message` as its headline, so every
plugin-cache install saw the literal "null" where the verdict belongs — and
that is the one install kind with no commit count to fall back on, so the
line carried nothing at all.

The plugin branch of getUpdatesStatus compares versions exactly like the
checkout branch does; it now builds the same four sentences from what it
already computed (newer release / newest / no local version to compare /
release lookup failed).

The nogit regression test asserted `message === null` as a proxy for "did
not fall through to the not-a-git-clone payload" — it now asserts that on
the message's content instead, which is what it always meant.
2026-09-11 15:54:03 +07:00
nntrivi2001 63ed90ba05 docs(plugins): stop promising an MCP server the plugin no longer ships
c7dbaf2 dropped it. The doctor row list also claimed an 'MCP build
freshness' check that plugin-doctor.js does not emit.
2026-09-11 15:39:24 +07:00
21 changed files with 895 additions and 63 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: 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.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.
@@ -43,6 +43,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
## 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 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.
@@ -14,6 +14,8 @@
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
* auto-setup calls against a cwd that must actually exist on disk first.
* An optional "Create/update readme file" checkbox starts the lane's terminal
* run right after creation with `/sg-all:sg-md-writing` as its first message.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -94,6 +96,7 @@ export function AddLaneModal({
const [branches, setBranches] = useState<string[] | null>(null);
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [writeDocs, setWriteDocs] = useState(false);
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [tool, setTool] = useState<LaneTool>("claude");
@@ -114,6 +117,7 @@ export function AddLaneModal({
setBranches(null);
setBase("");
setPipeline("default");
setWriteDocs(false);
setTool("claude");
setBranchesError(null);
setError(null);
@@ -177,6 +181,14 @@ export function AddLaneModal({
return () => window.clearTimeout(timer);
}, [mode, sourceRepo, t]);
// The checkbox below runs the docs skill by opening the lane's own terminal
// run with the slash command as its first message — a skill only exists
// inside a CLI session, so there is nothing server-side to call instead.
// Claude-only: codex has no slash commands (same reason LaneConsolePane
// prefills prose for a codex lane).
const startDocsRun = (laneId: number, cwd: string) =>
api.run.start({ laneId, cwd, initialPrompt: "/sg-all:sg-md-writing" });
const submit = async () => {
const repo = sourceRepo.trim();
const name = title.trim();
@@ -192,6 +204,7 @@ export function AddLaneModal({
pipeline,
tool,
});
if (writeDocs && tool === "claude") await startDocsRun(result.lane.id, result.lane.cwd);
onAdded(result.lane);
reset();
onClose();
@@ -242,6 +255,10 @@ export function AddLaneModal({
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
});
if (writeDocs && tool === "claude") {
await startDocsRun(result.lane.id, provisionedLane.cwd);
}
if (import.meta.env.DEV) {
console.info("[add-lane] auto-setup result:", {
profile:
@@ -390,6 +407,38 @@ export function AddLaneModal({
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
{tool === "claude" && (
<label
// Card that lights up when ticked, matching LaneStripCard's
// selected/unselected treatment.
className={`flex cursor-pointer items-start gap-2.5 rounded-lg border p-3 shadow-sm transition-colors ${
writeDocs
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<input
type="checkbox"
// The wrapping label also holds the hint, so name the input
// itself — otherwise its accessible name is label + hint.
aria-label={t("addLaneWriteDocsLabel")}
checked={writeDocs}
onChange={(e) => setWriteDocs(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-accent"
/>
<span>
<span
className={`block text-xs font-medium ${writeDocs ? "text-accent" : "text-fg-secondary"}`}
>
{t("addLaneWriteDocsLabel")}
</span>
<span className="mt-1 block text-[10px] text-fg-muted">
{t("addLaneWriteDocsHint")}
</span>
</span>
</label>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
},
run: { start: vi.fn() },
},
}));
@@ -398,6 +399,42 @@ describe("AddLaneModal — folder browse", () => {
});
});
describe("AddLaneModal — create/update readme", () => {
it("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
vi.mocked(api.run.start)
.mockReset()
.mockResolvedValue({} as never);
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByLabelText("Create/update readme file"));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.run.start).toHaveBeenCalledWith({
laneId: 9,
cwd: "/Users/tester/projects/repo",
initialPrompt: "/sg-all:sg-md-writing",
})
);
});
it("is hidden for a codex lane, which has no slash commands", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Codex" }));
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
});
});
describe("AddLaneModal — pipeline template", () => {
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
// Creation is the ONLY point the UI can set a template, so a lane born on
+2
View File
@@ -40,6 +40,8 @@
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
"addLaneWriteDocsLabel": "Create/update readme file",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
+2
View File
@@ -40,6 +40,8 @@
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
@@ -9537,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.5.5 (rebuild client to match)
UI build v1.5.10 (rebuild client to match)
</p>
</div>
<div
+4 -1
View File
@@ -456,7 +456,10 @@ Environment=NODE_ENV=production
Environment=PORT=4820
Environment=DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
# `always`, not `on-failure`: a graceful SIGTERM exits 0, which `on-failure`
# reads as "meant to stop" — one stray kill and the dashboard stays down.
# `systemctl stop` still stops it. Same reasoning as the plugin's own unit.
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
+2
View File
@@ -43,6 +43,8 @@ The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `agenthub lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
Under the *Pipeline template* select, a **Create/update readme file** checkbox (Claude lanes only — Codex has no slash commands) starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set. It requires the `sg-all` plugin to be installed in Claude Code; without it the session simply reports an unknown command. Leave it unticked to create the lane without starting a run.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
## Lane tool: Claude or Codex
+65 -5
View File
@@ -1,6 +1,6 @@
# AgentHub — Plugin Marketplace
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server, `agenthub` CLI and MCP tools, no checkout required. On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
@@ -134,7 +134,7 @@ waits on an install:
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 (API and MCP still work, 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. **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
`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
@@ -144,6 +144,9 @@ waits on an install:
`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
(see [Who may stop the server](#who-may-stop-the-server)). Deliberately
after step 9.
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)
@@ -172,11 +175,68 @@ On macOS, Windows, or a Linux box without systemd, nothing changes: the
server is a detached spawn started fresh on the next `SessionStart` after a
reboot, same as always.
### Who may stop the server
**When a `agenthub.service` unit of ours exists, nothing else may stop the
dashboard.** The bootstrap that performs a self-update is spawned by the
running server, so it lives inside that server's systemd cgroup. SIGTERM-ing
the server from there breaks two ways at once:
- the server exits **0**, and the unit's old `Restart=on-failure` read a clean
exit as "meant to stop" — so systemd did not bring it back;
- `KillMode=control-group` (systemd's default) reaps **every** process in the
cgroup when the main process exits, including the bootstrap itself — mid-run,
before it could repoint the unit at the new plugin root or write
`runtime/state.json`.
The result was a dashboard that went down on every self-update and stayed down
until some later Claude Code session happened to bootstrap again, leaving a
stale `.bootstrap.lock` and a `state.json` still naming the previous version.
So the order is fixed, and `scripts/systemd-service.js` splits its API to
enforce it:
| Situation | What the bootstrap does |
|---|---|
| A unit of ours owns the server (`unitIsOurs`) | Never stops it. `writeUserService` repoints the unit at the new plugin root **without starting anything**, the rest of the bootstrap runs, state is written, the lock is released — and only then `restartUserService` asks systemd to restart, with `--no-block` so being killed by its own request costs nothing. |
| No unit, or a foreign one | Unchanged: stop the old server, then `installUserService` (or a detached spawn where there is no systemd). Nothing here is inside a unit's cgroup, so the stop is safe. |
The unit is written with **`Restart=always`**, not `on-failure`: a graceful
SIGTERM from anything at all would otherwise leave the dashboard down for good.
An explicit `systemctl stop` — which is what `agenthub service uninstall` and
the Settings panel do — still stops it.
`agenthub service install` (the checkout path) keeps using `installUserService`:
it runs from your own shell, outside the unit's cgroup, so it may block and
start in one step.
**A unit pointing at a retired plugin root repairs itself on the next session.**
This is the residue an interrupted update leaves behind, and it is invisible
while the dashboard is up: the server runs fine, every doctor row reads OK, and
then a reboot starts a cache directory Claude Code deleted during the update —
so the dashboard never comes back, for a reason nothing recorded. The fast path
therefore checks it (`unitIsStale`, pure fs — it may not spawn a process on
every `SessionStart`) and refuses to short-circuit, and the bootstrap rewrites
the unit.
Repairing the *unit* is not restarting the *server*: when the running server is
current and healthy, the corrected unit is simply left to take effect at the
next start (`pendingSystemdRestart = systemdOwned && needsServer`). Restarting
a working dashboard to fix a file that only matters at boot would be churn the
user feels for nothing. For the same reason the detached-spawn fallback is
guarded by `needsServer` — entering that branch for a stale unit alone would
start a second server onto a port the live one already holds.
`systemdOwned` requires the unit to be **active**, not merely present: an
installed-but-stopped unit owns nothing, and treating it as the owner would
skip the stop and then ask systemd to bind an occupied port — a crash loop,
courtesy of `Restart=always`.
### Commands
| Command | Does |
|---|---|
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), MCP build freshness, UI bundle |
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **systemd** (`WARN` when the unit names a retired plugin root — the dashboard will not survive a reboot — or when it is installed but stopped; no unit at all is fine), **tmux** (`WARN` when absent — only the run console, `agenthub lanes shell` and session reloads need it), **skill plugins** (see below), UI bundle |
| `/agenthub-update` | Pull the latest `agenthub` release via `claude plugin marketplace update` + `claude plugin update agenthub@<marketplace> -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. |
| `/agenthub-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes |
| `/ship-full <requirement>` | Put the lane on the 16-node `ship-feature` template, then run `/agenthub:ship-feature-lane` **in this session**. The in-session twin of `agenthub ship-full` (which spawns its own `claude` from a terminal) |
@@ -268,8 +328,8 @@ not assumed. It does **not**:
For a clean machine, do all of this yourself:
```bash
# 1. Stop the server. On Linux, check for the systemd unit first — a bare
# `kill` just gets restarted by it (Restart=on-failure).
# 1. Stop the server. On Linux, disable the systemd unit FIRST — the unit is
# Restart=always, so a bare `kill` is always undone by systemd.
systemctl --user disable --now agenthub.service 2>/dev/null
rm -f ~/.config/systemd/user/agenthub.service && systemctl --user daemon-reload 2>/dev/null
# Fallback for a detached (non-systemd) server, or if the unit above was absent:
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.5.5
version: 1.5.10
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ĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.5.5",
"version": "1.5.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.5.5",
"version": "1.5.10",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.5.5",
"version": "1.5.10",
"bin": {
"agenthub": "bin/agenthub.js"
},
+6 -1
View File
@@ -1,5 +1,5 @@
---
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, tmux, UI build
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, systemd, tmux, UI build
---
Run the diagnostic and report what it found. Do not fix anything unless the user
@@ -31,6 +31,11 @@ Print the output verbatim, then add at most three lines of guidance based on the
means that attempt has not run or did not succeed — say `/agenthub-update` retries
it, and print the `claude plugin install …` line from the output as the manual
route.
- `WARN systemd` → when it says the unit names a **previous plugin version**,
the dashboard is running now but will NOT come back after a reboot. Starting
a new Claude Code session repairs it; print the `--force` command from the
output as the immediate fix. "installed but not running" just needs
`systemctl --user start agenthub`.
- `WARN tmux` → only the run console, `agenthub lanes shell` and the
session-reload keystrokes are affected; monitoring, lanes and the CLI are not.
Print the install command from the output. If the row says `ok` but Start Run
+86 -25
View File
@@ -107,6 +107,12 @@ function fastPathOk(rt = runtimeDir()) {
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
// actual check (manifest + every user SKILL.md) runs once, in the worker.
if (!state.skillPluginsSettled) return false;
// A live server says nothing about whether it will come back. When our unit
// still names a previous plugin cache directory, everything looks healthy
// until the next reboot — and then the dashboard is simply gone, from a path
// Claude Code deleted long ago. Reading one file is cheap enough for a hot
// path; `unitIsStale` deliberately spawns nothing.
if (require("./systemd-service").unitIsStale("agenthub", PLUGIN_ROOT)) return false;
return serverIsLive();
}
@@ -465,6 +471,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
return "locked";
}
// Declared out here because the restart it guards has to happen in the
// `finally`, after the lock is released and the state is on disk: it is the
// step that may cost this process its own life.
let pendingSystemdRestart = false;
try {
const wantDeps = depsHash();
const state = readState(rt) || {};
@@ -504,44 +515,85 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
log(rt, "built the dashboard UI bundle");
}
const svc = require("./systemd-service");
// Who owns the running server decides how it may be retired. A unit of
// ours means systemd owns it — and this process is a descendant of that
// server, so it sits in the unit's cgroup. SIGTERM-ing the main process
// there does two fatal things at once: the clean exit(0) tells systemd
// "meant to stop", and `KillMode=control-group` reaps this bootstrap
// before it can write state or point the unit at the new cache dir. That
// is the bug that left a dashboard dead after every self-update until the
// next Claude Code session happened to bootstrap again.
// ACTIVE matters, not merely present: a unit of ours that is installed but
// stopped owns nothing right now. If a detached server is holding the port
// in that state, treating systemd as the owner would skip the stop and then
// ask systemd to start a second server onto a bound port — a crash loop,
// courtesy of Restart=always.
const systemdOwned =
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
// After a plugin update the running server executes code from a cache
// directory Claude Code has already discarded — retire it first.
// directory Claude Code has already discarded — retire it first. Only
// when nothing else owns its lifecycle; otherwise systemd retires it, in
// one restart job, once everything below is durable.
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
if (movedRoot && stopDashboard()) {
if (movedRoot && !systemdOwned && stopDashboard()) {
log(rt, "stopped the server started from the previous plugin version");
waitForPortsFree();
}
if (movedRoot || !serverIsLive()) {
const svc = require("./systemd-service");
const result = svc.systemctlAvailable()
? svc.installUserService({
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: PLUGIN_ROOT,
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
env: {
NODE_ENV: "production",
NODE_PATH: path.join(rt, "node_modules"),
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
// systemd --user units get systemd's own minimal PATH, not the
// shell's — forward it so the server can find `claude`/`codex`.
PATH: process.env.PATH || "",
},
})
: "unavailable";
// Two different needs, deliberately not collapsed. `needsServer` is "the
// running process must be replaced"; `unitStale` is "the unit names a cache
// directory that is no longer this one", which is invisible until a reboot
// and must be repaired even while a perfectly healthy server is up.
const needsServer = movedRoot || !serverIsLive();
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
if (needsServer || unitStale) {
const unit = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: PLUGIN_ROOT,
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
env: {
NODE_ENV: "production",
NODE_PATH: path.join(rt, "node_modules"),
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
// systemd --user units get systemd's own minimal PATH, not the
// shell's — forward it so the server can find `claude`/`codex`.
PATH: process.env.PATH || "",
},
};
// Two shapes of the same step. When systemd already owns the server the
// unit is only REWRITTEN here — pointing at the new plugin root — and
// the restart is deferred to `pendingSystemdRestart` below, because
// starting it now would kill this process mid-bootstrap. Otherwise
// there is no cgroup to be reaped by, and install-and-start is fine.
const result = systemdOwned
? svc.writeUserService(unit)
: svc.systemctlAvailable()
? svc.installUserService(unit)
: "unavailable";
if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger();
// Only when the RUNNING process has to change. A stale unit beside a
// healthy server is now corrected on disk and left to take effect at
// the next start — restarting a working dashboard to fix a file that
// only matters at boot would be churn the user feels for no reason.
pendingSystemdRestart = systemdOwned && needsServer;
log(
rt,
result === "written"
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
: "systemd --user service already up to date"
result === "unchanged"
? "systemd --user service already up to date"
: unitStale && !needsServer
? "repointed the systemd --user service at the current plugin version (it named a retired one)"
: "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
);
} else {
} else if (needsServer) {
// No systemd (macOS/Windows/WSL without it), or the unit exists but
// wasn't ours to touch — same detached-spawn fallback as before,
// scoped to this session only.
// scoped to this session only. Guarded by `needsServer`: entering this
// block for a stale unit alone must never spawn a second server onto
// the port the live one already holds.
const pid = startDashboard(rt);
log(
rt,
@@ -572,6 +624,15 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
return "ok";
} finally {
releaseLock(rt);
// LAST, and after the lock: systemd stops the old main process to start
// the new one, and this process shares its cgroup, so it may not outlive
// this call. Everything that had to be durable already is — the state
// file is written and the lock is gone, so even being killed here leaves
// no debris and no half-recorded bootstrap.
if (pendingSystemdRestart) {
log(rt, "handing the server over to systemd (restarting the unit)");
require("./systemd-service").restartUserService("agenthub");
}
}
}
+48
View File
@@ -83,6 +83,7 @@ function diagnose() {
);
add(...cliStatus());
add(...systemdStatus());
add(...tmuxStatus());
add(...skillPluginStatus());
@@ -220,6 +221,53 @@ function skillPluginStatus() {
];
}
/**
* The failure this row exists for is silent by construction: a unit still
* naming a retired plugin cache directory keeps a running dashboard looking
* perfectly healthy, and only bites at the next reboot — when the server does
* not come back, from a path Claude Code deleted weeks earlier. Nothing else
* in this report would show it.
*
* WARN, never FAIL: the dashboard in front of you is working, and on macOS,
* Windows or a Linux box without systemd there is no unit to have an opinion
* about.
*/
function systemdStatus() {
const svc = require("./systemd-service");
const unit = svc.unitPath("agenthub");
if (!svc.unitIsOurs("agenthub")) {
if (fs.existsSync(unit)) {
return ["warn", "systemd", `${unit} exists but was not written by AgentHub — left untouched`];
}
return [
"ok",
"systemd",
svc.systemctlAvailable()
? "no unit — the server is started per session (install one with `agenthub service install`)"
: "not available on this platform — the server is started per session",
];
}
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
return [
"warn",
"systemd",
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
];
}
if (!svc.unitIsActive("agenthub")) {
return [
"warn",
"systemd",
"unit is installed and current but not running — start it with `systemctl --user start agenthub`",
];
}
return ["ok", "systemd", "unit is current and running — the dashboard survives reboots"];
}
/**
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
+117 -13
View File
@@ -26,9 +26,71 @@ function unitPath(name) {
return path.join(unitDir(), `${name}.service`);
}
// Test seam: swap the systemctl implementation so unit tests never talk to a
// real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
let execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
function __setExecImpl(fn) {
execImpl = fn;
}
function __reset() {
execImpl = (args, opts) => spawnSync("systemctl", ["--user", ...args], opts);
}
function systemctlAvailable() {
if (process.platform !== "linux") return false;
return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0;
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
}
/**
* Is there a unit by this name, and did WE write it? The distinction decides
* whether the dashboard may be stopped by hand: a unit of ours means systemd
* owns the process, and SIGTERM-ing it out of band is the one thing that must
* never happen (see `restartUserService`).
*/
function unitIsOurs(name) {
try {
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
} catch {
return false;
}
}
/**
* `Restart=always`, not `on-failure`: a graceful SIGTERM exits 0, which
* `on-failure` reads as "meant to stop". One stray kill — or an out-of-band
* stop during a plugin self-update — then left the dashboard down until some
* later Claude Code session happened to run the bootstrap. An explicit
* `systemctl stop` (what uninstall does) still stops it for good.
*/
/**
* Does our unit still point at `root`? A plugin update moves the install to a
* new cache directory, and a unit left naming the old one is a failure that
* shows NOTHING until the next reboot — the dashboard simply does not come
* back, from a path Claude Code garbage-collected weeks earlier.
*
* Pure fs, no `systemctl`: this is called from the bootstrap's fast path, which
* runs on every single SessionStart and may not spawn a process.
*
* A foreign unit is never "stale" — it is not ours to judge or rewrite.
*/
function unitIsStale(name, root) {
let body;
try {
body = fs.readFileSync(unitPath(name), "utf8");
} catch {
return false; // no unit at all: nothing to be stale
}
if (!body.includes(MARKER)) return false;
return !body.includes(`\nWorkingDirectory=${root}\n`);
}
/** Is the unit currently running? Costs a `systemctl` call — never call it
* from the fast path. */
function unitIsActive(name) {
if (!systemctlAvailable()) return false;
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
return String(res.stdout || "").trim() === "active";
}
function unitContent({ description, workingDir, execStart, env }) {
@@ -44,7 +106,7 @@ After=network.target
Type=simple
WorkingDirectory=${workingDir}
ExecStart=${execStart}
Restart=on-failure
Restart=always
RestartSec=3
${envLines}
@@ -54,17 +116,18 @@ WantedBy=default.target
}
function systemctl(args) {
return spawnSync("systemctl", ["--user", ...args], { stdio: "inherit" });
return execImpl(args, { stdio: "inherit" });
}
/**
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
* cache path after an update) is reloaded and restarted.
* Writes (or refreshes) the unit file and enables it for boot — but starts
* nothing. Split out of `installUserService` for the plugin self-update, which
* must be able to point the unit at the new cache directory *before* anything
* stops the running server, and defer the restart to its very last act.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function installUserService({ name, description, workingDir, execStart, env = {} }) {
function writeUserService({ name, description, workingDir, execStart, env = {} }) {
if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ description, workingDir, execStart, env });
const target = unitPath(name);
@@ -72,22 +135,56 @@ function installUserService({ name, description, workingDir, execStart, env = {}
if (fs.existsSync(target)) {
const current = fs.readFileSync(target, "utf8");
if (!current.includes(MARKER)) return "foreign";
if (current === body) {
systemctl(["enable", "--now", `${name}.service`]);
return "unchanged";
}
if (current === body) return "unchanged";
}
fs.mkdirSync(unitDir(), { recursive: true });
fs.writeFileSync(target, body, "utf8");
systemctl(["daemon-reload"]);
systemctl(["enable", "--now", `${name}.service`]);
systemctl(["restart", `${name}.service`]);
systemctl(["enable", `${name}.service`]);
return "written";
} catch {
return "failed";
}
}
/**
* Ask systemd to restart the unit and do NOT wait for it.
*
* `--no-block` is the whole point, not a nicety. The caller is the bootstrap
* spawned by the running server, so it lives in that server's cgroup; the
* moment systemd stops the old main process, `KillMode=control-group` reaps
* the caller too. Blocking here means being killed mid-call, which is why the
* restart must be the last thing the bootstrap does — by then there is nothing
* left to lose.
*/
function restartUserService(name) {
if (!systemctlAvailable()) return "unavailable";
const res = systemctl(["restart", "--no-block", `${name}.service`]);
return res.status === 0 ? "restarted" : "failed";
}
/**
* Writes (or refreshes) the unit file and enables + starts it. Idempotent:
* an unchanged unit is left running as-is; a changed one (e.g. a plugin
* cache path after an update) is reloaded and restarted.
*
* Safe for `agenthub service install`, which is invoked from a user's own
* shell and is therefore not inside the unit's cgroup. The plugin bootstrap
* is, and uses `writeUserService` + `restartUserService` instead.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function installUserService(opts) {
const result = writeUserService(opts);
if (result === "unchanged") {
systemctl(["enable", "--now", `${opts.name}.service`]);
} else if (result === "written") {
systemctl(["enable", "--now", `${opts.name}.service`]);
systemctl(["restart", `${opts.name}.service`]);
}
return result;
}
/** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
function uninstallUserService(name) {
if (!systemctlAvailable()) return "unavailable";
@@ -110,8 +207,15 @@ function enableLinger() {
module.exports = {
systemctlAvailable,
unitIsOurs,
unitIsStale,
unitIsActive,
writeUserService,
restartUserService,
installUserService,
uninstallUserService,
enableLinger,
unitPath,
__setExecImpl,
__reset,
};
+65
View File
@@ -129,6 +129,71 @@ describe("fast path", () => {
);
assert.equal(boot.fastPathOk(RT), false);
});
it("is false when the systemd unit still names a retired plugin root", () => {
// The one failure a live server hides completely: everything is healthy
// until the next reboot, and then the dashboard never comes back because
// its unit points into a cache directory Claude Code has deleted. The fast
// path has to reject it, or no SessionStart ever repairs it.
//
// Everything else here is deliberately HEALTHY — including a live server,
// without which `fastPathOk` is false anyway and this test would pass
// against the unfixed code.
const ROOT = path.resolve(__dirname, "..", "..");
const pkg = require("../../package.json");
const infoPath = path.join(TMP_HOME, ".agenthub.json");
const prevHome = process.env.HOME;
// HOME first: `unitPath()` resolves against it, and computing the path
// before this line points every write in this test at the REAL
// ~/.config/systemd/user/agenthub.service — which the cleanup below then
// deletes. (Asked how I know.)
process.env.HOME = TMP_HOME;
const unit = require("../../scripts/systemd-service").unitPath("agenthub");
assert.ok(
unit.startsWith(TMP_HOME + path.sep),
`refusing to touch a unit outside the temp home: ${unit}`
);
const writeUnit = (workingDir) => {
fs.mkdirSync(path.dirname(unit), { recursive: true });
fs.writeFileSync(
unit,
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
`[Service]\nWorkingDirectory=${workingDir}\n`,
"utf8"
);
};
try {
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
fs.writeFileSync(
infoPath,
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: "x" }] })
);
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: ROOT,
skillPluginsSettled: "nothing-missing",
},
RT
);
// Control: a unit naming THIS root is the steady state — fast path taken.
writeUnit(ROOT);
assert.equal(boot.fastPathOk(RT), true, "a current unit must not block the fast path");
// The regression: same healthy install, unit left on a retired root.
writeUnit("/plugins/cache/agenthub/deadbeef");
assert.equal(boot.fastPathOk(RT), false);
} finally {
fs.rmSync(infoPath, { force: true });
fs.rmSync(unit, { force: true });
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
}
});
});
describe("skill-plugin auto-install gate", () => {
@@ -0,0 +1,121 @@
/**
* @file Tests the `systemd` row of scripts/plugin-doctor.js.
*
* The row exists for a failure nothing else in the report can see: a unit still
* naming a retired plugin cache directory leaves a running, healthy-looking
* dashboard that simply does not come back after the next reboot — it starts a
* path Claude Code deleted during the update. Every other row would read OK.
*
* WARN and never FAIL: the dashboard in front of the user works, and plenty of
* machines legitimately have no unit at all.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-systemd-"));
const prevHome = process.env.HOME;
// Before anything resolves a unit path: `unitPath()` reads HOME, and a path
// computed against the real one makes these tests rewrite the developer's own
// ~/.config/systemd/user/agenthub.service.
process.env.HOME = TMP_HOME;
process.env.CLAUDE_HOME = path.join(TMP_HOME, ".claude");
const svc = require("../../scripts/systemd-service");
const { diagnose } = require("../../scripts/plugin-doctor");
const UNIT = svc.unitPath("agenthub");
const PLUGIN_ROOT = path.resolve(__dirname, "..", "..");
assert.ok(UNIT.startsWith(TMP_HOME + path.sep), `refusing to touch ${UNIT}`);
beforeEach(() => {
fs.rmSync(UNIT, { force: true });
// Claim systemd exists and the unit is running, so each test varies exactly
// one thing: the unit file on disk.
svc.__setExecImpl((args) => ({
status: 0,
stdout: args[0] === "is-active" ? "active\n" : "",
}));
});
after(() => {
svc.__reset();
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
delete process.env.CLAUDE_HOME;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
function row() {
const found = diagnose().find((r) => r.label === "systemd");
assert.ok(found, "the doctor must always emit a systemd row");
return found;
}
function writeUnit(workingDir) {
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
fs.writeFileSync(
UNIT,
"[Unit]\n# agenthub-managed-unit — regenerated on install, do not edit by hand\n" +
`[Service]\nWorkingDirectory=${workingDir}\n`,
"utf8"
);
}
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
describe("plugin-doctor: systemd row", linuxOnly, () => {
it("warns, never fails, when the unit names a retired plugin root", () => {
writeUnit("/plugins/cache/sg/agenthub/deadbeef");
const r = row();
assert.equal(r.level, "warn", "a working dashboard must not fail the doctor run");
assert.match(r.detail, /previous plugin version/i);
// The consequence is the whole point — a reader who does not know it will
// ignore the row.
assert.match(r.detail, /reboot/i);
assert.match(r.detail, /plugin-bootstrap\.js --force|new Claude Code session/);
});
it("passes when the unit is current and running", () => {
writeUnit(PLUGIN_ROOT);
const r = row();
assert.equal(r.level, "ok", r.detail);
assert.match(r.detail, /current and running/i);
});
it("warns when the unit is current but stopped", () => {
writeUnit(PLUGIN_ROOT);
svc.__setExecImpl((args) => ({
status: 3,
stdout: args[0] === "is-active" ? "inactive\n" : "",
}));
const r = row();
assert.equal(r.level, "warn");
assert.match(r.detail, /not running/i);
});
it("reports no unit as fine, not as a problem", () => {
// A plugin install without `agenthub service install` is a normal setup:
// the server is started per session.
const r = row();
assert.equal(r.level, "ok", r.detail);
assert.match(r.detail, /no unit/i);
});
it("warns about a unit it did not write, and never claims it is stale", () => {
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
fs.writeFileSync(UNIT, "[Service]\nExecStart=/opt/someone-elses-agenthub\n", "utf8");
const r = row();
assert.equal(r.level, "warn");
assert.match(r.detail, /not written by AgentHub|left untouched/i);
});
});
+194
View File
@@ -0,0 +1,194 @@
/**
* @file Tests scripts/systemd-service.js, and specifically the split that
* exists because of one incident: the plugin self-update killed the dashboard
* and left it dead.
*
* The bootstrap that performs an update is spawned by the running server, so
* it lives in that server's systemd cgroup. The old code SIGTERM-ed the server
* from inside that cgroup, which (a) exits 0, and `Restart=on-failure` reads a
* clean exit as "meant to stop", and (b) trips `KillMode=control-group`, so
* systemd reaped the bootstrap before it could write state or repoint the unit
* at the new plugin root. The dashboard then stayed down until some later
* Claude Code session happened to bootstrap again.
*
* Hence: writing the unit must not start anything, restarting must not block,
* and a clean exit must no longer be read as "leave it stopped".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-systemd-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const svc = require("../../scripts/systemd-service");
/** Every systemctl invocation this test saw, as flat argv strings. */
let calls = [];
beforeEach(() => {
calls = [];
// Each test starts from "no unit on disk" — otherwise a previous test's
// write turns the next one's `written` into `unchanged`.
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
// `--version` is the availability probe every entry point runs first, not
// an action — recording it would bury the calls these tests are about.
if (args[0] !== "--version") calls.push(args.join(" "));
return { status: 0 };
});
});
after(() => {
svc.__reset();
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
const UNIT = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: "/plugin/root/new",
execStart: "/usr/bin/node /plugin/root/new/server/index.js",
env: { NODE_ENV: "production" },
};
function unitFile() {
return fs.readFileSync(svc.unitPath("agenthub"), "utf8");
}
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
describe("systemd-service — unit content", linuxOnly, () => {
it("restarts always, because a graceful SIGTERM exits 0", () => {
svc.writeUserService(UNIT);
const body = unitFile();
// `on-failure` is what let one stray kill (or an out-of-band stop during
// an update) leave the dashboard down indefinitely.
assert.match(body, /^Restart=always$/m);
assert.doesNotMatch(body, /^Restart=on-failure$/m);
});
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
svc.writeUserService(UNIT);
assert.match(unitFile(), /agenthub-managed-unit/);
});
});
describe("systemd-service — writeUserService", linuxOnly, () => {
it("writes and enables the unit but starts NOTHING", () => {
// The point of the split: the update must be able to repoint the unit at
// the new plugin root while the old server is still serving requests.
const result = svc.writeUserService(UNIT);
assert.equal(result, "written");
assert.ok(
calls.some((c) => c.startsWith("daemon-reload")),
"a rewritten unit must be reloaded"
);
assert.ok(
calls.some((c) => c === "enable agenthub.service"),
"it must still be enabled for boot"
);
assert.ok(
!calls.some((c) => c.includes("--now") || c.startsWith("restart") || c.startsWith("start")),
`nothing may be started here, got: ${JSON.stringify(calls)}`
);
});
it("reports an identical unit as unchanged and touches systemd not at all", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.writeUserService(UNIT), "unchanged");
assert.deepEqual(calls, [], "an unchanged unit needs no systemctl call");
});
it("refuses a unit it did not write", () => {
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8");
assert.equal(svc.writeUserService(UNIT), "foreign");
assert.equal(svc.unitIsOurs("agenthub"), false);
});
});
describe("systemd-service — restartUserService", linuxOnly, () => {
it("never blocks, because the caller is inside the cgroup being restarted", () => {
assert.equal(svc.restartUserService("agenthub"), "restarted");
assert.deepEqual(calls, ["restart --no-block agenthub.service"]);
});
it("reports failure rather than throwing when systemctl refuses", () => {
// Available, but the restart itself is rejected — distinct from "no
// systemd here", which must not be reported as a failure.
svc.__setExecImpl((args) => ({ status: args[0] === "--version" ? 0 : 1 }));
assert.equal(svc.restartUserService("agenthub"), "failed");
});
});
describe("systemd-service — unitIsStale", linuxOnly, () => {
it("is true when the unit names a plugin root that is no longer the current one", () => {
// Invisible while the server runs, fatal at the next reboot: the unit
// starts a path Claude Code garbage-collected after the update.
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true);
});
it("is false for a unit already naming the current root", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
});
it("is false when there is no unit, and for one we do not own", () => {
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
assert.equal(
svc.unitIsStale("agenthub", "/plugin/root/new"),
false,
"a foreign unit is not ours to call stale, or to rewrite"
);
});
it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => {
svc.writeUserService(UNIT);
calls = [];
svc.unitIsStale("agenthub", "/plugin/root/new");
assert.deepEqual(calls, []);
});
});
describe("systemd-service — unitIsActive", linuxOnly, () => {
it("is true only for `active`", () => {
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
assert.equal(svc.unitIsActive("agenthub"), true);
svc.__setExecImpl((args) => ({
status: 3,
stdout: args[0] === "is-active" ? "inactive\n" : "",
}));
assert.equal(svc.unitIsActive("agenthub"), false);
});
});
describe("systemd-service — unitIsOurs", linuxOnly, () => {
it("is false when no unit exists at all", () => {
fs.rmSync(svc.unitPath("agenthub"), { force: true });
assert.equal(svc.unitIsOurs("agenthub"), false);
});
it("is true for a unit this code wrote", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitIsOurs("agenthub"), true);
});
});
@@ -64,6 +64,7 @@ function makePluginInstall(name, opts = {}) {
source = { source: "url", url: "https://forge.example.com/Owner/Repo.git", ref: "main" },
version = "1.5.1",
knownMarketplaces = true,
repository,
} = opts;
const home = path.join(tmpDir, name, ".claude");
@@ -74,7 +75,7 @@ function makePluginInstall(name, opts = {}) {
fs.writeFileSync(
path.join(mktDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({ name: "sg", plugins: [{ name: "agenthub", source }] })
JSON.stringify({ name: "sg", repository, plugins: [{ name: "agenthub", source }] })
);
if (knownMarketplaces) {
fs.writeFileSync(
@@ -128,10 +129,31 @@ describe("pluginMarketplaceUrl", () => {
);
});
it("falls back to the marketplace's repository for a relative source", () => {
// This project's own entry is `"source": "./"` — the plugin IS the
// marketplace repo. Without this the dashboard answered `no_remote` and
// could never see a release on any plugin-cache install.
makePluginInstall("relsrc", {
source: "./",
repository: "https://forge.example.com/Owner/Repo",
});
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), "https://forge.example.com/Owner/Repo");
});
it("returns null for a relative source when the manifest declares no repository", () => {
makePluginInstall("relnorepo", { source: "./plugins/agenthub" });
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null for a github owner/repo shorthand rather than guessing a host", () => {
// Inventing github.com here would produce an API URL that 404s and a
// confidently wrong "up to date".
makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" } });
// Not even when the marketplace declares a repository: a shorthand source
// names a DIFFERENT repo, so borrowing the marketplace's is a wrong answer.
makePluginInstall("ghshort", {
source: { source: "github", repo: "Owner/Repo" },
repository: "https://forge.example.com/Owner/Marketplace",
});
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
@@ -176,8 +198,15 @@ describe("getUpdatesStatus — plugin cache with no .git", () => {
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
// The old behaviour: message set, everything else absent.
assert.equal(s.message, null, "must not fall through to the not-a-git-clone payload");
// The old behaviour: the not-a-git-clone message, everything else absent.
// Asserted on the message's CONTENT, not on it being null — a plugin
// install now carries its own sentence, and null was only ever a proxy for
// "did not fall through here".
assert.doesNotMatch(
s.message,
/not a git clone/i,
"must not fall through to the not-a-git-clone payload"
);
assert.equal(s.release_check, "ok", "the release layer must actually run");
assert.equal(s.install_kind, "plugin");
assert.equal(s.plugin_marketplace, "sg");
@@ -208,6 +237,31 @@ describe("getUpdatesStatus — plugin cache with no .git", () => {
assert.ok(s.plugin_marketplace && s.plugin_name && s.repo_root);
});
it("carries a human-readable message in every outcome, never a null", async () => {
// `agenthub update-check` prints `message` as its headline. Null here made
// it print the literal "null" on every plugin-cache install — the one kind
// that has no commit count to fall back on.
const cases = [
[makePluginInstall("msg-newer").root, { latestRelease: release("v1.6.0") }, /v1\.6\.0/],
[makePluginInstall("msg-current").root, { latestRelease: release("v1.5.0") }, /newest/i],
[
makePluginInstall("msg-failed").root,
{ latestRelease: { ok: false, reason: "unreachable" } },
/unreachable/,
],
[
makePluginInstall("msg-noversion", { version: null }).root,
{ latestRelease: release("v1.6.0") },
/no version|cannot be compared/i,
],
];
for (const [root, opts, expected] of cases) {
const s = await getUpdatesStatus(root, opts);
assert.equal(typeof s.message, "string", `message must be a string, got ${s.message}`);
assert.match(s.message, expected);
}
});
it("does not treat an older published release as an update", async () => {
const { root } = makePluginInstall("older");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.4.0") });
+32 -8
View File
@@ -11,11 +11,12 @@
* rather than reporting a false "up to date".
*
* **Two independent paths, chosen by install kind.** A checkout derives the API
* URL from its `origin` remote. A plugin-cache install does NOT: when a
* marketplace entry declares `"source": "url"` (this project's own does) Claude
* Code unpacks an archive, so the cache directory has no `.git` and there is no
* URL from its `origin` remote. A plugin-cache install does NOT: Claude Code
* unpacks an archive, so the cache directory has no `.git` and there is no
* remote to read. That path is answered entirely without git — the repository
* URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`) and
* URL comes from the marketplace manifest on disk (`pluginMarketplaceUrl`,
* which for this project's own relative `"source": "./"` entry resolves to the
* manifest's top-level `repository`) and
* the package.json version is the only comparison signal. Getting this wrong is
* what shipped in 1.5.1: the `.git` probe ran first, returned "not a git clone",
* and skipped the release layer, so `update_available` stayed false however many
@@ -346,11 +347,24 @@ function pluginMarketplaceUrl(marketplace, plugin) {
const entry = (manifest?.plugins || []).find((p) => p && p.name === plugin);
if (!entry) return null;
// `source` is either a string (a bare URL or owner/repo) or an object with
// its own `source` discriminator plus `url`/`repo`.
// `source` is either a string (a bare URL, a relative path, or owner/repo)
// or an object with its own `source` discriminator plus `url`/`repo`.
const src = entry.source;
if (typeof src === "string") return src.includes("://") ? src : null;
if (typeof src === "string" && src.includes("://")) return src;
if (src && typeof src.url === "string" && src.url) return src.url;
// A RELATIVE source ("./", "./plugins/x") means the plugin ships inside the
// marketplace repository itself — this project's own entry is exactly that
// ("source": "./") — so the manifest's declared `repository` is where its
// releases are published. Without this fallback every plugin-cache install
// of this very plugin answered `no_remote` and could never see a release.
const relative = typeof src === "string" && /^\.{0,2}\//.test(src);
if (relative) {
const repo = manifest?.repository;
const url = typeof repo === "string" ? repo : repo?.url;
if (typeof url === "string" && url.includes("://")) return url;
}
// A `github`-style `repo: "owner/repo"` shorthand carries no host; this
// build is distributed from its own forge, so rather than guessing
// github.com the caller reports "remote_not_http" and prints the manual
@@ -402,7 +416,17 @@ async function pluginStatus(root, pluginInstall, options = {}) {
`commands — "Update now" runs them for you and restarts the dashboard. An already-open ` +
`Claude Code session still needs '/reload-plugins' to pick up new skills and commands.`,
manual_command: manualParts.join(" && "),
message: null,
// A plugin install compares versions exactly like a checkout does, so it
// gets the same sentence. Left null, `agenthub update-check` printed the
// literal "null" as its headline on every plugin-cache install — the one
// install kind that cannot fall back to a commit count.
message: releaseIsNewer
? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).`
: comparable
? `Running the newest release (${release.tag}).`
: release.ok
? `This install reports no version, so it cannot be compared with release ${release.tag}.`
: `Could not read the latest release (${release.reason}); version comparison unavailable.`,
install_kind: "plugin",
plugin_marketplace: pluginInstall.marketplace,