Add mosaic launch <seat> with seat registration for the control board (#1504)

New package packages/seat and wrapper scripts/mosaic. `launch <seat>` writes
<dataRoot>/seats/<layout>/<seat>/registration.json and then execs the seat's
launch.sh unchanged; `seat task <seat> <text>` edits the task only. The board
reads registrations, matches by sessions directory, and lets a registered
task, project or workspace override the derived value with a source tag.
The four repository launch scripts register themselves unless already
registered or run with --check. Fleet launchers untouched; one-liner on the
plan page.

Review found the record path keyed by seat name alone (repo and fleet
"darkwing" would collide); fixed by keying on layout. Also: the Pi pin
refusal now names installed and required versions.

Tests: seat 15, control-board 89, launch scripts 5, registry 69, config 24.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 10:56:17 -05:00
co-authored by Claude Fable 5.1
parent 01d9a19612
commit 69f99323c7
25 changed files with 1766 additions and 46 deletions
+39
View File
@@ -2063,3 +2063,42 @@ a live pane, the rest from the log; every fleet seat reads `.mosaic` as
its active project because `~/.mosaic` is a git checkout and the roster
names no project. Missing launcher signals are listed in the plan page
Step 3 log. Sonnet review: APPROVED, no findings.
## 2026-09-12 — Seat registration: `mosaic launch <seat>` (#1504)
Before: the board derived task, project and workspace from the session log
and tmux, and had no way to know what a seat was actually told to do.
Launch scripts left no record.
After: `scripts/mosaic launch <seat|seat-dir> [--task ..] [-- args]`
(package `packages/seat`) writes
`<dataRoot>/seats/<layout>/<seat>/registration.json` (seat, project, task,
workspace, tmux, harness, startedAt, pid, plus sessionsDir, seatDir,
launchScript, layout, updatedAt, version) and then replaces itself with the
seat's launch.sh via `process.execve`, so pid, terminal, exit code and the
tmux foreground command are the script's own. `scripts/mosaic seat task
<seat> <text>` edits the task only. The board reads every registration on
each scan, matches by sessions directory, and lets a registered task,
project or workspace override the derived value with `taskSource` /
`activeProjectSource` / `workspaceSource` = `registration`; the page shows a
"registered" tag and a Registered detail line. The four repository launch
scripts re-enter through the launcher unless `MOSAIC_LAUNCH_REGISTERED` is
set or `--check` is passed; fleet launchers are untouched, their one-line
change is on the plan page for orch-01.
Verification: seat suite 15/15, control-board 89/89 (was 79), launch-script
suites 5/5 (each real launch.sh in a fixture that is its own data root),
registry 69/69, config 24/24. Live: the real `agents/darkwing/launch.sh`
through the launcher against a temporary data root, then a scratch board on
7332 reading it; the darkwing row showed the registered task, both source
tags and the Registered line, and a `seat task` change appeared with its
update time.
Corrections: sonnet review (first pass) found the record path keyed by seat
name alone, which would have made the repo and fleet "darkwing" overwrite
each other; fixed by keying on layout and seat, `seat task` refuses an
ambiguous bare name unless `--layout` is given; second pass APPROVED. Found
on the way: Jason's own launch refused on the Pi pin because
`node_modules` still held 0.84.4 after commit 557aba0f moved the pin to
0.85.1; `npm ci` fixed it and the refusal message now names both versions.
The pin is canon and stays unless Jason decides otherwise.
+5
View File
@@ -2,4 +2,9 @@
# Darkwing's native development mode through the Mosaic agent entry point.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Register this seat with the control board (packages/seat) unless already
# registered by `mosaic launch` or only running the checks.
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi darkwing -- "$@"
fi
exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@"
+5
View File
@@ -2,4 +2,9 @@
# Dewey's native development mode through the Mosaic agent entry point.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Register this seat with the control board (packages/seat) unless already
# registered by `mosaic launch` or only running the checks.
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi dewey -- "$@"
fi
exec "$REPO/scripts/agent.sh" --host-dev dewey "$@"
+5
View File
@@ -2,5 +2,10 @@
# Filbert's native development mode through the Mosaic agent entry point.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Register this seat with the control board (packages/seat) unless already
# registered by `mosaic launch` or only running the checks.
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi filbert -- "$@"
fi
exec "$REPO/scripts/agent.sh" --host-dev filbert "$@" \
--provider openai-codex --model gpt-6-astra --thinking low
+5
View File
@@ -2,6 +2,11 @@
# Rocko's native Claude Code development session.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Register this seat with the control board (packages/seat) unless already
# registered by `mosaic launch` or only running the checks.
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness claude-code rocko -- "$@"
fi
cd "$REPO"
fail() { echo "rocko: $*" >&2; exit 1; }
usage() {
+1
View File
@@ -220,3 +220,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, fourth refinement (#1503): Jason asked for "N of N" in the project header; now "shown of total" while a checkbox hides rows, plain total otherwise. Page-only change plus one static test (66/66); sonnet review APPROVED (no findings); live check in Chrome at all four edges.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, mid-tool-call rule (#1503), asked by the professor session on Jason's behalf: scanner already honoured the rule via stopReason; added a content-level toolCall check, 3 fixture tests + 3 table cases (69/69); live check on orch-01/rev-code-01 (working) and velma (waiting); sonnet review APPROVED (no findings; thinking-block nuance already recorded).
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, Gate A fix (#1503), asked by the professor session on Jason's behalf: per-row Task (first user message), Active project (nearest git checkout above the workspace) and Workspace (live tmux pane path, else session cwd), "unknown" when not derivable; missing launcher signals logged on the plan page. Fixture test per field (79/79); live check on a scratch server; sonnet review APPROVED (no findings).
- 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise.
+17
View File
@@ -130,6 +130,23 @@ Push is never automatic.
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper | Token never on argv/stdout |
## Seat launch with registration (`scripts/mosaic`)
```bash
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
```
Runs a seat's `launch.sh` unchanged (arguments after `--` pass through) and
leaves one registration record at `<dataRoot>/seats/<layout>/<seat>/registration.json`
that the control board reads: seat, project, task, workspace, tmux session,
harness, start time, pid. Rewritten on every launch. `seat task` changes the
task field only. The four repository seats (`agents/*/launch.sh`) register
themselves through this path unless called with `--check`. Not the
npm-global `mosaic` CLI; run by path. Exit codes: the launch script's own
once it runs; before that 1 could not start, 2 invalid config or seat, 4
usage. Details and the record's fields: `packages/seat/README.md`.
## Tools (host-side)
Host-side helpers under `tools/`, outside the `scripts/` command surface.
+82 -2
View File
@@ -114,6 +114,10 @@ Two kinds of agents are scanned today:
- 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.
@@ -144,12 +148,18 @@ node --test packages/control-board/tests/
## Boundaries
- No changes to any launcher script.
- 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`.
- 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.
@@ -210,3 +220,73 @@ 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.
One-line change for orch-01 to apply 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.
+21 -7
View File
@@ -22,13 +22,27 @@ First step-3 refinement landed 2026-09-12: liveness follows the tmux pane
"Waiting on you" until the agent writes again
(`docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md`).
Next action: step 3 of #1503 — daily use. Jason opens the page while he works
and reports what is wrong, missing or confusing; each report becomes a small
fix on this package with a test, committed and pushed to `refactor`. Close
#1503 when Jason says the page answers "who is waiting on me" without him
opening a terminal. Out of scope until he asks: auth, hooks/plugins, comms,
memory, launcher changes, non-Pi harnesses, new root files. The registry line
(increment 3, headless identity-env leak) stays parked; #1500 is closed.
Gate A (task, active project, workspace per row) passed on 2026-09-12 and
Jason's go on the professor session's brief opened #1504: seat registration.
`scripts/mosaic launch <seat>` runs a seat's launch script unchanged and
leaves one record at `<dataRoot>/seats/<layout>/<seat>/registration.json` that the
board reads instead of guessing; `scripts/mosaic seat task <seat> <text>`
changes the task. The four repository launch scripts register themselves.
Package: `packages/seat`. Fleet launchers under `~/.mosaic` are not edited
from this repository; the one-line change for them is in the plan page.
Next action: step 3 of #1503 continues — daily use with #1504 landed. Jason
launches his seats through the new path (or the launch scripts, which now
register), sets tasks with `seat task`, and reports what is wrong, missing
or confusing; each report becomes a small fix with a test, committed and
pushed to `refactor`. The sequence Jason set stays: seat registration (done),
then reply-from-board, then the WebUI on Dewey's Console design absorbs the
board as its first screen. Close #1503 when Jason says the page answers "who
is waiting on me" without him opening a terminal. Out of scope until he
asks: auth or provider registry, roster schema changes, hooks/plugins, comms,
memory, multiple sessions per seat, stopping seats, new root files. The
registry line (increment 3, headless identity-env leak) stays parked; #1500
is closed.
## Completed checkpoint: #1500 increment 2 (historical)
+20 -3
View File
@@ -82,7 +82,7 @@ node --test packages/control-board/tests/
```
<dataRoot>/board/
index.json # summary: counts, waiting-on-you list, seen list, all records
index.json # summary: counts, waiting-on-you, seen, registered, registrationErrors, all records
seen.json # Jason's "seen" marks (see below)
sessions/
<project>/
@@ -95,8 +95,9 @@ only when Jason clicks "Seen" or "Unsee" on the page (via `POST
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 derived from the
session log and tmux or shown as the word "unknown", never guessed:
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
@@ -112,6 +113,20 @@ session log and tmux or shown as the word "unknown", never guessed:
tmux could not be asked, the `cwd` from the session log. The record says
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
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.
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
@@ -140,6 +155,8 @@ values), the scan refuses rather than silently dropping every mark.
"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,
"lastAssistantText": "Ready for the next step whenever you are.",
+5 -3
View File
@@ -40,8 +40,9 @@ async function main() {
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive });
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
@@ -49,7 +50,8 @@ async function main() {
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive });
const index = scan(specs, { boardDir, isAlive, seatsDir });
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
if (opts.print) {
for (const s of index.sessions) {
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
@@ -57,7 +59,7 @@ async function main() {
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
}
}
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen)\n`);
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered)\n`);
}
main().catch((err) => {
+25 -6
View File
@@ -65,6 +65,7 @@
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
@@ -184,6 +185,21 @@
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
}
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")];
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
if (reg.harness) parts.push("harness " + esc(reg.harness));
if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout");
return parts.join("; ");
}
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
@@ -193,12 +209,14 @@
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
// Gate A fields: derived from the log and tmux, or "unknown". Never guessed.
// Gate A fields: from the seat's registration (mosaic launch), else derived
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>"
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>"
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<tr class="' + cls + '">' +
@@ -219,9 +237,10 @@
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + (rec.workspaceSource ? " (from " + esc(rec.workspaceSource) + ")" : "") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
+70 -14
View File
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
const TEXT_LIMIT = 240;
@@ -243,15 +244,52 @@ function liveness(result) {
return { alive: result ?? null, workspace: null };
}
// Registrations written by `mosaic launch <seat>` (packages/seat): one
// record per seat under <dataRoot>/seats/<layout>/<seat>/registration.json. Returns
// the readable records plus one error line per unreadable one; a bad record
// must not take the whole board down, but it is not silently dropped either.
export function loadRegistrations(seatsDir) {
const registrations = [];
const errors = [];
if (!seatsDir || !existsSync(seatsDir)) return { registrations, errors };
for (const layout of LAYOUTS) {
const layoutDir = join(seatsDir, layout);
if (!existsSync(layoutDir) || !statSync(layoutDir).isDirectory()) continue;
for (const seat of readdirSync(layoutDir).sort()) {
if (!statSync(join(layoutDir, seat)).isDirectory()) continue;
try {
const rec = readRegistration(seatsDir, seat, layout);
if (rec) registrations.push(rec);
} catch (err) {
if (!(err instanceof SeatError)) throw err;
errors.push(`seat ${layout}/${seat}: ${err.message}`);
}
}
}
return { registrations, errors };
}
// A registration belongs to a row when it names the same sessions directory.
// Seat names alone are not enough: the repo and fleet layouts both have a
// "darkwing", and they are different seats.
export function matchRegistration(spec, registrations) {
return registrations.find((r) => r.sessionsDir && samePath(r.sessionsDir, spec.sessionsDir)) ?? null;
}
// One agent -> one status record.
//
// Three fields answer "what is this seat doing, and where" (Gate A ask,
// 2026-09-12). Each is derived, never guessed; null means "unknown".
// task the session's first user message. The log has no task
// envelope entry, so this is the only assignment signal it holds.
// workspace the live pane path from tmux, else the session log's cwd.
// activeProject basename of the nearest git repo root above the workspace.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {} } = {}) {
// 2026-09-12). Each is derived, never guessed; null means "unknown". A seat
// started through `mosaic launch` has a registration, and a registered task,
// project or workspace wins over the derived value; the *Source field says
// which one the row shows.
// task registration.task, else the session's first user message
// (the log has no task envelope entry).
// workspace registration.workspace, else the live pane path from tmux,
// else the session log's cwd.
// activeProject registration.project, else the basename of the nearest git
// repo root above the workspace.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null } = {}) {
const live = liveness(isAlive(spec.tmux));
const alive = live.alive;
const file = findNewestSession(spec.sessionsDir);
@@ -263,8 +301,17 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const needsYou = state === "waiting" || state === "error";
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
const cwd = session?.cwd ?? null;
const workspace = live.workspace ?? cwd;
const repoRoot = workspace ? findRepoRoot(workspace) : null;
const reg = registration && typeof registration === "object" ? registration : null;
const derivedWorkspace = live.workspace ?? cwd;
const workspace = reg?.workspace ?? derivedWorkspace;
const workspaceSource = reg?.workspace ? "registration" : live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null;
const repoRoot = derivedWorkspace ? findRepoRoot(derivedWorkspace) : null;
const derivedProject = repoRoot ? basename(repoRoot) : null;
const activeProject = reg?.project ?? derivedProject;
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
const firstUserText = session?.firstUserText ?? null;
const task = reg?.task ? reg.task : firstUserText;
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
return {
agent: spec.agent,
project: spec.project,
@@ -276,11 +323,15 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
sessionFile: file,
sessionId: session?.sessionId ?? null,
cwd,
task: session?.firstUserText ?? null,
taskSource: session?.firstUserText ? "first-user-message" : null,
task,
taskSource,
workspace,
workspaceSource: live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null,
activeProject: repoRoot ? basename(repoRoot) : null,
workspaceSource,
activeProject,
activeProjectSource,
registered: reg
? { startedAt: reg.startedAt, updatedAt: reg.updatedAt, harness: reg.harness, pid: reg.pid, tmux: reg.tmux, layout: reg.layout, launchScript: reg.launchScript }
: null,
lastActivity,
ageSeconds,
lastAssistantText: session?.lastAssistantText ?? null,
@@ -317,10 +368,13 @@ function writeAtomic(path, data) {
}
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
export function scan(specs, { boardDir, isAlive, now } = {}) {
// seatsDir (optional): where `mosaic launch` registrations live; read only.
export function scan(specs, { boardDir, isAlive, now, seatsDir = null } = {}) {
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
const seen = loadSeen(boardDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen }));
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations) }));
for (const rec of records) {
const dir = join(boardDir, "sessions", rec.project);
mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -332,6 +386,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) {
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
seen: records.filter((r) => r.seen).map(seenKey),
registered: records.filter((r) => r.registered).map(seenKey),
registrationErrors,
sessions: records,
};
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
+3 -3
View File
@@ -67,7 +67,7 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage() }) {
return createHttpServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/seen") {
@@ -79,7 +79,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage()
return sendJson(res, 400, { error: err.message });
}
try {
sendJson(res, 200, scan(specs, { boardDir, isAlive, now }));
sendJson(res, 200, scan(specs, { boardDir, isAlive, now, seatsDir }));
} catch (err) {
sendJson(res, 500, { error: err.message });
}
@@ -97,7 +97,7 @@ export function createServer({ specs, boardDir, isAlive, now, page = loadPage()
if (url.pathname === "/api/board") {
let index;
try {
index = scan(specs, { boardDir, isAlive, now });
index = scan(specs, { boardDir, isAlive, now, seatsDir });
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
+247
View File
@@ -10,6 +10,7 @@ import {
readdirSync,
existsSync,
statSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, basename } from "node:path";
@@ -31,7 +32,10 @@ import {
findRepoRoot,
loadSeen,
markSeen,
loadRegistrations,
matchRegistration,
} from "../src/scan.mjs";
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
@@ -413,6 +417,249 @@ test("scan: the written record carries task, workspace and activeProject", () =>
assert.equal(onDisk.workspace, "/w");
});
// ---------------------------------------------------------------------------
// 4d. Registration (mosaic launch)
// ---------------------------------------------------------------------------
test("registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const registration = makeRegistration({
resolved: {
seat: "a",
project: "resolved-project",
sessionsDir,
seatDir: join(root, "seat-dir"),
launchScript: join(root, "seat-dir", "launch.sh"),
layout: "repo",
defaultWorkspace: "/resolved/workspace",
},
task: "registered task",
project: "registered-project",
workspace: "/registered/workspace",
harness: "pi",
tmux: { socket: null, session: "a" },
pid: 4242,
now: () => new Date("2026-09-12T13:00:00Z"),
});
const rec = scanAgent(spec, { isAlive: () => true, now: GATE_NOW, registration });
assert.equal(rec.task, "registered task");
assert.equal(rec.taskSource, "registration");
assert.equal(rec.activeProject, "registered-project");
assert.equal(rec.activeProjectSource, "registration");
assert.equal(rec.workspace, "/registered/workspace");
assert.equal(rec.workspaceSource, "registration");
assert.equal(rec.project, "p", "the grouping column (spec.project) is not touched by a registration");
assert.ok(rec.registered, "registered must be non-null once a matching registration is passed");
assert.equal(rec.registered.startedAt, registration.startedAt);
assert.equal(rec.registered.harness, "pi");
assert.equal(rec.registered.pid, 4242);
assert.deepEqual(rec.registered.tmux, { socket: null, session: "a" });
assert.equal(rec.registered.layout, "repo");
assert.equal(rec.registered.launchScript, join(root, "seat-dir", "launch.sh"));
});
test("registration: empty task and null project/workspace leave the derived values in place; registered is still non-null", () => {
const root = makeRoot();
const repo = join(root, "repo");
mkdirSync(join(repo, ".git"), { recursive: true });
const sessionsDir = join(repo, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: repo }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const registration = makeRegistration({
resolved: {
seat: "a",
project: null,
sessionsDir,
seatDir: repo,
launchScript: join(repo, "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "",
project: null,
workspace: null,
now: () => new Date("2026-09-12T13:00:00Z"),
});
const rec = scanAgent(spec, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW, registration });
assert.equal(rec.task, "derived task");
assert.equal(rec.taskSource, "first-user-message");
assert.equal(rec.activeProject, "repo");
assert.equal(rec.activeProjectSource, "workspace-git-root");
assert.equal(rec.workspace, repo);
assert.equal(rec.workspaceSource, "session-cwd");
assert.notEqual(rec.registered, null, "an empty task and null project/workspace still leave a registration attached");
});
test("registration: no registration leaves the Gate A fields exactly as before, and registered is null", () => {
const root = makeRoot();
const repo = join(root, "repo");
mkdirSync(join(repo, ".git"), { recursive: true });
const sessionsDir = join(repo, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: repo }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const withoutReg = scanAgent(spec, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(withoutReg.registered, null);
assert.equal(withoutReg.task, "derived task");
assert.equal(withoutReg.taskSource, "first-user-message");
assert.equal(withoutReg.activeProject, "repo");
assert.equal(withoutReg.activeProjectSource, "workspace-git-root");
assert.equal(withoutReg.workspace, repo);
assert.equal(withoutReg.workspaceSource, "session-cwd");
const plainRoot = makeRoot();
const plainSessions = join(plainRoot, "sessions");
writeSessionFile(plainSessions, "s.jsonl", [sessionLine({ id: "s2", timestamp: "2026-09-12T14:00:00Z", cwd: null })]);
const noWorkspace = scanAgent({ agent: "a", project: "p", sessionsDir: plainSessions, tmux: {} }, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(noWorkspace.registered, null);
assert.equal(noWorkspace.activeProjectSource, null, "no workspace at all means no repo root, so the source is null, not a guess");
});
test("loadRegistrations: a missing seatsDir gives empty lists", () => {
const root = makeRoot();
assert.deepEqual(loadRegistrations(join(root, "no-such-seats")), { registrations: [], errors: [] });
assert.deepEqual(loadRegistrations(null), { registrations: [], errors: [] });
});
test("loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored", () => {
const root = makeRoot();
const seatsDir = join(root, "seats");
const good = makeRegistration({
resolved: {
seat: "good",
project: "p",
sessionsDir: join(root, "sessions"),
seatDir: join(root, "good"),
launchScript: join(root, "good", "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "do it",
});
writeRegistration(seatsDir, good);
mkdirSync(join(seatsDir, "repo", "broken"), { recursive: true });
writeFileSync(join(seatsDir, "repo", "broken", "registration.json"), "{ not json");
const unknownField = { ...good, seat: "weird", extraField: "surprise!!!" };
mkdirSync(join(seatsDir, "repo", "weird"), { recursive: true });
writeFileSync(join(seatsDir, "repo", "weird", "registration.json"), JSON.stringify(unknownField));
// A stray non-directory entry directly under seatsDir must be ignored, not
// treated as a broken seat.
writeFileSync(join(seatsDir, "not-a-seat.txt"), "stray file, not a seat directory");
writeFileSync(join(seatsDir, "repo", "not-a-seat.txt"), "stray file inside a layout directory");
// A record whose contents disagree with its path is an error, not a match.
mkdirSync(join(seatsDir, "fleet", "good"), { recursive: true });
writeFileSync(join(seatsDir, "fleet", "good", "registration.json"), JSON.stringify(good));
const { registrations, errors } = loadRegistrations(seatsDir);
assert.equal(registrations.length, 1);
assert.equal(registrations[0].seat, "good");
assert.equal(errors.length, 3);
assert.ok(errors.some((e) => e.startsWith("seat fleet/good:") && e.includes("does not match its path")), "a record under the wrong layout is reported");
assert.ok(errors.some((e) => e.startsWith("seat repo/broken:")), "the broken record's error names its seat");
assert.ok(errors.some((e) => e.startsWith("seat repo/weird:")), "the unknown-field record's error names its seat");
for (const e of errors) {
assert.ok(!e.includes("surprise!!!"), "an error must not echo a record's field values");
assert.ok(!e.includes("not json"), "an error must not echo the raw file contents");
}
});
test("matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing)", () => {
const root = makeRoot();
const sessionsDir = join(root, "repo-sessions");
mkdirSync(sessionsDir, { recursive: true });
const spec = { agent: "darkwing", project: "repo-project", sessionsDir, tmux: {} };
const reg = makeRegistration({
resolved: {
seat: "darkwing",
project: null,
sessionsDir,
seatDir: root,
launchScript: join(root, "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "",
});
assert.equal(matchRegistration(spec, [reg]), reg);
// The spec's sessionsDir differs textually from the registration's, but
// resolves (via realpath) to the same real directory.
const linkPath = join(root, "sessions-link");
symlinkSync(sessionsDir, linkPath);
const specViaLink = { ...spec, sessionsDir: linkPath };
assert.equal(matchRegistration(specViaLink, [reg]), reg);
// A registration with sessionsDir null (layout "unknown") never matches.
const regNoSessions = { ...reg, sessionsDir: null };
assert.equal(matchRegistration(spec, [regNoSessions]), null);
// Same seat name, different sessionsDir: the repo and fleet layouts can
// both have a "darkwing", and they are different seats.
const fleetSessions = join(root, "fleet-sessions");
mkdirSync(fleetSessions, { recursive: true });
const fleetReg = { ...reg, sessionsDir: fleetSessions };
assert.equal(matchRegistration(spec, [fleetReg]), null);
});
test("scan: writes the registration override to disk; index.json carries registered and registrationErrors", () => {
const root = makeRoot();
const boardDir = join(root, "board");
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived"] }),
]);
const seatsDir = join(root, "seats");
const reg = makeRegistration({
resolved: {
seat: "a",
project: "p",
sessionsDir,
seatDir: join(root, "a"),
launchScript: join(root, "a", "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "registered task",
});
writeRegistration(seatsDir, reg);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const index = scan([spec], { boardDir, seatsDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.deepEqual(index.registered, ["p/a"]);
assert.deepEqual(index.registrationErrors, []);
const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8"));
assert.equal(onDisk.task, "registered task");
assert.equal(onDisk.taskSource, "registration");
assert.ok(onDisk.registered);
});
test("scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before", () => {
const root = makeRoot();
const boardDir = join(root, "board");
assert.throws(() => scan([], { boardDir, seatsDir: "relative/seats", isAlive: () => true, now: GATE_NOW }), ConfigError);
const index = scan([], { boardDir, isAlive: () => true, now: GATE_NOW });
assert.deepEqual(index.registered, []);
assert.deepEqual(index.registrationErrors, []);
});
// ---------------------------------------------------------------------------
// 5. scanAgent
// ---------------------------------------------------------------------------
+93 -1
View File
@@ -14,6 +14,7 @@ import { spawnSync, spawn } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { ConfigError, markSeen } from "../src/scan.mjs";
import { isLoopbackHost, startServer } from "../src/serve.mjs";
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
@@ -201,6 +202,54 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
}
});
test("startServer: a seatsDir registration overrides the row and index.registered reflects it", async () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
]);
const boardDir = join(root, "board");
const seatsDir = join(root, "seats");
const reg = makeRegistration({
resolved: {
seat: "agent1",
project: "proj",
sessionsDir,
seatDir: join(root, "agent1"),
launchScript: join(root, "agent1", "launch.sh"),
layout: "repo",
defaultWorkspace: null,
},
task: "registered task",
});
writeRegistration(seatsDir, reg);
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({
host: "127.0.0.1",
port: 0,
specs,
boardDir,
seatsDir,
isAlive: () => ({ alive: true, workspace: null }),
page: "<html></html>",
});
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/board`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.sessions[0].taskSource, "registration");
assert.equal(body.sessions[0].task, "registered task");
assert.deepEqual(body.registered, ["proj/agent1"]);
assert.deepEqual(body.registrationErrors, []);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 4. /api/board: scan failure surfaces as a 500 with an error field
// ---------------------------------------------------------------------------
@@ -518,7 +567,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
const row = r.stdout.split("\n").find((l) => l.includes("agent1"));
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
assert.equal(row[0], "s");
assert.match(r.stdout, /1 seen\)\s*$/m);
assert.match(r.stdout, /1 seen, 0 registered\)\s*$/m);
});
// ---------------------------------------------------------------------------
@@ -588,3 +637,46 @@ test("page.html: every row shows Task and Active project, derived or the word un
assert.equal(heads.length, 3, "all three tables carry the two new headers");
assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
});
// ---------------------------------------------------------------------------
// 9. page.html: registration source tags and the Registered detail row
// ---------------------------------------------------------------------------
test("page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
assert.ok(rowPair, "buildRowPair() must exist in page.html");
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
assert.match(
rowPair[0],
/<dt>Registered<\/dt><dd>" \+ registeredText\(rec\.registered\) \+ "<\/dd>/,
"the detail list has a Registered row built by registeredText()",
);
const sourceLabelMatch = html.match(/var SOURCE_LABEL = (\{.*\});/);
assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
const sourceLabel = JSON.parse(sourceLabelMatch[1]);
assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
// sourceTag() and fromSource() are one-liners in page.html; match them by
// line rather than by a "function ... { ... \n }" block regex, which
// assumes a closing brace on its own line.
const lines = html.split("\n");
const sourceTagLine = lines.find((l) => l.includes("function sourceTag(source)"));
assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
const fromSourceLine = lines.find((l) => l.includes("function fromSource(source)"));
assert.ok(fromSourceLine, "fromSource() must exist in page.html");
assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
const registeredTextFn = html.match(/function registeredText\(reg\) \{[\s\S]*?\n \}/);
assert.ok(registeredTextFn, "registeredText() must exist in page.html");
for (const line of registeredTextFn[0].split("\n")) {
if (!/\breg\.[a-zA-Z]/.test(line)) continue;
assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
}
});
+138
View File
@@ -0,0 +1,138 @@
# seat
`mosaic launch <seat>` starts a seat through its existing launch script,
unchanged, and leaves one registration record that the control board reads
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
that record. Nothing else. No provider or auth registry, no roster schema
change, no stopping or killing of seats, one record per seat.
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
## Commands
The repository wrapper is `scripts/mosaic`. It is not on PATH and is not
the npm-global `mosaic` CLI from the estate tooling, which has no `launch`
or `seat` command; run it by path.
```
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
```
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
current directory) or a path to a seat directory such as
`~/.mosaic/fleet/agents/orch-01`. Either way the directory must hold an
executable `launch.sh`.
- Everything after `--` goes to the launch script untouched.
- `launch` writes the registration, then replaces itself with the launch
script (`process.execve`). The seat keeps the same pid, terminal and
process group as if you had run `launch.sh` yourself, the launch script's
exit code is yours, and the tmux pane's foreground command stays the
harness, which the control board's liveness check depends on. Node marks
`process.execve` experimental (present since 24); it is the only way to
keep the seat's pid, so the package accepts that and pins Node 24 or
newer. The seat tests exercise it directly (exit passthrough).
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
launch script that sees this variable is already registered and must not
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
The four repository seats (`agents/darkwing`, `agents/dewey`,
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
re-enters through `scripts/mosaic launch` unless already registered or
called with `--check`. So `agents/darkwing/launch.sh` and
`scripts/mosaic launch darkwing --task "..."` are the same path; the second
form is how you attach a task. A `--check` run never writes a record.
## The record
`<dataRoot>/seats/<layout>/<seat>/registration.json`, directory 0700, file 0600,
written atomically. `layout` is `repo`, `fleet` or `unknown` (see below);
it is part of the path because a seat name alone is not unique, and
`seat task` refuses a bare name that is registered in more than one layout
until `--layout` says which. `dataRoot` comes from `~/.config/mosaic-dev/config.json`
(or `$MOSAIC_CONFIG`); a missing or unreadable config refuses the launch.
The record is rewritten on every launch. It is written before the launch
script runs, so a launch the script itself refuses (a failed `--check`-style
precondition, a missing context file) still leaves a record with a pid that
is no longer running; the next launch replaces it. It is a launch record,
not a run record: it is not evidence, it holds one seat's latest launch only, and it
lives outside `<dataRoot>/board/` because the board never writes here and a
scan never changes it.
```json
{
"version": 1,
"seat": "darkwing",
"project": "mosaic-stack",
"task": "Control board: seat registration (#1504)",
"workspace": "/mnt/storage/src/mosaic-stack",
"tmux": { "socket": null, "session": "darkwing" },
"harness": "pi",
"startedAt": "2026-09-12T16:20:11.000Z",
"pid": 431734,
"sessionsDir": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions",
"seatDir": "/mnt/storage/src/mosaic-stack/agents/darkwing",
"launchScript": "/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh",
"layout": "repo",
"updatedAt": null
}
```
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
socket, from the `TMUX` variable of the pane the launch ran in; null outside
tmux), `harness` (only what `--harness` says; the repository launch scripts
pass `pi` or `claude-code`), `startedAt`, `pid`.
Fields added, and why:
- `sessionsDir`: how the board matches a record to a row. Seat names are not
unique across layouts (there is a `darkwing` in this repository and a
`darkwing` in the fleet), so the record names the sessions directory the
board already scans, and only an exact match counts.
- `seatDir`, `launchScript`, `layout`: what was launched and how the paths
were derived, so a wrong record can be traced without re-running anything.
`layout` is `repo` (`<repo>/agents/<seat>` with `<repo>/.git`), `fleet`
(`<seatDir>/.pi` exists) or `unknown` (nothing derived, `sessionsDir` null,
the record is still written but the board cannot match it).
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
from a relaunch.
- `version`: so a later shape change can be refused rather than misread.
`project` and `workspace` are derived only for the repo layout (the
repository's basename and root). For the fleet layout they are null unless
`--project` and `--workspace` are given, because the roster has no project
field and all fleet seats run in `~/.mosaic`; the board then keeps its own
derived values for those rows.
## How the board uses it
`packages/control-board` reads every `<dataRoot>/seats/<layout>/<seat>/registration.json`
on each scan. A registered task, project or workspace wins over the derived
value, and the row's `taskSource`, `activeProjectSource` or
`workspaceSource` says `registration`. An empty task or a null project or
workspace in the record leaves the derived value in place. Rows without a
registration are unchanged. A malformed record is reported in
`registrationErrors` on the index and skipped; it never takes the board down
and it is never treated as absent silently.
## Exit codes
`launch` exits with the launch script's own code once the script runs.
Before that: 1 the launch script could not be started (the record is
removed again), 2 invalid data or configuration (missing config, no such
seat, no executable `launch.sh`, bad record on disk), 4 usage.
`seat task`: 0 ok, 1 no registration for that seat, 2 as above, 4 usage.
## Tests
```
node --test packages/seat/tests/
```
The repository launch scripts are covered end to end by
`scripts/test-darkwing-launch.mjs` (darkwing, dewey, filbert) and
`scripts/test-rocko-launch.mjs`, which run each `launch.sh` in a fixture
root that is also its own data root, so no registration reaches a real one.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@mosaic/seat",
"version": "0.1.0",
"private": true,
"description": "Seat launch and registration: `mosaic launch <seat>` runs a seat's launch script unchanged and leaves one registration record the control board reads.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"bin": { "mosaic": "src/cli.mjs" },
"exports": { ".": "./src/seat.mjs" },
"scripts": { "test": "node --test tests/" }
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
// Usage:
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
//
// `launch` writes <dataRoot>/seats/<seat>/registration.json, then replaces
// itself with the seat's launch.sh, unchanged, with everything after `--` as
// its arguments and MOSAIC_LAUNCH_REGISTERED set to the record path. A launch
// script that sees that variable is already registered and must not call
// mosaic launch again. The exit code is the launch script's own.
//
// Exit codes: the launch script's code (launch); 0 ok; 1 operation failed;
// 2 invalid data or configuration; 4 usage.
import { rmSync } from "node:fs";
import {
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
makeRegistration, writeRegistration, updateTask,
} from "./seat.mjs";
const USAGE = [
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
].join("\n");
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
function parseLaunch(argv) {
const opts = { seat: null, task: "", project: undefined, workspace: undefined, harness: null, repo: process.cwd(), config: defaultConfigPath(), args: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => {
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
return argv[++i];
};
if (a === "--") { opts.args = argv.slice(i + 1); break; }
else if (a === "--task") opts.task = next();
else if (a === "--project") opts.project = next();
else if (a === "--workspace") opts.workspace = next();
else if (a === "--harness") opts.harness = next();
else if (a === "--repo") opts.repo = next();
else if (a === "--config") opts.config = next();
else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.seat === null) opts.seat = a;
else throw new SeatError(`unexpected argument: ${a} (put launch script arguments after --)`, 4);
}
if (opts.seat === null) throw new SeatError(USAGE, 4);
return opts;
}
function parseSeatTask(argv) {
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--layout") {
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
opts[a.slice(2)] = argv[++i];
} else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.seat === null) opts.seat = a;
else if (opts.task === null) opts.task = a;
else throw new SeatError(`unexpected argument: ${a}`, 4);
}
if (opts.seat === null || opts.task === null) throw new SeatError(USAGE, 4);
return opts;
}
function launch(argv) {
const opts = parseLaunch(argv);
const resolved = resolveSeat(opts.seat, { repo: opts.repo });
const seats = seatsDir(loadDataRoot(opts.config));
// Register first, then replace this process with the launch script (same
// pid, same terminal, same process group). The launch script's exit code
// and signals are then the shell's to see directly, and the tmux pane's
// foreground command stays the harness itself, which the control board's
// liveness check depends on.
const record = makeRegistration({
resolved, task: opts.task, project: opts.project, workspace: opts.workspace,
harness: opts.harness, tmux: tmuxContext(), pid: process.pid,
});
const path = writeRegistration(seats, record);
process.stderr.write(`mosaic launch: registered ${resolved.seat} (${resolved.layout} layout) at ${path}\n`);
try {
process.execve(resolved.launchScript, [resolved.launchScript, ...opts.args], { ...process.env, [REGISTERED_ENV]: path });
} catch (err) {
rmSync(path, { force: true });
throw new SeatError(`could not run ${resolved.launchScript}: ${err.message}`, 1);
}
return 0;
}
function seatTask(argv) {
const opts = parseSeatTask(argv);
const seats = seatsDir(loadDataRoot(opts.config));
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout });
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`);
return 0;
}
function main(argv) {
if (process.env[REGISTERED_ENV] && argv[0] === "launch") {
throw new SeatError(`${REGISTERED_ENV} is already set; refusing to register a seat from inside a registered launch`, 4);
}
if (argv[0] === "launch") return launch(argv.slice(1));
if (argv[0] === "seat" && argv[1] === "task") return seatTask(argv.slice(2));
throw new SeatError(USAGE, 4);
}
try {
process.exitCode = main(process.argv.slice(2));
} catch (err) {
if (!(err instanceof SeatError)) throw err;
process.stderr.write(`refused: ${err.message}\n`);
process.exitCode = err.exitCode;
}
+236
View File
@@ -0,0 +1,236 @@
// Seat registration: one small record per seat under <dataRoot>/seats/<layout>/<seat>/
// registration.json, written by `mosaic launch <seat>` and read by the control
// board so the board can show what a seat was told to do instead of guessing.
//
// A registration is rewritten on every launch. It is a launch record, not a
// board file: the board must not write here, and a scan never changes it.
// `mosaic seat task <seat> <text>` changes the task field only.
//
// Records are keyed by layout and seat name, <seats>/<layout>/<seat>/, because
// a name alone is not unique (this repository and the fleet both have a
// "darkwing"). Two layouts of seat directory are known:
// repo <repo>/agents/<seat>/launch.sh, where <repo>/.git exists.
// Sessions live in <repo>/.pi/state/<seat>/sessions.
// fleet <seatDir>/launch.sh with <seatDir>/.pi (the ~/.mosaic fleet layout).
// Sessions live in <seatDir>/.pi/agent/sessions.
// Anything else is layout "unknown" and records nulls; nothing is guessed.
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs";
import { join, basename, dirname, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
export const REGISTRATION_VERSION = 1;
export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export const TASK_LIMIT = 2000;
// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or
// configuration, 4 usage.
export class SeatError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.name = "SeatError";
this.exitCode = exitCode;
}
}
export function defaultConfigPath(env = process.env) {
return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json");
}
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
export function loadDataRoot(path = defaultConfigPath()) {
if (!existsSync(path)) throw new SeatError(`config not found: ${path}`);
let raw;
try {
raw = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new SeatError(`config is not valid JSON: ${path} (${err.message})`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`);
return raw.dataRoot;
}
export function seatsDir(dataRoot) {
return join(dataRoot, "seats");
}
export const LAYOUTS = Object.freeze(["repo", "fleet", "unknown"]);
// Keyed by layout and seat, because a seat name is not unique across
// layouts (this repository and the fleet both have a "darkwing").
export function registrationPath(seats, seat, layout) {
if (!SEAT_NAME.test(String(seat))) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout: ${JSON.stringify(layout)}`, 4);
return join(seats, layout, seat, "registration.json");
}
// Turn "<name>" or "<path to seat dir>" into everything a launch needs to
// know. A name resolves under <repo>/agents; a path is taken as the seat dir.
export function resolveSeat(arg, { repo = process.cwd() } = {}) {
if (typeof arg !== "string" || arg.length === 0) throw new SeatError("seat name or seat directory required", 4);
let seatDir;
if (arg.includes("/") || (existsSync(arg) && statSync(arg).isDirectory())) seatDir = resolve(arg);
else if (SEAT_NAME.test(arg)) seatDir = join(resolve(repo), "agents", arg);
else throw new SeatError(`invalid seat name: ${JSON.stringify(arg)}`, 4);
const seat = basename(seatDir);
if (!SEAT_NAME.test(seat)) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: ${seatDir}`);
const launchScript = join(seatDir, "launch.sh");
if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: ${seatDir}`);
if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: ${launchScript}`);
const parent = dirname(seatDir);
const root = basename(parent) === "agents" ? dirname(parent) : null;
if (root && existsSync(join(root, ".git"))) {
return {
seat, seatDir, launchScript, layout: "repo",
project: basename(root),
sessionsDir: join(root, ".pi", "state", seat, "sessions"),
defaultWorkspace: root,
};
}
if (existsSync(join(seatDir, ".pi"))) {
return { seat, seatDir, launchScript, layout: "fleet", project: null, sessionsDir: join(seatDir, ".pi", "agent", "sessions"), defaultWorkspace: null };
}
return { seat, seatDir, launchScript, layout: "unknown", project: null, sessionsDir: null, defaultWorkspace: null };
}
// The tmux session this process runs in, from the TMUX/TMUX_PANE variables
// tmux sets for its panes. null outside tmux or when tmux cannot answer.
// socket is null on the default server, else the socket file's name
// (the value tmux -L takes), matching the control board's spec shape.
export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
const tmux = env.TMUX;
if (typeof tmux !== "string" || tmux.length === 0) return null;
const socketPath = tmux.split(",")[0];
if (!socketPath) return null;
const args = ["-S", socketPath, "display-message", "-p"];
if (env.TMUX_PANE) args.push("-t", env.TMUX_PANE);
args.push("#{session_name}");
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error || r.status !== 0) return null;
const session = String(r.stdout ?? "").trim();
if (!session) return null;
const socket = basename(socketPath);
return { socket: socket === "default" ? null : socket, session };
}
const FIELDS = Object.freeze([
"version", "seat", "project", "task", "workspace", "tmux", "harness",
"startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt",
]);
const isNullableString = (v) => v === null || typeof v === "string";
const isTimestamp = (v) => typeof v === "string" && Number.isFinite(Date.parse(v));
// Shape check for a record read from disk or about to be written. Throws
// SeatError with the failing field; never echoes the offending value.
export function validateRegistration(record) {
if (!record || typeof record !== "object" || Array.isArray(record)) throw new SeatError("registration is not an object");
for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${key}`);
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) {
if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`);
}
if (!LAYOUTS.includes(record.layout)) throw new SeatError("registration.layout is invalid");
if (record.tmux !== null) {
const t = record.tmux;
if (!t || typeof t !== "object" || Array.isArray(t)) throw new SeatError("registration.tmux must be an object or null");
if (!isNullableString(t.socket) || typeof t.session !== "string") throw new SeatError("registration.tmux is invalid");
}
if (!isTimestamp(record.startedAt)) throw new SeatError("registration.startedAt must be a timestamp");
if (record.updatedAt !== null && !isTimestamp(record.updatedAt)) throw new SeatError("registration.updatedAt must be a timestamp or null");
if (record.pid !== null && !(Number.isInteger(record.pid) && record.pid > 0)) throw new SeatError("registration.pid must be a positive integer or null");
return record;
}
export function makeRegistration({ resolved, task = "", project, workspace, harness = null, tmux = null, pid = null, now = () => new Date() }) {
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
return validateRegistration({
version: REGISTRATION_VERSION,
seat: resolved.seat,
project: project ?? resolved.project ?? null,
task,
workspace: workspace ?? resolved.defaultWorkspace ?? null,
tmux,
harness,
startedAt: now().toISOString(),
pid,
sessionsDir: resolved.sessionsDir,
seatDir: resolved.seatDir,
launchScript: resolved.launchScript,
layout: resolved.layout,
updatedAt: null,
});
}
// Private: directory 0700, file 0600, atomic tmp+rename so a reader never
// sees a half-written record.
export function writeRegistration(seats, record) {
validateRegistration(record);
const path = registrationPath(seats, record.seat, record.layout);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
return path;
}
// null when the seat has no registration. A present but unreadable or
// malformed record throws SeatError rather than being treated as absent.
export function readRegistration(seats, seat, layout) {
const path = registrationPath(seats, seat, layout);
if (!existsSync(path)) return null;
let parsed;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`);
}
try {
const record = validateRegistration(parsed);
if (record.seat !== seat || record.layout !== layout) throw new SeatError("registration does not match its path");
return record;
} catch (err) {
throw new SeatError(`${err.message}: ${path}`);
}
}
// Every registration for a seat name, across layouts. Unreadable records
// throw; a name with no record gives [].
export function findRegistrations(seats, seat) {
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
}
// Change the task field only. Refuses when the seat was never launched
// through `mosaic launch`, because there is nothing to attach the task to.
// A name that exists in more than one layout must be qualified with layout.
export function updateTask(seats, seat, task, { layout = null, now = () => new Date() } = {}) {
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat);
if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1);
if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4);
const record = found[0];
const updated = { ...record, task, updatedAt: now().toISOString() };
writeRegistration(seats, updated);
return updated;
}
// True when two paths name the same directory: equal once resolved, or the
// same real path when both exist (symlinked checkouts).
export function samePath(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
if (resolve(a) === resolve(b)) return true;
try {
return realpathSync(a) === realpathSync(b);
} catch {
return false;
}
}
+562
View File
@@ -0,0 +1,562 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
readdirSync,
rmSync,
chmodSync,
symlinkSync,
statSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname, basename } from "node:path";
import { spawnSync } from "node:child_process";
import {
SeatError,
loadDataRoot,
seatsDir,
registrationPath,
resolveSeat,
tmuxContext,
validateRegistration,
makeRegistration,
writeRegistration,
readRegistration,
updateTask,
findRegistrations,
samePath,
TASK_LIMIT,
} from "../src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "seat-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
function writeFile(path, content) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
}
function writeExecutable(path, content) {
writeFile(path, content);
chmodSync(path, 0o755);
}
const FAKE_LAUNCH_SH = [
"#!/usr/bin/env bash",
'printf \'%s\\n\' "$@" > "$(dirname "$0")/args.txt"',
'printf \'%s\\n\' "${MOSAIC_LAUNCH_REGISTERED:-unset}" > "$(dirname "$0")/env.txt"',
'exit "${FAKE_EXIT:-0}"',
"",
].join("\n");
// Repo layout fixture: <root>/.git, <root>/agents/<seat>/launch.sh.
function buildRepoSeat(root, seat, { launchContent = FAKE_LAUNCH_SH, executable = true } = {}) {
mkdirSync(join(root, ".git"), { recursive: true });
const seatDir = join(root, "agents", seat);
const launchScript = join(seatDir, "launch.sh");
if (executable) writeExecutable(launchScript, launchContent);
else writeFile(launchScript, launchContent);
return seatDir;
}
// Fleet layout fixture: <root>/fleet/agents/<seat>/{launch.sh,.pi/}, no <root>/.git.
function buildFleetSeat(root, seat) {
const seatDir = join(root, "fleet", "agents", seat);
writeExecutable(join(seatDir, "launch.sh"), FAKE_LAUNCH_SH);
mkdirSync(join(seatDir, ".pi"), { recursive: true });
return seatDir;
}
function buildConfig(root, dataRoot = join(root, "data")) {
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ configVersion: 1, dataRoot }));
return { configPath, dataRoot };
}
function cliEnv(overrides = {}) {
const env = { ...process.env, TMUX: "", TMUX_PANE: "" };
delete env.MOSAIC_LAUNCH_REGISTERED;
return { ...env, ...overrides };
}
function runCli(args, { cwd, env = cliEnv() } = {}) {
return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: "utf8", env, timeout: 15000 });
}
function baseResolved(seatDir, seat = "myseat") {
return {
seat,
seatDir,
launchScript: join(seatDir, "launch.sh"),
layout: "repo",
project: "someproject",
sessionsDir: join(seatDir, "sessions"),
defaultWorkspace: dirname(dirname(seatDir)),
};
}
// ---------------------------------------------------------------------------
// 1-3. resolveSeat
// ---------------------------------------------------------------------------
test("resolveSeat: by name under --repo resolves the repo layout", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
assert.equal(resolved.seat, "myseat");
assert.equal(resolved.seatDir, seatDir);
assert.equal(resolved.layout, "repo");
assert.equal(resolved.project, basename(root));
assert.equal(resolved.sessionsDir, join(root, ".pi", "state", "myseat", "sessions"));
assert.equal(resolved.defaultWorkspace, root);
assert.equal(resolved.launchScript, join(seatDir, "launch.sh"));
});
test("resolveSeat: by path resolves the fleet layout", () => {
const root = makeRoot();
const seatDir = buildFleetSeat(root, "myseat");
const resolved = resolveSeat(seatDir);
assert.equal(resolved.seat, "myseat");
assert.equal(resolved.layout, "fleet");
assert.equal(resolved.project, null);
assert.equal(resolved.sessionsDir, join(seatDir, ".pi", "agent", "sessions"));
assert.equal(resolved.defaultWorkspace, null);
});
test("resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout", () => {
const root = makeRoot();
// Missing seat directory entirely.
assert.throws(() => resolveSeat(join(root, "agents", "ghost")), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 2);
return true;
});
// Seat directory exists but has no launch.sh.
const noLaunchDir = join(root, "agents", "nolaunch");
mkdirSync(noLaunchDir, { recursive: true });
assert.throws(() => resolveSeat(noLaunchDir), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 2);
return true;
});
// launch.sh present but not executable.
const notExecDir = join(root, "agents", "notexec");
writeFile(join(notExecDir, "launch.sh"), FAKE_LAUNCH_SH);
chmodSync(join(notExecDir, "launch.sh"), 0o644);
assert.throws(() => resolveSeat(notExecDir), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 2);
return true;
});
// Invalid seat name.
assert.throws(() => resolveSeat("Bad Name"), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
return true;
});
// Parent not named "agents", no .git, no .pi: layout "unknown".
const looseDir = join(root, "loose", "myseat");
writeExecutable(join(looseDir, "launch.sh"), FAKE_LAUNCH_SH);
const resolved = resolveSeat(looseDir);
assert.equal(resolved.layout, "unknown");
assert.equal(resolved.project, null);
assert.equal(resolved.sessionsDir, null);
assert.equal(resolved.defaultWorkspace, null);
});
// ---------------------------------------------------------------------------
// 4. tmuxContext
// ---------------------------------------------------------------------------
test("tmuxContext: outside tmux, default socket, custom socket, and exec failure", () => {
// No TMUX var at all.
assert.equal(tmuxContext({ env: {} }), null);
// Empty TMUX (matches TMUX: "" in CLI fixtures).
assert.equal(tmuxContext({ env: { TMUX: "" } }), null);
// Default socket, with TMUX_PANE set: session name comes from exec stdout.
let captured = null;
const okExec = (cmd, args) => {
captured = { cmd, args };
return { status: 0, stdout: "sess\n" };
};
const result = tmuxContext({
env: { TMUX: "/tmp/tmux-1000/default,123,0", TMUX_PANE: "%3" },
exec: okExec,
});
assert.deepEqual(result, { socket: null, session: "sess" });
assert.equal(captured.cmd, "tmux");
assert.deepEqual(captured.args.slice(0, 4), ["-S", "/tmp/tmux-1000/default", "display-message", "-p"]);
assert.ok(captured.args.includes("-t"));
assert.equal(captured.args[captured.args.indexOf("-t") + 1], "%3");
// Non-default socket name is surfaced.
const namedSocket = tmuxContext({
env: { TMUX: "/tmp/tmux-1000/mosaic-fleet,1,0" },
exec: () => ({ status: 0, stdout: "other\n" }),
});
assert.deepEqual(namedSocket, { socket: "mosaic-fleet", session: "other" });
// Non-zero exit and exec error both yield null.
assert.equal(tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ status: 1, stdout: "" }) }), null);
assert.equal(
tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ error: new Error("no tmux"), status: null }) }),
null
);
});
// ---------------------------------------------------------------------------
// 5. makeRegistration + validateRegistration
// ---------------------------------------------------------------------------
test("makeRegistration produces a record that validates; each shape violation throws SeatError", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const record = makeRegistration({ resolved, task: "do things", pid: 1234 });
assert.deepEqual(validateRegistration(record), record);
const mutate = (patch) => ({ ...record, ...patch });
assert.throws(() => validateRegistration(mutate({ bogusField: "x" })), SeatError);
assert.throws(() => validateRegistration(mutate({ version: 2 })), SeatError);
assert.throws(() => validateRegistration(mutate({ task: "x".repeat(TASK_LIMIT + 1) })), SeatError);
assert.throws(() => validateRegistration(mutate({ pid: 1.5 })), SeatError);
assert.throws(() => validateRegistration(mutate({ pid: "1234" })), SeatError);
assert.throws(() => validateRegistration(mutate({ tmux: { socket: 5, session: "s" } })), SeatError);
assert.throws(() => validateRegistration(mutate({ tmux: { socket: null } })), SeatError);
});
// ---------------------------------------------------------------------------
// 6. writeRegistration / readRegistration
// ---------------------------------------------------------------------------
test("writeRegistration/readRegistration: round trip, permissions, absence, and malformed records", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const seats = seatsDir(join(root, "data"));
const record = makeRegistration({ resolved, task: "hello", pid: 42 });
const path = writeRegistration(seats, record);
assert.deepEqual(readRegistration(seats, "myseat", "repo"), record);
assert.equal(statSync(path).mode & 0o777, 0o600);
assert.equal(statSync(dirname(path)).mode & 0o777, 0o700);
// No leftover *.tmp-* artifacts after the atomic write.
for (const name of readdirSync(dirname(path))) assert.ok(!name.includes(".tmp-"), `leftover tmp file: ${name}`);
// Missing registration reads as null.
assert.equal(readRegistration(seats, "ghost", "repo"), null);
// Malformed JSON throws.
const malformedSeat = "malformed";
writeFile(registrationPath(seats, malformedSeat, "repo"), "{ not json");
assert.throws(() => readRegistration(seats, malformedSeat, "repo"), SeatError);
// A record with an unknown field throws rather than reading as absent.
const unknownFieldSeat = "unknownfield";
const badRecord = { ...record, seat: unknownFieldSeat, extra: "nope" };
writeFile(registrationPath(seats, unknownFieldSeat, "repo"), JSON.stringify(badRecord));
assert.throws(() => readRegistration(seats, unknownFieldSeat, "repo"), (err) => {
assert.ok(err instanceof SeatError);
assert.match(err.message, /unknown field/);
return true;
});
});
// ---------------------------------------------------------------------------
// 7. updateTask
// ---------------------------------------------------------------------------
test("updateTask: changes task and updatedAt only, and refuses appropriately", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const seats = seatsDir(join(root, "data"));
const record = makeRegistration({ resolved, task: "original", pid: 1 });
writeRegistration(seats, record);
const updated = updateTask(seats, "myseat", "revised");
assert.equal(updated.task, "revised");
assert.notEqual(updated.updatedAt, null);
for (const key of Object.keys(record)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`);
}
// Refuses when there is no registration to attach to.
assert.throws(() => updateTask(seats, "ghost", "x"), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 1);
return true;
});
// Refuses an over-limit task.
assert.throws(() => updateTask(seats, "myseat", "x".repeat(TASK_LIMIT + 1)), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
return true;
});
// The same name in a second layout makes the bare name ambiguous; the
// layout qualifier resolves it and each layout keeps its own record.
const fleetRecord = { ...record, layout: "fleet", sessionsDir: join(root, "fleet", "myseat", ".pi", "agent", "sessions"), task: "fleet task" };
writeRegistration(seats, fleetRecord);
assert.throws(() => updateTask(seats, "myseat", "x"), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
assert.match(err.message, /more than one layout/);
return true;
});
assert.equal(updateTask(seats, "myseat", "fleet revised", { layout: "fleet" }).task, "fleet revised");
assert.equal(readRegistration(seats, "myseat", "repo").task, "revised");
assert.equal(readRegistration(seats, "myseat", "fleet").task, "fleet revised");
assert.equal(findRegistrations(seats, "myseat").length, 2);
});
// ---------------------------------------------------------------------------
// 8. CLI launch end to end
// ---------------------------------------------------------------------------
test("CLI launch: registers, execs the fake launch script, and passes args through", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r = runCli(["launch", "myseat", "--task", "do x", "--config", configPath, "--", "--foo", "bar"], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const seatDir = join(root, "agents", "myseat");
assert.equal(readFileSync(join(seatDir, "args.txt"), "utf8"), "--foo\nbar\n");
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
assert.equal(readFileSync(join(seatDir, "env.txt"), "utf8").trim(), path);
const record = JSON.parse(readFileSync(path, "utf8"));
assert.equal(record.seat, "myseat");
assert.equal(record.project, basename(root));
assert.equal(record.task, "do x");
assert.equal(record.workspace, root);
assert.equal(record.harness, null);
assert.equal(record.tmux, null);
assert.ok(Number.isInteger(record.pid) && record.pid > 0);
assert.equal(record.layout, "repo");
assert.ok(Number.isFinite(Date.parse(record.startedAt)));
});
test("CLI launch: --harness lands in the record", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r = runCli(["launch", "myseat", "--task", "t", "--harness", "pi", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const record = JSON.parse(readFileSync(path, "utf8"));
assert.equal(record.harness, "pi");
});
test("CLI launch: the launch script's own exit code passes through", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath } = buildConfig(root);
const r = runCli(["launch", "myseat", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath, FAKE_EXIT: "7" }),
});
assert.equal(r.status, 7, r.stderr);
});
// ---------------------------------------------------------------------------
// 9. CLI relaunch rewrites the single registration file
// ---------------------------------------------------------------------------
test("CLI launch: relaunching a seat rewrites the one registration record", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r1 = runCli(["launch", "myseat", "--task", "first", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r1.status, 0, r1.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const record1 = JSON.parse(readFileSync(path, "utf8"));
const r2 = runCli(["launch", "myseat", "--task", "second", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r2.status, 0, r2.stderr);
const record2 = JSON.parse(readFileSync(path, "utf8"));
assert.deepEqual(readdirSync(dirname(path)), ["registration.json"]);
assert.equal(record2.task, "second");
assert.ok(Date.parse(record2.startedAt) >= Date.parse(record1.startedAt));
});
// ---------------------------------------------------------------------------
// 10. CLI launch with no --task
// ---------------------------------------------------------------------------
test("CLI launch: omitting --task records an empty string, not null", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r = runCli(["launch", "myseat", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const record = JSON.parse(readFileSync(path, "utf8"));
assert.equal(record.task, "");
});
// ---------------------------------------------------------------------------
// 11. CLI seat task
// ---------------------------------------------------------------------------
test("CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(launchResult.status, 0, launchResult.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const before = JSON.parse(readFileSync(path, "utf8"));
const r = runCli(["seat", "task", "myseat", "new text", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const after1 = JSON.parse(readFileSync(path, "utf8"));
assert.equal(after1.task, "new text");
for (const key of Object.keys(before)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(after1[key], before[key], `field ${key} changed unexpectedly`);
}
// No registration for a seat that was never launched.
const root2 = makeRoot();
buildRepoSeat(root2, "unlaunched");
const { configPath: configPath2 } = buildConfig(root2);
const r2 = runCli(["seat", "task", "unlaunched", "text", "--config", configPath2], {
cwd: root2,
env: cliEnv({ MOSAIC_CONFIG: configPath2 }),
});
assert.equal(r2.status, 1);
assert.match(r2.stderr, /no registration/);
});
// ---------------------------------------------------------------------------
// 12. CLI refusals
// ---------------------------------------------------------------------------
test("CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure", () => {
// No args: usage, exit 4.
const rNoArgs = runCli([]);
assert.equal(rNoArgs.status, 4);
assert.match(rNoArgs.stderr, /usage: mosaic launch/);
// Unknown flag: exit 4.
const rUnknown = runCli(["launch", "myseat", "--bogus"]);
assert.equal(rUnknown.status, 4);
assert.match(rUnknown.stderr, /unknown argument/);
// Missing config with an otherwise-valid seat: exit 2.
const root = makeRoot();
buildRepoSeat(root, "myseat");
const missingConfig = join(root, "missing.json");
assert.ok(!existsSync(missingConfig));
const rMissingConfig = runCli(["launch", "myseat", "--config", missingConfig], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: missingConfig }),
});
assert.equal(rMissingConfig.status, 2);
assert.match(rMissingConfig.stderr, /config not found/);
// MOSAIC_LAUNCH_REGISTERED already set: exit 4, no registration written.
const root2 = makeRoot();
buildRepoSeat(root2, "myseat");
const { configPath: configPath2, dataRoot: dataRoot2 } = buildConfig(root2);
const rRegistered = runCli(["launch", "myseat", "--config", configPath2], {
cwd: root2,
env: cliEnv({ MOSAIC_CONFIG: configPath2, MOSAIC_LAUNCH_REGISTERED: "/some/path" }),
});
assert.equal(rRegistered.status, 4);
assert.match(rRegistered.stderr, /MOSAIC_LAUNCH_REGISTERED/);
assert.ok(!existsSync(registrationPath(seatsDir(dataRoot2), "myseat", "repo")));
// Launch script that fails to exec: exit 1, no registration left behind.
const root3 = makeRoot();
buildRepoSeat(root3, "myseat", { launchContent: "#!/nonexistent/interp\necho hi\n" });
const { configPath: configPath3, dataRoot: dataRoot3 } = buildConfig(root3);
const rExecFail = runCli(["launch", "myseat", "--config", configPath3], {
cwd: root3,
env: cliEnv({ MOSAIC_CONFIG: configPath3 }),
});
assert.equal(rExecFail.status, 1);
assert.match(rExecFail.stderr, /could not run/);
assert.ok(!existsSync(registrationPath(seatsDir(dataRoot3), "myseat", "repo")));
});
// ---------------------------------------------------------------------------
// 13. samePath
// ---------------------------------------------------------------------------
test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", () => {
const root = makeRoot();
const target = join(root, "target");
mkdirSync(target);
const other = join(root, "other");
mkdirSync(other);
const link = join(root, "link");
symlinkSync(target, link, "dir");
assert.equal(samePath(target, target), true);
assert.equal(samePath(target, link), true);
assert.equal(samePath(target, other), false);
assert.equal(samePath(1, "x"), false);
assert.equal(samePath(null, target), false);
});
+2 -1
View File
@@ -67,7 +67,8 @@ USER_FILE="${USER_FILE:-$MOSAIC_DATA_ROOT/user/USER.md}"
PI="$REPO/node_modules/.bin/pi"
[ -x "$PI" ] || fail 'run npm ci --ignore-scripts --no-audit --no-fund in the repository first'
PIN="$(node -p "require('./package.json').dependencies['@earendil-works/pi-coding-agent']")"
[ "$("$PI" --version)" = "$PIN" ] || fail "local Pi must match package.json ($PIN); run npm ci"
PI_VERSION="$("$PI" --version)"
[ "$PI_VERSION" = "$PIN" ] || fail "local Pi is $PI_VERSION, package.json pins $PIN; run npm ci in $REPO"
command -v flock >/dev/null || fail 'flock is required'
CONTEXT_FILES=("$CONSTITUTION" "$REPO/contracts/STANDARDS.md" "$SOUL" "$USER_FILE" "$REPO/AGENTS.md" "$REPO/agents/$AGENT/CONTEXT.md")
for file in "${CONTEXT_FILES[@]}"; do
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: seat launch
# with registration for the control board. See packages/seat/README.md.
# Not the npm-global `mosaic` CLI from the estate tooling; this one is
# repository-local and only reachable as scripts/mosaic.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
+34 -4
View File
@@ -1,9 +1,9 @@
// Offline launcher checks. Fake Pi captures argv; script(1) supplies a real PTY.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, cpSync, existsSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, cpSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { join, resolve, basename } from 'node:path';
import { spawnSync } from 'node:child_process';
const source = resolve(import.meta.dirname, '..');
@@ -23,6 +23,14 @@ for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch cont
assert.equal(existsSync(join(root, `agents/${agent}/scratch`)), false);
cpSync(join(source, 'scripts/agent-host-dev.sh'), join(root, 'scripts/agent-host-dev.sh'));
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh'));
// Seat registration (packages/seat): the launch script re-enters itself
// through scripts/mosaic, which needs a config naming a data root. The
// fixture is its own data root so no registration reaches a real one.
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
mkdirSync(join(root, '.git'));
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
const registration = join(root, `seats/repo/${agent}/registration.json`);
cpSync(join(source, 'skills'), join(root, 'skills'), { recursive: true });
writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { '@earendil-works/pi-coding-agent': '0.85.1' } }));
for (const file of ['contracts/CONSTITUTION.md', 'contracts/STANDARDS.md', 'user/USER.md', 'AGENTS.md']) {
@@ -34,14 +42,15 @@ for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch cont
writeFileSync(join(root, 'node_modules/.bin/pi'), `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === '--version') { console.log('0.85.1'); process.exit(0); }
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION}));
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION, registered: process.env.MOSAIC_LAUNCH_REGISTERED}));
`, { mode: 0o755 });
const launch = (args = '', tty = true) => spawnSync(tty ? 'script' : 'bash', tty
? ['-q', '-e', '-c', `bash agents/${agent}/launch.sh ${args}`, '/dev/null']
: [`agents/${agent}/launch.sh`, ...args.split(' ').filter(Boolean)],
{ cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit' }, encoding: 'utf8', timeout: 10000 });
{ cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit', MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }, encoding: 'utf8', timeout: 10000 });
const ok = (result) => assert.equal(result.status, 0, result.stdout + result.stderr);
ok(launch('--check', false));
assert.equal(existsSync(join(root, 'seats')), false);
const direct = spawnSync('bash', [join(root, 'scripts/agent.sh'), '--host-dev', agent, '--check'],
{ cwd: tmpdir(), encoding: 'utf8', timeout: 10000 });
ok(direct);
@@ -61,6 +70,18 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag
let captured = JSON.parse(readFileSync(join(root, 'capture.json')));
assert.equal(captured.incarnation, undefined);
assert.equal(captured.agent, agent);
// The seat registered itself before Pi started, and Pi saw the record path.
assert.equal(captured.registered, registration);
const record = JSON.parse(readFileSync(registration, 'utf8'));
assert.equal(record.seat, agent);
assert.equal(record.harness, 'pi');
assert.equal(record.layout, 'repo');
assert.equal(record.project, basename(root));
assert.equal(record.workspace, root);
assert.equal(record.task, '');
assert.equal(record.sessionsDir, join(root, `.pi/state/${agent}/sessions`));
assert.ok(Number.isInteger(record.pid) && record.pid > 0);
const firstStart = record.startedAt;
assert.equal(captured.args[captured.args.indexOf('--provider') + 1], agent === 'filbert' ? 'openai-codex' : 'test');
assert.equal(captured.args[captured.args.indexOf('--model') + 1], agent === 'filbert' ? 'gpt-6-astra' : 'test-model');
if (agent === 'filbert') assert.equal(captured.args[captured.args.indexOf('--thinking') + 1], 'low');
@@ -88,6 +109,15 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag
assert.notEqual(captured.args[captured.args.indexOf('--append-system-prompt') + 1], firstPrompt);
ok(launch('--fresh'));
assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).args.includes('--continue'), false);
// Relaunch rewrites the one record; nothing accumulates.
const relaunched = JSON.parse(readFileSync(registration, 'utf8'));
assert.ok(Date.parse(relaunched.startedAt) >= Date.parse(firstStart));
assert.deepEqual(readdirSync(join(root, `seats/repo/${agent}`)), ['registration.json']);
// Launching through scripts/mosaic directly is the same path, with a task.
ok(spawnSync('script', ['-q', '-e', '-c', `scripts/mosaic launch --repo "${root}" --task "fixture task" ${agent} -- --fresh`, '/dev/null'],
{ cwd: root, env: { ...process.env, MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }, encoding: 'utf8', timeout: 10000 }));
assert.equal(JSON.parse(readFileSync(registration, 'utf8')).task, 'fixture task');
assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).registered, registration);
assert.equal(readFileSync(session, 'utf8'), history);
writeFileSync(session, 'broken JSON\n');
assert.notEqual(launch().status, 0);
+17 -2
View File
@@ -16,13 +16,19 @@ test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusal
writeFileSync(join(root, file), `fixture ${file}\n`);
}
writeFileSync(join(root, 'scripts/common.sh'), 'load_config() { MOSAIC_DATA_ROOT="$PWD"; }\n');
// Seat registration (packages/seat): the fixture is its own data root.
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
mkdirSync(join(root, '.git'));
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
const registration = join(root, 'seats/repo/rocko/registration.json');
writeFileSync(join(root, 'bin/claude'), `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === '--version') { console.log('fixture Claude'); process.exit(0); }
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION}));
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION, registered: process.env.MOSAIC_LAUNCH_REGISTERED}));
if (process.env.FAKE_CLAUDE_FAIL) process.exit(7);
`, { mode: 0o755 });
const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, MOSAIC_LAUNCH_INCARNATION: 'do-not-inherit' };
const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, MOSAIC_LAUNCH_INCARNATION: 'do-not-inherit', MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' };
const run = (args = '', tty = true, extraEnv = {}) => spawnSync(tty ? 'script' : 'bash', tty
? ['-q', '-e', '-c', `bash agents/rocko/launch.sh ${args}`, '/dev/null']
: [join(root, 'agents/rocko/launch.sh'), ...args.split(' ').filter(Boolean)],
@@ -33,10 +39,19 @@ if (process.env.FAKE_CLAUDE_FAIL) process.exit(7);
ok(run('--check', false));
assert.equal(existsSync(state), false);
assert.equal(existsSync(join(root, 'capture.json')), false);
assert.equal(existsSync(join(root, 'seats')), false);
ok(run());
let captured = capture();
assert.equal(captured.cwd, root);
assert.equal(captured.agent, 'rocko');
// Registered before Claude started; the record names the Claude harness.
assert.equal(captured.registered, registration);
const record = JSON.parse(readFileSync(registration, 'utf8'));
assert.equal(record.seat, 'rocko');
assert.equal(record.harness, 'claude-code');
assert.equal(record.layout, 'repo');
assert.equal(record.workspace, root);
assert.ok(Number.isInteger(record.pid) && record.pid > 0);
assert.equal(captured.incarnation, undefined);
assert.equal(captured.args[captured.args.indexOf('--model') + 1], 'sonnet');
assert.equal(captured.args[captured.args.indexOf('--name') + 1], 'Rocko');