Files
AgentHub/server/lib/claude-home.js
T
nntrivi2001 c7dbaf2d9f 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.
2026-09-08 09:48:40 +07:00

342 lines
13 KiB
JavaScript

/**
* @file claude-home.js
* @description Centralized Claude Code home directory path management.
* Resolves the projects directory, transcript paths (main + per-subagent),
* and settings file location. Supports a custom root via the CLAUDE_HOME
* environment variable (e.g. ~/.codefuse/engine/cc/) so the dashboard can
* track non-default Claude Code installations.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
const os = require("os");
const fs = require("fs");
function getClaudeHome() {
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
}
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/.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() {
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;
}
}
/**
* Dashboard-owned directory where imported transcripts are snapshotted so the
* Conversation tab survives Claude Code pruning the originals in
* ~/.claude/projects. Lives next to the SQLite DB under the shared data dir.
*/
function getTranscriptSnapshotDir() {
return path.join(getDataDir(), "transcripts");
}
function getSettingsPath() {
return path.join(getClaudeHome(), "settings.json");
}
/**
* Claude Code path encoding: replace all non-alphanumeric characters with "-".
* Example: "/Users/txj/.codefuse" → "-Users-txj--codefuse"
* Note: not just "/", characters like "." are also replaced.
*/
function encodeCwd(cwd) {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}
/**
* Infer the main session JSONL file path from sessionId and cwd.
* Encoding rule: all non-alphanumeric characters replaced with "-".
* Falls back to scanning all project directories if the encoded path doesn't exist.
*/
function getTranscriptPath(sessionId, cwd) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
// Fallback: scan projects/ subdirectories
return findTranscriptPath(sessionId);
}
/**
* Resolve a per-agent transcript file inside a session's `subagents` directory,
* supporting BOTH on-disk layouts Claude Code has used for sub-agent transcripts:
* - flat: <subagents>/agent-<agentId>.jsonl
* (regular sub-agents, and older Workflow-tool builds)
* - nested: <subagents>/workflows/<runId>/agent-<agentId>.jsonl
* (current Workflow-tool fan-out runs)
*
* The flat path is checked first, so regular sub-agents resolve exactly as
* before. For the nested layout: when `runId` is known the run directory is read
* directly; when it is unknown the nested tree is scanned and a match is
* returned ONLY if exactly one run contains that agent — an ambiguous agentId
* across multiple runs resolves to null rather than guessing.
*
* @param {string} subagentsDir absolute path to a `.../subagents` directory
* @param {string} agentId the agent-<agentId>.jsonl key (no prefix/suffix)
* @param {string|null} [runId] the workflow run id, when known
* @returns {string|null} absolute transcript path, or null. Never throws.
*/
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
if (!subagentsDir) return null;
const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(flat)) return flat;
const workflowsDir = path.join(subagentsDir, "workflows");
if (!fs.existsSync(workflowsDir)) return null;
if (runId) {
const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`);
return fs.existsSync(nested) ? nested : null;
}
// Unknown run: accept only an unambiguous single match across all runs.
try {
const matches = [];
for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`);
if (fs.existsSync(cand)) matches.push(cand);
if (matches.length > 1) break;
}
return matches.length === 1 ? matches[0] : null;
} catch {
return null;
}
}
/**
* Infer the sub-agent JSONL file path from sessionId, cwd, agentId, and
* (optionally) the Workflow runId. Resolves both the flat and nested
* Workflow-tool layouts via resolveAgentTranscriptInDir. Falls back to scanning
* all project directories if the encoded path doesn't exist.
*/
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const subagentsDir = path.join(getProjectsDir(), encoded, sessionId, "subagents");
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (direct) return direct;
// Fallback: scan all project directories
return findSubagentTranscriptPath(sessionId, agentId, runId);
}
/**
* When cwd is unknown, scan projects/ subdirectories to find the JSONL file for a sessionId.
* Returns the found path or null.
*/
function findTranscriptPath(sessionId) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
}
} catch {
// Permission or IO error, ignore
}
return null;
}
/**
* Path to the dashboard's durable transcript snapshot for a session, if one
* exists. Snapshots are written at import time (see snapshotTranscript in
* scripts/import-history.js) so the Conversation tab keeps working after Claude
* Code deletes the original under its `cleanupPeriodDays` retention (default
* 30d). Returns the path or null.
*/
function getSnapshotTranscriptPath(sessionId) {
const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`);
return fs.existsSync(candidate) ? candidate : null;
}
/**
* Path to a snapshotted subagent transcript, mirroring the live layout
* `<snapshotDir>/<sessionId>/subagents/agent-<agentId>.jsonl` (flat) and
* `<snapshotDir>/<sessionId>/subagents/workflows/<runId>/agent-<agentId>.jsonl`
* (nested Workflow-tool runs, preserved by the snapshot writer). Supports the
* same compaction prefix-fuzzy match as findSubagentTranscriptPath. Returns
* the path or null.
*/
function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
if (!fs.existsSync(subDir)) return null;
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
if (hit) return hit;
if (agentId.startsWith("acompact-")) {
try {
const match = fs
.readdirSync(subDir)
.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subDir, match);
} catch {
/* ignore */
}
}
return null;
}
/**
* Find a sub-agent JSONL file path by scanning when cwd is unknown.
* Supports both layouts (flat + nested Workflow-tool, via
* resolveAgentTranscriptInDir) and a prefix fuzzy match:
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
*/
function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
const projectsDir = getProjectsDir();
if (!fs.existsSync(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
if (!fs.existsSync(subagentsDir)) continue;
// Exact match (flat or nested Workflow-tool layout)
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
if (hit) return hit;
// Prefix fuzzy match (compaction type: agentId starts with "acompact-")
if (agentId.startsWith("acompact-")) {
const files = fs.readdirSync(subagentsDir);
const match = files.find((f) => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
if (match) return path.join(subagentsDir, match);
}
}
} catch {
// Ignore
}
return null;
}
/**
* Update CLAUDE_HOME at runtime. Updates process.env so getClaudeHome()
* immediately returns the new value, and persists to .env file.
* Returns the resolved absolute path.
*/
function setClaudeHome(newPath) {
const resolved = newPath.replace(/^~(?=\/)/, os.homedir());
if (!path.isAbsolute(resolved)) {
throw new Error("CLAUDE_HOME must be an absolute path");
}
if (!fs.existsSync(resolved)) {
throw new Error(`Directory does not exist: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
process.env.CLAUDE_HOME = resolved;
writeEnvFile("CLAUDE_HOME", resolved);
return resolved;
}
/**
* Write or update a key=value line in the .env file.
* Creates the file if it doesn't exist.
*/
function writeEnvFile(key, value) {
const envPath = path.resolve(__dirname, "..", "..", ".env");
let lines = [];
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, "utf8").split("\n");
}
let found = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith(`${key}=`)) {
lines[i] = `${key}=${value}`;
found = true;
break;
}
}
if (!found) {
lines.push(`${key}=${value}`);
}
// Write atomically: write to temp file then rename to prevent corruption
const tempPath = envPath + ".tmp";
fs.writeFileSync(tempPath, lines.join("\n") + "\n", "utf8");
fs.renameSync(tempPath, envPath);
}
module.exports = {
getClaudeHome,
getProjectsDir,
getDataDir,
migrateLegacyDataDir,
getTranscriptSnapshotDir,
getSettingsPath,
getTranscriptPath,
resolveAgentTranscriptInDir,
getSubagentTranscriptPath,
getSnapshotTranscriptPath,
getSnapshotSubagentTranscriptPath,
findTranscriptPath,
findSubagentTranscriptPath,
setClaudeHome,
writeEnvFile,
};