Files
stack/packages/cli/README.md
T
jason.woltje 00d4cc1f69 stack: several Mosaic Stack instances on one host (row 190, #1688)
Squash of Darkwing's 176a6bf4b..df65a3d4c on origin/darkwing/row190-r3,
the 25 manifest paths, build-r3.patch efe898c2, manifest cc0ea9e8
(Filbert approve round 3, comment 29713). Design revision 3 in
agents/darkwing/work/queue-190/DESIGN.md. Gate on base d0bbc5c1c with
the patch: 15 package suites 2664 pass 0 fail, 10 scripts/test-*.sh
suites 429 pass 0 fail (plus 156 node tests in the queue suite).
Refactor moved to 99a406ed3 since, a window record touching no manifest
path. Mutants 20 of 20 killed (BUILD.md, round 3). No live change: the
stack runs from copy 4429a104f until a re-pin.
2026-10-20 17:49:06 -05:00

60 KiB

CLI

Jason's front door to the bus (slice 1 row S4, #1521): mosaic inbox, decide, tasks, agents and trail, and the bus host mosaic bus start|stop|status with its Discord notifier (row S4, #1521); the session launcher in the host and mosaic talk, stop and launches (row S6, #1523); and mosaic with no argument, the full-screen app (queue row 55, #1544). Briefs: docs/plans/2026-10-04_slice-1.md, rows S4 and S6, and docs/plans/2026-10-10_design-implementation.md, "Full-screen terminal app". Rulings: lead decisions 70 and 77 in docs/plans/2026-09-26_lead-decisions.md.

Every command reads through the broker. None opens the SQLite file.

scripts/mosaic                         # the app, on a terminal
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-…        # then: scripts/mosaic trail vikunja:1/7
scripts/mosaic bus status
node --test 'packages/cli/tests/*.test.mjs'

Human commands

mosaic inbox [--business <id>] [--json]
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--business <id>] [--json] [--tester]
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--business <id>] [--tester]
mosaic thread open <thread> [--business <id>] [--tester]
mosaic thread read <thread> [--business <id>] [--json] [--tester]
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
mosaic follow --thread <thread> [--business <id>] [--tester]
mosaic name <instance> <name> [--business <id>] [--tester]
mosaic names [--business <id>] [--json] [--tester]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
  • The business is --business, or else the business of the bus host running on this data root. With neither, the command exits 4.
  • The transport is packages/bus/src/human-cli.mjs <socket>, run as a child. The command writes {business, verb, args} on its stdin and reads the JSON reply on its stdout. The child proves a human shell: it re-executes itself with a nonce, and the broker checks its process and ancestry for agent markers. The command carries no capability and the bus is unchanged.
  • Each command refuses with exit 3 before it touches the bus when an agent marker is in its environment (REQ-DEC-3). The broker would refuse anyway; this check only gives a clear message first. Agent seats never run these commands. The tests use a stand-in transport.
  • inbox lists the open decisions routed to the human, gated first, in broker order. For a gated decision it prints what approving authorizes: the action, its target, and the one choice that authorizes it. Text output then lists all unread messages to the human, newest first, without marking any delivered or read. --json keeps its existing decision-row array.
  • inbox --read <id>... (row 150, #1644) marks unread messages read with the broker's message.read. Each id is the 8-character one inbox prints, or any longer prefix of exactly one unread message's id. A shorter one refuses with too-short. A prefix that matches two or more refuses with ambiguous and names them; one that matches none refuses with no-unread-match, unless it is a full message id, which goes to the broker as given. --read-all marks every message inbox lists, oldest first. Each id prints marked <id> or not marked <id>: <code> (--read-all prints only the refusals), then marked N of M. Every id is tried; if any was refused the command exits 2 after the rest. A message the human never received has to be received first, so the command calls message.receive until it has that message. Every message that call receives drops out of a later follow, like the ones plain talk skips, and stays unread in inbox until marked. Neither flag takes --json or --tester (exit 4).
  • decide takes the full id, or a unique prefix of at least 8 characters, from the inbox. It prints the decision and whether the choice authorizes or declines, then asks [y/N] on a terminal. Without a terminal it needs --yes, else it exits 4. On outcome-unknown it does not resend: it exits 1 and asks you to check mosaic inbox or mosaic trail <id> first.
  • Projects and threads (row 128, #1622) are the human's side of the bus's project.* and thread.* ops. A thread project named for a business-file repository must carry that same repo: mosaic project create stack --repo stack. project create stack refuses with guidance to add --repo stack or choose another name. An unresolved repository name explains how to create its thread project, when the business file can be read. project create and project list work on the bus record; project init and project check (docs/TOOLS.md, "Local project records") work on the repository on disk and keep their arguments. project list prints name, repo (- for none) and the first 8 characters of the id. threads prints <project>/<name>, the instance and the short id of the caller's threads, optionally for one project. thread create prints the id and the instance the broker chose; thread open writes the open row the launcher reads; thread read pages the thread's messages oldest first and marks nothing read.
  • A project is its name or id. A thread is its id, an id prefix of at least 8 characters, or <project>/<name>. The resolver reads project.list and thread.list and refuses an unknown or ambiguous reference with exit 2 before anything is sent. A full id that thread.list doesn't show goes to the broker as given, which answers thread-not-owned for another user's thread and thread-not-found for none. The CLI adds no ownership, uniqueness or instance check of its own; thread-not-owned, thread-not-found, project-not-found, project-exists, thread-exists, unknown-repo, thread-instance-missing and thread-instance-ambiguous print as mosaic: <code> and exit 2.
  • talk --thread sends thread.send as a REQUEST. Without --follow it prints only the message whose in_reply_to is the sent id, like plain talk, and leaves the rest unread. With --follow it prints every message of that thread after the one it sent and marks read those addressed to the human; other threads' messages and messages outside any thread stay unread for mosaic inbox. follow --thread is the same loop without the send, from the start of the thread. The loop reads the thread first, so a thread that isn't the user's is refused before any message is received. A plain mosaic talk <role> <text> to the threaded instance has no default thread: it exits 2 and names --thread and mosaic threads.
  • With --tester (row 184, #1682) thread create, open and read, talk --thread and follow --thread run as the tester on the tester's own threads, which the bus keeps apart from the human's (owner tester, row 177). threads --tester lists only those. A human thread's full id refuses thread-not-owned and its <project>/<name> or prefix isn't in the tester's list, as for another user's thread above. The reply comes back through message.receive as the tester's message; inbox --tester lists decisions only. Tests: tests/tester-threads.test.mjs.
  • trail prints rows in the broker's order (at, table, seq). A decision's trail ends with its task_ref and follow with: mosaic trail <task>. It does not pull in the task's rows.
  • talk sends <text> to the role as a REQUEST, then polls the human's inbox every 2 s for up to --wait seconds (default 900). It prints the reply to its REQUEST, marks only that read, and stops. Any other message that arrives meanwhile is not printed and stays unread; mosaic inbox lists it. No reply in time exits 1; the reply may still come, and mosaic inbox shows it. When no run holds the role (row 146, #1640), a line after sent says so: pm isn't running, chief has been told when the bus told launch.by; pm isn't running, and the business names no launch.by with no launch block; pm isn't running and isn't in the launch block, so chief can't launch it when the role isn't one of launch.instances (row 152, #1646). A --tester message to such a role is refused instead (see --tester below). When launch.by itself isn't running, talk prints <launch.by> isn't running (or isn't running either after the told line, but not after the launch-block line) and the command that starts it: systemctl --user restart mosaic-bus@<business>, which starts it only if the unit runs with --pm (scripts/bus-service.sh status <business> shows pm: on) and stops running sessions. A hand-started host takes mosaic bus stop, then mosaic bus start <business> --pm. No verb launches launch.by on a running host. --wait 0 only sends. talk --follow keeps printing and marking every message read until Ctrl-C, including messages with no in_reply_to. follow runs that same loop without sending. --wait and --follow together refuse. These commands consume the same human inbox; run one receiving CLI at a time. Console only reads history, so a reply to a Console send stays unread in inbox until it is marked read.
  • name gives a role instance a name (row 141, #1635, REQ-MSG-2) and prints named <instance> <name>. talk and every message.send then accept the name where they took the instance. A name is unique in the business and can't be an instance key, a role definition, human or tester; the bus refuses with name-taken, name-is-instance or name-is-role. Naming an instance again replaces its name and frees the old one. names lists each named instance and its current name.
  • launches off and on are the broker's launch.revoke and launch.restore. While off, every role.launch refuses with launch-revoked; running sessions keep running. Stop them with stop. bus start --pm doesn't ask the broker (launchPm skips authorizeLaunch): the human launches the PM, so launches off doesn't stop it. The other checks in "Sessions" below still apply.
  • stop and launches list read <dataRoot>/bus-host/sessions.json, not the bus (see "Sessions" below). stop refuses inside an agent run; launches list does not, because the file is readable to the same user anyway.
  • --tester (row 110, #1604) runs inbox, tasks, agents, trail, talk and follow as the business's tester through packages/bus/src/tester-cli.mjs instead of as the human. It is the one exception to the agent check above: Mos or a T3 seat may run it for a PM test, and the broker still refuses it under a managed run (tester-required) or while the business var bus.tester isn't true (tester-off), each exit 3. Like the human commands, --tester works only from the tree the bus runs from ("One pinned copy" below). Row 175 (#1673, Q173, mos-0870 N2) adds the reads usage, names, limits, plugins, threads, project list and business instances, which print what the human's forms print, and the writes plugins set, business instance add and set, name, project create and project init (below), each recorded as tester. routes.set has no CLI form yet. talk --tester sends any text. When no run holds the role (row 170, #1667), the bus refuses the message with tester-target-not-running and stores nothing, and talk prints pm isn't running, so the tester's message was refused and nothing was stored and exits 3. A run launched but not yet claimed counts as not running. The role's reply comes back to the tester, not to the human's inbox. decide, stop, launches, inbox --read or --read-all, limits set, reset and migrate, project check and bus start or stop take no --tester (exit 4). Row 184 (#1682) adds the thread commands and talk and follow --thread (above).
  • project init --tester (row 175, mos-0872 Q2) may run inside an agent. Before anything touches the disk it refuses a repository outside <dataRoot>/projects/<business>/, or that directory itself, since a repository there would hold the other projects' default ones (tester-project-area, exit 2), then binds the tester with one agents read, so tester-off and tester-required refuse (exit 3), then commits the layout with tester as author and committer. Tests: tests/tester-reads.test.mjs.

The app

mosaic

With no argument, on a terminal, mosaic opens a full-screen app over the same broker reads as the verbs: Inbox, Tasks, Agents, Trail and Help. src/app.mjs handles keys and reads; src/screens.mjs draws the frames and nothing else.

  • In an agent run it exits 3 with the same refusal as the verbs, before anything else. Without a terminal on both stdin and stdout it exits 4 and prints the seat usage (mosaic launch, mosaic seat task) and the verbs above. A script never gets the app.
  • The business is the one of the bus host on this data root. With no host the app says so and :business <id> picks one.
  • Keys: c i t a r switch views (Talk, Inbox, Tasks, Agents, Trail). ↑↓ or j k move, PgUp and PgDn page, Enter opens the selection whole, v opens its trail, : the command line, ? help, Esc closes a box, q or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the arrows type nothing. Commands: :decide <decision> <option>, :trail <task|decision>, :business <id>, :q.
  • Deciding: in the Inbox, digit n picks the selected decision's nth option and runs resolveDecision in src/cli.mjs, the code mosaic decide runs. The box shows the same lines and the same [y/N] question. y or Y resolves; any other key answers No, and so does quitting. Keys that arrive within 200 ms of the question being drawn are type-ahead and are dropped: a paste such as 1 yes, or keys typed while a read held the screen, never answer it. After outcome unknown the app shows the CLI's sentence and offers the trail (v) or a re-read (r). No key sends the choice again.
  • Reads happen on entering a view, after a decision, and every 30 s while no box is open. A failed read keeps the last list on screen and marks it stale. A refused read (exit 3 codes) says refused. Broker text goes through visible() from packages/conversation/src/terminal.mjs, so control characters and bidi overrides show as ^[ and <U+202E>.
  • Talk (c) points to mosaic talk for now. The conversation pane and stop come in a later row.
  • 16 ANSI colours. Under NO_COLOR the only escape codes in a frame are reverse video on the selected row (the design's tokens keep selection visible without colour). Frames fit 80x24; the layout is designed at 120x36, and under 100 columns the nav shows keys only and the Inbox drops RAISED BY. Below 40x10 the frame only says the terminal is too small. If the decide question is open then, the frame says so, and any key answers it No, y included. Widths count terminal cells: East Asian Width W and F characters and emoji 2, a one-cell emoji base with U+FE0F 2. ZWJ sequences and flags count more than they draw, so such a row can come out short, never too wide. Wrapping breaks between graphemes, so U+FE0F and combining marks stay with their base.
  • A decide box taller than the screen cuts the end of the question and nothing else: the decision line, the options, the recommendation, the action and target, what the choice authorizes, the task, your choice and the [y/N] question stay. The cut line points to mosaic inbox --json. When even those lines don't fit, the box says only "Too small to show this decision. Any key answers No. Resize, or use mosaic decide.", and the status line says "Too small to confirm here." instead of promising a y. y resolves only while the frame shows the whole box apart from the cut, and the app checks the size at each key, so a terminal shrunk after the question appeared answers No. Under 40 columns or 10 rows the app shows only the too-small screen, however tall the terminal is. How many rows a box needs depends on its lines. For example, a plain gated decision with a task needs 20 rows at 80 columns (21 with a cut question) and 29 rows at 40 columns; one with longer lines needs more.
  • The transport is the verbs' spawnSync child, so the screen does not redraw while a read is in flight (up to its 30 s timeout).
  • On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a resize redraw included) the app restores the screen, the cursor and cooked mode. After anything but an error it then prints (app closed with q; N decisions resolved this session); an error goes to stderr instead.

Bus host

mosaic bus start <business> [--pm]
                                the host; run by the unit, not by hand.
                                --pm also launches the business's PM
mosaic bus stop                 SIGTERM to the recorded host, then wait
mosaic bus status [--json]      host state file, socket, writer.lock

bus start does the following:

  1. It reads the system config through scripts/mosaic-config.mjs validate, the business file and the role files, and builds the broker's {op: 'boot'} message (src/config.mjs). The message has launches: [] and readers: [<business>]. It gets config.trackers (below) when a tracker is set.
  2. It reads the notifier config (below).
  3. It takes <dataRoot>/bus-host/host.lock (0600), which holds the same state as host.json (step 6). It writes the state to a file of its own and hard-links it in as host.lock. The link fails if the file exists, so of two hosts started at once, one gets it. If a live host holds the lock, or a live host started before host.lock existed holds host.json, the start refuses with exit 3: a bus host already runs for <business> (pid <pid>). A lock whose host no longer runs is taken over whatever its age: it is moved aside, checked to be the file that was read, and removed; if another host's claim was moved aside in that race, it goes back. Of three starts racing one stale lock, two can both claim it, and then the broker's writer.lock refuses the second broker. Each refusal below removes host.lock again, so a host that didn't start leaves no file.
  4. It forks packages/bus/src/process.mjs and waits up to 30 s for the boot reply. If the broker answers {ok: false, error}, the host exits 3 and prints broker refused to start: <code>. A timeout or an early exit gives exit 1.
  5. It forks src/notifier-process.mjs and hands it the reader capability over IPC in {op: 'start'}. A notifier refusal closes the broker, and the host exits 3.
  6. It writes <dataRoot>/bus-host/host.json (0600): the pid, the process start time, the business, the start time as text and the notifier binding. The file holds no capability. bus stop, bus status and the human commands' default business read it. It appears only once the broker and notifier are up, so a reader never finds a booting host with no broker socket.
  7. It starts the session launcher (src/launcher.mjs, "Sessions" below) and, with --pm, launches the business's launch.by instance. A PM launch that refuses closes the host with the refusal's exit code.

No capability appears in argv, stdout, the environment or a file. startHost() in src/host.mjs is also the in-process API the launcher uses: bindLaunch(record) binds a launched run's process identity (pid, startTime) and returns {business, run, cap}; op(message) sends one of the broker process's launch ops (identity, authorizeLaunch, refuse, endLaunch, credentialStatus, and for the git socket checkPush, recordPush, reviewFetch); beforeClose(fn) runs fn before the broker closes, so the launcher stops its sessions first. Requests to the broker process go one at a time. Each carries an id, and the broker echoes it on the reply. If a reply doesn't arrive within 10 s, or arrives with an id no request is waiting for, the channel is broken: the waiting request refuses with broker-channel-broken, so does every later one, and the host stops with exit 1 for the unit to restart. A late reply never answers a later request, and a launch waiting on one refuses.

SIGTERM or SIGINT stops the notifier after its poll in flight, then closes the broker and removes host.json, then host.lock. If either child dies on its own, the host goes down with exit 1. The unit then restarts both. The host watches the children once both have started, and it checks each child's exit state at that point too, so a broker that dies while the notifier starts still takes the host down.

bus stop signals only a pid that still runs with the recorded start time and whose command line is …/packages/cli/src/cli.mjs bus start. bus status never removes a lock. A writer.lock whose pid is gone means a broker was killed hard. Check, then remove the lock by hand (packages/bus/README.md).

Sessions

The launcher (src/launcher.mjs) starts managed role sessions (packages/seat/src/session.mjs, packages/harness). It serves <dataRoot>/bus-host/launch.sock (0600), which the PM's launch tool calls through its runner. One request is one line, {"cap": "…", "instance": "<role instance>"}, at most 4096 bytes; the reply is {"ok": true, "result": {run, instance, harness, model}} or {"ok": false, "error": "<code>"}. Launches run one at a time. In order:

Check Refusal
the request is one JSON line with a string instance invalid-request
the capability is live and names a role of this business unauthenticated, not-a-role
the business file has a launch block launch-not-configured
launch.instances lists the instance (for --pm: it is launch.by) instance-not-listed
the instance isn't running instance-running
the instance resolves unsupported-harness
it sets the harness, model and thinking vars, and all three fallback. vars if it sets any (row 65); the message names the missing var no-route
each route's model is a launch.models key unsupported-harness
its role has a contract file no-role-contract
plugins.harness switches on the primary's harness and a stated fallback's harness-off
the plug-in loader loads the primary's module; the message names it and the loader's code harness-refused
the module applies the route's thinking level thinking-unsupported
the module's preflight passes, else the stated fallback's harness passes the three checks above and its own preflight the module's code (no-session-credential, harness-binary-missing, …); a module that throws is harness-failed
on a harness that counts the account, with launch.account set, its name is not the value of any session login variable the bus host sets, since usage records keep the name account-is-credential
on that harness, the account names one of the login sets and the bus host holds all of it account-credential-missing
on that harness, the bus host sets no other login variable, ANTHROPIC_BASE_URL included account-credential-ambiguous
bwrap runs here sandbox-unavailable
total, provider and exact provider/model limits have room; the PM counts too capacity-full
the broker authorizes role.launch for the caller the broker's code (launch-revoked, decision-required, …)
the workspace has a clone of each project, or the host can make one checkout-failed, with the project id
the bundle builds and the runner starts launch-failed

Row 126 (#1620): only a launch on a harness whose module's countsAccount() is true spends launch.account.name. It goes to the mayLaunch limit checks, to the bus with the run, onto the run's usage records, and onto the launch record as account. Of the shipped modules that is Claude Code. A pi launch has account null: its records say null, no daily quota covers it, and the two account checks above are skipped. Both account refusals name the variables, never a value.

Row 65 (#1558): the route comes from the instance's vars only. The system's execution.adapter and execution.model never fill a missing one; they stay for run-task.sh. The harness is a module under packages/plugins/harness/ (packages/plugins/README.md, "Harness"), loaded through a fresh loader for each launch, and the launcher names no harness itself. A fallback is taken only when the primary's preflight fails: an off or refused primary, or one whose thinking level it doesn't apply, refuses without trying it. If the fallback's preflight fails too, the refusal carries its code and names both reasons. The account, capacity and limit checks run on the route taken. The manifest records route (primary or fallback) and routeReason (the primary's preflight code, or null). The reply's harness and model are the route's.

Every refusal is an action.refused event (the broker's refuse, or its own refusal for the last broker check) and a line in the launch log. Then the launcher reads the instance's credential status from the broker (metadata only) and makes sure the instance's workspace has a clone of each project in the business file (packages/seat/README.md, "The project clones"). It makes a missing clone and fast-forwards a clean one. A clone on another branch or dirty is fetched and not merged, a checkout-fetched line in the launch log with the project and the reason (#1611). A clone it leaves alone is a checkout-kept line with the project and the reason. Either way the launch goes on. The bundle's prompt names each clone's path. Then the launcher builds the bundle in the run directory, spawns the runner in its bwrap sandbox (packages/seat/README.md, "The sandbox"), binds the outer bwrap process's pid and start time, adds the session to sessions.json, and writes the capability as one line on the runner's stdin. The capability is never on disk, in argv or in the environment. When the runner exits, the launcher sends endLaunch with the end reason from its exit code (packages/harness/README.md), which writes session.ended, releases the role and kills the run's capabilities.

A Claude Code session logs in with CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL (a proxy's key), from the host's environment, which the allowlist passes on. The bus unit loads it ("Unit" below). Nothing from the host's ~/.claude reaches a session, whose HOME is its own. With none of them set, a Claude Code launch refuses no-session-credential before anything starts. A Pi session gets the same variables through the allowlist. A shell the harness opens, Claude Code's Bash or Pi's bash, can read them.

A Pi session logs in only through the variables ENV_ALLOW passes. Its HOME is the run's own, so Pi never reads the operator's ~/.pi/agent (auth.json, models.json, settings.json). A Pi launch has no credential check: a Pi role on a provider whose login lives only in those files, or whose key isn't an ENV_ALLOW variable, launches and then fails at its first turn.

The manifest's harnessVersion is the module's version(): for pi the repository's pin (the @earendil-works/pi-coding-agent dependency in the root package.json), for Claude Code --version from the binary sessions run, MOSAIC_CLAUDE_BIN when set (the unit pins it, below), else claude on PATH.

On disk, under the data root: launches/<business>/<run>/ (0700) is the run directory; launches/<business>.jsonl (0600, append-only) logs every launch, refusal and end; workspaces/<business>/<instance>/ (0700) is kept across launches and holds the instance's project clones; bus-host/sessions.json (0600) lists the running sessions.

When the host closes, the launcher stops taking requests and drops every open launch.sock connection, so a client that never ends its side can't hold the close. It sends SIGTERM to every runner (again each second, see "Limits"), waits up to 30 s, then SIGKILLs what is left and ends those launches as killed.

A host that starts reads the sessions.json the last host left. An entry for this business means that host died without ending the launch, and its run would keep holding the role. Before the launch socket opens, the launcher SIGKILLs each such session that still runs (same start time, bwrap on its command line), binds the run again with the recorded identity and ends it as host-lost, which releases the role. A sessions.json that can't be read refuses the host start with exit 3; move it aside after checking which sessions still run.

mosaic stop <run> checks that the runner in sessions.json still runs with the recorded start time and that its command line is the runner's, then sends SIGTERM until it exits (30 s). A stale entry prints "not running" and exits 0; an unknown run exits 2.

Thread sessions (row 121, #1615)

A thread instance is an instance whose role definition is assistant (packages/bus/README.md, "Projects and threads"). It runs once per thread and never on its own: a launch request or --pm for it refuses thread-required, and a thread launch of any other instance refuses thread-not-allowed. instance-running counts a thread instance per thread, so two threads run at once when the limits have room.

Every threadTickMs (2 s) the launcher asks the broker for the business's threads (threadsState) and acts on each one:

  • A thread with no running session starts one when a message to its instance waits undelivered, or when thread.open came after its last claim (REQ-LAUNCH-1). The start runs the checks in the table above, as the human, so a full limit refuses capacity-full and the next tick tries again. A refusal is logged once per code per thread, not once per tick. Each new message or open starts at most one session: a session that ends without taking the work waits for the next message or open.
  • A running thread session stops once the thread has had no message or open for launch.threadIdleSeconds (packages/business/README.md) and the run is between turns. The broker's threadIdle decides that from the store, not from the tick's snapshot: it releases the claim only with nothing delivered and unread and nothing waiting. If a message came in after the snapshot, nothing is released and the session keeps running. After a release the launch log gets a thread-idle line, and the runner gets SIGTERM, as mosaic stop sends it. An idle thread has no session, so it counts against no limit (REQ-THREAD-2).

A thread run's session directory isn't in its run directory. It is threads/<business>/<thread>/session/ under the data root, kept across runs (packages/seat/README.md, "Thread stores"), so the next run for the thread resumes the harness session (REQ-THREAD-2). The launcher checks the store before anything else; a thread id that isn't a plain id, or an unsafe directory, refuses thread-store-refused and launches nothing. Launch, refusal and end lines for a thread run carry thread, and so does its sessions.json entry, so a host that starts after a crash binds the run again with its thread and ends it as host-lost.

Thread runs of one instance share its workspace, workspaces/<business>/<instance>/. The clone check runs at each start, so it can fetch while another thread's session is running.

Landing runs (row 156, #1651)

Every launch of a non-thread instance asks the broker's landHolders (launch op) which approved, unspent merge decisions each non-thread instance holds, with each decision's task ref. The instance's own entries go into its session.json as landing: [{decision, task_ref}], and the same answer decides land_work and the git socket, so a landing entry never comes without the tool. The key is left out when there is none. The runner takes one turn per entry before its receive loop (packages/harness/README.md). A thread launch still asks landHeld and gets no landing.

After the thread pass, every tick asks landHolders again and acts on each holder that launch.instances lists:

  • A holder with no running session starts, through the checks in the table above, as the human, once per decision. A run that ends without landing isn't started again for that decision. A refused start is logged once per code per decision as decision <id> (<instance>): landing start refused: <code>, with a refused record that carries decision, and the next tick tries again. A landing turn whose state read fails is skipped (the runner logs it). The run still counts as started for that decision, so this host doesn't start it again, and the role-file steps below apply.
  • A running holder whose landing doesn't name the decision started before the resolution, so it has no land_work for it. The launcher asks the broker's landRelease to end it. The broker releases the role only between turns, with nothing delivered and unread, by land-relaunch; a message still waiting stays undelivered for the next run. A landing turn reads no message, so the broker counts a run as past its landing turns only once it has asked message.receive, which the runner first does after them. While a landing turn or a message turn runs, or a delivered message is unread, nothing is released and a later tick asks again. A refusal is logged once per code per run as run <run>: landRelease refused: <code>. After a release the launch log gets a land-relaunch record (instance, run, decision) and the runner gets SIGTERM, as mosaic stop sends it. The next tick starts the instance with the decision in its landing.

The launcher keeps none of this on disk. A host that restarts ends the earlier host's runs (host-lost), so its first tick starts each holder. If the host doesn't start a landing run, row 149's steps in the role files still apply: the chief of staff ends the raising run and launches.

Git socket

The launcher also serves <dataRoot>/bus-host/git.sock (0600, src/git-socket.mjs, row 93, #1587). A coder's runner pushes work to the business's working repository through it, and a reviewer's runner fetches a commit it was asked to review (push_working and fetch_review, packages/harness/README.md). A role granted git.merge.protected cross-role whose merge decision was approved, by its arbiter or the human, lands its task on the repository's main (land_work, row 137, #1631). The sandbox binds the socket only for a role that holds git.push.working or review.verdict, or one that holds a merge decision it could land: landHolders lists it (a non-thread launch) or landHeld says so (a thread launch). A session's session.json names it as gitSocket only then.

Only a git bundle crosses. The host never runs git in a session's clone and never opens a path in its workspace; packages/seat/README.md ("The working repositories") has what it does with the bundle. A request is one JSON line of at most 4096 bytes:

Op Request Reply
push {cap, op: "push", task_ref, project, branch, size} {ok: true, send: <size>}; the runner sends exactly that many bundle bytes; then {ok: true, result: {ref, commit, previous}}
fetch {cap, op: "fetch", task_ref, request, have} {ok: true, result: {project, ref, commit, size}}, then that many bytes of a bundle whose one ref is refs/mosaic/review
land {cap, op: "land", task_ref, decision} {ok: true, result: {landing: "started", commit, base, reviewed, patch_id}} once every check before the gate passed; commit is the squash that will land

The host checks the caller's identity (a role of this business), then the request (task ref, project, branch name, a size of at most 32 MiB), then the broker: checkPush for a push, reviewFetch for a fetch, checkLand for a landing. Only then does it read bundle bytes. The broker records a push with recordPush under the repository's lock once the ref moved; when it refuses, the ref goes back. A refusal is {ok: false, error: <code>} and ends the connection. The host's own refusals go through the broker's refuse with the task ref as subject, so each is an action.refused event on the task's trail. Eight connections are served at once; more refuse git-busy. A connection holds its slot until it ends, so a push's bundle has to come within a deadline: no gap over 30 s, and all of it within 30 s plus 1 s per 256 KB (bodyDeadline, 161 s at the 32 MiB cap). Past either it refuses body-timeout.

Codes a reviewer may see that aren't the broker's:

  • nothing-to-fetch: the reviewer's clones already have the commit, by the have list the runner sent, so there is no bundle to send. The commit is already in one of its clones under refs/remotes/.
  • unknown-project: the review names a project this business no longer has.
  • fetch-aborted: the reviewer's side closed the connection before the whole bundle was sent. That is the reviewer's doing, not the host's, so it isn't logged.

git-failed (an error in the host that has no code of its own) and rollback-failed (a push the broker refused whose work ref couldn't go back) are the host's own faults, and the host logs them as well.

A landing runs land-work's steps (scripts/land-work.sh) against the working repository's main, never the project root:

  1. The broker's checkLand checks the decision and the verdict and gives the host a ticket (packages/bus/README.md).
  2. prepareLand builds the squash in a worktree under bus-host/land/<id>/ (packages/seat/README.md, "Landing on main"). The message is land-work's. Its Resolved line names the channel from the ticket's via: the human via the CLI, or the arbiter via the broker.
  3. The project root has to be a mosaic-stack checkout with node_modules (isMosaicStack), or gate-missing. The root's node_modules is linked into the worktree.
  4. The host answers started, and the gate runs in the background, in the squash's worktree, with gateEnv's variables only.
  5. finishLand moves main and the broker's recordLand records it.

One landing runs at a time; another refuses land-busy. From the ticket on, every refusal goes to the broker's landRefused, on the task's trail. A refusal before the started answer is also the reply. After it, the role learns the outcome as a RESULT message from the bus: the landing, or gate-failed (naming the step and its log), base-moved, a broker refusal, or host-closing when the launcher closed during the gate, in which case nothing moved. The logs stay in bus-host/land/<id>/. The worktree and the gate's Docker network, <id>_default, go on every path. createGitSocket({gate}) takes the gate's three parts (LAND_GATE: classify, run, dropNetwork) so the tests can run a fake one.

A bundle file lives in bus-host/ (0700) under a random name and is deleted on every path. On close the launcher answers host-closing to a connection still sending its request line, since nothing of it ran yet, and drops every other connection. It waits for any push in flight to finish under its lock before the broker stops, and stops a running gate, whose landing is refused host-closing. It waits at most 2 s (CLOSE_WAIT_MS) for its host-closing answers to leave, then drops those connections too, so a peer that never reads can't hold the close.

Trackers

config.trackers[<business>] is {baseUrl, project, pollSeconds, reconcileMinutes}, taken from the tracker.* variables. tracker.project is a project-layer variable, so the entry comes from the one declared project whose .mosaic/project.json sets it. The resulting entry depends on the variables:

Variables Result
No tracker.baseUrl No entry and no task verbs
tracker.baseUrl set, no project setting tracker.project A warning on stderr and no entry
Two projects setting tracker.project Refusal with exit 3, because the boot shape holds one tracker project per business

Unit

scripts/bus-service.sh render|install|uninstall|status installs the template systemd/mosaic-bus.service.in as the systemd user unit [email protected], one instance per business (mosaic-bus@<business>). The template's file name has no @ because queue candidate manifests refuse it. It is modelled on scripts/discord-service.sh:

  • install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] writes the unit through a temp file and a rename, then runs systemctl --user daemon-reload. It prints the pinned claude and its --version.
  • render [--copy ID [--copies DIR]] [--pm] [--claude PATH] prints the unit without writing it.
  • uninstall refuses while an instance is active.
  • status <business> shows the unit's state and its Environment, which includes the pinned claude.

The unit settings:

  • ExecStart=@REPO@/scripts/mosaic bus start %i
  • Restart=on-failure
  • RestartPreventExitStatus=2 3 4, so refusals are never retried
  • KillSignal=SIGTERM
  • TimeoutStopSec=60
  • Environment=PATH=: /usr/local/bin:/usr/bin:/bin, with node's directory first when it isn't one of those
  • Environment=MOSAIC_CLAUDE_BIN=: the Claude Code binary that managed sessions and the manifest's version run
  • EnvironmentFile=: the Claude Code session credential (below)

MOSAIC_CLAUDE_BIN comes from --claude PATH, or else command -v claude in the installing shell. The path is kept as given, so the native install's ~/.local/bin/claude, a symlink into its versions directory, keeps following its updates; reinstall to change it. It must be absolute, an executable file, and only A-Z a-z 0-9 / . _ + -, because systemd expands % and splits Environment= on spaces; anything else refuses with exit 1 and writes nothing. With no claude found, the unit gets a comment line instead and sessions run claude from the unit's PATH, which in the sandbox finds only a claude under /usr. A running host keeps the environment it started with until it restarts.

The session credential comes from EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env, the one place the path is set. The file holds one line, for example CLAUDE_CODE_OAUTH_TOKEN=<token>, mode 0600. The operator writes it; nothing in the repository creates or reads it, and no session sees the file itself, only the variable. The leading - lets the unit start without it, so Pi sessions still launch and a Claude Code launch refuses no-session-credential. To change the credential, edit or swap the file (or this line and this paragraph), then restart the unit; a running host keeps the environment it started with.

A named stack instance (row 190) takes --instance NAME with any verb, in any position. The unit is then [email protected], it sets Environment=MOSAIC_CONFIG= to ~/.config/mosaic-NAME/config.json, its Description ends in [NAME], and its EnvironmentFile= is secrets/claude-session.env in that config directory, not mosaic-dev's. Install's busy and drop-in checks cover only mosaic-bus-NAME@* units and their .d directories, plus the prefix directories systemd applies to them: mosaic-bus-.service.d, mosaic-.service.d and service.d. A data root that another instance claims, or a named root with no instance.json, exits 3 before anything is read. Without the flag the unit renders byte for byte as before. Selection, names and refusals are in TOOLS.md, Stack instances.

The unit has no network-online.target ordering, since a user unit cannot order on that system target. The notifier needs no network at start: a send that fails is journaled and retried.

One pinned copy (row 85, #1579)

The bus, Console and mosaic wrapper must run the same copy: the broker's human proof accepts only its own human-cli entry path. stack-copy.sh creates a pushed, read-only tree with pinned npm dependencies under mosaic-stack/copies/. bus-service.sh render/install take --copy ID and --pm alongside the existing --claude pin. Without --copy render keeps today's checkout unit. A PM refusal exits 3 and the unit does not retry it. Install refuses every busy instance (also --no-reload), failed state reads and drop-ins overriding ExecStart or WorkingDirectory, including inherited mosaic-.service.d/ and service.d/ in every unit lookup directory. Status reads effective systemd properties, reports the tree/copy, PM flag, refactor drift and drop-in files; it changes nothing. The temporary PM ExecStart drop-in must be removed by Mos before installing the pinned unit.

The wrapper keeps the Q81 marker on line 2 and the final copy exec on the last line. Comments name the copy root and explain that the Q81 lease probe still answers at mosaic-old.

Copy npm ci may warn that @google/genai's preinstall, esbuild's postinstall and protobufjs's postinstall are blocked. This is expected for the current copy paths: Mosaic launches Pi's published bundled CLI, whose runtime does not load those installed package entrypoints. @google/genai's preinstall only echoes; esbuild's script installs/checks its build binary; protobufjs's script only warns about dependency version schemes. The copy runs the already published bundle and does not build it. No allowScripts change is needed for these paths; adding a runtime loader requires a fresh audit.

scripts/stack-release.sh runs the update and the rollback (row 162, D1): adopt records the running stack once, activate ID switches the bus, Console and wrapper to a copy and gates it, rollback returns to the previous copy from the log, and status reads. A failed gate rolls back to the old copy. The pointer and the log live under <dataRoot>/state/. The steps are in TOOLS.md, Pinned stack update. Each live run requires Mos's go; agents build and test fixtures only, and --plan and status change nothing. Restarting the bus ends its sessions, so no PM turn may be in flight. Queue writes stay on the canonical scripts/mosaic.

The human commands and --tester run only from the tree the bus runs from (row 130, #1625). The broker checks the path of the calling human-cli.mjs or tester-cli.mjs against its own. On a host with a pinned copy that tree is the copy, and mosaic on PATH is the wrapper that runs it. The checkout's scripts/mosaic exits 3 with human-required or tester-required and prints one more line: "the bus accepts these commands only from the tree it runs from; on a pinned host run mosaic on PATH". Queue writes still go through the checkout's scripts/mosaic.

Notifier

The notifier is a child of the host. Each poll (every 30 s) it reads the inbox through the reader capability and does two things:

  • DMs. It DMs each open blocking decision once. The DM holds the question, the options, the recommendation, what approving authorizes, the task, and mosaic decide <short id> <option>.
  • Digest. It sends one digest a day at 08:00 America/Chicago. The hour comes from the IANA zone, so daylight saving time is handled. If the host starts after 08:00 and the day has no digest yet, the digest goes at once. The digest lists the inbox. It marks each blocking decision with one of three states: "DM sent", "DM pending" or "DM refused, not retried". An empty inbox gets one line. Each message stays within Discord's 2000 characters.

The notifier only reads the bus. Its memory is the journal <dataRoot>/notify/<business>/sent.jsonl (directory 0700, file 0600), with one line per send attempt:

{"at": "…", "kind": "dm", "decision": "<id>", "outcome": "confirmed", "messageId": "…"}
{"at": "…", "kind": "digest", "decision": null, "day": "2026-10-08", "outcome": "unknown", "messageId": null}
  • A decision, or a day's digest, counts as sent once it has a confirmed line.
  • A refused or unknown send is retried. After an unknown outcome the wait starts at 30 s and doubles up to 30 min. A duplicate costs less than a miss. Every retry carries the same Discord nonce, so a retry inside Discord's dedupe window returns the first message. A DM's nonce comes from the decision id. A digest's comes from the business and the day.
  • A definite refusal is a DM refused with an HTTP 4xx other than 429. The next attempt waits the full 30 min, counted from the refusal's at in the journal, so a restart does not shorten it. After 5 of them for one decision, at least 2 hours apart end to end, the notifier appends one gave-up line, logs it once and never sends that DM again. The digest marks the decision "DM refused, not retried". The count comes from the journal, so a restart keeps it. An unknown outcome (network, 5xx or 429) retries without a limit (lead decisions 72 and 73).
  • Recovery after a give-up is manual. Fixing the binding does not resend the DM, which stays given up. The decision stays open in mosaic inbox, the digest lists it, and the operator decides it with mosaic decide.
  • On open, a final line without its newline is a torn write. The notifier copies those bytes to torn-<UTC stamp>.bin in the same directory (0600, a new file, synced), then truncates sent.jsonl to its last newline and syncs it. It logs both steps. A crash between the two leaves the torn tail in place, and the next open repairs it with a second copy. The next append therefore starts on a line of its own.
  • A malformed complete line refuses with exit 3 and changes nothing. Each line is type-checked: at an ISO timestamp, kind dm or digest, decision a string (required for dm, null for digest), day a real YYYY-MM-DD date (required for digest), outcome one of confirmed, refused, unknown or gave-up (gave-up only for dm), messageId a string (required when confirmed) or null, status an integer when present.
  • The journal refuses with exit 3 when its directory is looser than 0700, not yours, a symlink (dangling or not) or not writable, or cannot be created (for example, a parent is a file). It also refuses when sent.jsonl is a symlink, a directory or not writable, or is not a regular 0600 file you own. The message names the path.
  • No Discord channel or user id goes in the journal, a log line or an error.

The Discord side is packages/discord/src/notify.mjs. It uses the connector's REST client and the binding's tokenFile, and sends to the binding's fixed dmRecipient, who must be one of the binding's users.

Notifier config

<dataRoot>/notify/<business>/notify.json, mode 0600, owned by you. The journal shares the directory, so create it 0700 first (mkdir -m 0700 -p <dataRoot>/notify/<business>). A host with a binding refuses with exit 3 on a looser directory.

{"notifyVersion": 1, "binding": "sage-seat"}

"binding": null runs the host without DMs. A missing file refuses with exit 3, so a host never starts until someone decides whether it DMs.

Exit codes

Code Meaning
0 ok
1 failed, or outcome unknown: check before retrying
2 invalid input: unknown option, no such open decision, decision already closed, no such run, not a runner
3 refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start
4 usage

Limits

  • One broker per data root. The bus store takes <dataRoot>/bus/writer.lock. The unit is a template per business, but only one instance can run on one data root.
  • digest.sent is unused. The bus schema has this event kind, but decision 70 puts the notifier's memory in the journal, and a reader capability cannot write events.
  • The real human transport is untested here. No test runs the real human-cli.mjs, because its proof needs a human shell. The live run covers it.
  • stop is same-UID process control. It checks the start time and the command line, not authority. Any process of the same user can signal a runner directly.
  • Signals before the runner's handlers. A session's runner is pid 1 of its PID namespace, and pid 1 ignores a signal it has no handler for. The runner installs its handlers first, but Node's startup leaves a short window, so the launcher and stop resend SIGTERM every second.
  • If the host dies hard, its sessions keep running until their broker polls fail, then exit 23, and their launches stay open in the bus until the next host starts and ends them as host-lost ("Sessions" above). With no next host, nothing ends them.
  • talk and the launches verbs are tested only with the in-process transport, as the other human commands are.
  • Tracker boot is tested only without trackers. The trackers boot case is tested once S3's broker change lands.

Not in this piece

The WebUI (S5).

Agent limits and models

mosaic limits [--business ID] [--json] [--tester] reads the model table, each role's selection, limits and running counts. mosaic limits set --business ID --file PATCH submits JSON with the read's revision and any of limits, models, instances. limits and models replace those entire objects; instances maps role instance names to model-table keys. assistants maps an assistant instance to any of its vars, tracker and definition; each given field replaces that field, and tracker: null removes the tracker. The definition stays an assistant one, and holder and credentials stay hand edits (row 155, Q169). The chief of staff sends the same patch with change_organization and reads the revision itself with organization_revision (row 134). Both commands use the existing human proof and broker; neither is available to a managed role. A tester may read (row 175), but limits set refuses --tester with exit 4. The Console Settings form uses the same write path and requires signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata.

Unset limits mean unlimited; zero blocks new launches. Limits are checked afresh at each launch in total, per provider and per exact provider/model. Lowering one leaves running sessions alive and counted against their recorded provider/model, including the PM. Capacity refusals log the scope, configured limit and running count; launches log harness, provider and external model.

limits replaces the whole object, token limits included. Token limits (row 125) sit under limits.tokens: per role instance at instances.NAME, per provider at providers.NAME, per model at models.PROVIDER.MODEL and per key at keys.NAME. Each is { "limit": N }, a token count of 0 or more. The writer adds since, the time the limit was set, and keeps it on later saves. A patch that sends a different since is refused. Only these paths are token limits; the agent limits above are never read as one, whatever their names.

mosaic limits reset WORD... [--business ID] moves one token limit's since to now, so its count restarts. The path is one word per level, as the plain output prints it: mosaic limits reset tokens models anthropic claude-opus-5-5. A path that isn't a token limit is refused. The reset is logged with its actor in changes.jsonl, like any limit change.

A token limit is counted and enforced only by a switched-on limit-check plug-in. The one that ships is token-limit (packages/plugins/limit-check/token-limit/README.md). A limit that no switched-on check counts reads not enforced, and so does every limit when no check is switched on. An enforced limit refuses a launch whose subject is at or over it, and stops a running session at the end of the turn that reaches it. A check that fails, or that is switched on but not loaded, refuses launches, stops sessions at the end of their turn and shows every limit as check failed (fail closed).

Without --json, mosaic limits, set and reset print the read as JSON without tokens, then a token limits: section with one line per token limit: its path, limit and since, then either its count (with input, output, cacheWrite, cacheRead and unknown turns), not enforced and any reason, or check failed with the code and check. That output is not JSON as a whole; scripts use --json, which keeps tokens as a list.

mosaic limits migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR] needs no bus. If a role inherits its model, it reads and validates the system config and requires the mapping to preserve both provider and exact model. Missing or invalid config refuses. It prints the whole converted document and never writes, including on refusal. There is no --write. Mapping JSON supplies models (keys to provider/model pairs), families (old family names to keys), and optional instances or selections (old model strings to keys). Unknown or ambiguous mappings refuse. Every held role must map too. A migrated file prints unchanged. Jason installs the output himself in Mos's window with the PM run idle, and the re-pin follows in the same window (decision 145).

Role instances

Row 165 (#1662, Q171, REQ-ROLE-3).

mosaic business instances BUSINESS [--json] [--tester]
mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]

scripts/mosaic sends business instances and business instance here and every other business command to packages/business. instances prints the file's revision, each instance with its definition and its marks (launcher, launched, protected), the definitions add and set accept, and the definitions that declare credentials. add and set send the bus's human-only instances.set verb. set needs at least one of --definition, --vars and a launch flag. --vars FILE is a JSON object that replaces the instance's vars whole. --launch and --no-launch add the instance to launch.instances or remove it.

The business package's instance writer runs the checks mosaic business validate runs before it writes, and logs the change in changes.jsonl with the human as actor. It refuses, and the file keeps its bytes:

Code When
organization-credentials-out-of-scope the definition declares credentials, or a definition change on an instance that holds them
organization-instance-exists add names an instance the file has
organization-unknown-instance set names an instance the file lacks
organization-definition-refused an assistant definition or instance; those change through mosaic limits set
organization-launch-not-configured a launch flag when the file has no launch block
organization-no-change the file already says that
organization-project-mismatch, organization-unresolvable-instance the change would fail mosaic business validate against a project file
revision-conflict the file changed since --revision

The bus sends a refusal's code alone, so the command adds the reason. A refusal exits 2; an agent session or a non-human transport exits 3; a usage error exits 4 before any bus call.

The commands bind no credentials. A definition that declares them needs credentials and a tracker bot in the instance, and those, with holder, stay hand edits of the business file. A vars or launch change on an instance that holds credentials succeeds and leaves them as they are. There is no delete.

An add or a definition change prints restart needed: the running bus keeps the roles it loaded at start; this command does not restart it. The output names no one; the restart itself needs Mos's go or Jason's hand. A vars or launch change prints no restart, because the launcher reads it at its next launch check. The Console Settings page has the same read and write (packages/webui).

Token usage

mosaic usage [--business ID] [--json] [--tester] reads the bus's accounting, without opening its database or transcripts. Text lists the total and run, instance, provider/model, provider and account groups, with input/output/cacheWrite/ cacheRead and unknown turns beside every total. JSON retains the full view. Account is unassigned today. Unknown turns can have used tokens: known sums are a best reading of session-editable transcripts, not proof or a limit check.