Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49a041b1dc | |||
| de8d1079e0 | |||
| 0335e0e6bc | |||
| 18614f378d | |||
| 63ed90ba05 | |||
| 3ed88062d6 | |||
| 27313d4413 | |||
| d4b949a6f4 | |||
| 8930865376 | |||
| 29858ddccb | |||
| 7000a9c5ce | |||
| 99e718ad91 |
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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ĩ
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.5.3",
|
||||
"version": "1.5.7",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user