Files
Claude-Code-Monitor/scripts/dev.js
T
nntrivi2001 57dc91585d 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.
2026-07-30 14:39:03 +07:00

142 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Dev orchestrator. Picks a free port for the dev server (starting at the
* conventional 4820), exports it via `DASHBOARD_PORT`, then spawns the
* existing concurrently pipeline. Both `dev:server` (server/index.js) and
* `dev:client` (vite.config.ts) read the same env var, so they stay in
* lockstep.
*
* Why this exists: on machines that hold 4820 via an SSH `LocalForward`,
* SSH binds the loopback specifically (`127.0.0.1:4820` and `[::1]:4820`),
* Node's wildcard `server.listen(4820)` "succeeds" without binding the
* loopback, and every Vite proxy request to `localhost:4820` lands on SSH
* instead of Express — silent `ECONNRESET`s everywhere. Probing both IP
* families before we ever try to bind catches that.
*
* Built atop the macOS desktop app groundwork in PR #151 by @shuvamk.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const net = require("node:net");
const http = require("node:http");
const { spawn } = require("node:child_process");
const START = parseInt(process.env.DASHBOARD_PORT || "4820", 10);
const RANGE = 40;
/** Resolve true if a healthy dashboard already answers /api/health on `port`. */
function healthyDashboardOn(port) {
return new Promise((resolve) => {
const req = http.get({ host: "127.0.0.1", port, path: "/api/health", timeout: 600 }, (res) => {
let buf = "";
res.setEncoding("utf8");
res.on("data", (c) => (buf += c));
res.on("end", () => {
try {
resolve(JSON.parse(buf)?.status === "ok");
} catch {
resolve(false);
}
});
});
req.on("error", () => resolve(false));
req.on("timeout", () => {
req.destroy();
resolve(false);
});
});
}
function probeHost(host, port, timeoutMs) {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port });
const done = (busy) => {
sock.destroy();
resolve(busy);
};
sock.setTimeout(timeoutMs);
sock.once("connect", () => done(true));
sock.once("error", () => done(false));
sock.once("timeout", () => done(false));
});
}
async function busy(port) {
// IPv4 first (most common), IPv6 second. Either bind shadowing Node's
// wildcard listen is enough to break the proxy.
if (await probeHost("127.0.0.1", port, 600)) return true;
if (await probeHost("::1", port, 300)) return true;
return false;
}
async function pickPort() {
for (let p = START; p < START + RANGE; p++) {
if (!(await busy(p))) return p;
}
throw new Error(`No free port found in ${START}-${START + RANGE - 1}`);
}
(async () => {
let port;
try {
port = await pickPort();
} catch (err) {
console.error(`[dev] ${err.message}`);
process.exit(1);
}
if (port !== START) {
console.log(
`[dev] port ${START} is busy (something is on the loopback already — likely an SSH LocalForward); using ${port} instead`
);
// If the thing on the conventional port is itself a healthy dashboard, this
// dev server will run alongside it on the SAME shared database. Claude Code
// hooks fan out to every live dashboard, so each live event would be written
// twice — inflating counts. Warn so the developer can stop the other one.
if (await healthyDashboardOn(START)) {
console.log(
`[dev] ⚠ another dashboard is already running on :${START} and shares this database. ` +
`Live hook events will be counted by BOTH — stop the other dashboard (e.g. the desktop app) for accurate dev data.`
);
}
} else {
console.log(`[dev] dashboard server will listen on :${port}`);
}
// On Windows `npx` is a `npx.cmd` shim that `spawn` can only launch through a
// shell; without `shell: true` it fails with `spawn npx ENOENT`. POSIX has a
// real `npx` on PATH and is unaffected. With a shell, Node does not re-quote
// args, so the two space-containing `concurrently` commands must be quoted
// ourselves to survive as single tokens (on POSIX they're already one array
// element each, so we leave them bare).
const isWin = process.platform === "win32";
const cmd = (s) => (isWin ? `"${s}"` : s);
const child = spawn(
"npx",
[
"--no-install",
"concurrently",
"-n",
"server,client",
"-c",
"blue,green",
cmd("npm run dev:server"),
cmd("npm run dev:client"),
],
{
stdio: "inherit",
shell: isWin,
env: { ...process.env, DASHBOARD_PORT: String(port) },
}
);
// Propagate Ctrl-C / SIGTERM so concurrently can shut both legs down
// gracefully instead of being orphaned.
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => child.kill(sig));
}
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code || 0);
});
})();