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:
+141
@@ -0,0 +1,141 @@
|
||||
#!/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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user