/** * @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ĩ */ 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: /agent-.jsonl * (regular sub-agents, and older Workflow-tool builds) * - nested: /workflows//agent-.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-.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 * `//subagents/agent-.jsonl` (flat) and * `//subagents/workflows//agent-.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-.jsonl (or workflows//agent-.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: `process.env` changes at once and the value * persists to the settings file (`settings-env.js` — outside the repo, which a * plugin update replaces, and outside CLAUDE_HOME). The path passes the shared * `validateHomeDir` guard, which refuses anything that is or contains the * user's home (`EBADHOME`). * * The data dir is derived from CLAUDE_HOME, so the first change pins the one * in use as DASHBOARD_DATA_DIR (process + settings file) unless something * already sets it: otherwise the next start would open an EMPTY database under * the new home, and the plugin bootstrap and CLI — which never read the * settings file — would disagree with the server about where its runtime is. * The discovery file (`.agenthub.json`, server-info.js) is NOT pinned: it * follows CLAUDE_HOME, so hooks and the CLI running under the default home * find a dashboard on a non-default port only if they share its CLAUDE_HOME. * * @returns {string} the resolved absolute path. */ function setClaudeHome(newPath) { const { validateHomeDir, applySetting } = require("./settings-env"); const resolved = validateHomeDir("CLAUDE_HOME", newPath); if (!process.env.DASHBOARD_DATA_DIR) applySetting("DASHBOARD_DATA_DIR", getDataDir()); applySetting("CLAUDE_HOME", resolved); return resolved; } module.exports = { encodeCwd, getClaudeHome, getProjectsDir, getDataDir, migrateLegacyDataDir, getTranscriptSnapshotDir, getSettingsPath, getTranscriptPath, resolveAgentTranscriptInDir, getSubagentTranscriptPath, getSnapshotTranscriptPath, getSnapshotSubagentTranscriptPath, findTranscriptPath, findSubagentTranscriptPath, setClaudeHome, };