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
+29
View File
@@ -64,8 +64,37 @@ Routes served:
more than 4096 bytes): `{"project", "agent", "lastActivity", "seen"}`.
`seen` defaults to `true`; pass `false` to unsee. Bad input, a missing
header, or an oversized body gets a `400`.
- `POST /api/reply` — send one line of text to one registered seat through
`tools/tmux/agent-send.sh` (#1505). Body: `{"agent": "<project>/<agent>",
"text"}`, JSON, at most 4096 bytes, text at most 2000 characters. The
server rescans, finds the row, and runs
`agent-send.sh -s <session> -S <host>:control-board [-L <socket>] -m <text>`
with the session and socket from the row's registration; `MOSAIC_TMUX_SOCKET`
is stripped from the tool's environment so the registration is the only
source. The text is sent as typed, followed on its own line by a fixed
trailer: `(control-board: answer in your own session as usual; the board
reads your transcript. Do not agent-send to control-board.)`. The board is
a sender without a pane: replies to it are read from the seat's
transcript, never sent, and `agent-send.sh -s control-board` refuses
because no such session exists (Jason's refinement after the first real
exchange, 2026-09-12). It answers `200` with `{delivered, exitCode, signal, stdout,
stderr, agent, session, socket, sentAt}` whenever the tool ran, delivered
or not; a non-zero exit is reported with the tool's stderr, never retried
and never routed around. Refusals before the tool runs: `400` bad body,
empty or blank text, text too long; `404` unknown row; `409` the row has
no registration, the registration is stale (pid gone), or it has no tmux
session; `500` the tool could not be started. One seat per request; no
queue, no history, no broadcast, no raw `send-keys`.
- `GET /healthz`.
On the page, the detail of a row with a live registration and a tmux
session has a one-line reply box and a Send button; every other row's
detail says "reply needs a registered seat". After Send the row shows
`delivered <time> to tmux <session>` or `failed (exit N): <stderr>`. A
draft that has not been sent, and the last receipt, survive the page's
periodic refresh; a delivered reply clears the box and triggers a rescan
so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written, or the server stopped cleanly.
+97 -5
View File
@@ -66,6 +66,10 @@
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
@@ -195,6 +199,28 @@
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
// Reply-from-board (#1505): a box and Send only where the board has an
// address, i.e. a live registration naming a tmux session. Receipts and
// unsent drafts are kept per row id so a refresh does not lose them.
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
function replyReceipt(id) {
var r = replyReceipts[id];
if (!r) return "";
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
'<button type="submit">Send</button></form>' + replyReceipt(id);
}
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
@@ -249,6 +275,7 @@
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
@@ -360,12 +387,30 @@
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
// A refresh rebuilds the tables. If Jason is typing a reply, put the
// caret back where it was so the periodic refresh does not steal it.
function withReplyFocus(render) {
var active = document.activeElement;
var focus = active && active.classList && active.classList.contains("reply-text")
? { id: active.form.dataset.id, pos: active.selectionStart }
: null;
render();
if (!focus) return;
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
if (!input) return;
input.focus();
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
}
function renderAll() {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
withReplyFocus(function () {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
});
}
function renderHeaderStatus() {
@@ -462,6 +507,53 @@
});
}
function postReply(form) {
var id = form.dataset.id;
var input = form.querySelector(".reply-text");
var text = input.value;
if (!text.trim()) return;
var sendBtn = form.querySelector("button");
sendBtn.disabled = true;
fetch("/api/reply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agent: id, text: text })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
replyReceipts[id] = body;
if (body.delivered) delete replyDrafts[id];
renderAll();
// The seat's log shows the effect; fetch now instead of waiting a tick.
if (body.delivered && !paused) runFetch();
})
.catch(function (err) {
console.error("reply failed", err);
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
renderAll();
});
}
main.addEventListener("submit", function (e) {
var form = e.target.closest(".reply-form");
if (!form) return;
e.preventDefault();
postReply(form);
});
main.addEventListener("input", function (e) {
var input = e.target.closest(".reply-text");
if (!input) return;
replyDrafts[input.form.dataset.id] = input.value;
});
main.addEventListener("click", function (e) {
var seenBtn = e.target.closest(".seen-toggle");
if (seenBtn) return postSeen(seenBtn);
+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");
+193 -1
View File
@@ -7,13 +7,15 @@ import {
rmSync,
readFileSync,
existsSync,
chmodSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, basename } from "node:path";
import { spawnSync, spawn } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { ConfigError, markSeen } from "../src/scan.mjs";
import { isLoopbackHost, startServer } from "../src/serve.mjs";
import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER } from "../src/serve.mjs";
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
@@ -689,3 +691,193 @@ test("page.html: task and active project cells show their source via sourceTag()
assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
}
});
// ---------------------------------------------------------------------------
// 10. POST /api/reply: reply-from-board through a fake agent-send.sh (#1505)
// ---------------------------------------------------------------------------
// A fake tools/tmux/agent-send.sh: records argv and the env it saw, exits
// with FAKE_SEND_EXIT (default 0) and writes FAKE_SEND_STDERR to stderr.
function writeFakeAgentSend(root) {
const path = join(root, "agent-send.sh");
writeFile(path, `#!/usr/bin/env node
const fs = require("node:fs");
fs.writeFileSync(process.env.FAKE_SEND_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), tmuxSocketEnv: process.env.MOSAIC_TMUX_SOCKET ?? null }));
process.stdout.write("fake sent\\n");
if (process.env.FAKE_SEND_STDERR) process.stderr.write(process.env.FAKE_SEND_STDERR);
process.exit(Number(process.env.FAKE_SEND_EXIT || 0));
`);
chmodSync(path, 0o755);
return path;
}
function replyFixture({ tmux = { socket: null, session: "agent1" }, pid = process.pid, registered = true } = {}) {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done?"] }),
]);
const seatsDir = join(root, "seats");
if (registered) {
writeRegistration(seatsDir, makeRegistration({
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
task: "t", tmux, pid,
}));
}
const capture = join(root, "capture.json");
process.env.FAKE_SEND_CAPTURE = capture;
return { root, sessionsDir, seatsDir, boardDir: join(root, "board"), agentSend: writeFakeAgentSend(root), capture,
specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] };
}
async function postReply(base, body, headers = { "content-type": "application/json" }) {
const res = await fetch(`${base}/api/reply`, { method: "POST", headers, body: typeof body === "string" ? body : JSON.stringify(body) });
return { status: res.status, body: await res.json() };
}
test("POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams", async () => {
const f = replyFixture();
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", agentSend: f.agentSend, now: () => new Date("2026-09-12T18:00:00Z") });
const base = `http://127.0.0.1:${server.address().port}`;
process.env.MOSAIC_TMUX_SOCKET = "leaked-from-launcher";
delete process.env.FAKE_SEND_EXIT;
delete process.env.FAKE_SEND_STDERR;
try {
const { status, body } = await postReply(base, { agent: "proj/agent1", text: "yes, go ahead" });
assert.equal(status, 200);
assert.equal(body.delivered, true);
assert.equal(body.exitCode, 0);
assert.equal(body.stdout, "fake sent\n");
assert.equal(body.stderr, "");
assert.equal(body.session, "agent1");
assert.equal(body.socket, null);
assert.equal(body.sentAt, "2026-09-12T18:00:00.000Z");
const seen = JSON.parse(readFileSync(f.capture, "utf8"));
assert.equal(seen.tmuxSocketEnv, null, "the launcher's socket variable must not reach the tool");
const argv = seen.argv;
assert.equal(argv[argv.indexOf("-s") + 1], "agent1");
assert.match(argv[argv.indexOf("-S") + 1], /^[^:\s]+:control-board$/);
assert.equal(argv[argv.indexOf("-m") + 1], "yes, go ahead\n" + REPLY_TRAILER, "the text, then the fixed trailer on its own line");
assert.match(REPLY_TRAILER, /^\(control-board: .*Do not agent-send to control-board\.\)$/);
assert.equal(argv.includes("-L"), false);
assert.equal(argv.length, 6, "exactly -s, -S and -m: no send-keys, class or retries");
} finally {
delete process.env.MOSAIC_TMUX_SOCKET;
await closeServer(server);
}
});
test("POST /api/reply: a registration with a tmux socket adds -L <socket>", async () => {
const f = replyFixture({ tmux: { socket: "mosaic-fleet", session: "agent1" } });
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", agentSend: f.agentSend });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" });
assert.equal(status, 200);
assert.equal(body.socket, "mosaic-fleet");
const argv = JSON.parse(readFileSync(f.capture, "utf8")).argv;
assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
} finally {
await closeServer(server);
}
});
test("POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim", async () => {
const f = replyFixture();
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", agentSend: f.agentSend });
const base = `http://127.0.0.1:${server.address().port}`;
process.env.FAKE_SEND_EXIT = "4";
process.env.FAKE_SEND_STDERR = "agent-send.sh: REFUSING - session 'agent1' exists on multiple sockets\n";
try {
const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" });
assert.equal(status, 200);
assert.equal(body.delivered, false);
assert.equal(body.exitCode, 4);
assert.match(body.stderr, /REFUSING/);
} finally {
delete process.env.FAKE_SEND_EXIT;
delete process.env.FAKE_SEND_STDERR;
await closeServer(server);
}
});
test("POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called", async () => {
const cases = [
{ fixture: replyFixture(), body: { agent: "proj/agent1", text: "" }, status: 400, error: /non-empty/ },
{ fixture: replyFixture(), body: { agent: "proj/agent1", text: " \n" }, status: 400, error: /non-empty/ },
{ fixture: replyFixture(), body: { agent: "proj/agent1", text: "x".repeat(REPLY_LIMIT + 1) }, status: 400, error: /longer than/ },
{ fixture: replyFixture(), body: { agent: "proj/nobody", text: "hi" }, status: 404, error: /unknown row/ },
{ fixture: replyFixture(), body: { text: "hi" }, status: 400, error: /row id/ },
{ fixture: replyFixture({ registered: false }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /needs a registered seat/ },
{ fixture: replyFixture({ pid: 4242 }), isPidAlive: () => false, body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /stale/ },
{ fixture: replyFixture({ tmux: null }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /no tmux session/ },
{ fixture: replyFixture(), body: "{not json", status: 400, error: /invalid JSON/ },
];
for (const c of cases) {
const f = c.fixture;
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", agentSend: f.agentSend, isPidAlive: c.isPidAlive });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const { status, body } = await postReply(base, c.body);
assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
assert.match(body.error, c.error);
assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
} finally {
await closeServer(server);
}
}
});
test("POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash", async () => {
const f = replyFixture();
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", agentSend: join(f.root, "missing-agent-send.sh") });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const { status, body } = await postReply(base, { agent: "proj/agent1", text: "hi" });
assert.equal(status, 500);
assert.match(body.error, /missing-agent-send\.sh/);
const health = await fetch(`${base}/healthz`);
assert.equal(health.status, 200);
} finally {
await closeServer(server);
}
});
test("replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable", () => {
assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
assert.ok(existsSync(DEFAULT_AGENT_SEND));
assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
});
test("page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const lines = html.split("\n");
const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
assert.ok(canReplyFn, "canReply() must exist");
assert.match(canReplyFn[0], /reg\.alive !== false/, "a stale registration gets no box");
assert.match(canReplyFn[0], /reg\.tmux && reg\.tmux\.session/, "a registration without a tmux session gets no box");
const replyControlFn = html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/);
assert.ok(replyControlFn, "replyControl() must exist");
assert.match(replyControlFn[0], /if \(!canReply\(rec\)\) return '<span class="unknown">reply needs a registered seat<\/span>'/);
assert.match(replyControlFn[0], /class="reply-form" data-id="' \+ esc\(id\)/);
assert.match(replyControlFn[0], /value="' \+ esc\(replyDrafts\[id\] \|\| ""\)/, "an unsent draft is put back after a refresh");
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
assert.match(rowPair[0], /<dt>Reply<\/dt><dd>" \+ replyControl\(rec\) \+ "<\/dd>/);
const receiptFn = html.match(/function replyReceipt\(id\) \{[\s\S]*?\n \}/);
assert.ok(receiptFn, "replyReceipt() must exist");
for (const line of receiptFn[0].split("\n")) {
if (!/\br\.[a-zA-Z]/.test(line)) continue;
assert.match(line, /esc\(/, `every receipt value must be escaped: ${line.trim()}`);
}
assert.match(receiptFn[0], /esc\(r\.stderr \|\| r\.error/, "a failure shows the tool's stderr");
const postReplyFn = html.match(/function postReply\(form\) \{[\s\S]*?\n \}/);
assert.ok(postReplyFn, "postReply() must exist");
assert.match(postReplyFn[0], /fetch\("\/api\/reply", \{\s*method: "POST",\s*headers: \{ "content-type": "application\/json" \}/);
assert.match(postReplyFn[0], /JSON\.stringify\(\{ agent: id, text: text \}\)/);
assert.match(postReplyFn[0], /replyReceipts\[id\] = body/);
assert.ok(lines.some((l) => l.includes('main.addEventListener("submit"')), "the form submits through one delegated handler");
assert.ok(lines.some((l) => l.includes('main.addEventListener("input"')), "drafts are captured as they are typed");
assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
});