Files
stack/docs/plans/2026-09-12_control-board-mvp.md
T
jason.woltjeandClaude Fable 5.1 0bed9ba3db control board: show the model each seat is running (#1503)
Jason asked for the running model (sonnet, opus, gpt-6-astra) on the
board. readSession keeps model and provider from the log's latest
model_change entry or assistant turn, whichever is later, so a /model
switch shows on the next scan; scanAgent exposes both; the page shows the
model under the agent name with the provider in the hover and a Model
detail row. Blank when the log names none.

Live check on a scratch board against the real data root: all 42 rows
carried a model. Board 91/91. Sonnet review caught a double-escaped hover
title; fixed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 11:13:32 -05:00

353 lines
19 KiB
Markdown

# Control board MVP — plan
Tracking: Gitea issue #1503 (https://git.mosaicstack.dev/mosaicstack/stack/issues/1503)
## Why
Jason is building blind. He has many agent sessions running across projects
and no single place to see what they are doing or who is waiting on him.
This plan replaces the registry line as the next thing to build. Registry
increment 3 stays parked; it does not resume by inertia. Decision record:
`MOSAIC-STACK-D-001` (Jason, 2026-09-12).
## What "done" looks like for the MVP
One web page lists my running agent sessions across projects, shows each
one's current status, and flags which ones are waiting on me.
Acceptance:
- Jason opens one page and sees every live pi agent, grouped by project.
- Each agent shows a state in plain words (working, waiting, error,
offline, idle, unknown).
- A "waiting on you" section sits at the top of the page.
- The page refreshes itself. Jason does not have to reload it by hand.
- Every state on the page comes from a status file written by the scanner,
not from a guess made in the page itself.
## Three steps
### Step 1 (today): plan, ticket, scanner
- Write this plan and open the Gitea ticket.
- Build the status scanner script with tests. Running it writes one status
file per agent under `<dataRoot>/board/`.
- This step does not touch any page or launcher. It only produces files.
### Step 2: the web page
- Build a page that reads the status files the scanner writes and shows
them grouped by project, with the waiting-on-you section on top.
- The page refreshes itself on a short timer. The small local server behind
it may re-run the scanner on each refresh so the files stay fresh. No
separate daemon is needed for this step.
### Step 3: daily use and fixes
- Jason uses the page every day for real work.
- He says what is wrong or missing.
- Fix that one thing, then repeat. No new scope beyond what he actually hits.
### 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 '=<session>' -F '#{pane_current_command}'`) and only counts an
agent alive if a pane is running `pi`. A tmux session with no `pi` pane is
now "offline" instead of "waiting".
**2026-09-12 — a "Seen" mark for rows that don't need a reply.** Jason
noticed most "waiting" rows were completion reports, not real asks, and
they kept cluttering "Waiting on you". The page now has a "Seen" button on
waiting/error rows; clicking it stores the row's `lastActivity` in
`<dataRoot>/board/seen.json` (via `POST /api/seen`) and drops the row out
of "Waiting on you" while keeping its state visible. The mark clears
itself the moment the agent writes anything new, and "Unsee" puts a row
back by hand. `seen.json` is only ever written by that click, never by a
scan.
**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 "<text>"` 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.
## How the scanner decides state
The scanner reads the newest pi session log for each agent, plus whether
the agent's tmux session is still alive. It does not guess; it only reports
what the log and tmux say.
| State | Plain-words meaning |
|---------|----------------------|
| working | The agent is in the middle of a turn: thinking or running a tool. |
| waiting | The agent finished its turn. It is your move now. |
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
| offline | There is no live tmux session for this agent right now. |
| idle | The agent is live but has not had a conversation yet. |
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
Two kinds of agents are scanned today:
- Repo agents: session logs live under `.pi/state/<agent>/sessions` in a
project checkout, on the default tmux socket.
- Fleet agents: session logs live under
`~/.mosaic/fleet/agents/<agent>/.pi/agent/sessions`, on the tmux socket
named `mosaic-fleet`.
## Where files go
- One file per agent: `<dataRoot>/board/sessions/<project>/<agent>.json`
- One summary file for the whole board: `<dataRoot>/board/index.json`
- One registration per seat started through `mosaic launch`:
`<dataRoot>/seats/<layout>/<seat>/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 <seat>` (#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 <seat|seat-dir> [--task ..] [-- args]` resolves
`agents/<seat>/launch.sh` (repo layout) or a seat directory (fleet layout,
`<seatDir>/.pi`), writes `<dataRoot>/seats/<layout>/<seat>/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 <seat> <text>` 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/<layout>/<seat>/`); `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.