The broker gains a read-only state verb (launch state with the event that set it, open tasks capped). The runner fetches it before each turn and puts a "Current state" block at the top of the request; a failed read fails the turn. turnRequest() adds the message time. roles/pm.md gets one rule: the block is the truth for launch state and task status. Seat session, seat sandbox and cli launcher tests re-anchored to the new request shape. Built by Researcher, packet875c515e9. Filbert approved round 3, comment 28333, candidate manifest 0a62225e (11 paths). Sage gate on9774d70dbplus row109.patch: every package node suite and every scripts/test-*.sh green on the first run, skipped 0 in bus, harness and seat. Vikunja #259. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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/(--repodefaults 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 executablelaunch.sh.- Everything after
--goes to the launch script untouched. launchwrites 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 runlaunch.shyourself, 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 marksprocess.execveexperimental (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 callmosaic launchagain;mosaic launchrefuses to run when it is set. seat taskrecords who set the task intaskSetBy(#1511):--by NAMEif given, else$MOSAIC_AGENT_NAMEif set and non-empty, else the wordunknown. 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 malformedMOSAIC_AGENT_NAMEalso refuses rather than being reported asunknown;--byis 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 --taskdoes 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 adarkwingin this repository and adarkwingin 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.layoutisrepo(<repo>/agents/<seat>with<repo>/.git),fleet(<seatDir>/.piexists) orunknown(nothing derived,sessionsDirnull, the record is still written but the board cannot match it).updatedAt: set only byseat task, so a task change is distinguishable from a relaunch.taskSetBy: set only byseat task(see above). Optional in the shape: a record written before the field existed loads unchanged, the board showsunknownfor it, and nothing rewrites it until the nextseat taskor 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.
newRunpicks a run id (rand 12 hex digits) and makes its 0700 directory, refusing a data root too deep for the run's socket path.sessionEnvbuilds the session's environment from an allowlist (ENV_ALLOW), the repository'snode_modules/.binonPATH, andMOSAIC_RUN_ID. Founder credential variables never pass.MOSAIC_CLAUDE_BIN, the host's pinned Claude Code binary, does.spawnSessionstartspackages/harness/src/runner.mjsin its sandbox (src/sandbox.mjs, below), detached, with stdin open for the capability line and output torunner.log(0600).runnerOffinds the runner, the outer bwrap process's only child.Registryis the host's list of running sessions, mirrored tobus-host/sessions.json(0600);readSessionsreads it and marks each entry live or stale by the runner's start time, and refuses a file it can't read.stopSessionismosaic 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,/etcand the host's/bin,/lib,/lib64,/sbinlinks into/usr; the twelve repository files the runner loads (RUNNER_FILES); the adapter; for pi the repository'snode_modules, for Claude Code theMOSAIC_CLAUDE_BINbinary when it lives outside/usr; node when it lives outside/usr; the broker socket, the launch socket for the role whose launch tool calls it, and the host's git socket (bus-host/git.sock) for a role that holds git.push.working or review.verdict. - Read-write: the instance's workspace and the run directory. The run's
bundle/andsession.jsonare bound read-only again on top. - Private:
/tmpis the run'stmp/,HOMEthe run's emptyhome/, and/devand/procare the namespace's own.
The environment is sessionEnv's allowlist plus HOME, TMPDIR and the
session's git identity (#1598). gitIdentity() sets GIT_AUTHOR_NAME and
GIT_COMMITTER_NAME to the role instance's id, and GIT_AUTHOR_EMAIL and
GIT_COMMITTER_EMAIL to <instance>@<business>.mosaic.invalid, a domain
that can't receive mail. The launcher passes the instance and the business
id, and sandboxArgv throws when either isn't an id. The brief names the
role; the instance id is used instead, so two instances of one role commit
as different authors (Sage's decision on row 104). The identity lives in the
environment and not in the clone's config, because a user.name or
user.email key there makes every later launch keep the clone
(config-changed, below).
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-parentisn'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 orptraceanother session or the host by pid. - The outer bwrap exits with 128 plus the signal number when the runner is
killed;
sessionExitturns 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.
Each result also has the clone's branch and, for cloned, updated and
current, the commit it is at after the launch (null when git can't read
it). A kept clone has neither, and the host runs no more git in it to find
one. The bundle's prompt names the branch and commit, says the host
refreshes a clone only at a launch, and tells the session not to set git
config in its clone (#1598).
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 gets SIGTERM after 100 s and SIGKILL 5 s
later, as below.
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").
The working repositories (#1587)
A coder's pushed work goes to one bare repository per business and
project, <dataRoot>/working/<business>/<project>.git, which only the bus
host writes (src/working.mjs). It isn't the project's root and it isn't
a forge: no credential, no remote, and no sandbox binds it. A task's work
lands on refs/heads/work/vikunja-<project>-<task>, a name the host makes
from the task ref; the session only picks which branch of its bundle to
take.
Commits arrive as a git bundle over the host's git socket
(packages/cli/src/git-socket.mjs), never by the host reading the
session's clone. A live session can change its clone between any check and
any git call, so the host doesn't run git there at all. It reads the
bundle as it would a fetch from an untrusted remote.
A push runs under the repository's lock, in this order:
- The bundle starts with the v2 header; v3 can carry a filter. Otherwise
invalid-bundle. mainis fetched again from the project's root, which the host trusts. A root it can't read refusesroot-unreadable.git bundle verify. A prerequisite the repository lacks refusesmissing-prerequisite; anything else wrong isinvalid-bundle.- The bundle has the named branch, or
branch-not-in-bundle. - That one ref is fetched with
transfer.fsckObjectsinto a temp ref underrefs/mosaic/incoming/. A broken object refusesinvalid-bundle. - The commit shares history with
main, orunrelated-history. - The temp ref moves to the work ref with no
+, so git takes only a fast-forward (non-fast-forward). - The broker records the push, an
action.allowedforgit.push.workingnaming the commit. When it refuses, the work ref goes back to where it was. A work ref that can't go back refusesrollback-failedin place of the broker's code, with a message naming the ref and the commit it still holds. The host logs it, since the operator has to move that ref by hand.
The temp ref is deleted on every path, and so is the bundle file in
bus-host/. No other ref moves, and the root is only read.
The repository's lock is a promise chain inside the host's process, so it
assumes one launcher per dataRoot. startHost takes bus-host/host.lock
with a hard link, which fails if the file exists, and refuses while a live
host holds it, so two launchers started at the same moment can't both run
(packages/cli/README.md, bus start).
A host git call that runs past 100 s (GIT_TIMEOUT_MS) gets SIGTERM. git
removes its lockfiles on SIGTERM. Only a git still running 5 s later
(GIT_GRACE_MS) gets SIGKILL, which can leave a ref's .lock behind; that
lock refuses every later push to the ref until someone removes it. The
checkouts (checkout.mjs) and the working repositories (working.mjs) run
git the same way, through runGit. Each git is the leader of its own
process group, and both signals go to the group, so a hook or helper git
started can't outlive it. Once a signalled git has ended, the group gets
SIGKILL once more, for a process that ignored SIGTERM. A git that ends on
its own isn't signalled. Since git has its own group, a Ctrl-C at the
terminal of a host started by hand doesn't reach it; a systemd stop still
does, since it kills the unit's cgroup.
For a review, under the same lock, the host fetches main again and
writes a bundle of the requested commit with one ref,
refs/mosaic/review. It leaves out the commits the reviewer says its
clones already have (at most 32, HAVE_MAX), but only those the working
repository knows. A commit the repository doesn't have refuses
unknown-commit.
A bundle is at most 32 MiB (BUNDLE_MAX) both ways, or bundle-too-large.
For a push the git socket checks the size the request names before it
reads a byte; a review bundle over the cap isn't sent. Host git here runs as
it does for the clones: no system or global config, hooks at /dev/null,
core.fsmonitor off, only the file transport, no submodule recursion
and no auto-maintenance.
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.