control board: reply from the board through agent-send.sh (#1505)

Piece 2 of the MVP (#1503). A one-line reply box and Send in the detail
of rows with a live registration; POST /api/reply runs
tools/tmux/agent-send.sh -s <session> -S <host>:control-board
[-L <socket>] -m <text> once for one seat and returns the exit code,
stdout and stderr. The page shows delivered or failed with the tool's
stderr; other rows say "reply needs a registered seat". No send-keys,
queue, retries, history or broadcast; packages/seat and agent-send.sh
untouched. Every message ends with a fixed trailer telling the seat to
answer in its own session (Jason's refinement after the first Gate C
exchange; the board has no pane). Board suite 98/98.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 11:30:30 -05:00
co-authored by Claude Fable 5.1
parent a62ca1904f
commit 867619dca2
8 changed files with 508 additions and 19 deletions
+84 -6
View File
@@ -4,7 +4,10 @@
// 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
// (or clears the mark with seen:false); rescans, returns index
// POST /api/reply {agent: "<project>/<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
@@ -16,12 +19,72 @@
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { isIP } from "node:net";
import { scan, markSeen, ConfigError } from "./scan.mjs";
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 "[<sender> -> <host>:<session>]" 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 <project>/<agent>" } };
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}` } };
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");
@@ -67,9 +130,24 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage() }) {
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive });
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) => {
@@ -79,7 +157,7 @@ export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, p
return sendJson(res, 400, { error: err.message });
}
try {
sendJson(res, 200, scan(specs, { boardDir, isAlive, now, seatsDir }));
sendJson(res, 200, rescan());
} catch (err) {
sendJson(res, 500, { error: err.message });
}
@@ -97,7 +175,7 @@ export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, p
if (url.pathname === "/api/board") {
let index;
try {
index = scan(specs, { boardDir, isAlive, now, seatsDir });
index = rescan();
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");