Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e | |||
| 91af3dca86 | |||
| baa355bee4 | |||
| b7e81af0b6 | |||
| c90dd7b147 | |||
| 49a041b1dc | |||
| de8d1079e0 | |||
| 0335e0e6bc | |||
| 18614f378d | |||
| 63ed90ba05 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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ĩ
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.10",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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") });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user