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
+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:/);
});