Compare commits

...

12 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
nntrivi2001 3ed88062d6 chore(release): bump version to 1.5.5 2026-09-11 15:35:35 +07:00
nntrivi2001 27313d4413 fix(run): refuse a run with ETMUXMISSING when the host has no tmux
Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT`
as a 400 — blaming the request for a missing host package, naming no fix,
and reaching the user as a raw errno in the console pane.

The guard goes in spawnRun, which both spawn paths (POST /api/run and
POST /api/lanes/:id/start) already route through, so one check covers
them; the message carries the install command. Both routes map the code to
503: the request was fine, the dependency is not installed. The client
already renders `error.message` from the body verbatim, so no UI change.

Also: README's node advice said "stay on 20/22/24"; 1712 server + 488
client tests are green on v26.8.1, so only 25 is actually the bad one.
2026-09-11 15:32:24 +07:00
nntrivi2001 d4b949a6f4 docs(deployment): list git and tmux beside node in the prerequisites
The only prerequisites block that still said node+npm alone.
2026-09-11 15:22:11 +07:00
nntrivi2001 8930865376 test(cli): cover the doctor's tmux line
The row was added without a check here, so a regression that dropped it
would only surface when someone's Start Run failed silently again.
2026-09-11 15:20:30 +07:00
nntrivi2001 29858ddccb docs: document the system prerequisites, and report tmux in both doctors
The READMEs never said tmux was needed, so a fresh install only found out
when Start Run failed with nothing explaining why. Both doctors were silent
about it too.

- README/README.vi: a prerequisites table (node, npm, git, tmux, claude,
  codex, gh, systemd) and per-OS tmux install + verify steps; the feature
  list is regrouped into observe/drive/configure and now covers analytics,
  alerts, import, remote sources, the config explorer and Update all.
- INSTALL: Step 0 for the system packages, plus a troubleshooting entry
  separating "tmux missing" from "the server has a different PATH".
- SETUP/docs/README: the same troubleshooting entry and a pointer to Step 0.
- plugin-doctor: a tmux row, WARN and never FAIL - the run console and
  `lanes shell` break without it, nothing else does. Reuses tmux.js's own
  isTmuxAvailable().
- agenthub doctor: asks GET /api/run/tmux instead, because the PATH that
  decides whether a run starts is the server's, not the caller's shell's.
2026-09-11 10:44:10 +07:00
nntrivi2001 7000a9c5ce chore(release): bump version to 1.5.4 2026-09-10 23:21:45 +07:00
nntrivi2001 99e718ad91 fix(plugin): build the UI with devDependencies so a self-update ships its own bundle
After the 1.5.2 -> 1.5.3 self-update the server ran 1.5.3 while the dashboard
still served the 1.5.2 UI. The client build had failed, and `runtime/client-dist`
still held the previous version's bundle, so nothing looked broken — the only
signal was the Settings page's "UI build v1.5.2 (rebuild client to match)".

Cause: the whole client build toolchain (vite, typescript,
@vitejs/plugin-react, tailwindcss, @types/*) is a devDependency, and npm reads
NODE_ENV=production as --omit=dev (`NODE_ENV=production npm config get omit` ->
`dev`). The dashboard server runs with NODE_ENV=production, and every bootstrap
descended from it inherits that: the plugin-update chain, and any SessionStart
fired by a `claude` running in a tmux pane the server started. So `npm ci`
dropped vite, `tsc -b` died on missing type declarations, and buildClient
returned "failed" with the stale bundle left in place.

`--include=dev` on both install paths — a CLI flag outranks the env, which is
the point. Verified by running buildClient with NODE_ENV=production against the
installed plugin root: previously "failed", now "built", and the served bundle
carries 1.5.3.

Also: plugin-update-helper writes its verdict BEFORE the bootstrap handover as
well as after. That step retires the process tree the helper hangs off, and the
observed run reached the new server with no verdict file at all — the dashboard
reported `last_plugin_update: null` for an update that had actually succeeded.
2026-09-10 23:17:33 +07:00
33 changed files with 880 additions and 74 deletions
+1
View File
@@ -2138,6 +2138,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
+2 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
_Last synced: v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -43,6 +43,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
## Non-negotiable engineering rules
- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup.
- **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.
+74 -1
View File
@@ -30,12 +30,64 @@ dashboard, run it from source, or stay on Node 20.
| Node.js | 20+ (22+ recommended) | Required for server and client |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, and the Codex nudge. See [Step 0](#step-0--system-packages) |
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub lanes ci status` and the PR stages of `/agenthub:ship-full` |
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
| Git | Any | For cloning the repository |
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
---
## Step 0 — System packages
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
backs the run console**. The dashboard shells out to the `tmux` binary and has
no bundled fallback, so without it **Start Run** and `agenthub lanes shell`
fail while everything else keeps working.
```bash
sudo apt install git tmux # Debian / Ubuntu
sudo dnf install git tmux # Fedora / RHEL
brew install git tmux # macOS
sudo pacman -S git tmux # Arch
```
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
dashboard uses:
```bash
curl -s localhost:4820/api/run/tmux # {"available":true}
```
**No configuration is needed.** AgentHub creates its own session per lane,
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
depends on (mouse mode is set on the session explicitly). You can attach to the
exact same pane from a normal terminal:
```bash
agenthub lanes shell # from inside the lane's directory
tmux attach -t agenthub-lane-7 # or by name
tmux ls # every dashboard session is prefixed agenthub-lane-
```
Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
the browser terminal and your local terminal are two clients on one pane, and
restarting the dashboard never stops a run.
---
## Step 1 — Clone the repository
```bash
@@ -261,6 +313,27 @@ npm run setup
The Vite dev server and Express server run on different ports. Make sure both are running (`npm run dev` starts both). Open **http://localhost:5173**, not `http://localhost:4820`, during development.
### "Start Run" fails, or the terminal never attaches
The run console is tmux-backed. Check, in this order:
```bash
tmux -V # not installed → see Step 0
curl -s localhost:4820/api/run/tmux # {"available":false} → the SERVER cannot see tmux
curl -s "localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the server runs with
a different `PATH` — most often a systemd `--user` service, which does not read
your shell profile. Fix it by installing tmux system-wide (`/usr/bin/tmux`)
rather than into a shell-managed prefix, then `agenthub service uninstall &&
agenthub service install`.
The same `PATH` rule applies to `claude` and `codex`: a run starts the binary by
name, so a `claude` installed only through an interactive shell's profile is
invisible to a service-managed server. `{"found":false}` for the lane's tool is
that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
+78 -7
View File
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
*(Tiếng Việt: [README.vi.md](README.vi.md))*
## What it does
## Main features
**Observe**
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
searchable: tool calls, token usage, cost, subagent trees, transcripts.
- **Analytics and cost.** Per-model token totals and estimated cost, with
editable pricing rules; top tools, agent types, and workflow patterns.
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
targets that fire on them.
- **History import.** Every session under `~/.claude/projects/` is imported on
startup; archives from another machine come in through Settings → Import
History (idempotent, never double-counts tokens).
- **Remote sources.** Pull session history from other machines over SSH
(`agenthub remote-sources`).
**Drive**
- **Lanes.** One lane per working directory, surviving session restarts. A lane
moves through pipeline stages and the dashboard shows where it is.
- **Stage detection.** The stage is inferred from the tool stream, so a session
@@ -26,15 +40,72 @@ Internal build — all rights reserved.
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
you'd see locally, fully interactive, resumable, and attachable from a real
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
setting.
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus a CLI.
setting. **This is the part that needs `tmux` installed** — see
[Prerequisites](#prerequisites).
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
slot, ports and services per lane (`agenthub lanes up|down|logs`).
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
feature from intake to PR, declaring every stage to the dashboard as it goes.
## Requirements
**Configure**
Node **>= 20** (`engines` in `package.json`). Node **24** is what the test suites
are verified on — node 25 currently breaks 6 server tests through a
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
`settings.json` stay read-only: they race with the live CLI.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
## Prerequisites
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every lane operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane, the Codex nudge |
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
| **Codex CLI** | any | Optional — only for `codex` lanes |
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
Node **25** is the one version to avoid: it breaks 6 server tests through a
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change.
change. Both were gone again by 26 (1712 server + 488 client tests green on
v26.8.1).
### Installing tmux
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
**Start Run** and `agenthub lanes shell` fail without it. Everything else
(sessions, analytics, lanes, stage detection, the CLI) works fine.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows has no native tmux — run AgentHub under WSL2 and install it there.
Verify, both from your shell and from the server's point of view:
```bash
tmux -V # e.g. tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
A lane's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
shell`) are two clients on the same pane — they stay in sync, and killing the
dashboard never kills the run.
### The superpowers plugin
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
+93 -7
View File
@@ -13,10 +13,24 @@ Internal build — all rights reserved.
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
## Làm được gì
## Chức năng chính
**Quan sát**
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
được: tool call, token usage, cost, cây subagent, transcript.
- **Analytics và chi phí.** Token và cost ước tính theo từng model, với bảng
pricing sửa được; top tool, agent type và workflow pattern.
- **Activity feed và alert.** Luồng event trực tiếp, alert rule, và webhook
target bắn khi rule khớp.
- **Import lịch sử.** Mọi session dưới `~/.claude/projects/` được import lúc
khởi động; lịch sử từ máy khác đưa vào qua Settings → Import History
(idempotent, không bao giờ đếm trùng token).
- **Remote source.** Kéo lịch sử session từ máy khác qua SSH
(`agenthub remote-sources`).
**Điều khiển**
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
@@ -26,14 +40,86 @@ Internal build — all rights reserved.
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm CLI.
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
## Yêu cầu
**Cấu hình**
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
settings file, memory file, marketplace, keybinding và statusline mà Claude
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
tiếng Anh, Việt, Trung và Hàn.
## Yêu cầu hệ thống
| Cần | Phiên bản | Dùng cho |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
### Cài tmux
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
```bash
sudo apt install tmux # Debian / Ubuntu
sudo dnf install tmux # Fedora / RHEL
brew install tmux # macOS
sudo pacman -S tmux # Arch
```
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
```bash
tmux -V # ví dụ: tmux 3.4
curl -s localhost:4820/api/run/tmux # {"available":true}
```
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
### Plugin superpowers
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
thì skill mới resolve được (session sau tự nhận). Cài tay:
```bash
claude plugin install superpowers@claude-plugins-official
```
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
## Cài như Claude Code plugin
+20
View File
@@ -324,6 +324,26 @@ The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
+18 -1
View File
@@ -1279,6 +1279,23 @@ async function cmdDoctor() {
);
console.log(`${c.green("✔")} WS connections ${srv.ws_connections ?? 0}`);
// Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// else.
try {
const { available } = await get("/api/run/tmux");
console.log(
available
? `${c.green("✔")} tmux available to the server`
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
);
} catch {
console.log(`${c.dim("·")} tmux not reported by this server`);
}
try {
const { sources = [] } = await get("/api/remote-sources");
if (sources.length === 0) {
@@ -2995,7 +3012,7 @@ const COMMAND_GROUPS = [
[
"Administration",
[
["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"],
["doctor", "", "Connectivity, hooks, database, tmux, and remote sources diagnosis"],
["info", "", "Raw system info JSON"],
["export", "[file.json]", "Export all data as JSON"],
["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"],
@@ -9537,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.5.3 (rebuild client to match)
UI build v1.5.7 (rebuild client to match)
</p>
</div>
<div
+2
View File
@@ -245,6 +245,8 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
#### Preflight a destructive lane action
+1 -1
View File
@@ -272,7 +272,7 @@ Because a lane's services are fully detached, restarting the dashboard (or `agen
| Command | Description |
| ------- | ----------- |
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections |
| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile), remote-source status |
| `agenthub info` | The raw `/api/settings/info` JSON (pipe it to `jq`) |
| `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` |
| `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
+9 -1
View File
@@ -139,6 +139,11 @@ graph TB
- Node.js >= 20.0.0
- npm >= 9.0.0
- git — every lane operation shells out to it
- tmux — only for the browser run console and `agenthub lanes shell`; both
execute the binary directly and have no fallback. Per-OS install commands and
the two-sided check (your PATH vs the server's) are in
[INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages).
### Setup
@@ -451,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 -5
View File
@@ -1,6 +1,6 @@
# AgentHub — Plugin Marketplace
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server, `agenthub` CLI and MCP tools, no checkout required. On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
@@ -134,7 +134,7 @@ waits on an install:
4. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
5. **Legacy hook cleanup** — see above.
6. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
7. **UI build** — `client/` is copied into the runtime dir and built there (`npm install && npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. 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.
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,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
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
| Command | Does |
|---|---|
| `/agenthub-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **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-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 +306,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:
+6 -4
View File
@@ -70,10 +70,12 @@ graph LR
**Quick Start:**
1. Read [SETUP.md](../SETUP.md)
2. Run `npm run setup`
3. Run `npm run dev`
4. Open browser to `http://localhost:5173`
1. Install the system packages — `node` (20+), `git`, and `tmux` (the run
console has no fallback without it): [INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages)
2. Read [SETUP.md](../SETUP.md)
3. Run `npm run setup`
4. Run `npm run dev`
5. Open browser to `http://localhost:5173`
---
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 1.5.3
version: 1.5.7
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "1.5.3",
"version": "1.5.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "1.5.3",
"version": "1.5.7",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "1.5.3",
"version": "1.5.7",
"bin": {
"agenthub": "bin/agenthub.js"
},
+6 -1
View File
@@ -1,5 +1,5 @@
---
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, UI build
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, 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 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
still fails, the server has a different PATH than this check — ask it directly
with `curl -s localhost:4820/api/run/tmux`.
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
quotes a build error, report that error — it is the actual problem.
+54 -19
View File
@@ -465,6 +465,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,34 +509,55 @@ 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.
const systemdOwned = svc.systemctlAvailable() && svc.unitIsOurs("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";
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();
pendingSystemdRestart = systemdOwned;
log(
rt,
result === "written"
@@ -572,6 +598,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");
}
}
}
+26 -1
View File
@@ -5,7 +5,8 @@
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
* declared package, not just that node_modules exists), server liveness,
* duplicate hook entries (the one failure that silently doubles every token and
* cost figure), the `agenthub` CLI launcher and its PATH, whether the slash
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
* every browser-started run depends on, whether the slash
* commands the pipeline templates name as node drivers are actually resolvable
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
* build error when it is missing.
@@ -82,6 +83,7 @@ function diagnose() {
);
add(...cliStatus());
add(...tmuxStatus());
add(...skillPluginStatus());
add(...dashboardUiStatus(rt));
@@ -218,6 +220,29 @@ function skillPluginStatus() {
];
}
/**
* 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,
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
* sessions, analytics, lanes and the CLI keep working. Same reasoning as the
* skill-plugins row: a partial feature loss must not fail a doctor run.
*
* This checks the doctor's own PATH. A server started as a systemd `--user`
* service can still see a different one — `GET /api/run/tmux` is the server's
* own answer, and `agenthub doctor` reports that.
*/
function tmuxStatus() {
if (require("../server/lib/tmux").isTmuxAvailable()) {
return ["ok", "tmux", "on PATH — the run console can start sessions"];
}
return [
"warn",
"tmux",
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
}
function countLegacyHookEntries() {
try {
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
+11 -1
View File
@@ -73,7 +73,17 @@ function buildClient({
// tarball against the lockfile's integrity hash, so a bad cache entry is
// refetched instead of silently reused. It needs a lockfile that matches
// package.json — fall back to `install` when the copy has none or has drifted.
const npmFlags = ["--no-audit", "--no-fund"];
//
// `--include=dev` is not optional here: the whole build toolchain (vite,
// typescript, @vitejs/plugin-react, tailwindcss, @types/*) lives in the
// client's devDependencies, and npm reads `NODE_ENV=production` as
// `--omit=dev`. Every bootstrap descended from the running server inherits
// that NODE_ENV — the self-update chain and any SessionStart fired from a
// tmux pane the server started — so the install silently dropped `vite`,
// `npm run build` failed, and the PREVIOUS bundle stayed in place: the
// dashboard kept serving the old version's UI while the API was already the
// new one. An explicit CLI flag outranks the env, which is the point.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev"];
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
+12
View File
@@ -157,6 +157,18 @@ function main() {
}
log(`new plugin root: ${newRoot}`);
// Write the verdict BEFORE the handover, not only after it. The step below
// retires the process tree this helper hangs off, and an observed run reached
// the new server with no verdict file at all — so the dashboard reported
// `last_plugin_update: null` for an update that had in fact succeeded. A
// pre-written record is overwritten by the real outcome when this survives,
// and is the outcome when it does not.
record({
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
});
try {
// Foreground and blocking: this stops the old server (us), waits for the
// ports, then starts the new one. It must finish before we record success.
+87 -15
View File
@@ -26,11 +26,43 @@ function unitPath(name) {
return path.join(unitDir(), `${name}.service`);
}
function systemctlAvailable() {
if (process.platform !== "linux") return false;
return spawnSync("systemctl", ["--user", "--version"], { stdio: "ignore" }).status === 0;
// 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 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 }) {
const envLines = Object.entries(env)
.map(([k, v]) => `Environment=${k}=${v}`)
@@ -44,7 +76,7 @@ After=network.target
Type=simple
WorkingDirectory=${workingDir}
ExecStart=${execStart}
Restart=on-failure
Restart=always
RestartSec=3
${envLines}
@@ -54,17 +86,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 +105,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 +177,13 @@ function enableLinger() {
module.exports = {
systemctlAvailable,
unitIsOurs,
writeUserService,
restartUserService,
installUserService,
uninstallUserService,
enableLinger,
unitPath,
__setExecImpl,
__reset,
};
+5 -1
View File
@@ -369,7 +369,7 @@ describe("agenthub CLI — import & administration", () => {
assert.match(err, /Usage: agenthub import/);
});
it("doctor reports API, hooks, database, remotes, and uptime lines", async () => {
it("doctor reports API, hooks, database, tmux, remotes, and uptime lines", async () => {
const { code, out } = await agenthub("doctor");
// Exit 1 when hooks are missing (common in the test harness) — still prints a full report.
assert.ok(code === 0 || code === 1, `unexpected exit ${code}`);
@@ -377,6 +377,10 @@ describe("agenthub CLI — import & administration", () => {
assert.match(out, /Claude Code hooks/);
assert.match(out, /Database/);
assert.match(out, /Server uptime/);
// Asked of the server (GET /api/run/tmux), not of this shell: a missing
// tmux is why Start Run fails, and the server's PATH is the one that
// decides. Reported either way, and never fatal.
assert.match(out, /tmux/);
assert.match(out, /Remote sources/);
});
+15
View File
@@ -391,6 +391,21 @@ describe("buildClient dependency install", () => {
);
});
it("installs devDependencies explicitly, because NODE_ENV=production omits them", () => {
// The whole client build toolchain (vite, typescript, @types/*) is a
// devDependency, and npm turns NODE_ENV=production into --omit=dev. Every
// bootstrap descended from the running server inherits that env, so the
// install dropped vite, `npm run build` failed, and the dashboard kept
// serving the PREVIOUS version's bundle after a self-update.
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
for (const call of calls.filter((c) => c.startsWith("ci") || c.startsWith("install"))) {
assert.match(call, /--include=dev/, `dependency install must include dev deps: ${call}`);
}
});
it("fails without building when neither install path works", () => {
resetRuntime();
const calls = [];
@@ -0,0 +1,54 @@
/**
* @file Tests the `tmux` row of scripts/plugin-doctor.js — the check that tells
* an install its browser run console cannot work, because every run the
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
* and never FAIL: sessions, analytics, lanes and the CLI all work without the
* binary, so failing a doctor run over it would cry wolf. tmux is faked through
* the module's own exec seam, so the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, 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-tmux-"));
process.env.CLAUDE_HOME = TMP_HOME;
const tmux = require("../lib/tmux");
const { diagnose } = require("../../scripts/plugin-doctor");
after(() => {
tmux.__reset();
fs.rmSync(TMP_HOME, { recursive: true, force: true });
delete process.env.CLAUDE_HOME;
});
/** Fake the tmux binary as present or absent, then read back the row. */
function rowWithTmux(present) {
tmux.__setExecImpl(() => {
if (!present) throw new Error("spawnSync tmux ENOENT");
return "tmux 3.4\n";
});
const row = diagnose().find((r) => r.label === "tmux");
assert.ok(row, "the doctor must always emit a tmux row");
return row;
}
describe("plugin-doctor: tmux row", () => {
it("warns, never fails, when tmux is not on PATH", () => {
const row = rowWithTmux(false);
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
assert.match(row.detail, /install/i, "and how to fix it");
});
it("passes when tmux is available", () => {
const row = rowWithTmux(true);
assert.equal(row.level, "ok", row.detail);
});
});
+23
View File
@@ -58,6 +58,29 @@ describe("pty-run", () => {
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
// Before this guard the ENOENT escaped from three calls down as
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
// for a missing host package, with no hint of what to install.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }),
(err) => err.code === "ETMUXMISSING" && /install/i.test(err.message),
"a missing tmux must be reported as such, with the install command"
);
assert.ok(
!calls.some((c) => c[0] === "new-session"),
"must not attempt a session it already knows cannot be created"
);
});
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
const calls = [];
tmux.__setExecImpl((args) => {
+17
View File
@@ -118,6 +118,23 @@ describe("/api/run", () => {
assert.equal(body.error.code, "EBADCWD");
});
it("POST / answers 503, not 400, when the host has no tmux", async () => {
// The request is fine; the dependency is absent. 400 would tell the user to
// fix their input, and the body must carry the install line the UI shows.
tmux.__setExecImpl(() => {
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { laneId: 9, cwd: TMP },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ETMUXMISSING");
assert.match(body.error.message, /install/i);
});
it("POST / spawns a tmux session and GET /:id finds it", async () => {
let hasSessionCalls = 0;
tmux.__setExecImpl((args) => {
+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") });
// 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 +215,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") });
+15
View File
@@ -181,6 +181,21 @@ function spawnRun(args) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// A run IS a tmux session, so an absent binary is a precondition failure, not
// a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from
// three directories down — a message that names no fix — and the route maps
// it to 400, blaming the request for the host's missing package. Checked here
// rather than in each route because both spawn paths (POST /api/run and
// POST /api/lanes/:id/start) come through this function.
if (!tmux.isTmuxAvailable()) {
throw makeErr(
"ETMUXMISSING",
"tmux is not installed, and every run the dashboard starts is a tmux session. " +
"Install it (sudo apt install tmux / brew install tmux; on Windows run AgentHub under WSL2), " +
"then restart the dashboard. Everything else keeps working without it."
);
}
const id = runIdForLane(laneId);
const startedAt = Date.now();
const argv = buildArgv({
+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 ` +
`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,
+6
View File
@@ -1360,6 +1360,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
break;
}
} catch (err) {
// A host with no tmux cannot start a run, but nothing is broken on this
// side — 503 plus the install line, not a 500 the reader can only read as a
// dashboard bug.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
+5
View File
@@ -260,6 +260,11 @@ router.post("/", (req, res) => {
if (err.code === "ERUNLIVE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
// installed. 400 would tell the user to fix their input.
if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
}
if (err.code && err.code.startsWith("E")) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}