Files
stack/packages/control-board
jason.woltjeandClaude Opus 5.5 c9e771cf59 feat(webui): CHAT-02 Console, read-only conversation view (#1507)
History opens a seat's conversation from the Waiting card, table row
and inspector. It pages the whole branch through the CHAT-02 board
routes, renders untrusted text inert, polls with the follow cursor, and
marks every switch (branch, newer, reconcile, gone). The WebUI proxy
passes only the two conversation routes' queries upstream.

Dewey authored it. Filbert asked for changes on r1 (24b046af) and
approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer',
not 'reconcile', a deviation from brief 2.3 item 6 that Filbert
accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:05:11 -05:00
..

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:

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

{
  "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"
}