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
+19
View File
@@ -2133,3 +2133,22 @@ provider in the hover, and a Model detail row. Blank when the log names
none. Live check against the real data root on a scratch board: all 42
rows carried a model. Board suite 91/91 (was 90). Sonnet review recorded
in the plan page entry.
## 2026-09-12 — Control board piece 2: reply-from-board (#1505)
Before: the board could show a seat waiting on Jason but he had to find
its tmux pane to answer. After: the detail of a row with a live
registration has a one-line reply box; `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 tool's exit code,
stdout and stderr; the page shows `delivered <time>` or `failed (exit N):
<stderr>`. Rows without a live registration say "reply needs a registered
seat". No queue, retries, history, broadcast or raw send-keys; no change
to `packages/seat` or `agent-send.sh`. Live check through the real tool on
a scratch tmux session: delivered, and a refused target reported verbatim.
Board suite 98/98 (was 91). Gate C's first exchange on 2026-09-12:
Jason's "pizza?" reached darkwing and the row showed the answer within
one scan; the seat's attempt to agent-send back to `control-board` was
refused, correctly, since the board has no pane. Fix: every board message
ends with a fixed trailer saying to answer in the seat's own session.
+1
View File
@@ -223,3 +223,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise.
- 2026-09-12 — darkwing (Claude Code, coordinator) — #1504 follow-up on two defects the professor session reported and this session verified: the darkwing launch test leaked fixture registrations into the real data root (flock spawn without the fixture config; fixed and guarded so a leak fails), and a refused launch left a dead-pid record the board honoured (scanner now probes the pid; stale records are shown but do not override). Fleet launchers marked not planned per Jason. Board 90/90, seat 15/15, launch scripts 5/5. Committed and pushed to refactor.
- 2026-09-12 — darkwing (Claude Code, coordinator) — Control board: model per row (#1503) on Jason's ask: readSession tracks model/provider from the latest model_change entry or assistant turn; page shows the model under the agent name and a Model detail row. Live check on a scratch board: all 42 real rows named a model. Board 91/91. Committed and pushed to refactor.
- 2026-09-12 — darkwing (Claude Code, coordinator) — Control board piece 2, reply-from-board (#1505, brief by Jason): `POST /api/reply` runs `tools/tmux/agent-send.sh` for one registered, live seat and returns the exit code and streams; the page has a reply box in registered live rows' detail with delivered/failed receipts. Live check on a scratch tmux session through the real agent-send.sh: delivered, then a failure receipt with the tool's stderr. Board 98/98. Gate C first exchange ("pizza?") delivered and read back within one scan; the seat's send-back to control-board was refused as expected, so every board message now ends with a fixed trailer (Jason's ruling via the professor session). Committed and pushed to refactor.
@@ -407,3 +407,67 @@ of `gpt-5.6-*` and `glm-5.3`). Board suite 91/91. Jason's board on 7331
needs a restart to pick this up. Sonnet review found the model hover title
escaped an already escaped string (double encoding for names with `&` or
`<`); fixed before commit, then APPROVED on that basis.
**2026-09-12 — Piece 2: reply-from-board (#1505).** Built to the brief
above. `POST /api/reply {agent, text}` rescans, finds the row, and runs
`tools/tmux/agent-send.sh -s <session> -S <host>:control-board -m <text>`,
plus `-L <socket>` when the registration names one, with
`MOSAIC_TMUX_SOCKET` stripped from the tool's environment so the
registration is the only source of the socket. The answer carries
`delivered`, the exit code, stdout and stderr; a non-zero exit is
reported as is. Refusals before the tool runs: bad or empty or long text
400, unknown row 404, no registration or stale registration or no tmux
session 409, tool not startable 500. The page puts a one-line box and
Send in the detail of rows whose registration is live and has a tmux
session; other rows say "reply needs a registered seat". The receipt
reads `delivered <time> to tmux <session>` or `failed (exit N): <stderr>`;
drafts and receipts survive the periodic refresh and the caret is given
back to the box; a delivered reply clears the box and rescans at once.
Not built, per the brief: raw send-keys, queue, retries, history,
broadcast, more than one seat per request; no change to `packages/seat`,
the registration record or `agent-send.sh`.
Live check on a scratch server (port 7332) with a scratch tmux session
`cb-reply-test` running `cat` and a temp data root: `agent-send.sh`
accepted `dragon-lin:control-board` as sender, the text arrived in the
pane as `[dragon-lin:control-board -> dragon-lin:cb-reply-test] ...`, and
the page showed `delivered`. Killing the session also killed the
registered pid, so the row went stale and the box gave way to "reply
needs a registered seat", as intended. With a live pid pointed at a
missing session the receipt read `failed (exit 1): ERROR: invalid
resolved pane identity` and the draft stayed in the box. Tests: a fake
`agent-send.sh` on the server's configured path captures argv and the
environment; cases for `-s`/`-S`/`-m` without `-L`, with `-L`, exit 4 with
stderr, each refusal with the tool never called, a missing tool, and
static checks on the page (box only when `alive !== false` and a tmux
session, JSON POST, escaped receipts). Board suite 98/98 (was 91). Gate C
is Jason's to run against a real waiting seat; his board on 7331 needs a
restart first.
Sonnet review of the piece: one finding, the route comment at the top of
`serve.mjs` had the `/api/reply` line spliced into the middle of the
`/api/seen` description, so it read as if a reply rescanned and returned
the index; the comment was split back into two entries before commit.
Noted but not changed: the body limit is bytes and the text limit is
characters, so a non-ASCII text near 2000 characters is refused as an
oversized body rather than as long text; and a row open in both "Waiting
on you" and its project group has two forms with the same id, so the
refocus after a refresh may land on the other copy.
**2026-09-12 — Gate C, first exchange, and the trailer.** Jason answered
darkwing from his restarted board with "pizza?". The delivery worked
(`[dragon-lin:control-board -> dragon-lin:darkwing] pizza?` in darkwing's
session log) and the row showed the answer as its last message within one
scan, so the return path exists: the board reads the transcript. What
failed was the seat's side: the communications skill told the seat a reply
only exists once `agent-send.sh` runs, the seat aimed at `-s
control-board`, and the tool refused because `control-board` is not a
tmux session ("invalid resolved pane identity"). That refusal is correct;
the board is not made a pane. Fix in the board, relayed as Jason's ruling
by the professor session: every message the board sends ends with a fixed
line, `(control-board: answer in your own session as usual; the board
reads your transcript. Do not agent-send to control-board.)`, exported as
`REPLY_TRAILER`; `-S` stays `<host>:control-board` so the preamble stays
honest about the origin. The fake agent-send test now checks the message
text ends with the trailer; the receipt is unchanged. Committed with the
piece.
+21 -7
View File
@@ -32,13 +32,27 @@ Package: `packages/seat`. Fleet seats stay on their own launchers (Jason's
ruling, 2026-09-12); only `agents/` seats register. A record whose pid is
gone is stale and does not override the derived values.
Next action: step 3 of #1503 continues — daily use with #1504 landed. Jason
launches his seats through the new path (or the launch scripts, which now
register), sets tasks with `seat task`, and reports what is wrong, missing
or confusing; each report becomes a small fix with a test, committed and
pushed to `refactor`. The sequence Jason set stays: seat registration (done),
then reply-from-board, then the WebUI on Dewey's Console design absorbs the
board as its first screen. Close #1503 when Jason says the page answers "who
Gate B passed 2026-09-12 (seat registration shown and used on the board).
Piece 2, reply-from-board (#1505; brief in the plan page, section "Piece
2: reply-from-board", approved by Jason 2026-09-12), is built and pushed
2026-09-12: a text box and Send in the detail of registered, live rows;
`POST /api/reply` runs `tools/tmux/agent-send.sh -s <session> -S
"<host>:control-board" [-L <socket>] -m <text>` and returns the exit code,
stdout and stderr; the page shows `delivered` or `failed` with the stderr.
No send-keys, queue, history or broadcast; no change to `packages/seat` or
`agent-send.sh`. Board suite 98/98.
Gate C's first exchange happened 2026-09-12 ("pizza?" to darkwing:
delivered, read back within one scan; the seat's send-back to
`control-board` was refused as expected, so every board message now ends
with a fixed trailer telling the seat to answer in its own session).
Next action: Jason confirms Gate C on the trailer build (restart the board
on 7331, answer a `waiting` seat, the row turns `working` with the reply
as its newest user message and the seat answers in its own session).
Record the pass or the failure in the plan page and here. Then the WebUI on Dewey's Console design absorbs
the board as its first screen. Close #1503 when Jason says the page answers "who
is waiting on me" without him opening a terminal. Out of scope until he
asks: auth or provider registry, roster schema changes, hooks/plugins, comms,
memory, multiple sessions per seat, stopping seats, new root files. The
+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.
+92
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() {
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");
});