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
+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");
});