997 lines
57 KiB
Markdown
997 lines
57 KiB
Markdown
# 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.
|
|
|
|
```sh
|
|
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>]
|
|
mosaic project list [--business <id>] [--json]
|
|
mosaic threads [--project <project>] [--business <id>] [--json]
|
|
mosaic thread create <project> <name> [--business <id>]
|
|
mosaic thread open <thread> [--business <id>]
|
|
mosaic thread read <thread> [--business <id>] [--json]
|
|
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>]
|
|
mosaic follow --thread <thread> [--business <id>]
|
|
mosaic name <instance> <name> [--business <id>]
|
|
mosaic names [--business <id>] [--json]
|
|
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`.
|
|
- `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).
|
|
`talk --tester` text must start with
|
|
`[mos test]`, else exit 2 before any call. 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` and
|
|
`inbox --read` or `--read-all` take no `--tester` (exit 4).
|
|
|
|
## 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 `n`th
|
|
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.
|
|
|
|
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/<full id>. 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](../../docs/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:
|
|
|
|
```json
|
|
{"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.
|
|
|
|
```json
|
|
{"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]` 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
|
|
or tester. 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]
|
|
mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV
|
|
mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV
|
|
```
|
|
|
|
`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]` 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.
|