57dc91585d
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.
296 lines
9.5 KiB
JavaScript
296 lines
9.5 KiB
JavaScript
/**
|
|
* @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,
|
|
};
|