Files
stack/packages/control-board/README.md
T
jason.woltjeandClaude Fable 5.1 867619dca2 control board: reply from the board through agent-send.sh (#1505)
Piece 2 of the MVP (#1503). A one-line reply box and Send in the detail
of rows with a live registration; POST /api/reply runs
tools/tmux/agent-send.sh -s <session> -S <host>:control-board
[-L <socket>] -m <text> once for one seat and returns the exit code,
stdout and stderr. The page shows delivered or failed with the tool's
stderr; other rows say "reply needs a registered seat". No send-keys,
queue, retries, history or broadcast; packages/seat and agent-send.sh
untouched. Every message ends with a fixed trailer telling the seat to
answer in its own session (Jason's refinement after the first Gate C
exchange; the board has no pane). Board suite 98/98.

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

212 lines
11 KiB
Markdown

# 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. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. |
| waiting | The agent finished its turn with a text-only message. 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, or its session exists but no longer runs `pi`. |
| 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. |
Liveness means a pane in the agent's tmux session is actually running `pi`
(`tmux list-panes -s -t '=<session>' -F '#{pane_current_command}'`). A tmux session that still
exists but only runs bash or some other program counts as offline, not
waiting.
## Commands
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
```
`scan` runs once and writes the status files. `serve` starts a small local
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
never staler than that timer. There is no login, so the server refuses to
bind to anything but a loopback address.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
absolute `dataRoot`, or the scanner refuses to run.
- `--repo PATH` — path to a project checkout to scan for repo agents
(`.pi/state/<agent>/sessions`). Defaults to the current directory.
- `--fleet PATH|none` — path to the fleet agents directory
(`<fleet>/<agent>/.pi/agent/sessions`, tmux socket `mosaic-fleet`).
Defaults to `~/.mosaic/fleet/agents`. Pass `none` to skip fleet agents.
- `--liveness tmux|assume-alive` — how to decide if an agent is alive.
`tmux` (default) checks the real tmux session. `assume-alive` treats
every agent as alive, useful for tests or environments without tmux.
- `--print` — (`scan`) also print a one-line-per-agent table to stdout. The
first column is `*` for a row waiting on you, `s` for a seen row, or blank
otherwise. The final summary line reads
`board: <dir> (N sessions, N waiting on you, N seen)`.
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
Any non-loopback address is refused.
Routes served:
- `GET /` — the page.
- `GET /api/board` — rescan, returns `index.json`.
- `POST /api/seen` — mark or unmark a row as seen, then rescan and return
`index.json`. Body must be JSON (`Content-Type: application/json`, no
more than 4096 bytes): `{"project", "agent", "lastActivity", "seen"}`.
`seen` defaults to `true`; pass `false` to unsee. Bad input, a missing
header, or an oversized body gets a `400`.
- `POST /api/reply` — send one line of text to one registered seat through
`tools/tmux/agent-send.sh` (#1505). Body: `{"agent": "<project>/<agent>",
"text"}`, JSON, at most 4096 bytes, text at most 2000 characters. The
server rescans, finds the row, and runs
`agent-send.sh -s <session> -S <host>:control-board [-L <socket>] -m <text>`
with the session and socket from the row's registration; `MOSAIC_TMUX_SOCKET`
is stripped from the tool's environment so the registration is the only
source. The text is sent as typed, followed on its own line by a fixed
trailer: `(control-board: answer in your own session as usual; the board
reads your transcript. Do not agent-send to control-board.)`. The board is
a sender without a pane: replies to it are read from the seat's
transcript, never sent, and `agent-send.sh -s control-board` refuses
because no such session exists (Jason's refinement after the first real
exchange, 2026-09-12). It answers `200` with `{delivered, exitCode, signal, stdout,
stderr, agent, session, socket, sentAt}` whenever the tool ran, delivered
or not; a non-zero exit is reported with the tool's stderr, never retried
and never routed around. Refusals before the tool runs: `400` bad body,
empty or blank text, text too long; `404` unknown row; `409` the row has
no registration, the registration is stale (pid gone), or it has no tmux
session; `500` the tool could not be started. One seat per request; no
queue, no history, no broadcast, no raw `send-keys`.
- `GET /healthz`.
On the page, the detail of a row with a live registration and a tmux
session has a one-line reply box and a Send button; every other row's
detail says "reply needs a registered seat". After Send the row shows
`delivered <time> to tmux <session>` or `failed (exit N): <stderr>`. A
draft that has not been sent, and the last receipt, survive the page's
periodic refresh; a delivered reply clears the box and triggers a rescan
so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
## Tests
```
node --test packages/control-board/tests/
```
## Output layout
```
<dataRoot>/board/
index.json # summary: counts, waiting-on-you, seen, registered, registrationStale, registrationErrors, all records
seen.json # Jason's "seen" marks (see below)
sessions/
<project>/
<agent>.json # one status record per agent
```
`seen.json` holds `{ "<project>/<agent>": "<lastActivity>" }`. It is written
only when Jason clicks "Seen" or "Unsee" on the page (via `POST
/api/seen`); a scan reads it but never writes it. A mark applies only while
the agent's newest message still has that exact `lastActivity` timestamp —
as soon as the agent writes anything new, `lastActivity` changes, the mark
no longer matches, and the row falls back into "Waiting on you" on its own.
Every row also shows three "what and where" fields, each taken from the
seat's registration when it has one, else derived from the session log and
tmux, else shown as the word "unknown", never guessed:
- **Task** — the session's first user message (collapsed to one line, 240
characters). pi logs carry no task envelope, so this is the only
assignment signal available. For fleet seats it is usually the
fleet-comms envelope the seat was started with.
- **Active project** — the basename of the nearest git checkout (a `.git`
directory or worktree file) above the workspace. Fleet seats that run in
`~/.mosaic` therefore show `.mosaic`. The grouping column "Project" is
unchanged: it still comes from where the seat's logs live, and `seen.json`
keys depend on it.
- **Workspace** (detail row, and the hover title of Active project) — the
current path of the tmux pane running `pi`; when the seat is offline or
tmux could not be asked, the `cwd` from the session log. The record says
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
- **Model** (under the agent name, and a detail row with the provider) —
the model the seat is running, from the log's latest `model_change` entry
or assistant turn, whichever is later, so a `/model` switch mid-session
shows up on the next scan. Blank until the log names one; a seat with no
pi log (rocko) has none.
A seat started through `scripts/mosaic launch <seat>` (see
`packages/seat/README.md`, #1504) has a registration at
`<dataRoot>/seats/<layout>/<seat>/registration.json`. The scan reads every one of
those and matches a record to a row by its sessions directory, never by
name alone. A registered task, project or workspace replaces the derived
value and the row's source field (`taskSource`, `activeProjectSource`,
`workspaceSource`) reads `registration`; the page shows a small source tag
next to the value and a "Registered" line in the detail with the start time,
harness, pid and tmux session. An empty task or a null project or workspace
in the record leaves the derived value in place. Rows with no registration
are exactly as before. The board only reads `seats/`; `mosaic launch` and
`mosaic seat task` are the only writers. A malformed record is listed in
`registrationErrors` on the index (and on stderr for `scan`) and skipped.
A record is written before the launch script's own checks run, so a refused
launch leaves a record whose pid is gone. The scan probes the recorded pid
on every pass; when it is gone the record is stale: the row keeps the
Registered line (marked stale, `registered.alive` false) but the derived
task, project and workspace win and the sources say so. Stale rows are
listed under `registrationStale` on the index. A record with no pid is
never stale.
Marked rows are listed under a collapsed "Seen (N)" section on the page,
each with an "Unsee" button, so nothing marked is ever out of reach. Each
project table has "Hide offline" and "Hide seen" checkboxes (both on by
default) with a note saying how many rows each one hides. While a box hides
something the project header reads "N of N" (shown of total); otherwise it
shows the plain total.
If `seen.json` exists but is not valid JSON (or not an object of string
values), the scan refuses rather than silently dropping every mark.
## Example status record
```json
{
"agent": "darkwing",
"project": "mosaic-stack",
"state": "waiting",
"waitingOnYou": true,
"seen": false,
"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",
"task": "Read agents/darkwing/work/RESTART.md",
"taskSource": "first-user-message",
"workspace": "/mnt/storage/src/mosaic-stack",
"workspaceSource": "tmux-pane",
"activeProject": "mosaic-stack",
"activeProjectSource": "workspace-git-root",
"registered": null,
"lastActivity": "2026-09-12T15:04:33.000Z",
"ageSeconds": 42,
"model": "gpt-6-astra",
"provider": "openai-codex",
"lastAssistantText": "Ready for the next step whenever you are.",
"lastError": null,
"skippedLines": 0,
"scannedAt": "2026-09-12T15:05:15.000Z"
}
```