diff --git a/BUILD-LOG.md b/BUILD-LOG.md index f8f776fc..8c6e319d 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -1965,3 +1965,25 @@ 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. + +## 2026-09-12 — Control board step 3, first refinement (#1503) + +Before: after killing the darkwing and dewey pi sessions, Jason still saw +them under "Waiting on you", and most other waiting rows were completion +reports rather than questions for him. + +After: liveness now lists the panes of the agent's tmux session and counts +the agent alive only if a pane runs `pi`; both killed agents show offline. +A "Seen" button on waiting and error rows stores the row's last-activity +timestamp in `/board/seen.json` (written only by clicks, never by +a scan, fail closed if corrupt) and drops the row from "Waiting on you" +until the agent writes anything newer; "Unsee" reverses it. New +`POST /api/seen` route (JSON only, 4 KB limit). Suites: control-board 63/63, +registry 69/69. Live: waiting on Jason 18 → 16, Seen/Unsee round trip +verified in Chrome. Independent review APPROVED; its one finding (README +quoted an incomplete tmux command) is fixed. Receipt: +`docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md`. + +Still open by design: "waiting" cannot tell a question from a completion +report; a wording hint or an agent-side "Waiting on:" convention are the +next candidates if the Seen button is not enough. Next: Jason keeps using it. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index b814adf4..1fb1a76f 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -214,3 +214,4 @@ are never rewritten or removed; corrections are new entries. - 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. +- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, first refinement (#1503): Jason reported killed pi sessions still "waiting" and most waiting rows being completion reports. Liveness now requires a tmux pane running pi; "Seen" marks (seen.json, POST /api/seen, Seen/Unsee on the page) drop read rows out of "Waiting on you" until the agent writes again. Tests 63/63 (30 new by sonnet helper), registry 69/69; sonnet review APPROVED (README command fixed). Receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md. diff --git a/docs/plans/2026-09-12_control-board-mvp.md b/docs/plans/2026-09-12_control-board-mvp.md index 5f0afd20..4e87766a 100644 --- a/docs/plans/2026-09-12_control-board-mvp.md +++ b/docs/plans/2026-09-12_control-board-mvp.md @@ -48,6 +48,26 @@ Acceptance: - He says what is wrong or missing. - Fix that one thing, then repeat. No new scope beyond what he actually hits. +### Step 3 log + +**2026-09-12 — liveness follows the tmux pane, not just the session.** +Jason found agents marked "waiting" that were actually dead: their tmux +session still existed but no longer ran `pi` (it had exited to a shell or +something else). The scanner now checks which program each tmux pane is +running (`tmux list-panes -s -t '=' -F '#{pane_current_command}'`) and only counts an +agent alive if a pane is running `pi`. A tmux session with no `pi` pane is +now "offline" instead of "waiting". + +**2026-09-12 — a "Seen" mark for rows that don't need a reply.** Jason +noticed most "waiting" rows were completion reports, not real asks, and +they kept cluttering "Waiting on you". The page now has a "Seen" button on +waiting/error rows; clicking it stores the row's `lastActivity` in +`/board/seen.json` (via `POST /api/seen`) and drops the row out +of "Waiting on you" while keeping its state visible. The mark clears +itself the moment the agent writes anything new, and "Unsee" puts a row +back by hand. `seen.json` is only ever written by that click, never by a +scan. + ## How the scanner decides state The scanner reads the newest pi session log for each agent, plus whether diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index d7c461e7..fd2f741f 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -17,6 +17,11 @@ are done in this checkout: `packages/control-board` scans every Pi agent 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). +First step-3 refinement landed 2026-09-12: liveness follows the tmux pane +(killed pi sessions show offline) and a "Seen" button drops read rows out of +"Waiting on you" until the agent writes again +(`docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md`). + 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 diff --git a/docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md b/docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md new file mode 100644 index 00000000..15e1b24a --- /dev/null +++ b/docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md @@ -0,0 +1,69 @@ +# Control board step 3, first refinement: real liveness and "Seen" marks + +Date: 2026-09-12. Recorded by jarvis (Claude Code, coordinator). Tracking: #1503. +Plan: `docs/plans/2026-09-12_control-board-mvp.md` (Step 3 log). + +## What Jason reported + +After using the page: "most of the sessions are actually just in a complete +state rather than waiting on me". Also, he had terminated the darkwing and +dewey pi sessions, and both still showed "waiting". + +## Cause + +1. Liveness only asked tmux whether a session by that name existed. Both + tmux sessions still existed (now running bash and Claude), so the scanner + read the old pi session log as live status. +2. Pi records that the agent finished a reply, not whether that reply was a + question or a completion report. Both landed in "waiting". + +## What was built + +- `tmuxIsAlive` now lists the panes of the named tmux session and reports + alive only if one runs `pi`. A session that exists but runs something else + is offline. The tmux check is injectable for tests. +- "Seen" marks: `/board/seen.json` maps `project/agent` to the + `lastActivity` timestamp Jason marked. A marked row keeps its state but + leaves "Waiting on you" until the agent writes anything newer, which + changes the timestamp and clears the mark by itself. "Unsee" clears it by + hand. The file is written only by clicks (atomic, mode 0600); a scan reads + it and never rewrites it; a corrupt file refuses the scan (fail closed). +- Server: `POST /api/seen` with a JSON body; requires + `Content-Type: application/json` (so a page from another site cannot post + it without a CORS preflight, which this server does not answer); body limit + 4096 bytes; 400 on bad input; 500 if the rescan fails. Other methods 405. +- Page: Seen / Unsee button beside the state badge, "seen" tag, footer count. + CLI `scan --print` shows `s` for seen rows and a seen count. + +## Tests + +`node --test packages/control-board/tests/`: 63 pass, 0 fail (30 new, written +by a Claude Sonnet helper against the spec; no src change was needed). +`node --test packages/mosaic/tests/`: 69 pass, 0 fail, unchanged. + +## Live check + +Real tmux, full fleet, Chrome via DevTools on port 7332. Before: 18 rows +waiting on Jason including darkwing and dewey. After the liveness fix: 16, +both now offline (their tmux sessions run bash). Clicking Seen on zane wrote +`{"fleet/zane": ""}` (mode 600), the row left "Waiting on you" +and showed a seen tag with Unsee in its project table; the API reported +15 waiting and 1 seen. Unsee emptied the file and restored 16. Console: no +messages. Screenshot `/tmp/control-board-seen.png` (not committed). The +board was left with no marks. + +## Independent review + +Reviewer: code-review agent (Claude Sonnet), read-only. It checked the pi +runtime source to confirm a live pi pane reports `pi` as its command (pi sets +its process title, and tool subprocesses do not take over the pane), traced +the CORS argument for the POST route, the escaping in the new markup, the +single-response paths in the server, and the stale-mark fail-safe. Verdict: +APPROVED with one finding: the README quoted the tmux command without the +`-s -t '='` flags the code uses. Fixed in README and plan page. + +## Boundaries kept + +Loopback only, no auth, no daemon, no launcher changes, no new root files, +no run records touched. `seen.json` is Jason's input, not derived data, and +is the only board file a scan does not regenerate. diff --git a/packages/control-board/README.md b/packages/control-board/README.md index 6437d457..4bca6a02 100644 --- a/packages/control-board/README.md +++ b/packages/control-board/README.md @@ -14,10 +14,15 @@ rewritable; they are not run records and are not evidence. | working | The agent is in the middle of a turn: thinking or running a tool. | | waiting | The agent finished its turn. It is your move now. | | error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. | -| offline | There is no live tmux session for this agent right now. | +| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. | | 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. | +Liveness means a pane in the agent's tmux session is actually running `pi` +(`tmux list-panes -s -t '=' -F '#{pane_current_command}'`). A tmux session that still +exists but only runs bash or some other program counts as offline, not +waiting. + ## Commands ``` @@ -42,13 +47,24 @@ bind to anything but a loopback address. - `--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` — (`scan`) also print a one-line-per-agent table to stdout. +- `--print` — (`scan`) also print a one-line-per-agent table to stdout. The + first column is `*` for a row waiting on you, `s` for a seen row, or blank + otherwise. The final summary line reads + `board: (N sessions, N waiting on you, N seen)`. - `--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`. +Routes served: + +- `GET /` — the page. +- `GET /api/board` — rescan, returns `index.json`. +- `POST /api/seen` — mark or unmark a row as seen, then rescan and return + `index.json`. Body must be JSON (`Content-Type: application/json`, no + 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`. +- `GET /healthz`. ## Exit codes @@ -66,12 +82,22 @@ node --test packages/control-board/tests/ ``` /board/ - index.json # summary: counts, waiting-on-you list, all records + index.json # summary: counts, waiting-on-you list, seen list, all records + seen.json # Jason's "seen" marks (see below) sessions/ / .json # one status record per agent ``` +`seen.json` holds `{ "/": "" }`. It is written +only when Jason clicks "Seen" or "Unsee" on the page (via `POST +/api/seen`); a scan reads it but never writes it. A mark applies only while +the agent's newest message still has that exact `lastActivity` timestamp — +as soon as the agent writes anything new, `lastActivity` changes, the mark +no longer matches, and the row falls back into "Waiting on you" on its own. +If `seen.json` exists but is not valid JSON (or not an object of string +values), the scan refuses rather than silently dropping every mark. + ## Example status record ```json @@ -80,6 +106,7 @@ node --test packages/control-board/tests/ "project": "mosaic-stack", "state": "waiting", "waitingOnYou": true, + "seen": false, "alive": true, "tmux": { "socket": null, "session": "darkwing" }, "sessionFile": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions/2026-09-12.jsonl", diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs index 30ed4281..f871a9ee 100755 --- a/packages/control-board/src/cli.mjs +++ b/packages/control-board/src/cli.mjs @@ -52,12 +52,12 @@ async function main() { const index = scan(specs, { boardDir, isAlive }); if (opts.print) { for (const s of index.sessions) { - const flag = s.waitingOnYou ? "*" : " "; + const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " "; const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`; process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`); } } - process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`); + process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen)\n`); } main().catch((err) => { diff --git a/packages/control-board/src/page.html b/packages/control-board/src/page.html index 6cf3ac08..ef856ba5 100644 --- a/packages/control-board/src/page.html +++ b/packages/control-board/src/page.html @@ -52,6 +52,10 @@ .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} + .seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px; + padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle} + .seen-toggle:hover{border-color:var(--action)} + .seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle} .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)} @@ -151,6 +155,18 @@ return '' + esc(state) + ""; } + // A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows + // you have already marked. The mark clears itself when the agent writes again. + function seenControl(rec) { + var needsYou = rec.state === "waiting" || rec.state === "error"; + if (!needsYou || !rec.lastActivity) return ""; + var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"'; + if (rec.seen) { + return 'seen'; + } + return ''; + } + function buildRowPair(rec, showProject) { var idx = rowIdx++; var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/"); @@ -164,7 +180,7 @@ '' + projectCell + '" + - "" + badge(rec.state) + "" + + "" + badge(rec.state) + seenControl(rec) + "" + "" + esc(humanAge(rec.ageSeconds)) + "" + "" + msg + "" + ""; @@ -250,6 +266,7 @@ function renderFooter(data) { var counts = data.counts || {}; var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · "); + parts += " · Seen " + ((data.seen || []).length); footer.innerHTML = "

" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")

"; } @@ -320,7 +337,43 @@ renderHeaderStatus(); } + function postSeen(btn) { + if (fetching) return; + fetching = true; + btn.disabled = true; + fetch("/api/seen", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" }) + }) + .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("marking seen failed", err); + showError(err && err.message ? err.message : "unknown error"); + btn.disabled = false; + }) + .then(function () { + fetching = false; + renderHeaderStatus(); + scheduleNextFetch(); + }); + } + main.addEventListener("click", function (e) { + var seenBtn = e.target.closest(".seen-toggle"); + if (seenBtn) return postSeen(seenBtn); var btn = e.target.closest(".row-toggle"); if (!btn) return; var detail = document.getElementById("detail-" + btn.dataset.idx); diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index 950598fa..4593fb2b 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -5,11 +5,13 @@ // working - the agent is in the middle of a turn (thinking or running tools) // waiting - the agent finished its turn; it is your move // error - the agent's last turn ended in an error, was aborted, or was cut off; look at it -// offline - no live tmux session for this agent +// offline - no tmux session for this agent, or its session no longer runs pi // idle - the agent is live but has no conversation yet // unknown - liveness could not be checked (tmux missing or unresponsive); not a guess // -// Board files are derived and rewritable. They are not run records. +// Board files are derived and rewritable. They are not run records. The one +// exception is /seen.json, which holds Jason's "seen" marks and is +// only changed when he clicks; a scan reads it and never rewrites it. import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs"; import { join, basename, isAbsolute, resolve } from "node:path"; @@ -108,17 +110,82 @@ export function deriveState({ alive, session }) { return "working"; } -export function tmuxIsAlive({ socket, session }) { +// Programs that count as a live pi agent in a tmux pane. A tmux session that +// still exists but only runs a shell (or another harness) is not alive: its +// pi session log is history, not status. +export const PI_COMMANDS = Object.freeze(["pi"]); + +export function panesRunPi(listPanesOutput) { + return String(listPanesOutput) + .split("\n") + .map((l) => l.trim()) + .some((cmd) => PI_COMMANDS.includes(cmd)); +} + +// true: a pane in the tmux session runs pi. false: no such session, or no pane +// runs pi. null: tmux could not be run at all (reported as "unknown", never +// assumed alive). `exec` is injectable for tests. +export function tmuxIsAlive({ socket, session }, { exec = spawnSync } = {}) { const args = []; if (socket) args.push("-L", socket); - args.push("has-session", "-t", `=${session}`); - const r = spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 }); + args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}"); + const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 }); if (r.error) return null; - return r.status === 0; + if (r.status !== 0) return false; + return panesRunPi(r.stdout ?? ""); +} + +// "Seen" marks: { "/": "" }. A mark only +// applies while the agent's newest message still has that timestamp; anything +// the agent writes afterwards clears it automatically. +export function seenKey(rec) { + return `${rec.project}/${rec.agent}`; +} + +export function seenPath(boardDir) { + return join(boardDir, "seen.json"); +} + +// Fail closed: a present but unreadable seen.json refuses the scan rather than +// silently dropping every mark. +export function loadSeen(boardDir) { + const path = seenPath(boardDir); + if (!existsSync(path)) return {}; + let parsed; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`); + for (const [k, v] of Object.entries(parsed)) { + if (typeof v !== "string") throw new ConfigError(`seen marks file has a non-string value for ${JSON.stringify(k)}: ${path}`); + } + return parsed; +} + +export function saveSeen(boardDir, marks) { + mkdirSync(boardDir, { recursive: true, mode: 0o700 }); + writeAtomic(seenPath(boardDir), marks); +} + +// Set or clear one mark. Returns the updated map. +export function markSeen(boardDir, { project, agent, lastActivity, seen = true }) { + for (const [name, v] of Object.entries({ project, agent, lastActivity })) { + if (typeof v !== "string" || v.length === 0 || v.length > 512) throw new ConfigError(`${name} must be a non-empty string`); + } + if (project.includes("/")) throw new ConfigError("project must not contain '/'"); + if (typeof seen !== "boolean") throw new ConfigError("seen must be true or false"); + const marks = loadSeen(boardDir); + const key = seenKey({ project, agent }); + if (seen) marks[key] = lastActivity; + else delete marks[key]; + saveSeen(boardDir, marks); + return marks; } // One agent -> one status record. -export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date() } = {}) { +export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date(), seen = {} } = {}) { const alive = isAlive(spec.tmux); const file = findNewestSession(spec.sessionsDir); const session = file ? readSession(file) : null; @@ -126,11 +193,14 @@ export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date() const scannedAt = now(); const lastActivity = session?.lastTimestamp ?? null; const ageSeconds = lastActivity ? Math.max(0, Math.round((scannedAt.getTime() - Date.parse(lastActivity)) / 1000)) : null; + const needsYou = state === "waiting" || state === "error"; + const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity; return { agent: spec.agent, project: spec.project, state, - waitingOnYou: state === "waiting" || state === "error", + waitingOnYou: needsYou && !isSeen, + seen: isSeen, alive, tmux: spec.tmux, sessionFile: file, @@ -174,7 +244,8 @@ function writeAtomic(path, data) { // Scan every spec and write /sessions//.json plus index.json. export function scan(specs, { boardDir, isAlive, now } = {}) { if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path"); - const records = specs.map((spec) => scanAgent(spec, { isAlive, now })); + const seen = loadSeen(boardDir); + const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen })); for (const rec of records) { const dir = join(boardDir, "sessions", rec.project); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -184,7 +255,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) { const index = { generatedAt, counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])), - waitingOnYou: records.filter((r) => r.waitingOnYou).map((r) => `${r.project}/${r.agent}`), + waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey), + seen: records.filter((r) => r.seen).map(seenKey), sessions: records, }; mkdirSync(boardDir, { recursive: true, mode: 0o700 }); diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 6abe743a..4d09f936 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -3,8 +3,14 @@ // // 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 // GET /healthz {"ok":true} // +// POST requires Content-Type: application/json. A plain form post from another +// site in the browser cannot set that header without a CORS preflight, and this +// server answers no preflight, so a stray page cannot flip marks. +// // 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. @@ -12,7 +18,42 @@ 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"; +import { scan, markSeen, ConfigError } from "./scan.mjs"; + +const MAX_BODY = 4096; + +function sendJson(res, status, body) { + res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify(body) + "\n"); +} + +function readJsonBody(req) { + return new Promise((resolvePromise, reject) => { + const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase(); + if (type !== "application/json") return reject(new Error("Content-Type must be application/json")); + const chunks = []; + let size = 0; + req.on("data", (c) => { + size += c.length; + if (size > MAX_BODY) { + req.destroy(); + reject(new Error(`body larger than ${MAX_BODY} bytes`)); + return; + } + chunks.push(c); + }); + req.on("end", () => { + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object"); + resolvePromise(parsed); + } catch (err) { + reject(new Error(`invalid JSON body: ${err.message}`)); + } + }); + req.on("error", reject); + }); +} const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); @@ -29,6 +70,22 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) { export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) { return createHttpServer((req, res) => { const url = new URL(req.url, "http://localhost"); + if (req.method === "POST" && url.pathname === "/api/seen") { + return readJsonBody(req) + .then((body) => { + try { + markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true }); + } catch (err) { + return sendJson(res, 400, { error: err.message }); + } + try { + sendJson(res, 200, scan(specs, { boardDir, isAlive, now })); + } catch (err) { + sendJson(res, 500, { error: err.message }); + } + }) + .catch((err) => sendJson(res, 400, { error: err.message })); + } if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405, { "content-type": "text/plain" }); return res.end("method not allowed\n"); diff --git a/packages/control-board/tests/scan.test.mjs b/packages/control-board/tests/scan.test.mjs index e41699aa..f8d1e4c3 100644 --- a/packages/control-board/tests/scan.test.mjs +++ b/packages/control-board/tests/scan.test.mjs @@ -9,6 +9,7 @@ import { readFileSync, readdirSync, existsSync, + statSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, basename } from "node:path"; @@ -23,6 +24,10 @@ import { discoverRepoAgents, discoverFleetAgents, scan, + panesRunPi, + tmuxIsAlive, + loadSeen, + markSeen, } from "../src/scan.mjs"; const pkgRoot = resolve(import.meta.dirname, ".."); @@ -464,3 +469,229 @@ test("CLI: unknown --liveness value exits 2", () => { assert.equal(r.status, 2); assert.match(r.stderr, /^refused:/); }); + +// --------------------------------------------------------------------------- +// 9. panesRunPi / tmuxIsAlive: liveness means a pane actually runs pi +// --------------------------------------------------------------------------- + +test("panesRunPi: true when any trimmed line equals 'pi'", () => { + assert.equal(panesRunPi("bash\npi\n"), true); + assert.equal(panesRunPi("pi"), true); + assert.equal(panesRunPi(" pi \n"), true); +}); + +test("panesRunPi: false for bash-only, claude, empty, or node-pi-style lines", () => { + assert.equal(panesRunPi("bash"), false); + assert.equal(panesRunPi("claude"), false); + assert.equal(panesRunPi(""), false); + assert.equal(panesRunPi("node pi"), false); +}); + +function fakeExec(result) { + const calls = []; + const exec = (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return result; + }; + exec.calls = calls; + return exec; +} + +test("tmuxIsAlive: a pane running pi is alive", () => { + const exec = fakeExec({ status: 0, stdout: "bash\npi\n" }); + assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), true); +}); + +test("tmuxIsAlive: session exists but pi has exited is not alive", () => { + const exec = fakeExec({ status: 0, stdout: "bash\n" }); + assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), false); +}); + +test("tmuxIsAlive: no such tmux session is not alive", () => { + const exec = fakeExec({ status: 1, stdout: "" }); + assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), false); +}); + +test("tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive", () => { + const exec = fakeExec({ error: new Error("ENOENT") }); + assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), null); +}); + +test("tmuxIsAlive: passes -L only when a socket is given", () => { + const withSocket = fakeExec({ status: 0, stdout: "pi\n" }); + tmuxIsAlive({ socket: "mosaic-fleet", session: "name" }, { exec: withSocket }); + assert.equal(withSocket.calls[0].cmd, "tmux"); + assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]); + + const noSocket = fakeExec({ status: 0, stdout: "pi\n" }); + tmuxIsAlive({ socket: null, session: "name" }, { exec: noSocket }); + assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]); +}); + +// --------------------------------------------------------------------------- +// 10. Seen marks: loadSeen / markSeen +// --------------------------------------------------------------------------- + +test("loadSeen: missing file returns {}", () => { + const root = makeRoot(); + assert.deepEqual(loadSeen(root), {}); +}); + +test("loadSeen: invalid JSON throws ConfigError", () => { + const root = makeRoot(); + writeFile(join(root, "seen.json"), "{ not json"); + assert.throws(() => loadSeen(root), ConfigError); +}); + +test("loadSeen: a JSON array throws ConfigError", () => { + const root = makeRoot(); + writeFile(join(root, "seen.json"), "[]"); + assert.throws(() => loadSeen(root), ConfigError); +}); + +test("loadSeen: a non-string value throws ConfigError", () => { + const root = makeRoot(); + writeFile(join(root, "seen.json"), JSON.stringify({ "p/a": 123 })); + assert.throws(() => loadSeen(root), ConfigError); +}); + +test("markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + const marks = markSeen(boardDir, { project: "p", agent: "a", lastActivity: "2026-09-01T00:00:00Z", seen: true }); + assert.deepEqual(marks, { "p/a": "2026-09-01T00:00:00Z" }); + const path = join(boardDir, "seen.json"); + assert.ok(existsSync(path)); + assert.equal(statSync(path).mode & 0o777, 0o600); + const names = readdirSync(boardDir); + assert.ok(!names.some((n) => n.includes(".tmp-")), `leftover tmp file among: ${names.join(", ")}`); +}); + +test("markSeen: seen false deletes the key", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t1", seen: true }); + const marks = markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t1", seen: false }); + assert.deepEqual(marks, {}); +}); + +test("markSeen: missing, empty, or non-string fields throw ConfigError", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + assert.throws(() => markSeen(boardDir, { project: "p", agent: "a", lastActivity: "" }), ConfigError); + assert.throws(() => markSeen(boardDir, { project: "p", agent: "a" }), ConfigError); + assert.throws(() => markSeen(boardDir, { project: "p", agent: 5, lastActivity: "t" }), ConfigError); + assert.throws(() => markSeen(boardDir, { agent: "a", lastActivity: "t" }), ConfigError); +}); + +test("markSeen: project containing '/' throws ConfigError", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + assert.throws(() => markSeen(boardDir, { project: "p/x", agent: "a", lastActivity: "t" }), ConfigError); +}); + +test("markSeen: non-boolean seen throws ConfigError", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + assert.throws(() => markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t", seen: "true" }), ConfigError); +}); + +// --------------------------------------------------------------------------- +// 11. scanAgent: seen marks apply only to waiting/error, and only while the +// mark's timestamp still matches the agent's newest message +// --------------------------------------------------------------------------- + +test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou", () => { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + 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 seen = { "p/a": "2026-09-01T00:00:01Z" }; + const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen }); + assert.equal(rec.state, "waiting"); + assert.equal(rec.seen, true); + assert.equal(rec.waitingOnYou, false); +}); + +test("scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true", () => { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + 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 seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp + const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen }); + assert.equal(rec.state, "waiting"); + assert.equal(rec.seen, false); + assert.equal(rec.waitingOnYou, true); +}); + +test("scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error)", () => { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + 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: "toolUse", texts: ["thinking"] }), + ]); + const seen = { "p/a": "2026-09-01T00:00:01Z" }; + const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen }); + assert.equal(rec.state, "working"); + assert.equal(rec.seen, false); + assert.equal(rec.waitingOnYou, false); +}); + +test("scanAgent: an error-state session with a matching mark is seen", () => { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + 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: "aborted" }), + ]); + const seen = { "p/a": "2026-09-01T00:00:01Z" }; + const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen }); + assert.equal(rec.state, "error"); + assert.equal(rec.seen, true); + assert.equal(rec.waitingOnYou, false); +}); + +// --------------------------------------------------------------------------- +// 12. scan: reads seen.json, index reflects it, and scanning never rewrites it +// --------------------------------------------------------------------------- + +test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + const sessionsDir = join(root, "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const lastActivity = "2026-09-01T00:00:01Z"; + writeSessionFile(sessionsDir, "s.jsonl", [ + sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), + messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }), + ]); + mkdirSync(boardDir, { recursive: true }); + const seenPath = join(boardDir, "seen.json"); + writeFile(seenPath, JSON.stringify({ "p/agent1": lastActivity })); + const before = readFileSync(seenPath); + + const index = scan([{ agent: "agent1", project: "p", sessionsDir, tmux: {} }], { boardDir, isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") }); + assert.deepEqual(index.seen, ["p/agent1"]); + assert.deepEqual(index.waitingOnYou, []); + + const after = readFileSync(seenPath); + assert.deepEqual(before, after, "scan must never rewrite seen.json"); +}); + +test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + mkdirSync(boardDir, { recursive: true }); + writeFile(join(boardDir, "seen.json"), "{ bad json"); + assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError); +}); diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index 2d9eb48c..a60af13b 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -9,10 +9,10 @@ import { existsSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, resolve, basename } from "node:path"; import { spawnSync, spawn } from "node:child_process"; import { createServer as createNetServer } from "node:net"; -import { ConfigError } from "../src/scan.mjs"; +import { ConfigError, markSeen } from "../src/scan.mjs"; import { isLoopbackHost, startServer } from "../src/serve.mjs"; const pkgRoot = resolve(import.meta.dirname, ".."); @@ -336,3 +336,202 @@ test("page.html: esc() escapes every HTML-significant character", () => { const escCalls = (html.match(/\besc\(/g) || []).length; assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`); }); + +// --------------------------------------------------------------------------- +// 6. POST /api/seen +// --------------------------------------------------------------------------- + +test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it", 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 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 { + const postRes = await fetch(`${base}/api/seen`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }), + }); + assert.equal(postRes.status, 200); + const postBody = await postRes.json(); + assert.ok(postBody.seen.includes("proj/agent1")); + assert.ok(!postBody.waitingOnYou.includes("proj/agent1")); + + const boardRes = await fetch(`${base}/api/board`); + assert.equal(boardRes.status, 200); + const boardBody = await boardRes.json(); + assert.ok(boardBody.seen.includes("proj/agent1")); + assert.ok(!boardBody.waitingOnYou.includes("proj/agent1")); + + const clearRes = await fetch(`${base}/api/seen`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z", seen: false }), + }); + assert.equal(clearRes.status, 200); + const clearBody = await clearRes.json(); + assert.ok(!clearBody.seen.includes("proj/agent1")); + assert.ok(clearBody.waitingOnYou.includes("proj/agent1")); + } finally { + await closeServer(server); + } +}); + +test("POST /api/seen without a JSON content-type returns 400 and does not write a mark", 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 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 { + const res = await fetch(`${base}/api/seen`, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }), + }); + assert.equal(res.status, 400); + assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type"); + } finally { + await closeServer(server); + } +}); + +test("POST /api/seen with invalid JSON returns 400", async () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + 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 { + const res = await fetch(`${base}/api/seen`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ not json", + }); + assert.equal(res.status, 400); + assert.ok(!existsSync(join(boardDir, "seen.json"))); + } finally { + await closeServer(server); + } +}); + +test("POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark", async () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + 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}`; + const big = JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "x".repeat(5000) }); + + try { + let status = null; + try { + const res = await fetch(`${base}/api/seen`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: big, + }); + status = res.status; + } catch { + status = null; // a destroyed connection is an acceptable outcome too + } + if (status !== null) assert.equal(status, 400); + assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body"); + } finally { + await closeServer(server); + } +}); + +test("POST /api/seen with a missing agent returns 400", async () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + 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 { + const res = await fetch(`${base}/api/seen`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: "proj", lastActivity: "2026-09-01T00:00:01Z" }), + }); + assert.equal(res.status, 400); + assert.ok(!existsSync(join(boardDir, "seen.json"))); + } finally { + await closeServer(server); + } +}); + +test("POST /api/board returns 405; PUT /api/seen returns 405", async () => { + const root = makeRoot(); + const boardDir = join(root, "board"); + 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 { + const board = await fetch(`${base}/api/board`, { method: "POST" }); + assert.equal(board.status, 405); + const put = await fetch(`${base}/api/seen`, { method: "PUT" }); + assert.equal(put.status, 405); + } finally { + await closeServer(server); + } +}); + +// --------------------------------------------------------------------------- +// 7. CLI: seen rows in --print +// --------------------------------------------------------------------------- + +test("CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'", () => { + const root = makeRoot(); + const dataRoot = join(root, "data"); + const configPath = join(root, "config.json"); + writeFile(configPath, JSON.stringify({ dataRoot })); + const repoRoot = join(root, "repo"); + const sessionsDir = join(repoRoot, ".pi", "state", "agent1", "sessions"); + mkdirSync(sessionsDir, { recursive: true }); + const lastActivity = "2026-09-01T00:00:01Z"; + writeFile( + join(sessionsDir, "s.jsonl"), + [ + sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), + messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }), + ].join("\n") + "\n" + ); + const boardDir = join(dataRoot, "board"); + const project = basename(resolve(repoRoot)); + markSeen(boardDir, { project, agent: "agent1", lastActivity, seen: true }); + + const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive", "--print"]); + assert.equal(r.status, 0, r.stderr); + const row = r.stdout.split("\n").find((l) => l.includes("agent1")); + assert.ok(row, `expected an agent1 row in:\n${r.stdout}`); + assert.equal(row[0], "s"); + assert.match(r.stdout, /1 seen\)\s*$/m); +}); + +// --------------------------------------------------------------------------- +// 8. page.html: the seen-toggle control escapes its values and posts JSON +// --------------------------------------------------------------------------- + +test("page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type", () => { + const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8"); + const m = html.match(/function seenControl\(rec\) \{[\s\S]*?\n \}/); + assert.ok(m, "seenControl() must exist in page.html"); + const body = m[0]; + assert.match(body, /esc\(rec\.project\)/); + assert.match(body, /esc\(rec\.agent\)/); + assert.match(body, /esc\(rec\.lastActivity\)/); + assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json"); +});