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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user