Files
AgentHub/scripts/plugin-doctor.js
T
Nguyễn Ngọc Trí Vĩ cd427d218b fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests
- Server tests on Windows no longer reach the user's real psmux: psmux
  ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and
  the runner's kill-server ended every live lane pane — the pre-commit hook
  included. scripts/test-server.js now takes tmux off the suites' PATH there.
- Wheel scrolling on Windows: psmux never turns mouse tracking on for its
  client, so xterm.js never reported the wheel and a pane could not scroll.
  A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel
  report itself; psmux scrolls or forwards it to the pane's app.
- tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each
  platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7
  -> brew upgrade tmux / build the release on Linux). Used by the doctor,
  GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a
  sticky toast on the Workspace page. Advice only: nothing installs tmux.

Committed with --no-verify (user's call): the pre-commit hook runs the full
backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only
tests: sudo, /proc, systemd, members). Every test touching this change passes:
pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the
client tests related to the changed files; tsc and the header audit are clean.
2026-10-15 11:46:09 +07:00

323 lines
11 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() {
// The same npm the bootstrap and the UI build run (`npmCommand`), not PATH's.
const npm = boot.npmCommand(["-v"]);
const res = spawnSync(npm.file, npm.args, { encoding: "utf8", env: npm.env, shell: npm.shell });
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 the platform's minimum (`tmux.upgradeAdvice`: psmux 3.4.0
* on Windows, tmux 3.7 elsewhere — the same advice the Workspace shows), 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.
*/
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)",
];
}
const advice = tmux.upgradeAdvice(client);
if (advice) return ["warn", "tmux", `${client} works, but upgrade it: ${advice}`];
const server = tmux.serverVersion();
if (server && tmux.upgradeAdvice(server)) {
return [
"warn",
"tmux",
`${client} installed, but the running tmux server is ${server} — it is used ` +
"until every tmux session has closed (or after a reboot)",
];
}
return ["ok", "tmux", `${client} on PATH — the run console can start sessions`];
}
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 };