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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user