refactor: finish the AgentHub rename and drop the MCP server

Two leftovers from the rebrand, done together because they touch the same
docs and manifests.

**Data directory.** `agent-dashboard` could not simply be renamed in the
source: it names a real directory holding the live SQLite database. Startup
now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js`
— a same-filesystem rename, so it is instant even at multiple gigabytes and
cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to
the legacy path until the move happens; the first version of this put the
rename inside the getter, which meant `npm run test:server` relocated the
developer's real database as a side effect. The call sits behind
`require.main === module` and ahead of the router requires, because those pull
in db.js, which opens SQLite at module load. A failed rename is silent and the
old directory keeps being used: an unfashionable directory name beats a blank
database beside an unreachable one. Same treatment for the port-discovery file
(`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write).

Not renamed on purpose: the Sagi localStorage keys (renaming silently resets
everyone's prefs) and the PWA manifest `id` (changing it orphans an installed
app).

**MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556
lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard
that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills
and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`,
`docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section
describing it.

Kept, because they are unrelated things sharing the name: the Config
Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config
plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that
analytics must keep attributing.

Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both
directions of the migration proven — a sandbox-HOME test run leaves the legacy
directory untouched, and a real boot moves it with its contents intact.
This commit is contained in:
2026-09-08 09:48:40 +07:00
parent 11dcc04ac4
commit c7dbaf2d9f
210 changed files with 405 additions and 59408 deletions
+5 -7
View File
@@ -67,7 +67,6 @@ graph TB
subgraph "Clients"
Browser[Web Browser]
MCP[MCP Clients]
end
CC --> Hooks
@@ -80,7 +79,6 @@ graph TB
APIRouter --> DB
WSServer -->|Real-time events| Browser
MCP -->|HTTP| APIRouter
style Express fill:#000000,color:#fff
style DB fill:#003B57,color:#fff
@@ -1454,7 +1452,7 @@ test("POST /api/hooks/event ingests hook payload", async () => {
## Terminal Access (`agenthub` CLI)
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, lane adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed lane reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agent-dashboard.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, lane adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed lane reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agenthub.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
## Deployment
@@ -1528,10 +1526,10 @@ LOG_LEVEL=info # Log level (debug, info, warn, error)
NODE_ENV=production node server/index.js
# With PM2 (process manager)
pm2 start server/index.js --name agent-dashboard
pm2 start server/index.js --name agenthub
# With systemd
sudo systemctl start agent-dashboard
sudo systemctl start agenthub
```
### Docker Deployment
@@ -1562,8 +1560,8 @@ CMD ["node", "server/index.js"]
```bash
# Build and run
docker build -t agent-dashboard .
docker run -p 127.0.0.1:4820:4820 -v "$HOME/.claude/agent-dashboard:/app/data" agent-dashboard
docker build -t agenthub .
docker run -p 127.0.0.1:4820:4820 -v "$HOME/.claude/agenthub:/app/data" agenthub
```
---
+1 -1
View File
@@ -11,7 +11,7 @@
* spawnSync): the dashboard server lives in THIS process, so a synchronous
* spawn would block the event loop and deadlock every request the child
* makes. Port targeting uses the DASHBOARD_PORT env override, which takes
* precedence over ~/.claude/.agent-dashboard.json discovery.
* precedence over ~/.claude/.agenthub.json discovery.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
-85
View File
@@ -1,85 +0,0 @@
/**
* @file Tests scripts/check-mcp-build.js — the freshness gate for the committed
* mcp/build artifact. Content hashing is the whole point: mtimes are meaningless
* after a clone, where every file is stamped at checkout time in arbitrary order.
* Runs against synthetic trees, never the real mcp/.
* @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("fs");
const os = require("os");
const path = require("path");
const { sourceHash, mcpBuildStatus, writeHash } = require("../../scripts/check-mcp-build");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcpbuild-"));
const SRC = path.join(ROOT, "mcp", "src");
const BUILD = path.join(ROOT, "mcp", "build");
function makeTree() {
fs.rmSync(path.join(ROOT, "mcp"), { recursive: true, force: true });
fs.mkdirSync(SRC, { recursive: true });
fs.mkdirSync(BUILD, { recursive: true });
fs.writeFileSync(path.join(SRC, "index.ts"), "export const a = 1;\n");
fs.writeFileSync(path.join(ROOT, "mcp", "package.json"), '{"name":"x"}\n');
fs.writeFileSync(path.join(BUILD, "index.js"), "exports.a = 1;\n");
}
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("mcp build freshness", () => {
beforeEach(makeTree);
it("fails when the build has no recorded hash", () => {
const status = mcpBuildStatus(ROOT);
assert.equal(status.ok, false);
assert.match(status.reason, /no recorded source hash/);
});
it("passes right after the hash is stamped", () => {
writeHash(ROOT);
assert.equal(mcpBuildStatus(ROOT).ok, true);
});
it("fails when a source file changes after the build", () => {
writeHash(ROOT);
fs.writeFileSync(path.join(SRC, "index.ts"), "export const a = 2;\n");
const status = mcpBuildStatus(ROOT);
assert.equal(status.ok, false);
assert.match(status.reason, /stale/);
});
it("fails when a source file is added after the build", () => {
writeHash(ROOT);
fs.writeFileSync(path.join(SRC, "extra.ts"), "export const b = 1;\n");
assert.equal(mcpBuildStatus(ROOT).ok, false);
});
it("fails when the build output is missing entirely", () => {
writeHash(ROOT);
fs.rmSync(path.join(BUILD, "index.js"));
assert.match(mcpBuildStatus(ROOT).reason, /missing/);
});
it("ignores modification times — only content counts", () => {
const before = sourceHash(ROOT);
const future = Date.now() / 1000 + 10_000;
fs.utimesSync(path.join(SRC, "index.ts"), future, future);
assert.equal(sourceHash(ROOT), before);
});
it("tracks the manifest, so a dependency bump invalidates the build", () => {
writeHash(ROOT);
fs.writeFileSync(path.join(ROOT, "mcp", "package.json"), '{"name":"x","version":"2"}\n');
assert.equal(mcpBuildStatus(ROOT).ok, false);
});
});
describe("the committed mcp/build in this repo", () => {
it("matches mcp/src", () => {
const status = mcpBuildStatus(path.resolve(__dirname, "..", ".."));
assert.equal(status.ok, true, status.reason);
});
});
@@ -0,0 +1,154 @@
/**
* @file Tests the data-directory resolution in claude-home.js, and the one-time
* migration that carries an existing `~/.claude/agent-dashboard` (the name the
* product used before the AgentHub rebrand) over to `~/.claude/agenthub`.
*
* The stakes are why this is tested at all: that directory holds the live
* SQLite database. Resolving to the new path when the data is still under the
* old one would not lose the data, but it WOULD silently start an empty
* dashboard, which looks exactly like data loss to whoever opens it.
*
* The split matters as much as the migration. `getDataDir()` is a getter that
* gets asked for the path by tests, doctors and `--help`; if it also moved
* directories, every one of those would relocate a multi-gigabyte database as a
* side effect. So it is asserted pure here, and the move lives in its own
* function that only server startup calls.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-datadir-"));
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
// claude-home reads CLAUDE_HOME on every call, so each test can point it
// somewhere fresh without reloading the module.
const {
getDataDir,
migrateLegacyDataDir,
getTranscriptSnapshotDir,
} = require("../lib/claude-home");
let home;
let seq = 0;
beforeEach(() => {
seq += 1;
home = path.join(SUITE_ROOT, `home${seq}`, ".claude");
fs.mkdirSync(home, { recursive: true });
process.env.CLAUDE_HOME = home;
delete process.env.DASHBOARD_DATA_DIR;
});
const legacy = () => path.join(home, "agent-dashboard");
const current = () => path.join(home, "agenthub");
/** Put a recognisable database in `dir` so a migration can be proven. */
function seedDataDir(dir, marker) {
fs.mkdirSync(path.join(dir, "transcripts"), { recursive: true });
fs.writeFileSync(path.join(dir, "dashboard.db"), marker);
}
describe("getDataDir", () => {
it("uses the new name on a fresh install", () => {
assert.equal(getDataDir(), current());
});
it("resolves to the legacy directory until it is migrated, and moves nothing", () => {
seedDataDir(legacy(), "the-real-database");
assert.equal(getDataDir(), legacy(), "the data is still there, so that is the answer");
assert.deepEqual(fs.readdirSync(home), ["agent-dashboard"], "resolving must not move anything");
});
it("prefers the new directory once both exist", () => {
seedDataDir(legacy(), "old");
seedDataDir(current(), "new");
assert.equal(getDataDir(), current());
});
it("lets DASHBOARD_DATA_DIR win", () => {
seedDataDir(legacy(), "db");
const pinned = path.join(SUITE_ROOT, `pinned${seq}`);
process.env.DASHBOARD_DATA_DIR = pinned;
assert.equal(getDataDir(), pinned);
});
it("derives the transcript snapshot dir from the migrated location", () => {
seedDataDir(legacy(), "db");
migrateLegacyDataDir();
assert.equal(getTranscriptSnapshotDir(), path.join(current(), "transcripts"));
});
});
describe("migrateLegacyDataDir", () => {
it("moves an existing legacy directory to the new name, keeping its contents", () => {
seedDataDir(legacy(), "the-real-database");
assert.equal(migrateLegacyDataDir(), current());
assert.equal(fs.existsSync(legacy()), false, "the legacy directory should be gone");
assert.equal(
fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"),
"the-real-database"
);
assert.ok(fs.existsSync(path.join(current(), "transcripts")), "nested content must survive");
assert.equal(getDataDir(), current());
});
it("does nothing on a fresh install", () => {
assert.equal(migrateLegacyDataDir(), null);
assert.deepEqual(fs.readdirSync(home), [], "no directory should be created just by migrating");
});
it("migrates once and stays put on later calls", () => {
seedDataDir(legacy(), "db");
migrateLegacyDataDir();
fs.writeFileSync(path.join(current(), "dashboard.db"), "written-after-migration");
assert.equal(migrateLegacyDataDir(), null);
assert.equal(
fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"),
"written-after-migration",
"a second call must not re-migrate over newer data"
);
});
it("leaves both alone when the new directory already exists", () => {
// Nothing sensible to merge here, and picking wrong would hide a database:
// the new one wins and the legacy one is left untouched for the user.
seedDataDir(legacy(), "old");
seedDataDir(current(), "new");
assert.equal(migrateLegacyDataDir(), null);
assert.equal(fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"), "new");
assert.equal(fs.readFileSync(path.join(legacy(), "dashboard.db"), "utf8"), "old");
});
it("keeps using the legacy directory when it cannot be moved", () => {
// A read-only Claude home makes the rename fail. Starting a blank database
// beside 2 GB of unreachable history is the one outcome worth avoiding.
seedDataDir(legacy(), "db");
fs.chmodSync(home, 0o500);
try {
assert.equal(migrateLegacyDataDir(), null, "a failed move must not be reported as done");
assert.equal(getDataDir(), legacy());
} finally {
fs.chmodSync(home, 0o700);
}
});
it("never migrates behind an explicit DASHBOARD_DATA_DIR", () => {
seedDataDir(legacy(), "db");
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, `pinned${seq}`);
assert.equal(migrateLegacyDataDir(), null);
assert.ok(fs.existsSync(legacy()), "an explicit data dir must not trigger a migration");
});
});
+2 -2
View File
@@ -20,7 +20,7 @@ delete process.env.DASHBOARD_DATA_DIR;
const boot = require("../../scripts/plugin-bootstrap");
const RT = path.join(TMP_HOME, "agent-dashboard", "runtime");
const RT = path.join(TMP_HOME, "agenthub", "runtime");
function resetRuntime() {
fs.rmSync(RT, { recursive: true, force: true });
@@ -32,7 +32,7 @@ after(() => {
});
describe("serverIsLive", () => {
const infoPath = path.join(TMP_HOME, ".agent-dashboard.json");
const infoPath = path.join(TMP_HOME, ".agenthub.json");
beforeEach(() => fs.rmSync(infoPath, { force: true }));
after(() => fs.rmSync(infoPath, { force: true }));
@@ -253,20 +253,5 @@ describe("plugin marketplace", () => {
}
}
});
it("declares an MCP config that exists and resolves through CLAUDE_PLUGIN_ROOT", () => {
const mcpFile = path.join(REPO_ROOT, manifest.mcpServers);
assert.ok(fs.existsSync(mcpFile), `${manifest.mcpServers} does not exist`);
const mcp = readJson(mcpFile);
const args = JSON.stringify(mcp.mcpServers);
assert.match(args, /\$\{CLAUDE_PLUGIN_ROOT\}\/mcp\/build\/index\.js/);
});
it("ships the built MCP server the config points at", () => {
assert.ok(
fs.existsSync(path.join(REPO_ROOT, "mcp", "build", "index.js")),
"mcp/build/index.js must be committed — plugin MCP servers start before any bootstrap can build them"
);
});
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ function freshModule(dataDir) {
}
function writeDiscovery(servers) {
const infoPath = path.join(CLAUDE_HOME, ".agent-dashboard.json");
const infoPath = path.join(CLAUDE_HOME, ".agenthub.json");
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const recent = servers.reduce((a, b) =>
Date.parse(b.startedAt) > Date.parse(a.startedAt) ? b : a
@@ -179,6 +179,8 @@ describe("GET /api/workflows/skill-usage", () => {
ev("PostToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } });
}
ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } });
// AgentHub no longer ships an MCP server, but events recorded while it did
// are still in the database, and analytics must keep attributing them.
ev("PreToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
ev("PostToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
});
+1 -1
View File
@@ -111,7 +111,7 @@ function migrateLegacyDatabase(targetPath) {
// Resolution order: explicit DASHBOARD_DB_PATH wins; otherwise the file lives in
// the shared data dir — DASHBOARD_DATA_DIR if set, else the canonical user-global
// `~/.claude/agent-dashboard/` (see getDataDir). Resolving every launch path to
// `~/.claude/agenthub/` (see getDataDir). Resolving every launch path to
// the same file is what lets the web app and the native apps share ONE database.
const DB_PATH = process.env.DASHBOARD_DB_PATH || path.join(getDataDir(), "dashboard.db");
const DB_DIR = path.dirname(DB_PATH);
+12
View File
@@ -28,6 +28,18 @@ if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
}
})();
// Carry a pre-rebrand `~/.claude/agent-dashboard` over to `~/.claude/agenthub`.
//
// Placement is load-bearing in two directions. It must run before the router
// requires below, because those pull in db.js, which resolves its path and opens
// SQLite at module load — migrating after that would move the database out from
// under a live handle. And it must run only when this file *is* the process, not
// whenever it is required: the test suite imports it dozens of times, and an
// unguarded call would relocate the developer's real database as a side effect
// of running `npm test`. It sits after the .env loader so an explicit
// DASHBOARD_DATA_DIR still suppresses it.
if (require.main === module) require("./lib/claude-home").migrateLegacyDataDir();
const express = require("express");
const cors = require("cors");
const path = require("path");
+47 -2
View File
@@ -19,22 +19,66 @@ function getProjectsDir() {
return path.join(getClaudeHome(), "projects");
}
/** Data-directory name before the AgentHub rebrand. Still migrated from. */
const LEGACY_DATA_DIR_NAME = "agent-dashboard";
/**
* Canonical, user-global directory for the dashboard's writable state — the
* SQLite database, VAPID keys, and transcript snapshots. It resolves to the
* SAME absolute path for every launch path (`npm start`, `npm run dev`, and the
* macOS/Windows desktop app), so they all share ONE database instead of each
* host keeping its own. Lives under the Claude home, next to the hook discovery
* file (`~/.claude/.agent-dashboard.json`).
* file (`~/.claude/.agenthub.json`).
*
* An explicit `DASHBOARD_DATA_DIR` still wins — for tests, power users, or
* anyone pinning a custom location. The earlier default was the repo-local
* `data/` dir, which the desktop app (read-only bundle) couldn't use and which
* never coincided with the web server's copy; see db.js for the one-time
* migration that carries pre-existing databases into this location.
*
* Resolution is PURE — it stats, it never moves anything. An install predating
* the rebrand still has its state under `agent-dashboard/`, and until
* `migrateLegacyDataDir()` moves it, that is where the database is and that is
* what this returns. Doing the rename here instead would mean any code path
* that merely asks for the path — a test run, `--help`, a doctor probe —
* silently relocates a multi-gigabyte database as a side effect.
*/
function getDataDir() {
return process.env.DASHBOARD_DATA_DIR || path.join(getClaudeHome(), "agent-dashboard");
if (process.env.DASHBOARD_DATA_DIR) return process.env.DASHBOARD_DATA_DIR;
const { current, legacy } = dataDirCandidates();
if (fs.existsSync(current)) return current;
if (fs.existsSync(legacy)) return legacy;
return current;
}
function dataDirCandidates() {
const home = getClaudeHome();
return { current: path.join(home, "agenthub"), legacy: path.join(home, LEGACY_DATA_DIR_NAME) };
}
/**
* Move a pre-rebrand data directory to its current name. Called deliberately,
* once, from server startup — never from a getter (see getDataDir).
*
* A rename rather than a copy: same filesystem, so it is atomic and instant
* even for a multi-gigabyte database, and it cannot leave two divergent copies
* behind. Every failure is silent and non-fatal, because `getDataDir()` then
* keeps resolving to the legacy directory: an unfashionable directory name is
* strictly better than refusing to boot, and far better than a blank database
* beside an unreachable one.
*
* @returns {string|null} the new path when something moved, else null.
*/
function migrateLegacyDataDir() {
if (process.env.DASHBOARD_DATA_DIR) return null;
const { current, legacy } = dataDirCandidates();
if (fs.existsSync(current) || !fs.existsSync(legacy)) return null;
try {
fs.renameSync(legacy, current);
return current;
} catch {
return null;
}
}
/**
@@ -282,6 +326,7 @@ module.exports = {
getClaudeHome,
getProjectsDir,
getDataDir,
migrateLegacyDataDir,
getTranscriptSnapshotDir,
getSettingsPath,
getTranscriptPath,
+9
View File
@@ -37,6 +37,11 @@ const DEFAULT_PORT = 4820;
/** Absolute path of the discovery file. */
function getServerInfoPath() {
return path.join(getClaudeHome(), ".agenthub.json");
}
/** Where the discovery file lived before the AgentHub rebrand. */
function getLegacyServerInfoPath() {
return path.join(getClaudeHome(), ".agent-dashboard.json");
}
@@ -168,6 +173,10 @@ function writeServerInfo(port) {
try {
const dir = getClaudeHome();
fs.mkdirSync(dir, { recursive: true });
// Retire the pre-rebrand discovery file. It is dropped rather than merged:
// its contents are live pids and ports, all of which this write is about to
// re-derive, and leaving it would let an older reader resolve a dead port.
fs.rmSync(getLegacyServerInfoPath(), { force: true });
const existing = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && s.pid !== process.pid && isPidAlive(s.pid)
);