From b9f59a59031da0cd74fda2b1df7a1d9dbbe50233 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 12 Sep 2026 07:26:03 -0500 Subject: [PATCH] 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 /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 --- BUILD-LOG.md | 17 + docs/SESSIONS.md | 3 + docs/plans/2026-09-12_control-board-mvp.md | 107 ++++ docs/plans/CURRENT.md | 31 +- .../2026-09-12_control-board-step1-review.md | 50 ++ packages/control-board/README.md | 82 +++ packages/control-board/package.json | 16 + packages/control-board/src/cli.mjs | 53 ++ packages/control-board/src/scan.mjs | 193 ++++++++ packages/control-board/tests/scan.test.mjs | 466 ++++++++++++++++++ 10 files changed, 1003 insertions(+), 15 deletions(-) create mode 100644 docs/plans/2026-09-12_control-board-mvp.md create mode 100644 docs/plans/reviews/2026-09-12_control-board-step1-review.md create mode 100644 packages/control-board/README.md create mode 100644 packages/control-board/package.json create mode 100755 packages/control-board/src/cli.mjs create mode 100644 packages/control-board/src/scan.mjs create mode 100644 packages/control-board/tests/scan.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 75db0550..b9ac03ea 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index d658475f..15608064 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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. diff --git a/docs/plans/2026-09-12_control-board-mvp.md b/docs/plans/2026-09-12_control-board-mvp.md new file mode 100644 index 00000000..697b7542 --- /dev/null +++ b/docs/plans/2026-09-12_control-board-mvp.md @@ -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 `/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//sessions` in a + project checkout, on the default tmux socket. +- Fleet agents: session logs live under + `~/.mosaic/fleet/agents//.pi/agent/sessions`, on the tmux socket + named `mosaic-fleet`. + +## Where files go + +- One file per agent: `/board/sessions//.json` +- One summary file for the whole board: `/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. diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index 1ae8215a..af5b3def 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -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 `/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 +`/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) diff --git a/docs/plans/reviews/2026-09-12_control-board-step1-review.md b/docs/plans/reviews/2026-09-12_control-board-step1-review.md new file mode 100644 index 00000000..adba62aa --- /dev/null +++ b/docs/plans/reviews/2026-09-12_control-board-step1-review.md @@ -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 `/board/sessions//.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. diff --git a/packages/control-board/README.md b/packages/control-board/README.md new file mode 100644 index 00000000..3604c28a --- /dev/null +++ b/packages/control-board/README.md @@ -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//sessions`). Defaults to the current directory. +- `--fleet PATH|none` — path to the fleet agents directory + (`//.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 + +``` +/board/ + index.json # summary: counts, waiting-on-you list, all records + sessions/ + / + .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" +} +``` diff --git a/packages/control-board/package.json b/packages/control-board/package.json new file mode 100644 index 00000000..c0b6d723 --- /dev/null +++ b/packages/control-board/package.json @@ -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/" + } +} diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs new file mode 100755 index 00000000..4c91e914 --- /dev/null +++ b/packages/control-board/src/cli.mjs @@ -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; +} diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs new file mode 100644 index 00000000..950598fa --- /dev/null +++ b/packages/control-board/src/scan.mjs @@ -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: /.pi/state//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: //.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 /sessions//.json plus index.json. +export function scan(specs, { boardDir, isAlive, now } = {}) { + if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path"); + const records = specs.map((spec) => scanAgent(spec, { isAlive, now })); + 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; +} diff --git a/packages/control-board/tests/scan.test.mjs b/packages/control-board/tests/scan.test.mjs new file mode 100644 index 00000000..e41699aa --- /dev/null +++ b/packages/control-board/tests/scan.test.mjs @@ -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:/); +});