Files
AgentHub/scripts/plugin-doctor.js
T
nntrivi2001 6c605849d8 feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
2026-10-06 11:31:26 +07:00

335 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* @file plugin-doctor.js
* @description Reports the health of a plugin-installed AgentHub: Node version,
* 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, the `tmux` binary
* every browser-started run depends on, and the dashboard bundle — quoting the
* last build error when it is missing.
* Read-only — it diagnoses, it never repairs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const boot = require("./plugin-bootstrap");
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
const PLUGIN_ROOT = path.resolve(__dirname, "..");
/** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */
function diagnose() {
const rt = boot.runtimeDir();
const out = [];
const add = (level, label, detail) => out.push({ level, label, detail });
add(
boot.nodeVersionOk() ? "ok" : "fail",
"Node",
boot.nodeVersionOk()
? `v${process.versions.node}`
: `v${process.versions.node} — AgentHub needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)`
);
add("ok", "Plugin root", PLUGIN_ROOT);
add("ok", "Data dir", getDataDir());
const state = boot.readState(rt);
if (!state) {
add(
"warn",
"Bootstrap",
`no state recorded — start a new session, or check ${rt}/bootstrap.log`
);
} else {
const moved = state.pluginRoot !== PLUGIN_ROOT;
add(
moved ? "warn" : "ok",
"Bootstrap",
moved
? `recorded against a previous plugin version (${state.pluginRoot}) — run /agenthub-update`
: `last run ${state.updatedAt}`
);
}
add(...runtimeDepsStatus(rt));
add(...npmStatus());
add(...diskStatus());
const live = boot.liveServers();
add(
live.length ? "ok" : "fail",
"Server",
live.length
? `listening on ${live.map((s) => `http://localhost:${s.port}`).join(", ")}`
: `not running — see ${path.join(rt, "server.log")}`
);
const dup = countLegacyHookEntries();
add(
dup ? "fail" : "ok",
"Hooks",
dup
? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` +
`every event is counted twice. Remove them (a new session does it automatically).`
: "provided by the plugin only"
);
add(...cliStatus());
add(...systemdStatus());
add(...tmuxStatus());
add(...dashboardUiStatus(rt));
return out;
}
/**
* `node_modules` existing proves nothing: an interrupted install or a truncated
* cache entry leaves a directory that npm reports as installed and that is
* missing packages — the failure mode that took the client build down with
* TS2318. Check every declared runtime dependency actually landed.
*
* Only `dependencies` count. `optionalDependencies` (better-sqlite3) are allowed
* to be absent by design — the server falls back to `node:sqlite`.
*/
function runtimeDepsStatus(rt) {
const nm = path.join(rt, "node_modules");
if (!fs.existsSync(nm)) return ["fail", "Runtime deps", "missing — run /agenthub-update"];
let declared;
try {
declared = Object.keys(
JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8")).dependencies || {}
);
} catch {
return ["warn", "Runtime deps", `${nm} — no manifest beside it, cannot verify the tree`];
}
const missing = declared.filter((d) => !fs.existsSync(path.join(nm, d, "package.json")));
if (missing.length) {
return [
"fail",
"Runtime deps",
`${missing.join(", ")} declared but not installed — the tree is incomplete. ` +
`Delete ${nm} and run /agenthub-update`,
];
}
return ["ok", "Runtime deps", `${declared.length} packages in ${nm}`];
}
/** No npm, no dashboard bundle and no runtime install — both go through it. */
function npmStatus() {
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
encoding: "utf8",
shell: process.platform === "win32",
});
if (res.status !== 0) {
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
}
return ["ok", "npm", `v${res.stdout.trim()}`];
}
/**
* A full disk is the quiet cause of half-written installs and a SQLite file that
* refuses writes, and it reads as unrelated breakage everywhere else.
*/
function diskStatus() {
const dir = getDataDir();
let free;
try {
const s = fs.statfsSync(dir);
free = s.bavail * s.bsize;
} catch {
return ["ok", "Disk", "not measurable on this platform"];
}
const mb = Math.round(free / 1024 / 1024);
if (free < 200 * 1024 * 1024) {
return ["fail", "Disk", `${mb} MB free on ${dir} — installs and the database will truncate`];
}
if (free < 1024 * 1024 * 1024) return ["warn", "Disk", `${mb} MB free on ${dir}`];
return ["ok", "Disk", `${Math.round(mb / 1024)} GB free`];
}
/**
* When the bundle is missing the useful thing is not "run /agenthub-open" — it
* is why the last build failed, which sits in `client-build.log` and which
* nobody thinks to open.
*/
function dashboardUiStatus(rt) {
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
if (fs.existsSync(path.join(dist, "index.html"))) return ["ok", "Dashboard UI", dist];
const err = lastBuildError(path.join(rt, "client-build.log"));
return [
"warn",
"Dashboard UI",
err
? `not built — the last build failed with: ${err} (full log: ${path.join(rt, "client-build.log")})`
: "not built yet — run /agenthub-open",
];
}
/** First compiler/npm error line of the log's last build, or null. */
function lastBuildError(logPath) {
let lines;
try {
lines = fs.readFileSync(logPath, "utf8").split("\n");
} catch {
return null;
}
const hit = lines.filter((l) => /\berror\b|npm ERR!/i.test(l)).pop();
return hit ? hit.trim().slice(0, 200) : null;
}
/**
* The failure this row exists for is silent by construction: a unit still
* naming a retired plugin cache directory keeps a running dashboard looking
* perfectly healthy, and only bites at the next reboot — when the server does
* not come back, from a path Claude Code deleted weeks earlier. Nothing else
* in this report would show it.
*
* WARN, never FAIL: the dashboard in front of you is working, and on macOS,
* Windows or a Linux box without systemd there is no unit to have an opinion
* about.
*/
function systemdStatus() {
const svc = require("./service-manager");
const unit = svc.unitPath("agenthub");
const row = svc.kind;
if (!svc.unitIsOurs("agenthub")) {
if (fs.existsSync(unit)) {
return ["warn", row, `${unit} exists but was not written by AgentHub — left untouched`];
}
return [
"ok",
row,
svc.systemctlAvailable()
? "no unit — the server is started per session (install one with `agenthub service install`)"
: "not available on this platform — the server is started per session",
];
}
if (svc.unitIsStale("agenthub", PLUGIN_ROOT)) {
return [
"warn",
row,
`${unit} still points at a previous plugin version — the dashboard will NOT come back after a reboot. ` +
`Starting a new Claude Code session repairs it; or run: node ${path.join(PLUGIN_ROOT, "scripts", "plugin-bootstrap.js")} --force`,
];
}
if (!svc.unitIsActive("agenthub")) {
return [
"warn",
row,
svc.kind === "launchd"
? "LaunchAgent is installed and current but not running — start it with `agenthub service install`"
: "unit is installed and current but not running — start it with `systemctl --user start agenthub`",
];
}
return ["ok", row, `${svc.label} is current and running — the dashboard survives reboots`];
}
/**
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
* sessions, analytics, lanes and the CLI keep working. 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.
*
* Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns
* them on), and when a new binary still talks to an older running server — a
* server keeps its version until every tmux session has closed. The doctor
* only says so: it never installs or restarts tmux.
*/
const hasScrollbars = (version) => {
const m = /(\d+)\.(\d+)/.exec(version || "");
return !!m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 7));
};
function tmuxStatus() {
const tmux = require("../server/lib/tmux");
const client = tmux.clientVersion();
if (!client) {
return [
"warn",
"tmux",
"not found — Start Run and `agenthub workspace shell` will fail (everything else is unaffected). " +
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
}
if (!hasScrollbars(client)) {
return [
"warn",
"tmux",
`${client} works, but terminal panes get a scrollbar only from tmux 3.7. ` +
"Upgrade: brew upgrade tmux (macOS); on Linux, when the distro ships an older one " +
"(Ubuntu 24.04 has 3.4), build the release from https://github.com/tmux/tmux/releases " +
"(needs libevent-dev libncurses-dev bison: ./configure && make && sudo make install)",
];
}
const server = tmux.serverVersion();
if (server && !hasScrollbars(server)) {
return [
"warn",
"tmux",
`${client} installed, but the running tmux server is ${server} — panes get their ` +
"scrollbar once every tmux session has closed (or after a reboot)",
];
}
return ["ok", "tmux", `${client} on PATH — the run console can start sessions, with scrollbars`];
}
function countLegacyHookEntries() {
try {
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
if (!settings.hooks) return 0;
const isLegacy = boot.isCheckoutHookEntry();
return Object.values(settings.hooks)
.filter(Array.isArray)
.reduce((n, entries) => n + entries.filter(isLegacy).length, 0);
} catch {
return 0;
}
}
function cliStatus() {
const dir = path.join(os.homedir(), ".local", "bin");
const file = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
if (!fs.existsSync(file)) return ["warn", "agenthub CLI", `no launcher at ${file}`];
const onPath = (process.env.PATH || "")
.split(path.delimiter)
.some((p) => path.resolve(p) === path.resolve(dir));
return onPath
? ["ok", "agenthub CLI", file]
: [
"warn",
"agenthub CLI",
`${file} exists but ${dir} is not on PATH — add${
process.platform === "darwin" ? " to ~/.zshrc" : ""
}: export PATH="${dir}:$PATH"`,
];
}
function report() {
const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" };
const rows = diagnose();
for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`);
return rows.some((r) => r.level === "fail") ? 1 : 0;
}
if (require.main === module) process.exitCode = report();
module.exports = { diagnose, report, countLegacyHookEntries };