Files
stack/packages/seat/README.md
T
jason.woltjeandClaude Opus 5.5 d240645df0 cli, seat, harness: a project clone in each role workspace (#1585)
Row 91, round 3. Darkwing built it, Dewey approved it (comment 28150) on
manifest ca570607 (8 paths, base 2b2a9e14). At each launch the host
makes or updates a git clone of the project in the role's workspace,
and the session prompt names each clone. Round 3 closes Filbert's round
2 escape: a clone with a submodule is kept as it is, and no host git
recurses into one (decision 130 thread). Live at the next bus restart;
the prompt lines appear only when the launcher passes checkouts.
Sage gate on fc6d5822 plus the patch, 14:18:25Z to 14:22:27Z: bus 115,
business 60, cli 131, control-board 124, conversation 182, discord 178,
harness 67, ledger 78, mosaic 69, queue 150, runs 43, seat 89, tasks 89,
webui 104, all fail 0. Every scripts/test-*.sh exited 0; test-release
ran 14 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 09:23:10 -05:00

310 lines
16 KiB
Markdown

# 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.
```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
}
```
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.
`MOSAIC_CLAUDE_BIN`, the host's pinned Claude Code binary, does.
- `spawnSession` starts `packages/harness/src/runner.mjs` in its sandbox
(`src/sandbox.mjs`, below), detached, with stdin open for the capability
line and output to `runner.log` (0600). `runnerOf` finds the runner, the
outer bwrap process's only child.
- `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.
### The sandbox (#1586)
`src/sandbox.mjs` builds the bubblewrap command line a session runs under.
The mount namespace starts from an empty root and gets only these binds,
each at its own host path; `binds()` returns them with the reason for each,
and the file's header comment has the table.
- Read-only: `/usr`, `/etc` and the host's `/bin`, `/lib`, `/lib64`,
`/sbin` links into `/usr`; the ten repository files the runner loads
(`RUNNER_FILES`); the adapter; for pi the repository's `node_modules`,
for Claude Code the `MOSAIC_CLAUDE_BIN` binary when it lives outside
`/usr`; node when it lives outside `/usr`; the broker socket, and the
launch socket for the role whose launch tool calls it.
- Read-write: the instance's workspace and the run directory. The run's
`bundle/` and `session.json` are bound read-only again on top.
- Private: `/tmp` is the run's `tmp/`, `HOME` the run's empty `home/`, and
`/dev` and `/proc` are the namespace's own.
The root is remounted read-only, so a write anywhere else fails. Nothing
else of the host's filesystem exists inside: no data root, no other
workspace or run, no `~/.config`, `~/.mosaic`, `~/.ssh` or `~/.claude`, no
checkout beyond the runner's files. Claude Code logs in with a
credential variable the environment passes (`SESSION_CREDENTIALS`;
`packages/cli/README.md`, "Sessions"), never with a file from the host's
`~/.claude`. Pi likewise logs in only through `ENV_ALLOW`'s variables,
never with the host's `~/.pi/agent`.
There is no unconfined fallback. `sandboxProblem` runs bwrap once with the
system binds; when bwrap is missing or fails, the launcher refuses the
launch with `sandbox-unavailable` and the reason.
The namespaces and how a session dies:
- bwrap unshares the user, PID and IPC namespaces. The runner is pid 1 of
its PID namespace (`--as-pid-1`). A detached child is reparented to it,
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 runner dies when the outer bwrap process does: bwrap starts it
through `setpriv --pdeathsig KILL`. bwrap's own `--die-with-parent`
isn't used, because it also ties bwrap to the host, and a session has to
outlive a host that died hard so the next host can recover it. The
parent-death signal is set inside the namespace just before the runner's
exec; if bwrap died in that instant, the runner would keep going until
its broker polls fail.
- With its own `/proc`, a session sees no process outside its namespace,
so it can't signal or `ptrace` another session or the host by pid.
- The outer bwrap exits with 128 plus the signal number when the runner is
killed; `sessionExit` turns that back into the signal for the launch
log.
- The runner doesn't reap zombies other than its own turns; they go when
the namespace does.
The sandbox doesn't confine the network, abstract Unix sockets, or the
user: a session runs as the host's UID. `packages/harness/README.md`
("Limits") says what that leaves open.
### The project clones (#1585)
An instance's workspace holds one git clone per project in the business
file, at `<workspace>/<project id>/`. The launcher calls `ensureCheckouts`
(`src/checkout.mjs`) at each launch, before it builds the bundle, so a
session starts in the project and not beside it. The host runs git here,
outside the sandbox:
| The clone | What the host does | Outcome |
|---|---|---|
| doesn't exist | `git clone --no-hardlinks --template=` of the root, at the branch the root has checked out, into `.<project>.clone-<hex>` beside it, then renamed into place | `cloned` |
| is clean, on the root's branch | fetches its origin and fast-forwards | `updated`, or `current` when nothing moved |
| anything else | nothing | `kept`, with a reason |
The host never resets, cleans, deletes or force-updates a clone, and never
writes the root. A clone's origin is the root's path; no credential or
forge remote goes into it, and the root's own remotes and config don't
reach it.
A kept clone's reason is one of:
| Reason | The clone |
|---|---|
| `not-a-clone` | isn't a directory with a `.git` directory; or under `.git` there is a symlink, a special file, a file with a second hard link, a `commondir` or an `objects/info/alternates` |
| `hooks-present` | has a file in `.git/hooks` |
| `submodule` | has a `.git/modules`, or a gitlink (a submodule entry) in its index |
| `config-changed` | has a `.git/config` that isn't a regular file of at most 64 KiB, or that holds a key a clone doesn't write: anything beyond `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, the origin's one URL (the root) and fetch line, and `branch.<name>.remote` (`origin`) and `.merge` |
| `root-unreadable`, `root-detached` | is fine, but the root can't be read or has no branch checked out |
| `other-branch` | is on another branch, or on none |
| `dirty` | has a change, staged or not, or an untracked file |
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail |
| `not-fast-forward` | has commits the root's branch doesn't |
The session can write its clone, so the host treats every clone as
hostile. The checks above run before any git command in an existing clone,
and git doesn't run in a clone that fails one. Every git call also runs
with only the host's `PATH`, `HOME` at a directory that doesn't exist, no
system or global config, hooks at `/dev/null`, `core.fsmonitor` off and
only the `file` transport. A submodule has its own config, which none of
those checks reads and which can name a filter that `git status` would run,
so the host never recurses into one: a clone with a submodule is kept, and
`status` runs with `--ignore-submodules=all` and every call with
`submodule.recurse=false`. The root is read with `GIT_CEILING_DIRECTORIES`
at its parent, so a root that isn't a repository is never taken for the
one above it. Each git call is killed after 100 s.
The checks hold only while nothing else writes the clone. Each workspace
is one instance's, and the launcher refuses `instance-running` before it
gets here, so no session of that instance runs while the host checks and
updates its clone.
A missing clone that can't be made (the root can't be read, has no branch,
or the clone fails) rejects with a `CheckoutError` naming the project, and
the temp directory is removed. The launcher refuses the launch with
`checkout-failed` (`packages/cli/README.md`, "Sessions").
## 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.