Add control board web page and local server (#1503)

Step 2 of the control board MVP (MOSAIC-STACK-D-001): `serve` command starts
a loopback-only local server that serves one self-contained page and re-runs
the status scanner on each /api/board request. The page lists sessions
waiting on Jason first (errors on top), then one table per project with
plain-word states, ages, last messages, expandable detail rows, per-project
hide-offline, and a 10-second auto-refresh with pause.

Tests: control-board 33/33 (10 new: loopback rules, host refusal, all routes,
per-request rescan, 500 path, CLI refusals, live serve, page escaping guard);
registry 69/69 unchanged. Receipt:
docs/plans/reviews/2026-09-12_control-board-step2-review.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 07:58:38 -05:00
co-authored by Claude Fable 5.1
parent b9f59a5903
commit ebedd1281e
10 changed files with 914 additions and 26 deletions
+22
View File
@@ -1943,3 +1943,25 @@ Suites: control-board 23/23; registry 69/69 unchanged. Live scan writes
Corrections during the phase: pi `stopReason: "length"` was initially treated as
"working" (now "error"); an unrunnable tmux check was initially treated as alive
(now "unknown"). Registry increment 3 stays parked. Next: step 2, the web page.
## 2026-09-12 — Control board MVP step 2 (#1503)
Before: scanner and CLI only; status lived in JSON files under
`<dataRoot>/board/` with no way to look at them but `--print`.
After: `node packages/control-board/src/cli.mjs serve` starts a local,
loopback-only server (default http://127.0.0.1:7331/) that serves one
self-contained page and re-runs the scanner on every `/api/board` request.
The page lists "waiting on you" first (errors on top), then one table per
project with plain-word states, ages and last messages; rows expand to full
detail; offline rows are hidden per project by default; it refreshes every
10 seconds and can be paused. Suites: control-board 33/33 (10 new server and
page tests); registry 69/69 unchanged. Live check in Chrome with the full fleet:
18 waiting rows, 24 offline hidden, expanded detail survives a refresh.
Receipt: `docs/plans/reviews/2026-09-12_control-board-step2-review.md`.
Corrections during the phase: `startServer` first threw synchronously for a
non-loopback host (now always rejects); expanded rows first closed on every
refresh (now persisted); the browser reported a favicon 404 (now a 204 route).
The independent reviewer had no browser, so the live check was done by the
coordinator and is recorded as such. Next: step 3, Jason uses the page daily.
+2
View File
@@ -212,3 +212,5 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — dewey (Claude Code; owner-directed takeover of dewey pi session 01a08111) — Resumed WUI Phase 2 from agents/dewey/work/wui/TASKS.md: D01 source-linked feature inventory, then D02-D05 five dashboard mockups and comparison page, folding in MOSAIC-STACK-D-001 control board as the landing view. Prototype files only under agents/dewey/work/wui; no v1 inspection, backend, credentials, commit or push.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 1 (#1503, D-001): plan page, ticket, `packages/control-board` scanner + CLI, 23/23 tests, live scan 42 sessions/18 waiting on Jason; independent review CHANGES REQUIRED then APPROVED after three fixes (length→error, unknown liveness state, secrets-boundary test). Receipt docs/plans/reviews/2026-09-12_control-board-step1-review.md. CURRENT next action: step 2, the page.
- 2026-09-12 — dewey (Claude Code; takeover of pi session 01a08111) — WUI Phase 2 D01-D04 done, D05 delivered: five dashboard mockups (Rail, Deck, Console, Board, Stream) plus comparison at agents/dewey/work/wui/mockups/index.html; verifier 31 checks, 0 failed, 2 not verified (forced colours, screen reader); records in VERIFY.md, README.md, TASKS.md, HISTORY.md, DECISIONS.md. Stopped at Jason's design-selection gate. No v1 inspection, backend, credentials, commit or push.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 2 (#1503, D-001): `packages/control-board` gains `serve` (loopback-only local server, `/api/board` re-runs the scanner) and a single-file page (waiting-on-you first, per-project groups, hide-offline, expandable rows, 10s auto-refresh with pause). Suites: control-board 33/33, registry 69/69. Static review (sonnet) found no defects; live browser check by coordinator. Receipt docs/plans/reviews/2026-09-12_control-board-step2-review.md. CURRENT next action: step 3, daily use by Jason.
+9 -1
View File
@@ -83,12 +83,20 @@ Board files are derived. They can be deleted and rebuilt at any time by
running the scanner again. They are NOT run records and they are not
evidence under the repository's write-once rules.
## Command
## Commands
Scan once and print the table:
```
node packages/control-board/src/cli.mjs scan --print
```
Start the page (step 2), then open http://127.0.0.1:7331/ in a browser:
```
node packages/control-board/src/cli.mjs serve
```
Tests:
```
+13 -13
View File
@@ -10,20 +10,20 @@ update this file to the next action). No ambiguity, no re-planning.
Jason decided on 2026-09-12 (MOSAIC-STACK-D-001) that the MVP is the control
board: one web page listing running agent sessions across projects, showing each
one's status, and flagging which ones are waiting on him. Plan page:
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Step 1 (status
scanner `packages/control-board`, plan page, ticket) is done in this checkout;
running `node packages/control-board/src/cli.mjs scan --print` writes one status
file per agent under `<dataRoot>/board/`. The registry line (increment 3,
headless identity-env leak) stays parked; #1500 is closed.
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Steps 1 and 2
are done in this checkout: `packages/control-board` scans every Pi agent
(repo `.pi/state/*` and `~/.mosaic/fleet`) and serves the page.
Next action: step 2 of #1503 — the web page. One local page that reads
`<dataRoot>/board/index.json`, groups sessions by project, shows the plain-word
state per agent, puts a "waiting on you" section at the top, and refreshes
itself (re-running the scanner on each refresh is acceptable). Reuse Dewey's
control-board mockups under `agents/dewey/work/wui/` for layout where they fit.
Acceptance: Jason opens the page in a browser and sees the same rows the
`--print` output shows, with no manual steps beyond starting the page. No auth,
no hooks/plugins, no comms, no launcher changes, no new root files.
Start it with `node packages/control-board/src/cli.mjs serve` and open
http://127.0.0.1:7331/ (loopback only, no auth, no daemon; Ctrl-C stops it).
Next action: step 3 of #1503 — daily use. Jason opens the page while he works
and reports what is wrong, missing or confusing; each report becomes a small
fix on this package with a test, committed and pushed to `refactor`. 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, hooks/plugins, comms,
memory, launcher changes, non-Pi harnesses, new root files. The registry line
(increment 3, headless identity-env leak) stays parked; #1500 is closed.
## Completed checkpoint: #1500 increment 2 (historical)
@@ -0,0 +1,60 @@
# Control board MVP step 2: the page, implementation and review receipt
Date: 2026-09-12. Recorded by jarvis (Claude Code, coordinator). Tracking: #1503.
Plan: `docs/plans/2026-09-12_control-board-mvp.md`. Step 1 receipt:
`2026-09-12_control-board-step1-review.md`.
## What was built
- `packages/control-board/src/serve.mjs`: a small local web server with no
dependencies and no auth. Routes: `/` (the page), `/api/board` (re-runs the
scanner and returns `index.json`), `/healthz`, `/favicon.ico` (empty). It
refuses to bind to anything but a loopback address.
- `packages/control-board/src/page.html`: one self-contained page. "Waiting on
you" at the top (error rows first), then one table per project with a
plain-word state badge, age, and last message. Rows expand to a detail panel;
open panels and the per-project "hide offline" choice survive the 10-second
auto-refresh. Pause/Resume and a manual Refresh button. All API text is
escaped before it reaches the page.
- `src/cli.mjs`: new `serve` command (`--port`, `--host`).
## Tests
`node --test packages/control-board/tests/` — 33 tests, 33 pass, 0 fail
(23 scanner tests from step 1, 10 new: loopback rules, host refusal without a
socket, every route, per-request rescan, 500 path, CLI refusal/exit codes, a
live spawned `serve`, and a static check that the page's esc() helper escapes
every HTML-significant character and that no API value is joined into markup
without it).
## Live check in a real browser
Server started with the full fleet and real tmux liveness on port 7332; page
opened in Chrome through DevTools. Seen: 18 rows under "Waiting on you" with
the three fleet 429-error rows first; two project groups (mosaic-stack 4,
fleet 38) with "24 offline hidden"; unticking "Hide offline" showed the
offline rows; Pause changed the header to "auto-refresh paused"; a row toggle
opened its detail panel and stayed open across the next refresh. Console:
only a favicon 404 (a `/favicon.ico` route was added after; no page errors).
Screenshot: `/tmp/control-board-review.png` (not committed).
## Independent review
Reviewer: in-session code-review agent (Claude Sonnet), static read-only.
Findings: none at reportable confidence for XSS, host-check bypass, request
handling, fail-closed behaviour, or doc drift. It could not run the browser
check itself (no shell or browser tools in its session), so it withheld a
final approval on that ground; the browser check above was done by the
coordinator instead. Two minor notes acted on: a stale test comment, and
adding a test for the page's escaping. A separate test-writing helper flagged
that `startServer` threw synchronously for a bad host while other failures
rejected; it is now `async` so every failure is a rejection.
Corrections during this step: open detail panels originally closed on every
refresh (fixed, keyed by section/project/agent); the escaping guard test
initially flagged my own key-building line and a comment (reworded).
## Boundaries kept
No launcher changes, no new root files, no secrets, no auth (loopback only),
no daemon, no changes to `packages/mosaic`. Next: step 3, daily use by Jason.
+17 -4
View File
@@ -18,12 +18,19 @@ rewritable; they are not run records and are not evidence.
| idle | The agent is live but has not had a conversation yet. |
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
## Command
## Commands
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
```
`scan` runs once and writes the status files. `serve` starts a small local
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
never staler than that timer. There is no login, so the server refuses to
bind to anything but a loopback address.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
absolute `dataRoot`, or the scanner refuses to run.
@@ -35,11 +42,17 @@ node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liven
- `--liveness tmux|assume-alive` — how to decide if an agent is alive.
`tmux` (default) checks the real tmux session. `assume-alive` treats
every agent as alive, useful for tests or environments without tmux.
- `--print` — also print a one-line-per-agent table to stdout.
- `--print` (`scan`) also print a one-line-per-agent table to stdout.
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
Any non-loopback address is refused.
Routes served: `/` (the page), `/api/board` (rescan, returns `index.json`),
`/healthz`.
## Exit codes
- `0` — scan completed and status files were written.
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
+24 -8
View File
@@ -1,12 +1,17 @@
#!/usr/bin/env node
// Usage: node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
// Usage:
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
// Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path";
import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs";
import { startServer } from "./serve.mjs";
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
function parseArgs(argv) {
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
const next = () => {
@@ -18,19 +23,32 @@ function parseArgs(argv) {
else if (a === "--fleet") opts.fleet = next();
else if (a === "--liveness") opts.liveness = next();
else if (a === "--print") opts.print = true;
else if (a === "--port") {
opts.port = Number(next());
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
} else if (a === "--host") opts.host = next();
else throw new ConfigError(`unknown argument: ${a}`);
}
if (opts.command !== "scan") throw new ConfigError("usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]");
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts;
}
function main() {
async function main() {
const opts = parseArgs(process.argv.slice(2));
const { dataRoot } = loadConfig(opts.config);
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
const boardDir = join(dataRoot, "board");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive });
if (opts.print) {
for (const s of index.sessions) {
@@ -42,12 +60,10 @@ function main() {
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`);
}
try {
main();
} catch (err) {
main().catch((err) => {
if (err instanceof ConfigError) {
process.stderr.write(`refused: ${err.message}\n`);
process.exit(2);
}
throw err;
}
});
+354
View File
@@ -0,0 +1,354 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Control board</title>
<style>
:root{
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--radius:8px;--radius-lg:14px;
}
@media (prefers-color-scheme: dark){
:root{
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
}
}
*,*::before,*::after{box-sizing:border-box}
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
body{margin:0 auto;max-width:1100px;padding:16px}
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.status-line{margin:0;color:var(--muted);font-size:.9rem}
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
.btn:hover{background:var(--raised)}
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
.empty{color:var(--muted);font-style:italic}
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
tbody tr:last-child td{border-bottom:none}
tr.is-offline{opacity:.55}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
border-radius:99px;border:1px solid transparent;white-space:nowrap}
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
.badge-muted{color:var(--muted);background:var(--raised)}
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
cursor:pointer;text-align:left}
.row-toggle:hover{text-decoration:underline}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;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)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
.detail-list dd{margin:0;overflow-wrap:anywhere}
.project-group{margin-bottom:20px}
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
</style>
</head>
<body>
<header class="page-head">
<h1>Control board</h1>
<div class="head-controls">
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
<button id="refreshBtn" class="btn" type="button">Refresh</button>
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
</div>
</header>
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
<main id="main">
<section aria-labelledby="waiting-h">
<h2 id="waiting-h">Waiting on you</h2>
<div id="waitingBody"></div>
</section>
<section aria-labelledby="projects-h">
<h2 id="projects-h">By project</h2>
<div id="projectsBody"></div>
</section>
</main>
<footer id="footer" class="page-footer"></footer>
<script>
(function () {
"use strict";
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
var REFRESH_MS = 10000;
var headerStatus = document.getElementById("headerStatus");
var refreshBtn = document.getElementById("refreshBtn");
var pauseBtn = document.getElementById("pauseBtn");
var errorBanner = document.getElementById("errorBanner");
var waitingBody = document.getElementById("waitingBody");
var projectsBody = document.getElementById("projectsBody");
var footer = document.getElementById("footer");
var main = document.getElementById("main");
var lastData = null;
var paused = false;
var fetching = false;
var timerId = null;
var secondsLeft = REFRESH_MS / 1000;
var hideOfflineState = {};
// Open detail panels survive a refresh. Keyed by section, project and agent
// because the same agent can appear in both the waiting list and its group.
var openDetails = {};
var rowIdx = 0;
function esc(v) {
if (v === null || v === undefined) return "";
return String(v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function humanAge(s) {
if (s === null || s === undefined) return "—";
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
if (m < 60) return m + "m";
var h = Math.floor(m / 60);
if (h < 24) return h + "h";
return Math.floor(h / 24) + "d";
}
function timeAgo(iso) {
if (!iso) return "never";
var ms = Date.now() - Date.parse(iso);
if (Number.isNaN(ms)) return "never";
var s = Math.floor(ms / 1000);
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
var m = Math.floor(s / 60);
if (m < 60) return m + "m ago";
var h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
return Math.floor(h / 24) + "d ago";
}
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function badge(state) {
var cls = BADGE_CLASS[state] || "badge-muted";
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
}
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var msg = rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
var main =
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button></td>" +
"<td>" + badge(rec.state) + "</td>" +
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
var span = showProject ? 5 : 4;
var tmux = rec.tmux && rec.tmux.session
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
: "—";
var detail =
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Working directory</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
function renderWaiting(data) {
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
list.sort(function (a, b) {
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
if (ae !== be) return ae - be;
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
return aa - bb;
});
if (list.length === 0) {
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
return;
}
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
waitingBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
function projectOrder(a, b) {
if (a === "fleet" && b === "fleet") return 0;
if (a === "fleet") return 1;
if (b === "fleet") return -1;
return a.localeCompare(b);
}
function renderProjects(data) {
var sessions = data.sessions || [];
var byProject = {};
sessions.forEach(function (r) {
(byProject[r.project] = byProject[r.project] || []).push(r);
});
var projects = Object.keys(byProject).sort(projectOrder);
if (projects.length === 0) {
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
return;
}
projectsBody.innerHTML = projects.map(function (project) {
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
var hideOffline = hideOfflineState[project];
var visible = group.filter(function (r) { return !(hideOffline && r.state === "offline"); });
var hiddenCount = group.length - visible.length;
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
var note = hiddenCount > 0 ? '<p class="offline-note">' + hiddenCount + " offline hidden</p>" : "";
return (
'<div class="project-group">' +
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + group.length + ")</span></h3>" +
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
"</div>" +
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="4" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
note + "</div>"
);
}).join("");
}
function renderFooter(data) {
var counts = data.counts || {};
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
function renderAll() {
rowIdx = 0;
renderWaiting(lastData);
renderProjects(lastData);
renderFooter(lastData);
}
function renderHeaderStatus() {
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
headerStatus.textContent = "Updated " + updated + " · " + refresh;
}
function showError(msg) {
errorBanner.hidden = false;
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
}
function hideError() {
errorBanner.hidden = true;
errorBanner.textContent = "";
}
function scheduleNextFetch() {
clearTimeout(timerId);
if (paused) return;
secondsLeft = REFRESH_MS / 1000;
timerId = setTimeout(runFetch, REFRESH_MS);
}
function runFetch() {
if (fetching) return;
fetching = true;
fetch("/api/board", { cache: "no-store" })
.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) {
lastData = body;
hideError();
renderAll();
})
.catch(function (err) {
console.error("control board refresh failed", err);
showError(err && err.message ? err.message : "unknown error");
})
.then(function () {
fetching = false;
renderHeaderStatus();
scheduleNextFetch();
});
}
function setPaused(p) {
paused = p;
pauseBtn.textContent = paused ? "Resume" : "Pause";
pauseBtn.setAttribute("aria-pressed", String(paused));
if (paused) clearTimeout(timerId);
else scheduleNextFetch();
renderHeaderStatus();
}
main.addEventListener("click", function (e) {
var btn = e.target.closest(".row-toggle");
if (!btn) return;
var detail = document.getElementById("detail-" + btn.dataset.idx);
var expanded = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) delete openDetails[btn.dataset.key];
else openDetails[btn.dataset.key] = true;
});
main.addEventListener("change", function (e) {
var cb = e.target.closest(".hide-offline-toggle");
if (!cb) return;
hideOfflineState[cb.dataset.project] = cb.checked;
renderProjects(lastData);
});
refreshBtn.addEventListener("click", runFetch);
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
setInterval(function () {
if (!paused && secondsLeft > 0) secondsLeft -= 1;
renderHeaderStatus();
}, 1000);
renderHeaderStatus();
runFetch();
})();
</script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
// Control board step 2: a tiny local web server. No dependencies, no auth.
// It only ever binds to a loopback address (fail closed otherwise).
//
// GET / the page (src/page.html)
// GET /api/board re-runs the scanner and returns index.json as JSON
// GET /healthz {"ok":true}
//
// Every /api/board request rescans, so the page is never staler than its
// refresh timer. The scan rewrites the derived board files as a side effect.
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { isIP } from "node:net";
import { scan, ConfigError } from "./scan.mjs";
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
export function isLoopbackHost(host) {
if (LOOPBACK.has(host)) return true;
return isIP(host) === 4 && host.startsWith("127.");
}
export function loadPage(path = join(import.meta.dirname, "page.html")) {
return readFileSync(path, "utf8");
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
return createHttpServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { "content-type": "text/plain" });
return res.end("method not allowed\n");
}
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
return res.end(page);
}
if (url.pathname === "/api/board") {
let index;
try {
index = scan(specs, { boardDir, isAlive, now });
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
}
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify(index) + "\n");
}
if (url.pathname === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
if (url.pathname === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"ok":true}\n');
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found\n");
});
}
// Resolves to the listening server. Refuses any non-loopback host.
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
const server = createServer(rest);
return new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolvePromise(server);
});
});
}
+338
View File
@@ -0,0 +1,338 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync, spawn } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { ConfigError } from "../src/scan.mjs";
import { isLoopbackHost, startServer } from "../src/serve.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// ---------------------------------------------------------------------------
// Fixture helpers, copied from scan.test.mjs (kept local so that file stays
// untouched; do not import unexported helpers across test files).
// ---------------------------------------------------------------------------
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-serve-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
function writeFile(path, content) {
mkdirSync(resolve(path, ".."), { recursive: true });
writeFileSync(path, content);
}
function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", id, timestamp, cwd });
}
function messageLine({ timestamp, role, stopReason, texts }) {
const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason;
if (texts) message.content = texts.map((text) => ({ type: "text", text }));
return JSON.stringify({ type: "message", timestamp, message });
}
function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) {
const path = join(dir, name);
writeFile(path, lines.join("\n") + (trailingNewline ? "\n" : ""));
return path;
}
// ---------------------------------------------------------------------------
// serve.mjs-specific helpers
// ---------------------------------------------------------------------------
function closeServer(server) {
return new Promise((resolvePromise) => server.close(resolvePromise));
}
// Grab an ephemeral free port, then hand it back immediately so a caller can
// try to bind it themselves (used to prove startServer never opened a socket).
function getFreePort() {
return new Promise((resolvePromise, reject) => {
const probe = createNetServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const port = probe.address().port;
probe.close(() => resolvePromise(port));
});
});
}
function runCli(args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
}
// ---------------------------------------------------------------------------
// 1. isLoopbackHost
// ---------------------------------------------------------------------------
test("isLoopbackHost: recognizes loopback hosts", () => {
assert.equal(isLoopbackHost("127.0.0.1"), true);
assert.equal(isLoopbackHost("::1"), true);
assert.equal(isLoopbackHost("localhost"), true);
assert.equal(isLoopbackHost("127.5.5.5"), true);
});
test("isLoopbackHost: rejects non-loopback hosts", () => {
assert.equal(isLoopbackHost("0.0.0.0"), false);
assert.equal(isLoopbackHost("192.168.1.2"), false);
assert.equal(isLoopbackHost("::"), false);
assert.equal(isLoopbackHost(""), false);
assert.equal(isLoopbackHost("evil.example"), false);
});
// ---------------------------------------------------------------------------
// 2. startServer: fail-closed on a non-loopback host
// ---------------------------------------------------------------------------
test("startServer: refuses a non-loopback host with ConfigError, never opens a socket", async () => {
const root = makeRoot();
const port = await getFreePort();
let caught = null;
try {
// startServer is async, so the host refusal surfaces as a rejection
// before any listen() call happens.
await startServer({
host: "192.168.1.2",
port,
specs: [],
boardDir: join(root, "board"),
isAlive: () => true,
});
} catch (err) {
caught = err;
}
assert.ok(caught instanceof ConfigError, "expected a ConfigError");
// The port must still be free: startServer must never have called listen().
await new Promise((resolvePromise, reject) => {
const probe = createNetServer();
probe.once("error", reject);
probe.listen(port, "127.0.0.1", () => probe.close(resolvePromise));
});
});
// ---------------------------------------------------------------------------
// 3. createServer routes, via startServer on port 0
// ---------------------------------------------------------------------------
test("startServer: serves page, healthz, and a rescanning /api/board", async () => {
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 boardDir = join(root, "board");
const page = "<html><body>injected test page</body></html>";
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({
host: "127.0.0.1",
port: 0,
specs,
boardDir,
isAlive: () => true,
page,
});
const base = `http://127.0.0.1:${server.address().port}`;
try {
for (const path of ["/", "/index.html"]) {
const res = await fetch(`${base}${path}`);
assert.equal(res.status, 200, path);
assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path);
assert.equal(res.headers.get("cache-control"), "no-store", path);
assert.equal(await res.text(), page, path);
}
const health = await fetch(`${base}/healthz`);
assert.equal(health.status, 200);
assert.deepEqual(await health.json(), { ok: true });
const first = await fetch(`${base}/api/board`);
assert.equal(first.status, 200);
assert.equal(first.headers.get("content-type"), "application/json");
assert.equal(first.headers.get("cache-control"), "no-store");
const firstBody = await first.json();
assert.equal(firstBody.sessions[0].state, "waiting");
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
// Rewrite the fixture to a "user" last message (working state) and hit
// /api/board again: a fresh scan must reflect the new state, proving
// each request rescans instead of caching.
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:05:00Z", role: "user", texts: ["go again"] }),
]);
const second = await fetch(`${base}/api/board`);
assert.equal(second.status, 200);
const secondBody = await second.json();
assert.equal(secondBody.sessions[0].state, "working");
const missing = await fetch(`${base}/nope`);
assert.equal(missing.status, 404);
const posted = await fetch(`${base}/api/board`, { method: "POST" });
assert.equal(posted.status, 405);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 4. /api/board: scan failure surfaces as a 500 with an error field
// ---------------------------------------------------------------------------
test("startServer: /api/board returns 500 JSON with an error field when scan throws", async () => {
const server = await startServer({
host: "127.0.0.1",
port: 0,
specs: [],
// Relative boardDir: scan() throws ConfigError("boardDir must be an absolute path").
boardDir: "relative/board",
isAlive: () => true,
page: "<html></html>",
});
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/board`);
assert.equal(res.status, 500);
assert.equal(res.headers.get("content-type"), "application/json");
const body = await res.json();
assert.equal(typeof body.error, "string");
assert.ok(body.error.length > 0);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 5. CLI
// ---------------------------------------------------------------------------
test("CLI: serve refuses a non-loopback host with exit 2 and a refused: message", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
mkdirSync(repoRoot, { recursive: true });
const r = runCli(["serve", "--host", "0.0.0.0", "--config", configPath, "--repo", repoRoot, "--fleet", "none"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: serve rejects a non-numeric --port with exit 2", () => {
const r = runCli(["serve", "--port", "abc"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: scan still works after the async cli refactor", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
mkdirSync(repoRoot, { recursive: true });
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive"]);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^board: /m);
});
test("CLI: live serve prints its URL and answers /healthz", async () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
mkdirSync(repoRoot, { recursive: true });
const child = spawn(
process.execPath,
[cli, "serve", "--port", "0", "--liveness", "assume-alive", "--config", configPath, "--repo", repoRoot, "--fleet", "none"],
{ stdio: ["ignore", "pipe", "pipe"] }
);
let stdoutBuf = "";
let stderrBuf = "";
child.stderr.on("data", (chunk) => {
stderrBuf += chunk.toString();
});
let url;
try {
url = await new Promise((resolvePromise, reject) => {
const timer = setTimeout(() => {
reject(new Error(`timed out waiting for the server line; stdout=${JSON.stringify(stdoutBuf)} stderr=${JSON.stringify(stderrBuf)}`));
}, 15000);
child.stdout.on("data", (chunk) => {
stdoutBuf += chunk.toString();
const match = stdoutBuf.match(/^control board: (http:\/\/127\.0\.0\.1:\d+)\//m);
if (match) {
clearTimeout(timer);
resolvePromise(match[1]);
}
});
child.on("exit", (code) => {
clearTimeout(timer);
reject(new Error(`child exited early with code ${code}; stderr=${stderrBuf}`));
});
});
const res = await fetch(`${url}/healthz`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true });
} finally {
child.kill("SIGTERM");
await new Promise((resolvePromise) => {
if (child.exitCode !== null || child.signalCode !== null) return resolvePromise();
child.on("exit", resolvePromise);
});
}
});
// The page's only XSS defence is its inline esc() helper. Pull that function
// out of page.html by name and check it inerts every HTML-significant char.
test("page.html: esc() escapes every HTML-significant character", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function esc\(v\) \{[\s\S]*?\n \}/);
assert.ok(m, "esc() must exist in page.html");
const esc = new Function(`${m[0]}; return esc;`)();
assert.equal(esc('<script>alert("x")</script>&\''), "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&amp;&#39;");
assert.equal(esc(null), "");
assert.equal(esc(undefined), "");
assert.equal(esc(42), "42");
// The page builds HTML by string concatenation. Any API value joined
// straight into markup ("+ rec.x" / "+ project" / "+ data.x") would bypass
// esc(); require zero such joins so a regression is caught here.
const rawJoins = [...html.matchAll(/\+\s*(rec\.[\w.]+|project|data\.[\w.]+)\b(?!\s*\|\|)/g)].map((x) => x[0]);
assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
const escCalls = (html.match(/\besc\(/g) || []).length;
assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
});