// Control board step 2: a tiny local web server. No dependencies, no auth. // It only ever binds to a loopback address (fail closed otherwise). // // GET / the page (src/page.html) // GET /api/board re-runs the scanner and returns index.json as JSON // POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen // (or clears the mark with seen:false); rescans, returns index // POST /api/reply {agent: "/", text} delivers text to the // seat's tmux pane through tools/tmux/agent-send.sh (#1505); // answers {delivered, exitCode, stdout, stderr, ...} // GET /healthz {"ok":true} // // POST requires Content-Type: application/json. A plain form post from another // site in the browser cannot set that header without a CORS preflight, and this // server answers no preflight, so a stray page cannot flip marks. // // Every /api/board request rescans, so the page is never staler than its // refresh timer. The scan rewrites the derived board files as a side effect. import { createServer as createHttpServer } from "node:http"; import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { isIP } from "node:net"; import { hostname } from "node:os"; import { spawnSync } from "node:child_process"; import { scan, markSeen, seenKey, ConfigError } from "./scan.mjs"; const MAX_BODY = 4096; // Reply-from-board (#1505). The board owns no transport: a reply is handed to // the repository's own inter-agent channel, tools/tmux/agent-send.sh, which // prepends the "[ -> :]" preamble and pastes into the // seat's pane. The tool's exit code is the receipt; the board never retries, // queues or broadcasts, and never calls tmux send-keys itself. export const DEFAULT_AGENT_SEND = resolve(import.meta.dirname, "..", "..", "..", "tools", "tmux", "agent-send.sh"); export const REPLY_LIMIT = 2000; export const REPLY_SENDER = "control-board"; // Appended to every message on its own line. The board is a sender without // a pane: it reads the seat's transcript, so a seat must answer in its own // session as usual and never agent-send back to "control-board" (that // target does not exist and the tool refuses it). Jason's refinement after // the first real exchange, 2026-09-12. export const REPLY_TRAILER = "(control-board: answer in your own session as usual; the board reads your transcript. Do not agent-send to control-board.)"; const REPLY_TIMEOUT_MS = 15000; // Decide and, when allowed, send. Returns { status, body } for the HTTP layer. // Refusals (4xx) happen before the tool runs and carry { error }. Once the // tool has run the answer is 200 with delivered true/false, the exit code and // both output streams verbatim, whatever the code was. export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) { if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id /" } }; if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } }; if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } }; const rec = index.sessions.find((r) => seenKey(r) === key); if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } }; if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } }; const reg = rec.registered; if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } }; if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } }; if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } }; const session = reg.tmux.session; const socket = reg.tmux.socket || null; const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`]; if (socket) args.push("-L", socket); args.push("-m", `${text}\n${REPLY_TRAILER}`); // The registration says which socket the seat is on. A launcher-exported // MOSAIC_TMUX_SOCKET in this server's own environment must not override it. const env = { ...process.env }; delete env.MOSAIC_TMUX_SOCKET; const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }); if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } }; const exitCode = r.status; return { status: 200, body: { delivered: exitCode === 0, exitCode, signal: r.signal ?? null, stdout: String(r.stdout ?? ""), stderr: String(r.stderr ?? ""), agent: key, session, socket, sentAt: now().toISOString(), }, }; } function sendJson(res, status, body) { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body) + "\n"); } function readJsonBody(req) { return new Promise((resolvePromise, reject) => { const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase(); if (type !== "application/json") return reject(new Error("Content-Type must be application/json")); const chunks = []; let size = 0; req.on("data", (c) => { size += c.length; if (size > MAX_BODY) { req.destroy(); reject(new Error(`body larger than ${MAX_BODY} bytes`)); return; } chunks.push(c); }); req.on("end", () => { try { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object"); resolvePromise(parsed); } catch (err) { reject(new Error(`invalid JSON body: ${err.message}`)); } }); req.on("error", reject); }); } const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); export function isLoopbackHost(host) { if (LOOPBACK.has(host)) return true; return isIP(host) === 4 && host.startsWith("127."); } export function loadPage(path = join(import.meta.dirname, "page.html")) { return readFileSync(path, "utf8"); } // specs: agent specs to scan on each request. boardDir: where scan writes. export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) { const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); return createHttpServer((req, res) => { const url = new URL(req.url, "http://localhost"); if (req.method === "POST" && url.pathname === "/api/reply") { return readJsonBody(req) .then((body) => { let index; try { index = rescan(); } catch (err) { return sendJson(res, 500, { error: err.message }); } const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now }); sendJson(res, out.status, out.body); }) .catch((err) => sendJson(res, 400, { error: err.message })); } if (req.method === "POST" && url.pathname === "/api/seen") { return readJsonBody(req) .then((body) => { try { markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true }); } catch (err) { return sendJson(res, 400, { error: err.message }); } try { sendJson(res, 200, rescan()); } catch (err) { sendJson(res, 500, { error: err.message }); } }) .catch((err) => sendJson(res, 400, { error: err.message })); } if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405, { "content-type": "text/plain" }); return res.end("method not allowed\n"); } if (url.pathname === "/" || url.pathname === "/index.html") { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(page); } if (url.pathname === "/api/board") { let index; try { index = rescan(); } catch (err) { res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify({ error: err.message }) + "\n"); } res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify(index) + "\n"); } if (url.pathname === "/favicon.ico") { res.writeHead(204); return res.end(); } if (url.pathname === "/healthz") { res.writeHead(200, { "content-type": "application/json" }); return res.end('{"ok":true}\n'); } res.writeHead(404, { "content-type": "text/plain" }); res.end("not found\n"); }); } // Resolves to the listening server. Refuses any non-loopback host. export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) { if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`); const server = createServer(rest); return new Promise((resolvePromise, reject) => { server.once("error", reject); server.listen(port, host, () => { server.off("error", reject); resolvePromise(server); }); }); }