# 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. ### Step 3 log **2026-09-12 — Gate A fix: task, active project and workspace per row.** Asked by the professor session on Jason's behalf. Each row now shows Task (the session's first user message), Active project (basename of the nearest git checkout above the workspace) and, in the detail row, Workspace (the live `#{pane_current_path}` of the pane running `pi`, else the session log's `cwd`). Anything that cannot be derived shows the word "unknown". Missing signals, for launchers: (1) pi session logs carry no task envelope entry, so Task is the free-text first user message, which for fleet seats is a fleet-comms envelope and for hand-started seats is whatever was typed ("resume", "who are you?"); a launcher that wants a clean task label should emit one as the first line of the first message or write a per-seat status file the scanner can read. (2) Neither `roster.yaml` nor a seat's `profile.json` names a project, and every fleet seat runs in `~/.mosaic`, which is itself a git checkout, so Active project reads `.mosaic` for all of them; a `project` field in the roster would fix that. (3) Workspace is only live while a `pi` pane exists; for offline seats it is history from the log. Fixture test per field, a tmux-inspect parse test, and a page test (79/79). Sonnet review: APPROVED, no findings. **2026-09-12 — liveness follows the tmux pane, not just the session.** Jason found agents marked "waiting" that were actually dead: their tmux session still existed but no longer ran `pi` (it had exited to a shell or something else). The scanner now checks which program each tmux pane is running (`tmux list-panes -s -t '=' -F '#{pane_current_command}'`) and only counts an agent alive if a pane is running `pi`. A tmux session with no `pi` pane is now "offline" instead of "waiting". **2026-09-12 — a "Seen" mark for rows that don't need a reply.** Jason noticed most "waiting" rows were completion reports, not real asks, and they kept cluttering "Waiting on you". The page now has a "Seen" button on waiting/error rows; clicking it stores the row's `lastActivity` in `/board/seen.json` (via `POST /api/seen`) and drops the row out of "Waiting on you" while keeping its state visible. The mark clears itself the moment the agent writes anything new, and "Unsee" puts a row back by hand. `seen.json` is only ever written by that click, never by a scan. **2026-09-12 — Gate B passed: seat registration on the board.** Jason restarted the board on 17153fe1 and relaunched darkwing through `agents/darkwing/launch.sh`. The row shows the registration (pid alive, not stale), workspace and active project from the record, and the last message updating as the session runs. Task still comes from the first user message because the launch gave no `--task`; the next real use is `scripts/mosaic seat task darkwing ""` so the row shows the assigned task instead. Fleet seats stay on the old launchers; only `agents/` seats register. Next piece in Jason's order: reply-from-board. **2026-09-12 — Gate C passed: reply-from-board (#1505).** Board restarted on 867619dc. Jason sent "pizza?" from the board to filbert (Pi, `openai-codex/gpt-6-astra`). The message arrived with the trailer, the seat answered in its own session ("Yes. Pepperoni?") and made no send attempt; the row showed the answer on the next scan. Before the trailer the same model followed ms-communications to the letter and tried `agent-send.sh -s control-board`, which the tool refused (no such pane). That was correct behaviour on both sides, not a model defect; a weaker model that skipped the send was ignoring the skill, not doing better. Sequence next: the ledger script, then the WebUI on the Console design absorbs the board. ## Piece 2: reply-from-board Approved by Jason 2026-09-12 after Gate B ("seat task update works. I want to keep things building"). Same shape as #1503/#1504: one piece, one yes/no gate, small commits with tests on `refactor`. Darkwing opens the issue and updates `CURRENT.md` when he picks it up. ### What it is A reply box on the board. Jason types a message on a seat's row and the board delivers it to that seat's tmux pane through the repository's own channel. No new transport, no message store, no WebSocket. - **Page**: a one-line text box and a Send button in each row's detail (registered rows only, see below). After Send the row shows one of two receipts: `delivered` with the time, or `failed` with the tool's stderr. Nothing else changes on the page; the next scan shows the effect (the seat's newest user message is the text just sent, and a `waiting` row turns `working`). - **Server**: `POST /api/reply` with `{ "agent": "", "text": "..." }`. The handler resolves the row's tmux session, runs `tools/tmux/agent-send.sh -s -S ":control-board" -m ` and returns the exit code, stdout and stderr. The exit code is the receipt; a non-zero code is a failure the page shows verbatim. `-S` labels the sender so the seat's preamble reads `[:control-board -> …]`. Fleet sockets pass `-L` from the registration's `tmux.socket` when set. - **Which rows**: only rows whose registration (#1504) names a tmux session, and whose registration is not stale. Everything else shows "reply needs a registered seat" instead of the box. Seat name alone is not an address; the registration's session is. - **Never**: raw `tmux send-keys`, a queue, retries, a history panel, a broadcast, sending to more than one seat per request. The durable record of what was said is the seat's own session log, which the scanner already reads. ### Gate C (yes/no) Jason answers a seat that is `waiting` from the board, without opening a terminal, and within one scan the row is `working` and its Task or last message reflects the reply. One real exchange is the pass. ### Tests - Server: a fake `agent-send.sh` on PATH captures its argv; assert `-s`, `-S`, `-L` (when the registration has a socket) and the message text. - Refusals with exit codes surfaced: unknown row, empty text, row without registration, stale registration, tool exit non-zero (stderr returned). - Page: the box appears only on registered live rows; a failure receipt shows the stderr text. - Loopback only stays as is; no auth is added for this piece. ### Boundaries - No change to `packages/seat` or the registration record. - No change to `tools/tmux/agent-send.sh`; if it refuses, the refusal is the finding (ms-communications rule), reported on the page and in the issue. - Fleet seats are out: they are not registered (Jason, 2026-09-12). ## Piece 3: ledger (numbers for the rails) Approved by Jason 2026-09-12 ("go for it") after Gate C. Purpose: replace "it feels like the rails work" with numbers read from evidence that already exists. Same shape: one package, one gate, tests, small commits on `refactor`. Darkwing opens the issue and updates `CURRENT.md` when he picks it up. ### What it is `node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues]`. Read-only. Prints two tables and a totals line to stdout; `--json` prints the same as one object. Sources, in this order: 1. **Local git log on `refactor`** (no network): commits in range whose subject carries `#N`. Nothing else in the message is parsed. 2. **Gitea issues** through `scripts/gitea-api.sh` (`repos/mosaicstack/stack`): number, title, `created_at`, `closed_at`. Missing or unreadable credentials: exit 2 with the reason, unless `--no-issues`, which prints the issue table with hours-open as "unknown". 3. **Session logs** under `.pi/state//sessions/*.jsonl` for the seats in `agents/` (repo layout only, no fleet sources): entries with `type: "message"` and `message.role: "user"` whose timestamp falls in range. Table 1, one row per issue touched in range (a commit or a close in range): issue, title (truncated), opened, hours open (or "open"), commits, follow-ups (commits after the first one for that issue), seats (from the session logs that mention `#N` in a user message; blank when none). Table 2, one row per seat: user messages in range split three ways by the first line of the text: **board** (preamble source session `control-board`), **agent** (any other `[a:b -> c:d]` preamble), **human** (no preamble). Totals line: issues closed, median hours open, commits, follow-ups per issue (one decimal), human messages per closed issue (one decimal). ### What it does not claim - Agent versus human authorship. Every commit and every issue carries Jason's identity; the ledger does not guess. - PR metrics. This work is issue-tagged commits on `refactor`; a PR count would read zero and mean nothing. - Gate results. Those are written by hand in this log. - Quality. A follow-up commit is a count, not a verdict; the reader decides. ### Gate D (yes/no) Jason runs the ledger for 2026-09-06 to 2026-09-12, picks the one number he wants to move next week, and writes that sentence with the number into `CURRENT.md`. Pass: the sentence exists, and its number came from the ledger's output, not from memory. ### Tests - Fixture git repository built in a temp dir with commits tagged `#1`, `#2` (two commits, so one follow-up) and one untagged commit; table 1 counts match. - Fixture session logs with one message of each kind (board, agent, human) and one out of range; table 2 counts match. - Fake `gitea-api.sh` on PATH returning fixture JSON; hours open computed from `created_at`/`closed_at`; an open issue prints "open". - Missing credentials: exit 2 with a message; with `--no-issues`, exit 0 and "unknown" in the hours column. - Empty range: both tables with zero rows, totals of zero, exit 0. - `--json` output parses and carries the same numbers as the tables. ### Boundaries - Read-only. No writes under the data root, no board changes, no new root files, no schedule, no charts, no `packages/mosaic` changes. - No fleet sources (`~/.mosaic`) and no network beyond the one Gitea call. - If a number cannot be computed from evidence it prints "unknown", never a guess. ## Piece 4: WebUI first screen (Console) Approved by Jason 2026-09-12 ("speed walk to success") after Gate D. Decision MOSAIC-STACK-D-002: Dewey's design 3, Console (`agents/dewey/work/wui/mockups/d3-console.html`), is the WebUI starting point. The board is its first and only screen. Owner: dewey. Dewey opens the issue and updates `CURRENT.md` when picking it up. ### What it is `packages/webui`: `node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331]`. Loopback only. One page, the Console layout from the mockup, populated from the control board's `/api/board` JSON: project tree on the left, dense session table (state, task with its source tag, active project, workspace, model, registered line, last activity), inspector drawer on the row with the last assistant text and the reply box. Actions proxy to the board: `POST /api/seen` and `POST /api/reply` unchanged in shape. "Simulated" notices from the mockup are gone; every control either does the board thing or does not exist yet. - Data: only what `/api/board` already serves. No new scanner, no new sources, no seat writes. If the mockup shows a field the board does not have, the field is dropped for this piece and a line goes to `docs/plans/DEFERRED.md`. - Style: the mockup's CSS and brand assets as they are, palette and appearance switches kept if they are already built, otherwise dropped. - Refresh: same as the board page (its scan on load or refresh); no polling loop beyond what the board page already does. - No auth, no accounts, no settings page, no second screen, no router. ### Gate E (yes/no) Jason works Monday 2026-09-14 from the WebUI and does not open the board's own page or a terminal to a repo seat to answer "who is waiting on me". Pass is his say-so at the end of the day, with any exception written as a line in `DEFERRED.md` with the reason. ### Tests - Page renders the fixture `/api/board` JSON (the board's own test fixture) into the table and tree; counts match the fixture. - Seen and reply proxy to the board with the same body; a fake board on localhost captures the requests. - Board unreachable: page says so with the URL, no blank screen. - Loopback bind only, as the board. - Dewey's existing mockup verifier (`work/wui/checks/mockups-verify.mjs`) or its equivalent runs against the served page for contrast and 320px width. ### Boundaries - `packages/control-board` changes only if the WebUI needs a field the scanner already computes but does not serialize; each such change is one commit with a test, nothing else. - No fleet sources, no comms changes, no `packages/seat` changes. - Dewey coordinates with Darkwing before touching any board file. ## Piece 5: darkwing on point (orchestrator behaviour) Approved in principle by Jason 2026-09-12 evening ("have darkwing on the fleet running point"). Starts when Jason sends darkwing the start message, planned Sunday 2026-09-13. Owner: darkwing. Purpose: move the Gate D number (18.9 human messages per closed issue) by having a seat do the assigning. ### What it is Mostly behaviour, with one small code change. **Code (one commit, with tests):** `mosaic seat task` records who set the task: a `taskSetBy` field from `MOSAIC_AGENT_NAME`, or `--by NAME`, or "unknown"; the scanner serializes it and the page shows it next to the task source. Closes the "set by" line in `DEFERRED.md`. **Behaviour, darkwing's loop for every item it runs:** 1. Read `docs/plans/CURRENT.md` and `docs/plans/DEFERRED.md`. Take the next queue item Jason has called, or one bounded Open line from DEFERRED that touches no file another seat is on. Never take two. 2. Open the Gitea issue with the acceptance sentence and the owning seat. 3. Set the owning seat's task with `scripts/mosaic seat task ""`. 4. Send the start message to the seat through `tools/tmux/agent-send.sh` aimed at the tmux session in the seat's registration (ms-communications rules apply; a refusal is a finding, not something to route around). 5. Receive the seat's report the same way. Verify: tests run, review by a seat that did not write the code, acceptance sentence met. 6. Close the issue, move the DEFERRED line to Done with the commit, update `CURRENT.md`. 7. Report to Jason only at a gate or a block, in darkwing's own session (the board reads it). No progress narration. **What darkwing does not do:** brief new pieces (Jason and the coordinator do), start a queue item Jason has not called, push anywhere but `refactor`, touch `~/.mosaic`, or message Jason for routine decisions the plan already settles (AGENTS.md "Decide and continue"). ### First assignment under this piece Filbert: the DEFERRED line "Relaunched seat shows the old last message": when a registration's `startedAt` is newer than the session's last activity, the row shows "relaunched at X, no messages since" instead of the stale activity. Board test with a fixture registration newer than the log. Darkwing runs steps 1 to 7 for it without Jason's involvement. ### Gate F (yes/no) Filbert's item goes from a DEFERRED line to a closed issue with Jason having sent filbert nothing. Evidence: the ledger for 2026-09-13 shows filbert's Human column at 0 and the issue closed; Jason's only start message that day is the one to darkwing. ### Boundaries - No new package, no scheduler, no daemon; darkwing runs the loop while its session is live and stops when it is not. - No fleet seats, no changes to `tools/tmux/agent-send.sh` or the comms skill; a refusal from either is reported as a finding. - `packages/seat` and `packages/control-board` change only for the `taskSetBy` field. ## 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` - One registration per seat started through `mosaic launch`: `/seats///registration.json` (#1504). Written by the launcher, read by the board, rewritten on relaunch. Not under `board/` because the board never writes it. - `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. ## Commands Scan once and print the table: ``` node packages/control-board/src/cli.mjs scan --print ``` Start the page (step 2), then open http://127.0.0.1:7331/ in a browser: ``` node packages/control-board/src/cli.mjs serve ``` Tests: ``` node --test packages/control-board/tests/ ``` ## Boundaries - No changes to any launcher script. Corrected 2026-09-12 by Jason's go on the `mosaic launch` brief (#1504): the four repository launch scripts (`agents/{darkwing,dewey,filbert,rocko}/launch.sh`) gained a guard that re-enters them through `scripts/mosaic launch` for registration. The fleet launchers under `~/.mosaic` are still untouched; their one-line change is written in the Step 3 log for orch-01 to apply. - 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`. Still true: seat registration went into a new package, `packages/seat`, not the hardened registry. - This plan does not authorize push or merge beyond whatever the existing refactor-branch plan already allows. **2026-09-12 — a place to find what you marked Seen.** After using the button, Jason noted the board had no way to show seen rows again. The page now has a collapsed "Seen (N)" section between "Waiting on you" and "By project" that lists every marked row with an Unsee button. It stays open or closed across refreshes. **2026-09-12 — "Hide seen" per project.** Jason suggested a checkbox like "Hide offline" so seen rows stop cluttering the fleet table now that the Seen section exists. Each project header has both boxes, on by default, and the note under the table reads "N offline hidden · N seen hidden". **2026-09-12 — Mid-tool-call rule checked and pinned.** Requested by the professor session on Jason's behalf. Finding: the scanner already honoured the rule, but only through pi's `stopReason` (`toolUse` and tool results were `working`, `stop` was `waiting`; `deriveState` in `scan.mjs`). It now also looks at the content: an assistant message with a `toolCall` block is `working` whatever its stop reason; an error stop reason still wins; a `thinking` block does not stop a text turn from being `waiting`. Three fixture tests (tool call after a question-looking text, tool result last, finished text-only turn) plus three state-table cases. Live check at 14:27Z: orch-01 mid-tool-call → working, rev-code-01 tool result last → working, velma finished text-only turn → waiting; all 13 waiting seats had text-only (or thinking+text) last messages, none had a tool call. **2026-09-12 — Header count "N of N".** Jason pointed out that "fleet (38)" sat above a table showing 13 rows. The header now reads "fleet (13 of 38)" while a checkbox hides something and falls back to "fleet (38)" when nothing is hidden. The note under the table stays: the header says how many, the note says why. One static test. **2026-09-12 — Parked until the board passes its gate.** Jason's ruling: nothing else starts until he has used the board in place of cycling tmux. Parked, not cancelled, and not to be resumed by inertia: - Dewey's dashboard mockup selection (task D05, `agents/dewey/work/wui/`). It gets chosen against a board Jason has used, not before. - Registry increment 3 (already parked above; restated so it stays parked). - New seat directories. The roster is frozen at its current seats until the rails pass condition is written (phase C of the 48-hour plan). **2026-09-12 — Rule: a seat mid-tool-call is `working`, never `waiting`.** Added as an acceptance rule, not a fix log entry, because it is the second way the top section can lie (the first was the dead-session case above). If the newest session-log entry is an assistant message that contains a tool call, or a tool result with no assistant text after it, the state is `working` even if the last text the agent printed looks like a question. `waiting` requires the last entry to be an assistant message whose content is text only and whose turn has ended. Verify with one live seat caught mid-tool-call and one that has finished a turn; add a fixture test for each so the rule cannot regress silently. **2026-09-12 — Jason chose Dewey design 3 (Console) as the WebUI starting point.** This answers task D05 and lifts the "chosen against a board Jason has used" hold above: the selection is made, on evidence from today's use. It does not start WebUI build work. Sequence stays: seat registration via `mosaic launch` (in progress), then reply-from-board, then the WebUI on the Console design absorbs the board as its first screen. **2026-09-12 — Seat registration: `mosaic launch ` (#1504).** Gate A passed on Jason's go, relayed by the professor session with a brief. New package `packages/seat` and wrapper `scripts/mosaic`: - `scripts/mosaic launch [--task ..] [-- args]` resolves `agents//launch.sh` (repo layout) or a seat directory (fleet layout, `/.pi`), writes `/seats///registration.json`, then replaces itself with the launch script (`process.execve`), so the pid, the terminal, the exit code and the tmux foreground command are the script's own. The record: seat, project, task, workspace, tmux, harness, startedAt, pid, plus sessionsDir (the board matches on it, not on the name, because "darkwing" exists in both layouts), seatDir, launchScript, layout, updatedAt, version. Rewritten on every launch. - `scripts/mosaic seat task ` changes the task only; refuses when the seat was never launched this way. - Scanner: registrations are read on every scan; a registered task, project or workspace wins and `taskSource`, `activeProjectSource`, `workspaceSource` read `registration`; empty or null registered fields leave the derived value; rows without a registration are unchanged. A malformed record is listed in `registrationErrors` and skipped. Page: a small "registered" tag next to the value and a Registered line in the detail (start, harness, pid, tmux, layout). - The four repository launch scripts (`agents/{darkwing,dewey,filbert, rocko}/launch.sh`) gained a five-line guard: unless `MOSAIC_LAUNCH_REGISTERED` is set or `--check` is among the arguments, re-enter through `scripts/mosaic launch --harness pi|claude-code`. So the plain `agents/darkwing/launch.sh` registers too. rocko registers but is still not on the board: it has no pi session log to scan. Gap, not a bug. - Fleet launchers under `~/.mosaic` are not edited from this repository. Not planned (Jason's ruling, 2026-09-12, relayed by the professor session): fleet seats stay on the old launchers; only `agents/` seats use `scripts/mosaic`. The one-liner below is kept for the record only. One-line change that would do it, in `~/.mosaic/fleet/bin/launch-seat.sh`, after `SEAT_DIR=` is set (the per-seat `launch.sh` files are shims and need nothing): `[[ -n "${MOSAIC_LAUNCH_REGISTERED:-}" || "$CHECK" -eq 1 ]] || exec /mnt/storage/src/mosaic-stack/scripts/mosaic launch --harness pi "$SEAT_DIR" -- "$@"` Fleet records carry project and workspace null (roster has no project field; all fleet seats run in `~/.mosaic`), so those rows keep their derived values until `--project`/`--workspace` are passed or the roster gains a field. - Sonnet review found one defect before commit: the record path was keyed by seat name alone, so the repo and fleet "darkwing" would have overwritten each other. Fixed by keying on layout and seat (`seats///`); `seat task` refuses a bare name registered in more than one layout unless `--layout` is given. Second review pass approved. The reviewer also noted `process.execve` is marked experimental in Node; recorded, accepted (Node 24+ is the floor and the API is the only way to keep the seat's pid). - Name clash noted: the npm-global `mosaic` CLI (estate tooling) has no `launch` or `seat` command; the repository wrapper is run by path. Tests: seat package 15 (record write via a fake launch.sh, args and exit passthrough, relaunch rewrite, `--task`, tmux null, tmuxContext with a fake exec, task update and its refusals, malformed records, samePath through a symlink, recursion guard); control board 79 → 89 (override precedence, empty registration, no registration parity, loadRegistrations errors, matchRegistration by realpath and the same-name-different-layout case, scan on disk, server round trip, page source checks); the two launch-script suites now run each real `launch.sh` in a fixture that is its own data root and assert the record and the `MOSAIC_LAUNCH_REGISTERED` value Pi/Claude saw (5/5); registry 69/69; config 24/24. Live check: the real `agents/darkwing/launch.sh` run through the launcher against a temporary data root, then a scratch board on 7332 reading that root: the darkwing row shows the registered task, "registered" on task and project, and the Registered detail line. Found on the way: Jason's own launch refused with "local Pi must match package.json (0.85.1)". Commit 557aba0f (#1499) moved the pin without an `npm ci` in this checkout (node_modules held 0.84.4; the global pi was already 0.85.1, which hid it). Fixed by `npm ci`; the refusal now names both versions. Jason questioned the pin itself; it is canon (AGENTS.md "Version pin") and stays unless he decides otherwise. **2026-09-12 — Two defects in 69f99323, reported by the professor session and verified here.** (1) The darkwing launch test's flock-contention spawn inherited the plain environment, so the launch script re-entered `scripts/mosaic` with the default config and wrote fixture records for darkwing, dewey and filbert into the real data root (dead pids, workspaces under `/tmp`), overwriting the record of Jason's live darkwing. Fix: that spawn names the fixture config, and both launch test files now set `MOSAIC_CONFIG` to a nonexistent path and clear `MOSAIC_LAUNCH_REGISTERED` for the whole process, so any spawn that forgets fails instead of polluting. The three fixture records were deleted by hand; Jason's next darkwing launch writes the real one. (2) A registration is written before the launch script's own checks, so a refused launch (Jason's Pi pin refusal) left a record with a dead pid that the board showed as registered. The scanner now probes the recorded pid on every pass (`pidAlive`, signal 0); a gone pid makes the record stale: still on the Registered line, marked stale with `registered.alive` false, but derived task, project and workspace win. Index gains `registrationStale`; the CLI summary a stale count. Tests: board 90/90 (was 89), seat 15/15, launch scripts 5/5; the launch tests run against the real data root untouched (checked by timestamp). Also from Jason: fleet seats stay on the old launchers; the orch-01 one-liner above is marked not planned. Observation from Jason, same day: after relaunching darkwing, the row's "Last message" was still from the earlier session. Cause, not a scanner bug: Pi writes the session log on the first message, and `--continue` resumes the newest existing log, so until the relaunched seat sends or receives a message there is nothing newer to read. The registration's `startedAt` and pid are the only trace of the relaunch, which is what the Registered line is for. A later refinement could say "relaunched at X, no messages since" when `startedAt` is newer than the last activity. **2026-09-12 — Model per row (#1503).** Jason: "The running agent (sonnet, opus, gpt-6-astra) should surface in the interface as well." Pi logs name the model twice: a `model_change` entry (`provider`, `modelId`) at launch and on every `/model` switch, and `model`/`provider` on each assistant turn. `readSession` now keeps the latest of either; the record gains `model` and `provider`; the page shows the model in small type under the agent name (provider in the hover) and a Model detail row. A `/model` switch shows on the next scan even before the seat answers. Blank when the log names no model; rocko has no pi log and stays off the board. Checked on a scratch board against the real data root: all 42 rows carried a model (darkwing and sage `gpt-6-astra`, filbert `glm-5.3`, fleet rows a mix of `gpt-5.6-*` and `glm-5.3`). Board suite 91/91. Jason's board on 7331 needs a restart to pick this up. Sonnet review found the model hover title escaped an already escaped string (double encoding for names with `&` or `<`); fixed before commit, then APPROVED on that basis. **2026-09-12 — Piece 2: reply-from-board (#1505).** Built to the brief above. `POST /api/reply {agent, text}` rescans, finds the row, and runs `tools/tmux/agent-send.sh -s -S :control-board -m `, plus `-L ` when the registration names one, with `MOSAIC_TMUX_SOCKET` stripped from the tool's environment so the registration is the only source of the socket. The answer carries `delivered`, the exit code, stdout and stderr; a non-zero exit is reported as is. Refusals before the tool runs: bad or empty or long text 400, unknown row 404, no registration or stale registration or no tmux session 409, tool not startable 500. The page puts a one-line box and Send in the detail of rows whose registration is live and has a tmux session; other rows say "reply needs a registered seat". The receipt reads `delivered