Check pi liveness per tmux pane and add "Seen" marks to the control board (#1503)

First step-3 refinement from Jason's daily use. Liveness now lists the
panes of the agent's tmux session and counts it alive only if a pane runs
pi, so killed pi sessions whose tmux session still exists show offline
instead of waiting. A "Seen" button on waiting and error rows stores the
row's lastActivity in <dataRoot>/board/seen.json (clicks only, never
rewritten 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, 400 on bad input.

Tests: control-board 63/63 (30 new), registry 69/69. Review APPROVED;
receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 08:24:49 -05:00
co-authored by Claude Fable 5.1
parent ebedd1281e
commit 88d21defde
12 changed files with 777 additions and 21 deletions
+22
View File
@@ -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 `<dataRoot>/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.
+1
View File
@@ -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.
@@ -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 '=<session>' -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
`<dataRoot>/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
+5
View File
@@ -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
@@ -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: `<dataRoot>/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": "<timestamp>"}` (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 '=<session>'` 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.
+32 -5
View File
@@ -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 '=<session>' -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: <dir> (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/
```
<dataRoot>/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/
<project>/
<agent>.json # one status record per agent
```
`seen.json` holds `{ "<project>/<agent>": "<lastActivity>" }`. 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",
+2 -2
View File
@@ -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) => {
+54 -1
View File
@@ -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 '<span class="badge ' + cls + '">' + esc(state) + "</span>";
}
// 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 '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
}
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
@@ -164,7 +180,7 @@
'<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>" + badge(rec.state) + seenControl(rec) + "</td>" +
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
@@ -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 = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
}
@@ -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);
+82 -10
View File
@@ -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 <boardDir>/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: { "<project>/<agent>": "<lastActivity ISO>" }. 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 <boardDir>/sessions/<project>/<agent>.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 });
+58 -1
View File
@@ -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");
+231
View File
@@ -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 <socket> 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);
});
+201 -2
View File
@@ -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: "<html></html>" });
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: "<html></html>" });
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: "<html></html>" });
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: "<html></html>" });
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: "<html></html>" });
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: "<html></html>" });
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");
});