Compare commits

..

5 Commits

Author SHA1 Message Date
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
12 changed files with 387 additions and 49 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - 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. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
## 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.
- **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.
@@ -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.5.5 (rebuild client to match) UI build v1.5.7 (rebuild client to match)
</p> </p>
</div> </div>
<div <div
+4 -1
View File
@@ -456,7 +456,10 @@ Environment=NODE_ENV=production
Environment=PORT=4820 Environment=PORT=4820
Environment=DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db Environment=DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
ExecStart=/usr/bin/node server/index.js 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 RestartSec=10
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
+43 -5
View File
@@ -1,6 +1,6 @@
# AgentHub — Plugin Marketplace # 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). 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. 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. 5. **Legacy hook cleanup** — see above.
6. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. 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 8. **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
@@ -144,6 +144,9 @@ waits on an install:
`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`. 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 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)
@@ -172,11 +175,46 @@ 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 server is a detached spawn started fresh on the next `SessionStart` after a
reboot, same as always. 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.
### Commands ### Commands
| Command | Does | | 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, **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-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 | | `/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) | | `/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 +306,8 @@ not assumed. It does **not**:
For a clean machine, do all of this yourself: For a clean machine, do all of this yourself:
```bash ```bash
# 1. Stop the server. On Linux, check for the systemd unit first — a bare # 1. Stop the server. On Linux, disable the systemd unit FIRST — the unit is
# `kill` just gets restarted by it (Restart=on-failure). # Restart=always, so a bare `kill` is always undone by systemd.
systemctl --user disable --now agenthub.service 2>/dev/null systemctl --user disable --now agenthub.service 2>/dev/null
rm -f ~/.config/systemd/user/agenthub.service && systemctl --user daemon-reload 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: # 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 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 1.5.5 version: 1.5.7
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ĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "1.5.5", "version": "1.5.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "1.5.5", "version": "1.5.7",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "1.5.5", "version": "1.5.7",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+54 -19
View File
@@ -465,6 +465,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
return "locked"; 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 { try {
const wantDeps = depsHash(); const wantDeps = depsHash();
const state = readState(rt) || {}; const state = readState(rt) || {};
@@ -504,34 +509,55 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
log(rt, "built the dashboard UI bundle"); 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.
const systemdOwned = svc.systemctlAvailable() && svc.unitIsOurs("agenthub");
// After a plugin update the running server executes code from a cache // 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); 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"); log(rt, "stopped the server started from the previous plugin version");
waitForPortsFree(); waitForPortsFree();
} }
if (movedRoot || !serverIsLive()) { if (movedRoot || !serverIsLive()) {
const svc = require("./systemd-service"); const unit = {
const result = svc.systemctlAvailable() name: "agenthub",
? svc.installUserService({ description: "AgentHub dashboard server (Claude Code plugin)",
name: "agenthub", workingDir: PLUGIN_ROOT,
description: "AgentHub dashboard server (Claude Code plugin)", execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
workingDir: PLUGIN_ROOT, env: {
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`, NODE_ENV: "production",
env: { NODE_PATH: path.join(rt, "node_modules"),
NODE_ENV: "production", DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
NODE_PATH: path.join(rt, "node_modules"), // systemd --user units get systemd's own minimal PATH, not the
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"), // shell's — forward it so the server can find `claude`/`codex`.
// systemd --user units get systemd's own minimal PATH, not the PATH: process.env.PATH || "",
// 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
: "unavailable"; // 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" || result === "unchanged") {
if (result === "written") svc.enableLinger(); if (result === "written") svc.enableLinger();
pendingSystemdRestart = systemdOwned;
log( log(
rt, rt,
result === "written" result === "written"
@@ -572,6 +598,15 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
return "ok"; return "ok";
} finally { } finally {
releaseLock(rt); 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");
}
} }
} }
+87 -15
View File
@@ -26,11 +26,43 @@ function unitPath(name) {
return path.join(unitDir(), `${name}.service`); return path.join(unitDir(), `${name}.service`);
} }
function systemctlAvailable() { // Test seam: swap the systemctl implementation so unit tests never talk to a
if (process.platform !== "linux") return false; // real service manager. Mirrors tmux.js's __setExecImpl/__reset style.
return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0; 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 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.
*/
function unitContent({ description, workingDir, execStart, env }) { function unitContent({ description, workingDir, execStart, env }) {
const envLines = Object.entries(env) const envLines = Object.entries(env)
.map(([k, v]) => `Environment=${k}=${v}`) .map(([k, v]) => `Environment=${k}=${v}`)
@@ -44,7 +76,7 @@ After=network.target
Type=simple Type=simple
WorkingDirectory=${workingDir} WorkingDirectory=${workingDir}
ExecStart=${execStart} ExecStart=${execStart}
Restart=on-failure Restart=always
RestartSec=3 RestartSec=3
${envLines} ${envLines}
@@ -54,17 +86,18 @@ WantedBy=default.target
} }
function systemctl(args) { 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: * Writes (or refreshes) the unit file and enables it for boot — but starts
* an unchanged unit is left running as-is; a changed one (e.g. a plugin * nothing. Split out of `installUserService` for the plugin self-update, which
* cache path after an update) is reloaded and restarted. * 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"} * @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/ */
function installUserService({ name, description, workingDir, execStart, env = {} }) { function writeUserService({ name, description, workingDir, execStart, env = {} }) {
if (!systemctlAvailable()) return "unavailable"; if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ description, workingDir, execStart, env }); const body = unitContent({ description, workingDir, execStart, env });
const target = unitPath(name); const target = unitPath(name);
@@ -72,22 +105,56 @@ function installUserService({ name, description, workingDir, execStart, env = {}
if (fs.existsSync(target)) { if (fs.existsSync(target)) {
const current = fs.readFileSync(target, "utf8"); const current = fs.readFileSync(target, "utf8");
if (!current.includes(MARKER)) return "foreign"; if (!current.includes(MARKER)) return "foreign";
if (current === body) { if (current === body) return "unchanged";
systemctl(["enable", "--now", `${name}.service`]);
return "unchanged";
}
} }
fs.mkdirSync(unitDir(), { recursive: true }); fs.mkdirSync(unitDir(), { recursive: true });
fs.writeFileSync(target, body, "utf8"); fs.writeFileSync(target, body, "utf8");
systemctl(["daemon-reload"]); systemctl(["daemon-reload"]);
systemctl(["enable", "--now", `${name}.service`]); systemctl(["enable", `${name}.service`]);
systemctl(["restart", `${name}.service`]);
return "written"; return "written";
} catch { } catch {
return "failed"; 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"} */ /** @returns {"removed"|"absent"|"foreign"|"unavailable"} */
function uninstallUserService(name) { function uninstallUserService(name) {
if (!systemctlAvailable()) return "unavailable"; if (!systemctlAvailable()) return "unavailable";
@@ -110,8 +177,13 @@ function enableLinger() {
module.exports = { module.exports = {
systemctlAvailable, systemctlAvailable,
unitIsOurs,
writeUserService,
restartUserService,
installUserService, installUserService,
uninstallUserService, uninstallUserService,
enableLinger, enableLinger,
unitPath, unitPath,
__setExecImpl,
__reset,
}; };
+147
View File
@@ -0,0 +1,147 @@
/**
* @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 — 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);
});
});
@@ -176,8 +176,15 @@ describe("getUpdatesStatus — plugin cache with no .git", () => {
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") }); const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
// The old behaviour: message set, everything else absent. // The old behaviour: the not-a-git-clone message, everything else absent.
assert.equal(s.message, null, "must not fall through to the not-a-git-clone payload"); // 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.release_check, "ok", "the release layer must actually run");
assert.equal(s.install_kind, "plugin"); assert.equal(s.install_kind, "plugin");
assert.equal(s.plugin_marketplace, "sg"); assert.equal(s.plugin_marketplace, "sg");
@@ -208,6 +215,31 @@ describe("getUpdatesStatus — plugin cache with no .git", () => {
assert.ok(s.plugin_marketplace && s.plugin_name && s.repo_root); 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 () => { it("does not treat an older published release as an update", async () => {
const { root } = makePluginInstall("older"); const { root } = makePluginInstall("older");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.4.0") }); const s = await getUpdatesStatus(root, { latestRelease: release("v1.4.0") });
+11 -1
View File
@@ -402,7 +402,17 @@ async function pluginStatus(root, pluginInstall, options = {}) {
`commands — "Update now" runs them for you and restarts the dashboard. An already-open ` + `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.`, `Claude Code session still needs '/reload-plugins' to pick up new skills and commands.`,
manual_command: manualParts.join(" && "), 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", install_kind: "plugin",
plugin_marketplace: pluginInstall.marketplace, plugin_marketplace: pluginInstall.marketplace,