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.
142 lines
4.8 KiB
JavaScript
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);
|
|
});
|
|
})();
|