Add control board status scanner and MVP plan (#1503)

Step 1 of the control board MVP (decision MOSAIC-STACK-D-001): a plan page,
Gitea #1503, and packages/control-board, which reads each agent's newest pi
session log plus tmux liveness and writes one status file per agent under
<dataRoot>/board/. 23/23 tests; independent review approved after three
fixes (length stopReason as error, unknown liveness state, secrets-boundary
test). CURRENT.md now points at step 2, the page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 07:26:03 -05:00
co-authored by Claude Fable 5.1
parent 1993039c76
commit b9f59a5903
10 changed files with 1003 additions and 15 deletions
+17
View File
@@ -1926,3 +1926,20 @@ Exact independently approved11255dd4 source committed/pushed at3daee5ad89dc6a006
## 2026-09-12 — #1500 owner acceptance and closure
Jason reran the two-test fixture demonstration himself on the canonical checkout at 5abbabb7: 2/2 pass. Taken as owner acceptance of the fixture-only increment (source 3daee5ad, Filbert manifest 11255dd4); receipt docs/plans/reviews/2026-09-12_m20-increment2-owner-acceptance.md. The 2026-09-11 "file not found" attempt was a pre-cutover shell whose cwd followed the retired v1 copy, not a checkout defect. Records-only change; no source, suite, live-service or production-refresh effect. Increment 3, launcher integration and the identity-env leak fix remain separately gated.
## 2026-09-12 — Control board MVP step 1 (#1503)
Before: #1500 closed; CURRENT.md asked for a re-plan against Jason's MVP
decision MOSAIC-STACK-D-001 (one page: running agent sessions across projects,
status, and who is waiting on him). No status data existed anywhere.
After: plan page `docs/plans/2026-09-12_control-board-mvp.md`; Gitea #1503;
new package `packages/control-board` (scanner + CLI, ESM, no dependencies).
Suites: control-board 23/23; registry 69/69 unchanged. Live scan writes
`~/.mosaic-dev/board/` with 42 agent status files (15 waiting, 3 error,
24 offline). Independent review approved after three corrections recorded in
`docs/plans/reviews/2026-09-12_control-board-step1-review.md`.
Corrections during the phase: pi `stopReason: "length"` was initially treated as
"working" (now "error"); an unrunnable tmux check was initially treated as alive
(now "unknown"). Registry increment 3 stays parked. Next: step 2, the web page.
+3
View File
@@ -209,3 +209,6 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — jarvis (Claude Code; owner-directed takeover of darkwing pi session 01a07cfa) — Recovered #1500 state from the session log; demo test reran 2/2 on 5abbabb7; issue open, remote refactor still 5abbabb7. Diagnosed owner demo failure: alacritty bash pid 2444405 (started 2026-09-07 14:26 UTC, before cutover) has cwd inode in the retired v1 copy; logical pwd printed the canonical path, HEAD read 5d277000, and a 2026-09-12 git pull fast-forwarded that copy to next 2101c9b4 (fred, #1502). No repository changes, commit or push; darkwing pi (pid 45527) left running.
- 2026-09-12 — jarvis (Claude Code, darkwing takeover) — #1500 owner acceptance recorded (Jason demo 2/2 at 5abbabb7) and issue closed via scripts/gitea-api.sh (comment 26007; Mosaic issue-close wrapper refused on seat token, fail-closed). Records-only commit; CURRENT next action is MVP re-plan against MOSAIC-STACK-D-001.
- 2026-09-12 — dewey (Claude Code; owner-directed takeover of dewey pi session 01a08111) — Resumed WUI Phase 2 from agents/dewey/work/wui/TASKS.md: D01 source-linked feature inventory, then D02-D05 five dashboard mockups and comparison page, folding in MOSAIC-STACK-D-001 control board as the landing view. Prototype files only under agents/dewey/work/wui; no v1 inspection, backend, credentials, commit or push.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board MVP step 1 (#1503, D-001): plan page, ticket, `packages/control-board` scanner + CLI, 23/23 tests, live scan 42 sessions/18 waiting on Jason; independent review CHANGES REQUIRED then APPROVED after three fixes (length→error, unknown liveness state, secrets-boundary test). Receipt docs/plans/reviews/2026-09-12_control-board-step1-review.md. CURRENT next action: step 2, the page.
+107
View File
@@ -0,0 +1,107 @@
# Control board MVP — plan
Tracking: Gitea issue #1503 (https://git.mosaicstack.dev/mosaicstack/stack/issues/1503)
## Why
Jason is building blind. He has many agent sessions running across projects
and no single place to see what they are doing or who is waiting on him.
This plan replaces the registry line as the next thing to build. Registry
increment 3 stays parked; it does not resume by inertia. Decision record:
`MOSAIC-STACK-D-001` (Jason, 2026-09-12).
## What "done" looks like for the MVP
One web page lists my running agent sessions across projects, shows each
one's current status, and flags which ones are waiting on me.
Acceptance:
- Jason opens one page and sees every live pi agent, grouped by project.
- Each agent shows a state in plain words (working, waiting, error,
offline, idle, unknown).
- A "waiting on you" section sits at the top of the page.
- The page refreshes itself. Jason does not have to reload it by hand.
- Every state on the page comes from a status file written by the scanner,
not from a guess made in the page itself.
## Three steps
### Step 1 (today): plan, ticket, scanner
- Write this plan and open the Gitea ticket.
- Build the status scanner script with tests. Running it writes one status
file per agent under `<dataRoot>/board/`.
- This step does not touch any page or launcher. It only produces files.
### Step 2: the web page
- Build a page that reads the status files the scanner writes and shows
them grouped by project, with the waiting-on-you section on top.
- The page refreshes itself on a short timer. The small local server behind
it may re-run the scanner on each refresh so the files stay fresh. No
separate daemon is needed for this step.
### Step 3: daily use and fixes
- Jason uses the page every day for real work.
- He says what is wrong or missing.
- Fix that one thing, then repeat. No new scope beyond what he actually hits.
## How the scanner decides state
The scanner reads the newest pi session log for each agent, plus whether
the agent's tmux session is still alive. It does not guess; it only reports
what the log and tmux say.
| State | Plain-words meaning |
|---------|----------------------|
| 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. |
| 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. |
Two kinds of agents are scanned today:
- Repo agents: session logs live under `.pi/state/<agent>/sessions` in a
project checkout, on the default tmux socket.
- Fleet agents: session logs live under
`~/.mosaic/fleet/agents/<agent>/.pi/agent/sessions`, on the tmux socket
named `mosaic-fleet`.
## Where files go
- One file per agent: `<dataRoot>/board/sessions/<project>/<agent>.json`
- One summary file for the whole board: `<dataRoot>/board/index.json`
- `dataRoot` comes from `~/.config/mosaic-dev/config.json`. If that config
is missing or broken, the scanner refuses to run. It does not guess a
fallback location.
Board files are derived. They can be deleted and rebuilt at any time by
running the scanner again. They are NOT run records and they are not
evidence under the repository's write-once rules.
## Command
```
node packages/control-board/src/cli.mjs scan --print
```
Tests:
```
node --test packages/control-board/tests/
```
## Boundaries
- No changes to any launcher script.
- No new files at the repository root.
- No secrets read, stored, or printed.
- No daemon yet. In step 1 the scanner is run by hand. In step 2 the page
may trigger it on refresh; nothing runs it on a schedule.
- No changes to `packages/mosaic`.
- This plan does not authorize push or merge beyond whatever the existing
refactor-branch plan already allows.
+16 -15
View File
@@ -7,22 +7,23 @@ update this file to the next action). No ambiguity, no re-planning.
## Next action
Jason accepted the #1500 fixture-only increment on 2026-09-12 by running the
two-test demonstration himself on the canonical checkout at 5abbabb7 (2/2 pass);
receipt `docs/plans/reviews/2026-09-12_m20-increment2-owner-acceptance.md`.
#1500 is closed. No increment 3, launcher integration, production refresh or
deployment is authorized by that acceptance; the headless identity-env leak
remains a separate intake owned by Darkwing.
Jason decided on 2026-09-12 (MOSAIC-STACK-D-001) that the MVP is the control
board: one web page listing running agent sessions across projects, showing each
one's status, and flagging which ones are waiting on him. Plan page:
`docs/plans/2026-09-12_control-board-mvp.md`. Tracking: #1503. Step 1 (status
scanner `packages/control-board`, plan page, ticket) is done in this checkout;
running `node packages/control-board/src/cli.mjs scan --print` writes one status
file per agent under `<dataRoot>/board/`. The registry line (increment 3,
headless identity-env leak) stays parked; #1500 is closed.
Next action: re-plan against Jason's 2026-09-12 MVP decision MOSAIC-STACK-D-001
(control board: one page listing running agent sessions across projects with
status and a "waiting on you" section, deliberately smaller than the current
registry line). Present Jason a concrete plan that reconciles the registry
queue (increment 3 candidates, identity-env fix) with that MVP, with a
recommendation and acceptance evidence, before starting any new implementation.
Owner of this planning action: the active coordinator session (jarvis, Claude
Code, taking over darkwing's pi session). The registry queue does not resume by
inertia.
Next action: step 2 of #1503 — the web page. One local page that reads
`<dataRoot>/board/index.json`, groups sessions by project, shows the plain-word
state per agent, puts a "waiting on you" section at the top, and refreshes
itself (re-running the scanner on each refresh is acceptable). Reuse Dewey's
control-board mockups under `agents/dewey/work/wui/` for layout where they fit.
Acceptance: Jason opens the page in a browser and sees the same rows the
`--print` output shows, with no manual steps beyond starting the page. No auth,
no hooks/plugins, no comms, no launcher changes, no new root files.
## Completed checkpoint: #1500 increment 2 (historical)
@@ -0,0 +1,50 @@
# Control board MVP step 1: implementation and review receipt
Date: 2026-09-12. Recorded by jarvis (Claude Code, coordinator). Tracking: #1503.
Plan: `docs/plans/2026-09-12_control-board-mvp.md`. Decision: MOSAIC-STACK-D-001.
## What was built
`packages/control-board/` (ESM, no dependencies, node >= 24): `src/scan.mjs`
reads each agent's newest pi session log plus tmux liveness and writes one
status file per agent under `<dataRoot>/board/sessions/<project>/<agent>.json`
and a summary `index.json`. `src/cli.mjs` wraps it (`scan --print`). Config is
read fail-closed from `~/.config/mosaic-dev/config.json`. Board files are
derived and rewritable; they are not run records.
## Tests
`node --test packages/control-board/tests/` — 23 tests, 23 pass, 0 fail.
`node --test packages/mosaic/tests/` still 69/69 (untouched, checked for drift).
## Live run
`node packages/control-board/src/cli.mjs scan --print` on this host: 42 sessions
(4 repo agents, 38 fleet agents); 15 waiting, 3 error (all three are pi
usage-limit 429 errors on 2026-09-11), 24 offline, 0 working, 0 idle, 0 unknown;
18 flagged waiting on Jason. Spot checks: `arena-100` offline matched
`tmux -L mosaic-fleet has-session` refusing; the error rows matched the raw
`stopReason: "error"` entries.
## Independent review
Reviewer: in-session code-review agent (Claude Sonnet), read-only, with access to
the installed pi runtime for stopReason verification. First verdict: CHANGES
REQUIRED with three findings, all fixed in the same pass:
1. pi emits `stopReason: "length"` when a response is cut off; it fell through to
"working". Now mapped to "error".
2. A tmux check that could not run (`null`) was treated as alive. Now reported as
a distinct "unknown" state, never counted as waiting on you.
3. No test pinned the rule that the scanner only walks inside the given sessions
directory (fleet auth symlinks sit beside it). Added a sibling-secrets test.
Second verdict: APPROVED. The reviewer also noted one imprecision in my change
summary (a lastError test I described as new in the fix round had been added just
before the round); recorded here for honesty.
## Boundaries kept
No launcher changes, no new root files, no secrets read or printed, no changes to
`packages/mosaic`, no daemon, no web page yet. Next action (CURRENT.md): step 2,
the page.
+82
View File
@@ -0,0 +1,82 @@
# control-board
Step 1 of the control board MVP (`docs/plans/2026-09-12_control-board-mvp.md`).
This package scans your running pi agent sessions and writes one small JSON
status file per agent, plus one summary file, so a later web page can show
them all in one place. It reads pi session logs and checks tmux liveness. It
does not launch, stop, or talk to any agent. Board files are derived and
rewritable; they are not run records and are not evidence.
## States
| State | Plain-words meaning |
|---------|----------------------|
| 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. |
| idle | The agent is live but has not had a conversation yet. |
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
## Command
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
```
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
absolute `dataRoot`, or the scanner refuses to run.
- `--repo PATH` — path to a project checkout to scan for repo agents
(`.pi/state/<agent>/sessions`). Defaults to the current directory.
- `--fleet PATH|none` — path to the fleet agents directory
(`<fleet>/<agent>/.pi/agent/sessions`, tmux socket `mosaic-fleet`).
Defaults to `~/.mosaic/fleet/agents`. Pass `none` to skip fleet agents.
- `--liveness tmux|assume-alive` — how to decide if an agent is alive.
`tmux` (default) checks the real tmux session. `assume-alive` treats
every agent as alive, useful for tests or environments without tmux.
- `--print` — also print a one-line-per-agent table to stdout.
## Exit codes
- `0` — scan completed and status files were written.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
## Tests
```
node --test packages/control-board/tests/
```
## Output layout
```
<dataRoot>/board/
index.json # summary: counts, waiting-on-you list, all records
sessions/
<project>/
<agent>.json # one status record per agent
```
## Example status record
```json
{
"agent": "darkwing",
"project": "mosaic-stack",
"state": "waiting",
"waitingOnYou": true,
"alive": true,
"tmux": { "socket": null, "session": "darkwing" },
"sessionFile": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions/2026-09-12.jsonl",
"sessionId": "01a06e48-0718-71f2-a889-c263c4800fb9",
"cwd": "/mnt/storage/src/mosaic-stack",
"lastActivity": "2026-09-12T15:04:33.000Z",
"ageSeconds": 42,
"lastAssistantText": "Ready for the next step whenever you are.",
"lastError": null,
"skippedLines": 0,
"scannedAt": "2026-09-12T15:05:15.000Z"
}
```
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@mosaic/control-board",
"version": "0.1.0",
"private": true,
"description": "Control board MVP step 1: scan running agent sessions and write one status file per agent.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"bin": { "mosaic-board": "src/cli.mjs" },
"exports": {
".": "./src/scan.mjs"
},
"scripts": {
"test": "node --test tests/"
}
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Usage: node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
// Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path";
import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs";
function parseArgs(argv) {
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
const next = () => {
if (i + 1 >= argv.length) throw new ConfigError(`missing value for ${a}`);
return argv[++i];
};
if (a === "--config") opts.config = next();
else if (a === "--repo") opts.repo = next();
else if (a === "--fleet") opts.fleet = next();
else if (a === "--liveness") opts.liveness = next();
else if (a === "--print") opts.print = true;
else throw new ConfigError(`unknown argument: ${a}`);
}
if (opts.command !== "scan") throw new ConfigError("usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]");
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
return opts;
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const { dataRoot } = loadConfig(opts.config);
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
const boardDir = join(dataRoot, "board");
const index = scan(specs, { boardDir, isAlive });
if (opts.print) {
for (const s of index.sessions) {
const flag = s.waitingOnYou ? "*" : " ";
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`);
}
try {
main();
} catch (err) {
if (err instanceof ConfigError) {
process.stderr.write(`refused: ${err.message}\n`);
process.exit(2);
}
throw err;
}
+193
View File
@@ -0,0 +1,193 @@
// Control board step 1: read each agent's newest pi session log and tmux
// liveness, and write one small JSON status file per agent.
//
// States (plain words):
// 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
// 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.
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
const TEXT_LIMIT = 240;
export class ConfigError extends Error {}
export function defaultConfigPath() {
return join(homedir(), ".config", "mosaic-dev", "config.json");
}
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
export function loadConfig(path = defaultConfigPath()) {
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
throw new ConfigError(`config.dataRoot must be an absolute path: ${path}`);
}
return { dataRoot: raw.dataRoot };
}
// Newest *.jsonl under a directory tree, by mtime. Returns null when none.
export function findNewestSession(dir) {
if (!existsSync(dir)) return null;
let best = null;
const walk = (d) => {
for (const name of readdirSync(d)) {
const p = join(d, name);
const st = statSync(p);
if (st.isDirectory()) walk(p);
else if (name.endsWith(".jsonl") && (!best || st.mtimeMs > best.mtimeMs)) best = { path: p, mtimeMs: st.mtimeMs };
}
};
walk(dir);
return best ? best.path : null;
}
function collapse(text) {
const one = text.replace(/\s+/g, " ").trim();
return one.length > TEXT_LIMIT ? one.slice(0, TEXT_LIMIT - 1) + "…" : one;
}
// Read a pi session log. A partially written final line is skipped and counted,
// not treated as fatal, because pi may be appending while we read.
export function readSession(file) {
const lines = readFileSync(file, "utf8").split("\n");
let sessionId = null, cwd = null, lastTimestamp = null, lastMessage = null, lastAssistantText = null, lastError = null;
let skippedLines = 0;
for (const line of lines) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
skippedLines += 1;
continue;
}
if (entry.timestamp) lastTimestamp = entry.timestamp;
if (entry.type === "session") {
sessionId = entry.id ?? sessionId;
cwd = entry.cwd ?? cwd;
} else if (entry.type === "message" && entry.message) {
lastMessage = entry.message;
if (entry.message.role === "assistant" && Array.isArray(entry.message.content)) {
const text = entry.message.content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
if (text.trim()) lastAssistantText = collapse(text);
lastError = entry.message.stopReason === "error" && typeof entry.message.errorMessage === "string" ? collapse(entry.message.errorMessage) : null;
}
}
}
return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, skippedLines };
}
// Pure state rule. alive: true/false, or null when liveness could not be checked.
// A null check is reported as "unknown" rather than assumed alive (fail closed).
export function deriveState({ alive, session }) {
if (alive === false) return "offline";
if (alive !== true) return "unknown";
if (!session || !session.lastMessage) return "idle";
const m = session.lastMessage;
if (m.role === "assistant") {
if (m.stopReason === "stop") return "waiting";
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
return "working";
}
return "working";
}
export function tmuxIsAlive({ socket, session }) {
const args = [];
if (socket) args.push("-L", socket);
args.push("has-session", "-t", `=${session}`);
const r = spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error) return null;
return r.status === 0;
}
// One agent -> one status record.
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date() } = {}) {
const alive = isAlive(spec.tmux);
const file = findNewestSession(spec.sessionsDir);
const session = file ? readSession(file) : null;
const state = deriveState({ alive, session });
const scannedAt = now();
const lastActivity = session?.lastTimestamp ?? null;
const ageSeconds = lastActivity ? Math.max(0, Math.round((scannedAt.getTime() - Date.parse(lastActivity)) / 1000)) : null;
return {
agent: spec.agent,
project: spec.project,
state,
waitingOnYou: state === "waiting" || state === "error",
alive,
tmux: spec.tmux,
sessionFile: file,
sessionId: session?.sessionId ?? null,
cwd: session?.cwd ?? null,
lastActivity,
ageSeconds,
lastAssistantText: session?.lastAssistantText ?? null,
lastError: session?.lastError ?? null,
skippedLines: session?.skippedLines ?? 0,
scannedAt: scannedAt.toISOString(),
};
}
// Repo agents: <repo>/.pi/state/<agent>/sessions, tmux default socket, session = agent.
export function discoverRepoAgents(repoRoot) {
const stateDir = join(repoRoot, ".pi", "state");
if (!existsSync(stateDir)) return [];
const project = basename(resolve(repoRoot));
return readdirSync(stateDir)
.filter((n) => existsSync(join(stateDir, n, "sessions")))
.sort()
.map((agent) => ({ agent, project, sessionsDir: join(stateDir, agent, "sessions"), tmux: { socket: null, session: agent } }));
}
// Fleet agents: <fleet>/<agent>/.pi/agent/sessions, tmux socket mosaic-fleet, session = agent.
export function discoverFleetAgents(fleetRoot, { project = "fleet", socket = "mosaic-fleet" } = {}) {
if (!existsSync(fleetRoot)) return [];
return readdirSync(fleetRoot)
.filter((n) => existsSync(join(fleetRoot, n, ".pi", "agent", "sessions")))
.sort()
.map((agent) => ({ agent, project, sessionsDir: join(fleetRoot, agent, ".pi", "agent", "sessions"), tmux: { socket, session: agent } }));
}
function writeAtomic(path, data) {
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
}
// 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 }));
for (const rec of records) {
const dir = join(boardDir, "sessions", rec.project);
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeAtomic(join(dir, `${rec.agent}.json`), rec);
}
const generatedAt = (now ? now() : new Date()).toISOString();
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}`),
sessions: records,
};
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
writeAtomic(join(boardDir, "index.json"), index);
return index;
}
+466
View File
@@ -0,0 +1,466 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
utimesSync,
readFileSync,
readdirSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, basename } from "node:path";
import { spawnSync } from "node:child_process";
import {
loadConfig,
ConfigError,
findNewestSession,
readSession,
deriveState,
scanAgent,
discoverRepoAgents,
discoverFleetAgents,
scan,
} from "../src/scan.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
function writeFile(path, content) {
mkdirSync(resolve(path, ".."), { recursive: true });
writeFileSync(path, content);
}
function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", id, timestamp, cwd });
}
function messageLine({ timestamp, role, stopReason, texts }) {
const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason;
if (texts) message.content = texts.map((text) => ({ type: "text", text }));
return JSON.stringify({ type: "message", timestamp, message });
}
// Same transform readSession applies, kept here as an independent
// expectation rather than importing an unexported helper.
function collapseExpected(text) {
const one = text.replace(/\s+/g, " ").trim();
return one.length > 240 ? one.slice(0, 239) + "…" : one;
}
function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) {
const path = join(dir, name);
writeFile(path, lines.join("\n") + (trailingNewline ? "\n" : ""));
return path;
}
// ---------------------------------------------------------------------------
// 1. loadConfig fail-closed
// ---------------------------------------------------------------------------
test("loadConfig: missing file throws ConfigError", () => {
const root = makeRoot();
assert.throws(() => loadConfig(join(root, "nope.json")), ConfigError);
});
test("loadConfig: invalid JSON throws ConfigError", () => {
const root = makeRoot();
const path = join(root, "config.json");
writeFile(path, "{ not json");
assert.throws(() => loadConfig(path), ConfigError);
});
test("loadConfig: missing dataRoot throws ConfigError", () => {
const root = makeRoot();
const path = join(root, "config.json");
writeFile(path, JSON.stringify({}));
assert.throws(() => loadConfig(path), ConfigError);
});
test("loadConfig: relative dataRoot throws ConfigError", () => {
const root = makeRoot();
const path = join(root, "config.json");
writeFile(path, JSON.stringify({ dataRoot: "relative/path" }));
assert.throws(() => loadConfig(path), ConfigError);
});
test("loadConfig: valid config returns dataRoot", () => {
const root = makeRoot();
const path = join(root, "config.json");
const dataRoot = join(root, "data");
writeFile(path, JSON.stringify({ dataRoot }));
assert.deepEqual(loadConfig(path), { dataRoot });
});
// ---------------------------------------------------------------------------
// 2. findNewestSession
// ---------------------------------------------------------------------------
test("findNewestSession: picks the newest by mtime among two files", () => {
const root = makeRoot();
const older = join(root, "older.jsonl");
const newer = join(root, "newer.jsonl");
writeFile(older, "{}\n");
writeFile(newer, "{}\n");
utimesSync(older, new Date("2026-01-01T00:00:00Z"), new Date("2026-01-01T00:00:00Z"));
utimesSync(newer, new Date("2026-01-02T00:00:00Z"), new Date("2026-01-02T00:00:00Z"));
assert.equal(findNewestSession(root), newer);
});
test("findNewestSession: finds files in nested subdirectories", () => {
const root = makeRoot();
const nested = join(root, "a", "b");
mkdirSync(nested, { recursive: true });
const top = join(root, "top.jsonl");
const deep = join(nested, "deep.jsonl");
writeFile(top, "{}\n");
writeFile(deep, "{}\n");
utimesSync(top, new Date("2026-01-01T00:00:00Z"), new Date("2026-01-01T00:00:00Z"));
utimesSync(deep, new Date("2026-01-05T00:00:00Z"), new Date("2026-01-05T00:00:00Z"));
assert.equal(findNewestSession(root), deep);
});
test("findNewestSession: returns null for a missing dir", () => {
const root = makeRoot();
assert.equal(findNewestSession(join(root, "does-not-exist")), null);
});
// ---------------------------------------------------------------------------
// 3. readSession
// ---------------------------------------------------------------------------
test("readSession: extracts fields, collapses/truncates text, counts a truncated final line", () => {
const root = makeRoot();
const longText = " Field report: " + "alpha ".repeat(60) + " omega ";
const expectedText = collapseExpected(["First part.", longText].join("\n"));
assert.ok(expectedText.length === 240 && expectedText.endsWith("…"), "fixture text must exceed the 240-char limit");
const lines = [
sessionLine({ id: "sess-1", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-01T00:05:00.000Z", role: "user", texts: ["hi"] }),
messageLine({
timestamp: "2026-09-01T00:10:00.000Z",
role: "assistant",
stopReason: "stop",
texts: ["First part.", longText],
}),
// Truncated / partially-written final line: not valid JSON, no trailing newline.
'{"type":"message","timestamp":"2026-09-01T00:15:00.000Z","message":{"role":"assistant","stopReaso',
];
const path = writeSessionFile(root, "session.jsonl", lines, { trailingNewline: false });
const result = readSession(path);
assert.equal(result.file, path);
assert.equal(result.sessionId, "sess-1");
assert.equal(result.cwd, "/x");
assert.equal(result.lastTimestamp, "2026-09-01T00:10:00.000Z");
assert.equal(result.lastMessage.role, "assistant");
assert.equal(result.lastMessage.stopReason, "stop");
assert.equal(result.lastAssistantText, expectedText);
assert.equal(result.lastAssistantText.length, 240);
assert.ok(result.lastAssistantText.endsWith("…"));
assert.equal(result.skippedLines, 1);
});
test("readSession: lastError carries the assistant errorMessage only when the last assistant turn errored", () => {
const root = makeRoot();
const errored = JSON.stringify({
type: "message",
timestamp: "2026-09-01T00:10:00.000Z",
message: { role: "assistant", stopReason: "error", errorMessage: "429: usage limit\nreached", content: [] },
});
const path = writeSessionFile(root, "err.jsonl", [
sessionLine({ id: "sess-e", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-01T00:05:00.000Z", role: "assistant", stopReason: "stop", texts: ["fine"] }),
errored,
]);
const result = readSession(path);
assert.equal(result.lastError, "429: usage limit reached");
assert.equal(result.lastAssistantText, "fine");
const recovered = writeSessionFile(root, "ok.jsonl", [
sessionLine({ id: "sess-o", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
errored,
messageLine({ timestamp: "2026-09-01T00:15:00.000Z", role: "assistant", stopReason: "stop", texts: ["back"] }),
]);
assert.equal(readSession(recovered).lastError, null);
const rec = scanAgent({ agent: "a", project: "p", sessionsDir: root, tmux: { socket: null, session: "a" } }, { isAlive: () => true });
assert.equal(rec.lastError, null);
});
test("findNewestSession/scan: never read sibling auth or secrets next to a sessions dir", () => {
const root = makeRoot();
const agentDir = join(root, "agent-x", ".pi", "agent");
const sessionsDir = join(agentDir, "sessions", "nested");
writeFile(join(agentDir, "auth.json"), '{"token":"SECRET-AUTH-TOKEN"}');
writeFile(join(root, "agent-x", "secrets", "seat.json"), '{"token":"SECRET-SEAT-TOKEN"}');
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "sess-s", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-01T00:05:00.000Z", role: "assistant", stopReason: "stop", texts: ["ok"] }),
]);
assert.equal(findNewestSession(join(agentDir, "sessions")), join(sessionsDir, "s.jsonl"));
const boardDir = join(root, "board");
scan([{ agent: "agent-x", project: "p", sessionsDir: join(agentDir, "sessions"), tmux: { socket: null, session: "agent-x" } }], { boardDir, isAlive: () => true });
const written = [readFileSync(join(boardDir, "index.json"), "utf8"), readFileSync(join(boardDir, "sessions", "p", "agent-x.json"), "utf8")].join("\n");
assert.ok(!written.includes("SECRET-"), "board output must not contain secret material");
assert.ok(!written.includes("auth.json") && !written.includes("secrets/"), "board output must not reference auth or secrets paths");
});
// ---------------------------------------------------------------------------
// 4. deriveState table
// ---------------------------------------------------------------------------
test("deriveState: full state table", () => {
const cases = [
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
{ name: "alive=null (liveness not checkable) is unknown, never assumed alive", input: { alive: null, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "unknown" },
];
for (const c of cases) {
assert.equal(deriveState(c.input), c.expected, c.name);
}
});
// ---------------------------------------------------------------------------
// 5. scanAgent
// ---------------------------------------------------------------------------
test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", () => {
const root = makeRoot();
const waitingDir = join(root, "waiting", "sessions");
const workingDir = join(root, "working", "sessions");
const errorDir = join(root, "error", "sessions");
mkdirSync(waitingDir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
mkdirSync(errorDir, { recursive: true });
writeSessionFile(waitingDir, "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"] }),
]);
writeSessionFile(workingDir, "s.jsonl", [
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "toolUse", texts: ["thinking"] }),
]);
writeSessionFile(errorDir, "s.jsonl", [
sessionLine({ id: "s3", timestamp: "2026-09-01T00:00:00Z", cwd: "/e" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "aborted" }),
]);
const opts = { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") };
const waiting = scanAgent({ agent: "a", project: "p", sessionsDir: waitingDir, tmux: {} }, opts);
const working = scanAgent({ agent: "a", project: "p", sessionsDir: workingDir, tmux: {} }, opts);
const errored = scanAgent({ agent: "a", project: "p", sessionsDir: errorDir, tmux: {} }, opts);
assert.equal(waiting.state, "waiting");
assert.equal(waiting.waitingOnYou, true);
assert.equal(working.state, "working");
assert.equal(working.waitingOnYou, false);
assert.equal(errored.state, "error");
assert.equal(errored.waitingOnYou, true);
});
test("scanAgent: ageSeconds is computed from the injected now", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:00.000Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
]);
const now = new Date(Date.parse("2026-09-01T00:00:00.000Z") + 90_000);
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => now });
assert.equal(rec.ageSeconds, 90);
assert.equal(rec.scannedAt, now.toISOString());
});
test("scanAgent: sessionFile null and state idle when sessions dir is empty but alive", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date() });
assert.equal(rec.sessionFile, null);
assert.equal(rec.state, "idle");
assert.equal(rec.alive, true);
assert.equal(rec.sessionId, null);
assert.equal(rec.cwd, null);
assert.equal(rec.lastAssistantText, null);
});
// ---------------------------------------------------------------------------
// 6. discoverRepoAgents / discoverFleetAgents
// ---------------------------------------------------------------------------
test("discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name", () => {
const root = makeRoot();
const repoRoot = join(root, "repo");
mkdirSync(join(repoRoot, ".pi", "state", "b", "sessions"), { recursive: true });
mkdirSync(join(repoRoot, ".pi", "state", "a", "sessions"), { recursive: true });
mkdirSync(join(repoRoot, ".pi", "state", "c"), { recursive: true }); // no sessions dir: must be skipped
const specs = discoverRepoAgents(repoRoot);
assert.deepEqual(specs.map((s) => s.agent), ["a", "b"]);
const project = basename(resolve(repoRoot));
for (const spec of specs) {
assert.equal(spec.project, project);
assert.equal(spec.sessionsDir, join(repoRoot, ".pi", "state", spec.agent, "sessions"));
assert.deepEqual(spec.tmux, { socket: null, session: spec.agent });
}
});
test("discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields", () => {
const root = makeRoot();
const fleetRoot = join(root, "fleet");
mkdirSync(join(fleetRoot, "y", ".pi", "agent", "sessions"), { recursive: true });
mkdirSync(join(fleetRoot, "x", ".pi", "agent", "sessions"), { recursive: true });
mkdirSync(join(fleetRoot, "z", ".pi"), { recursive: true }); // no sessions dir: must be skipped
const specs = discoverFleetAgents(fleetRoot);
assert.deepEqual(specs.map((s) => s.agent), ["x", "y"]);
for (const spec of specs) {
assert.equal(spec.project, "fleet");
assert.equal(spec.sessionsDir, join(fleetRoot, spec.agent, ".pi", "agent", "sessions"));
assert.deepEqual(spec.tmux, { socket: "mosaic-fleet", session: spec.agent });
}
});
// ---------------------------------------------------------------------------
// 7. scan
// ---------------------------------------------------------------------------
test("scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files", () => {
const root = makeRoot();
const boardDir = join(root, "board");
const sessionsDir = join(root, "src-sessions");
mkdirSync(sessionsDir, { recursive: true });
const sessionPath = join(sessionsDir, "s.jsonl");
writeFile(
sessionPath,
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
].join("\n") + "\n"
);
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const opts = { boardDir, isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") };
const index1 = scan(specs, opts);
const agentFile = join(boardDir, "sessions", "proj", "agent1.json");
const indexFile = join(boardDir, "index.json");
assert.ok(existsSync(agentFile));
assert.ok(existsSync(indexFile));
const rec1 = JSON.parse(readFileSync(agentFile, "utf8"));
assert.equal(rec1.state, "waiting");
assert.equal(index1.counts.waiting, 1);
assert.equal(index1.counts.working, 0);
assert.deepEqual(index1.waitingOnYou, ["proj/agent1"]);
assert.equal(index1.generatedAt, "2026-09-01T00:01:00.000Z");
// Flip the fixture to a "working" state and rescan; files must reflect the new state.
writeFile(
sessionPath,
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:02:00Z", role: "assistant", stopReason: "toolUse", texts: ["thinking"] }),
].join("\n") + "\n"
);
const index2 = scan(specs, { ...opts, now: () => new Date("2026-09-01T00:03:00Z") });
const rec2 = JSON.parse(readFileSync(agentFile, "utf8"));
assert.equal(rec2.state, "working");
assert.equal(index2.counts.working, 1);
assert.equal(index2.counts.waiting, 0);
assert.deepEqual(index2.waitingOnYou, []);
// No leftover *.tmp-* artifacts anywhere under boardDir.
const walk = (dir) => {
const names = readdirSync(dir, { withFileTypes: true });
for (const entry of names) {
const p = join(dir, entry.name);
assert.ok(!entry.name.includes(".tmp-"), `leftover tmp file: ${p}`);
if (entry.isDirectory()) walk(p);
}
};
walk(boardDir);
});
test("scan: relative boardDir throws ConfigError", () => {
assert.throws(() => scan([], { boardDir: "relative/board", isAlive: () => true }), ConfigError);
});
// ---------------------------------------------------------------------------
// 8. CLI
// ---------------------------------------------------------------------------
function runCli(args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
}
test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files", () => {
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 });
writeFile(
join(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"] }),
].join("\n") + "\n"
);
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive", "--print"]);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^board: /m);
const boardDir = join(dataRoot, "board");
assert.ok(existsSync(join(boardDir, "index.json")));
assert.ok(existsSync(join(boardDir, "sessions", "repo", "agent1.json")));
const index = JSON.parse(readFileSync(join(boardDir, "index.json"), "utf8"));
assert.equal(index.sessions.length, 1);
assert.equal(index.counts.waiting, 1);
});
test("CLI: missing config exits 2 with a refused: message", () => {
const root = makeRoot();
const r = runCli(["scan", "--config", join(root, "no-such-config.json")]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: unknown command exits 2", () => {
const r = runCli(["bogus"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: unknown --liveness value exits 2", () => {
const r = runCli(["scan", "--liveness", "bogus"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});