feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
/**
* @file server-info.js
* @description Live discovery of every running dashboard server's TCP port.
*
* The conventional port is 4820, and a plain `npm start` setup almost always
* binds it. But more than one dashboard can run on a single machine — most
* commonly the macOS desktop app side-by-side with `npm run dev`. The hook
* handler fans out to every live dashboard that uses a **different** SQLite
* data directory. Servers sharing the same `dataDir` receive hooks through a
* single lowest-port ingest target so events are never duplicated.
*
* The on-disk file is a JSON document under the Claude Code home directory.
* Every server writes its own entry on startup, prunes any stale entries it
* finds, and the hook handler reads the file and fans out one POST per live
* entry. Stale entries (process gone) are dropped on every read.
*
* Backwards compatibility: the file always carries the **legacy** single-
* record fields (`port`, `pid`, `startedAt`) at its root, set to the most
* recently started live server. Older hook handlers — e.g. the one bundled
* inside a previously-installed `.app` that predates this multi-server
* format — still parse the file successfully and reach at least one live
* server. The new shape lives under `servers: [...]`.
*
* Every function here is best-effort and never throws: discovery must never
* block server startup, and the hook handler must never fail because of it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getClaudeHome, getDataDir } = require("./claude-home");
/** Conventional dashboard port — used when discovery yields nothing. */
const DEFAULT_PORT = 4820;
/** Absolute path of the discovery file. */
function getServerInfoPath() {
return path.join(getClaudeHome(), ".agent-dashboard.json");
}
/**
* Canonical absolute path for comparing data directories across processes.
* Falls back to `path.resolve` when the directory does not exist yet.
*
* @param {string} dir
* @returns {string}
*/
function normalizeDataDir(dir) {
if (!dir || typeof dir !== "string") return "";
try {
return fs.realpathSync(dir);
} catch {
return path.resolve(dir);
}
}
/**
* Grouping key for hook-ingest deduplication. Entries without `dataDir` are
* treated as unique (legacy servers before this field existed).
*
* @param {{ port: number, dataDir?: string }} server
* @returns {string}
*/
function ingestGroupKey(server) {
if (server.dataDir) return normalizeDataDir(server.dataDir);
return `__legacy__:${server.port}`;
}
/**
* Read the discovery file and return its `servers` list, normalised. Handles
* both the new array shape and the legacy single-record shape so a file
* written by an older server is still understood.
*
* @returns {Array<{port: number, pid: number, startedAt: string}>}
*/
function readInfoFile() {
try {
const raw = fs.readFileSync(getServerInfoPath(), "utf8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.servers)) {
return parsed.servers.filter((s) => s && Number.isInteger(s.port));
}
if (Number.isInteger(parsed.port)) {
// Legacy single-record file written by a server that predates this
// format. Treat the root object as the lone server entry.
return [{ port: parsed.port, pid: parsed.pid, startedAt: parsed.startedAt }];
}
return [];
} catch {
return [];
}
}
/**
* Whether a process is still running. `process.kill(pid, 0)` sends no signal;
* it only probes existence. EPERM means the process exists but is owned by
* another user — still "alive" for our purposes.
*
* @param {number} pid
* @returns {boolean}
*/
function isPidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return Boolean(err) && err.code === "EPERM";
}
}
/** Most recently started entry — used to populate the legacy root fields. */
function mostRecent(servers) {
return servers.reduce((a, b) => {
const at = Date.parse(a.startedAt) || 0;
const bt = Date.parse(b.startedAt) || 0;
return bt > at ? b : a;
});
}
/**
* Write `{ servers, ...legacy }` to disk via temp file + atomic rename. The
* read-modify-write here is not file-system locked — if two servers race to
* write at the exact same millisecond one entry may be momentarily lost; the
* loser's next write (or any read that triggers a prune) self-heals.
*/
function persist(servers) {
if (servers.length === 0) {
try {
fs.unlinkSync(getServerInfoPath());
} catch {
/* already gone */
}
return;
}
const recent = mostRecent(servers);
const payload = JSON.stringify(
{
// Legacy fields so an older hook handler (e.g. one bundled inside a
// previously-installed .app that predates the multi-server format)
// still resolves to a reachable port.
port: recent.port,
pid: recent.pid,
startedAt: recent.startedAt,
// The full list of live servers — the field new readers consume.
servers,
},
null,
2
);
const finalPath = getServerInfoPath();
const tmpPath = `${finalPath}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, payload);
fs.renameSync(tmpPath, finalPath);
}
/**
* Record the live server port so the hook handler (and any other local
* consumer) can find it. Other servers' entries are preserved; dead entries
* are pruned. Best-effort — a failure here never interrupts server startup.
*
* @param {number} port - The port the HTTP server is listening on.
*/
function writeServerInfo(port) {
if (!Number.isInteger(port) || port <= 0) return;
try {
const dir = getClaudeHome();
fs.mkdirSync(dir, { recursive: true });
const existing = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && s.pid !== process.pid && isPidAlive(s.pid)
);
const ours = {
port,
pid: process.pid,
startedAt: new Date().toISOString(),
dataDir: normalizeDataDir(getDataDir()),
};
persist([...existing, ours]);
} catch {
// Discovery is an optimization, not a requirement — never block startup.
}
}
/** Remove this process's entry from the file. Safe to call when absent. */
function removeServerInfo() {
try {
const remaining = readInfoFile().filter((s) => s.pid !== process.pid);
persist(remaining);
} catch {
// Already gone, never written, or unreadable — nothing to do.
}
}
/**
* Resolve every live dashboard server's port. Result is ordered most-recent
* last (the order entries appear in the file).
*
* 1. `CLAUDE_DASHBOARD_PORT` — explicit operator override; returned as the
* sole target so a test or one-off override doesn't fan out.
* 2. Live entries from the discovery file, pruned by PID liveness.
* 3. `[DEFAULT_PORT]` (`[4820]`) — the conventional fallback when nothing
* else resolves.
*
* @returns {number[]}
*/
function resolveAllDashboardPorts() {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
if (Number.isInteger(envPort) && envPort > 0) return [envPort];
const live = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && isPidAlive(s.pid)
);
if (live.length > 0) {
// Dedupe by port in case the same port appears twice (defensive).
return [...new Set(live.map((s) => s.port))];
}
return [DEFAULT_PORT];
}
/**
* Ports that should receive hook POSTs. When several live servers share the
* same SQLite data directory, only the lowest port per directory is returned
* so parallel instances (Docker + dev, two terminals on the same DB) never
* double-ingest events.
*
* @returns {number[]}
*/
function resolveHookIngestPorts() {
const envPort = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "", 10);
if (Number.isInteger(envPort) && envPort > 0) return [envPort];
const live = readInfoFile().filter(
(s) => Number.isInteger(s.port) && s.port > 0 && isPidAlive(s.pid)
);
if (live.length === 0) return [DEFAULT_PORT];
const byDataDir = new Map();
for (const server of live) {
const key = ingestGroupKey(server);
const prev = byDataDir.get(key);
if (!prev || server.port < prev.port) {
byDataDir.set(key, server);
}
}
return [...byDataDir.values()].map((s) => s.port).sort((a, b) => a - b);
}
/**
* Other live dashboard processes using the same SQLite data directory as this
* one. Used for startup warnings when multiple UIs point at one database.
*
* @returns {Array<{port: number, pid: number, startedAt: string}>}
*/
function peersSharingDataDir() {
try {
const mine = normalizeDataDir(getDataDir());
if (!mine) return [];
return readInfoFile().filter((s) => {
if (!Number.isInteger(s.port) || s.port <= 0) return false;
if (s.pid === process.pid) return false;
if (!isPidAlive(s.pid)) return false;
if (!s.dataDir) return false;
return normalizeDataDir(s.dataDir) === mine;
});
} catch {
return [];
}
}
/**
* Single-port helper kept for callers that have always asked the file for
* "the" port (e.g. legacy code paths and tests). Returns the first live
* server's port, or the default if none are alive.
*
* @returns {number}
*/
function resolveDashboardPort() {
return resolveAllDashboardPorts()[0] ?? DEFAULT_PORT;
}
module.exports = {
DEFAULT_PORT,
getServerInfoPath,
writeServerInfo,
removeServerInfo,
resolveDashboardPort,
resolveAllDashboardPorts,
resolveHookIngestPorts,
peersSharingDataDir,
// Exported for tests.
normalizeDataDir,
ingestGroupKey,
};