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
+97 -5
View File
@@ -66,6 +66,10 @@
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
@@ -195,6 +199,28 @@
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
// Reply-from-board (#1505): a box and Send only where the board has an
// address, i.e. a live registration naming a tmux session. Receipts and
// unsent drafts are kept per row id so a refresh does not lose them.
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
function replyReceipt(id) {
var r = replyReceipts[id];
if (!r) return "";
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
'<button type="submit">Send</button></form>' + replyReceipt(id);
}
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
@@ -249,6 +275,7 @@
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
@@ -360,12 +387,30 @@
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
// A refresh rebuilds the tables. If Jason is typing a reply, put the
// caret back where it was so the periodic refresh does not steal it.
function withReplyFocus(render) {
var active = document.activeElement;
var focus = active && active.classList && active.classList.contains("reply-text")
? { id: active.form.dataset.id, pos: active.selectionStart }
: null;
render();
if (!focus) return;
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
if (!input) return;
input.focus();
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
}
function renderAll() {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
withReplyFocus(function () {
rowIdx = 0;
renderWaiting(lastData);
renderSeen(lastData);
renderProjects(lastData);
renderFooter(lastData);
});
}
function renderHeaderStatus() {
@@ -462,6 +507,53 @@
});
}
function postReply(form) {
var id = form.dataset.id;
var input = form.querySelector(".reply-text");
var text = input.value;
if (!text.trim()) return;
var sendBtn = form.querySelector("button");
sendBtn.disabled = true;
fetch("/api/reply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agent: id, text: text })
})
.then(function (res) {
return res.json().catch(function () {
throw new Error("the server sent a response that was not valid JSON");
}).then(function (body) {
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
return body;
});
})
.then(function (body) {
replyReceipts[id] = body;
if (body.delivered) delete replyDrafts[id];
renderAll();
// The seat's log shows the effect; fetch now instead of waiting a tick.
if (body.delivered && !paused) runFetch();
})
.catch(function (err) {
console.error("reply failed", err);
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
renderAll();
});
}
main.addEventListener("submit", function (e) {
var form = e.target.closest(".reply-form");
if (!form) return;
e.preventDefault();
postReply(form);
});
main.addEventListener("input", function (e) {
var input = e.target.closest(".reply-text");
if (!input) return;
replyDrafts[input.form.dataset.id] = input.value;
});
main.addEventListener("click", function (e) {
var seenBtn = e.target.closest(".seen-toggle");
if (seenBtn) return postSeen(seenBtn);