Files
stack/packages/seat
jason.woltjeandClaude Opus 5.5 fe5c68fa03 feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.

Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.

Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 12:22:50 -05:00
..

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 and notes who set it. 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> [--by NAME] [--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.
  • seat task records who set the task in taskSetBy (#1511): --by NAME if given, else $MOSAIC_AGENT_NAME if set and non-empty, else the word unknown. An explicit value must be one token of 1 to 64 characters (letters, digits, . _ @ : -, starting with a letter or digit); anything else refuses with exit 4 before the record is touched. A set but malformed MOSAIC_AGENT_NAME also refuses rather than being reported as unknown; --by is the explicit way past it. The value is never echoed back in a refusal. This is what the caller said, not a verified identity. Nothing checks it, nothing grants on it; the board displays it next to the task and does no more with it. launch --task does not set the field.

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, and the board treats a record whose pid is gone as stale (shown, but the derived values win). 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.

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

After scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason the same record also carries "taskSetBy": "jason" and a non-null updatedAt; startedAt and every other field are unchanged.

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.
  • taskSetBy: set only by seat task (see above). Optional in the shape: a record written before the field existed loads unchanged, the board shows unknown for it, and nothing rewrites it until the next seat task or launch. The record version stays 1; a value that is not one bounded token makes the record unreadable (refused, not misread).
  • 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. A record whose pid is no longer running is stale: still shown on the Registered line, but it does not override anything. The row's taskSetBy is the record's setter only while the task shown is the registered one (taskSource registration); otherwise it is null, so a stale or empty registration never attributes a transcript-derived task, and a Discord connector's fixed task never borrows a native record's setter.

The repository launch scripts are the only launchers that register. Fleet seats under ~/.mosaic stay on their own launchers (Jason's ruling, 2026-09-12); a fleet seat can still be registered by hand with scripts/mosaic launch <seat-dir>.

Managed role sessions (slice 1 S6, #1523)

Separate from seats: src/session.mjs holds what the bus host's launcher (packages/cli/src/launcher.mjs) uses to start and stop a managed role session, and src/proc.mjs the /proc reads (start time, command line, children) that the host, the launcher and mosaic stop share. Nothing here decides authority or capacity; packages/cli/README.md ("Sessions") has the launch flow and the on-disk layout.

  • newRun picks a run id (r and 12 hex digits) and makes its 0700 directory, refusing a data root too deep for the run's socket path.
  • sessionEnv builds the session's environment from an allowlist (ENV_ALLOW), the repository's node_modules/.bin on PATH, and MOSAIC_RUN_ID. Founder credential variables never pass.
  • spawnSession starts packages/harness/src/runner.mjs under unshare --user --map-current-user --pid --fork --kill-child --mount-proc, detached, with stdin open for the capability line and output to runner.log (0600). runnerOf finds the runner inside it.
  • Registry is the host's list of running sessions, mirrored to bus-host/sessions.json (0600); readSessions reads it and marks each entry live or stale by the runner's start time, and refuses a file it can't read.
  • stopSession is mosaic stop: SIGTERM to a live runner, resent every second until it exits.

Limits of the PID namespace, which are limits and not a sandbox:

  • The runner is pid 1 of its namespace. A detached child is reparented to it, not to the user's init, so it stays under the launch's pid. Pid 1 ignores a signal it has no handler for; the runner installs its handlers first, and the senders resend SIGTERM to cover Node's startup.
  • The namespace isolates pids only. With its own /proc, a session sees no process outside it, so it can't signal or ptrace another session or the host by pid (checked on this host, Yama ptrace_scope 1). It still runs as the host's user, with that user's files, /tmp, network and sockets.
  • A session can still ask a process outside its namespace (a systemd user manager, an existing tmux server) to run a command for it.
  • The runner doesn't reap zombies other than its own turns; they go when the namespace does.

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.