One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:
- Row 18, Discord connector rows on the board (#1509): R3 approved by
Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
Dewey, candidate manifest 47769fad. All seven source files match it.
Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).
packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.
Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.
Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.
Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
296 lines
16 KiB
Markdown
296 lines
16 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 explicitly requested your input in its completed reply. |
|
|
| 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 and available, including after a normal completed reply. |
|
|
| 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.
|
|
|
|
### Explicit human attention
|
|
|
|
A completed assistant reply requests attention only when its first nonblank text
|
|
line begins at column zero with `Input needed: ` and a nonempty request. Example:
|
|
|
|
```text
|
|
Input needed: Choose staging or production for the approved test.
|
|
```
|
|
|
|
Ordinary replies such as `BOARD_REPLY_OK`, completion reports and questions without
|
|
this explicit signal are idle. Code/quote examples and thinking blocks do not
|
|
count. Tool activity/errors retain precedence. A later ordinary completed reply
|
|
clears the previous request; a user/tool message is working, not waiting.
|
|
|
|
Agents reserve the signal for Jason's decision or input, not another agent's
|
|
review or routine completion. It is display state, never action authorization.
|
|
Existing unmarked replies cannot establish a human blocker. Seen acknowledges
|
|
the current event and removes it from the attention list; it does not resolve a
|
|
genuine request or change waiting to idle. A new request reappears.
|
|
|
|
## Relaunch activity notice
|
|
|
|
A live native row with a positively live, matching registration gets a
|
|
`relaunchedAt` timestamp when that registration's valid `startedAt` is strictly
|
|
newer than valid recorded `lastActivity`. Both board presentations then show
|
|
`relaunched at X, no messages since` in the current activity/preview positions.
|
|
The inspector explicitly labels retained last activity, assistant text and errors
|
|
as historical. CLI `scan --print` also replaces its old preview/age with the notice. New recorded session activity at or after the launch timestamp
|
|
clears the notice. Equality does not assert a relaunch.
|
|
|
|
Missing/invalid timestamps, missing or mismatched registrations, unknown/dead PID
|
|
or row liveness, and connector rows yield `relaunchedAt: null`. Unknown is not
|
|
proof of relaunch. The comparison uses the existing session activity timestamp,
|
|
not a new transcript index or authenticated process-incarnation protocol.
|
|
|
|
This field changes presentation only. Historical transcript files and serialized
|
|
lastActivity/lastAssistantText/lastError remain intact. State, attention, Seen,
|
|
task selection/attribution and reply eligibility are unchanged. An old unresolved
|
|
waiting/error state therefore remains visible, with its text labelled historical,
|
|
rather than being silently cleared by the new notice. No launcher or live
|
|
registration mutation is required to test this behavior.
|
|
|
|
## Discord connector rows
|
|
|
|
The CLI discovers private `<dataRoot>/discord/<binding>.json` files on every
|
|
scan, including server rescans. Only matching safe binding name and seat identity
|
|
are used. Files must be regular, non-symlink, mode 0600 and at most 1 MiB.
|
|
Discovery never resolves token paths or projects binding policy, Discord IDs or
|
|
user/channel lists. Invalid bindings produce fixed, content-free discoveryErrors.
|
|
Library callers enable this with `discordDataRoot` on scan/startServer.
|
|
|
|
Rows use project `fleet` and agent `<seat> (discord: <binding>)`, distinct from
|
|
native seats. Task is fixed `Discord connector`, with source `connector`, never
|
|
inferred from the first user message: Discord routing envelopes contain private
|
|
IDs. Ordinary-agent task derivation and assistant transcript display are unchanged.
|
|
Session history comes from `sessions/discord-<binding>` without
|
|
following linked directories/files. Liveness uses the connector's readPid and
|
|
ownerState checks, not tmux or registration: only a live PID with matching boot
|
|
ID and start tick is live. Missing, invalid, dead or unverifiable owners are
|
|
non-live. The `connector` projection contains binding, alive, ownerState and
|
|
braked only; it does not expose the journal directory or owner record.
|
|
|
|
STOP presence is shown separately as braked, even when the owner is offline.
|
|
An unsafe journal path gives brake unknown, not an unbraked claim. STOP contents
|
|
are never read. Activity retains the usual idle/waiting/error rules. Both board
|
|
pages omit Reply for connector rows; the backend refuses connector replies before
|
|
transport even if a stale/forged native registration supplies tmux details.
|
|
|
|
This is read-only observation, not connector control. No brake, unlock, recovery,
|
|
Discord request, counter collection or engine action is performed. Tests use
|
|
isolated bindings, sessions and process identities; they do not prove live
|
|
service transitions or authorize replacing a running board.
|
|
|
|
## 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. When the task shown is the registered one,
|
|
the row also carries `taskSetBy` (#1511): the record's `taskSetBy` from
|
|
`mosaic seat task --by NAME` (else `$MOSAIC_AGENT_NAME`, else `unknown`),
|
|
or `unknown` for a record written before the field existed. Both pages show
|
|
it as a "set by NAME" tag after the task's source tag and a "Task set by"
|
|
detail row. It is what the caller claimed, not a verified identity: the
|
|
board escapes and displays it and nothing else reads it; in particular the
|
|
reply gate looks only at the registration. For every other task source
|
|
(`first-user-message`, `connector`, a stale registration, no task) it is
|
|
null, so a record never lends its setter to a task it did not set. 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",
|
|
"taskSetBy": null,
|
|
"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"
|
|
}
|
|
```
|