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