- autonomy set: an empty --authority-resolver clears the resolver as the panel does; --reverse-window-hours takes 1 to 168, as the page and bus do. - The default instance gets a one-line refusal (exit 3) naming the named instance and --dev-local-owner, not a stack trace. - tasks create|edit|archive: a value starting with -- is a usage error; --business is checked before the agent guard, and the guard also reads process.env. - Drop the unused consoleRefusal export. - Docs: --business is required, resolver clearing, window range, and the named-instance requirement.
1398 lines
84 KiB
Markdown
1398 lines
84 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 tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||
mosaic tasks edit task:<n> --expect <digest> [--title <text>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||
mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
|
||
mosaic agents [--business <id>] [--json] [--tester]
|
||
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 [--archived] [--business <id>] [--json] [--tester]
|
||
mosaic project archive <name-or-id> [--business <id>]
|
||
mosaic project unarchive <name-or-id> [--business <id>]
|
||
mosaic project north-star <project> [--business <id>] [--approve <draft-commit> --base <spec-commit>]
|
||
mosaic project interview <project>
|
||
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
|
||
mosaic thread create <project> <name> [--instance <key>] [--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).
|
||
- `agents` (row 277, #1782) prints the held roles, then a `thread` line for
|
||
each held thread claim from the bus verb `thread.claims`: thread id,
|
||
instance, run, harness and since when. It never prints a thread's name. If
|
||
the bus refuses `thread.claims`, the role lines still print, then `thread
|
||
claims: refused <code>`, and the command exits 0. `--json` prints the
|
||
`agents` array alone, and `--tester` makes only the `agents` call.
|
||
- `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; `--archived` includes archived projects, each marked `archived`.
|
||
`project archive <name-or-id>` (#797) is the owner's reversible retirement of
|
||
a project: it hides the project, refuses its new threads and messages, deletes
|
||
nothing, and `project unarchive <name-or-id>` restores it (resolution reads
|
||
the archived list too, so an archived project can be named). `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 `org instances` (old
|
||
spelling `business instances`), which print what the human's forms print,
|
||
and the writes `plugins set`, `org instance add` and `set` (old spelling
|
||
`business instance`), `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 `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 restart stop, then start with the recorded options
|
||
mosaic bus recover clear a dead host's locks, with a receipt
|
||
mosaic bus status [--json] host state file, socket, writer.lock
|
||
```
|
||
|
||
`bus start`, `bus stop`, `bus restart` and `bus recover`, and `console start`,
|
||
`console stop` and `console restart`, refuse inside an agent run (exit 3,
|
||
agent-guard #1873). They check the environment they are given and their own
|
||
process environment. `bus status` and `console status` stay open to agents.
|
||
|
||
`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 Pi
|
||
session on provider `zai` logs in with `ZAI_API_KEY`, which the allowlist
|
||
passes. Like every allowlist variable, it reaches every session the host
|
||
starts, Claude Code ones included. A shell the harness opens, Claude Code's
|
||
`Bash` or Pi's `bash`, can read all of these variables.
|
||
|
||
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/organization/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
|
||
run's calls between the release and the signal refuse `launcher-released`,
|
||
so the runner exits 0 either way (row 302). 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 credentials come from
|
||
`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`, the
|
||
one place the path is set. The file holds one line per variable, for
|
||
example `CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. It holds every session
|
||
provider key, not only Claude's. The operator writes it;
|
||
nothing in the repository creates it, and only the Case 2 precheck reads
|
||
it, printing variable names, never values. No session sees the file
|
||
itself, only the variables. 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](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
|
||
|
||
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.
|
||
|
||
On a host whose units are root's (row 232), `--units external` makes
|
||
stack-release.sh read the system units and start or stop none. `activate`
|
||
and `rollback --to` stage the copy and leave the `AWAITING-UNITS` marker;
|
||
root installs and starts the units, then `confirm` gates the start and
|
||
writes the pointer, `revert` undoes a staged run, and `gate --since` checks
|
||
a copy with no open activation. stack-copy.sh adds `copy --bundle FILE SHA`
|
||
for a host with no refactor checkout, `wrapper ID --create` for the first
|
||
wrapper, and `check --units external`. Details are in
|
||
[TOOLS.md, Root-owned units](../../docs/TOOLS.md#root-owned-units---units-external-row-232).
|
||
|
||
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] [--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 replies --file LIMITS [--business ID]` replaces the citation settings
|
||
through the human-only `library.limits.set` verb. LIMITS is a JSON object with optional
|
||
`requireCitation` (boolean, default false) and `citationsPerReply` (integer 1–1000,
|
||
default 20). Omitted keys use their defaults; unknown keys and invalid values refuse.
|
||
The next library reply uses the new settings without restarting the bus.
|
||
|
||
`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 org instances ORG [--json] [--tester]
|
||
mosaic org instance add ORG INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
|
||
mosaic org instance set ORG INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]
|
||
```
|
||
|
||
`mosaic business instances|instance|veto` is the old spelling, with the same
|
||
arguments. `scripts/mosaic` sends `business instances`, `business instance`
|
||
and `business veto` here and every other `business` command to
|
||
`packages/organization`; `org instances|instance|veto` come here with the rest
|
||
of `org`. Both spellings run `instancesCommand` (`src/instances.mjs`) over the
|
||
human-proof bus transport, so the principal checks, refusals, output and exit
|
||
codes are the same. Only a usage error differs: it prints the usage of the
|
||
spelling you used. These commands never use the dev-local-owner context that
|
||
`mosaic org create|show|update` use. `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 organization package's instance writer runs the checks `mosaic org config
|
||
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 org config 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`).
|
||
|
||
## credentials (row 199)
|
||
|
||
`mosaic credentials [status [AGENT]] [--json] [--tester]` lists each agent's
|
||
credential kinds with date and state, never a value. `issue AGENT gitea
|
||
--rotate-by DATE` and `issue AGENT vikunja --expires DATE` read the token on
|
||
stdin (pipe it, or paste it and end input) and send it once to the bus's
|
||
`credentials.issue`; it is never an argument. `rotate` is the same over an
|
||
entry the bus holds now, and refuses before reading stdin when there is none.
|
||
`revoke AGENT [KIND] --reason TEXT` and `delete AGENT --reason TEXT` drop the
|
||
entries; `delete` refuses `agent-running` while the agent holds a claim. With
|
||
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
|
||
the old token in Gitea or Vikunja yourself; the bus only forgets it.
|
||
|
||
## hosts (row 234)
|
||
|
||
`mosaic hosts [status] [--json] [--tester] [--business ID]` prints one tab-separated line per
|
||
enrolled host: id, state, `user@address:port`, `sudo` or `no-sudo`, the last check time or
|
||
`unchecked`, and any host lines a failed step left. `--json` prints the bus rows. `check HOST`
|
||
runs the bus's `hosts.check` and `remove HOST` its `hosts.remove`, each printing the JSON result.
|
||
`revoke AGENT HOST --reason R` runs `hosts.revoke`, which takes the agent's grant on the host away
|
||
at once, and `sweep HOST` runs `hosts.sweep`, which removes the host lines a failure left (row 235).
|
||
A status line shows `revoking:` with the tags of a revoke still in progress.
|
||
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
|
||
command. A usage error exits 4 and a bus refusal 2.
|
||
|
||
## grants (row 236)
|
||
|
||
`mosaic grants status gitea [--json] [--tester]` prints the Gitea grant path's state: the admin
|
||
slot, each grant and open decision, settle-pending rotations, held agents, service left behind and
|
||
revokes in progress. `run DECISION` starts the executor of a Gitea grant the human approved.
|
||
`revoke AGENT gitea --reason R` takes the agent's Gitea token away and deletes it at Gitea. `sweep
|
||
gitea` retries what a failure left. `admin set gitea --url URL --account NAME --rotate-by DATE`
|
||
reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE`, `admin rotate
|
||
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
|
||
prints a token. A usage error exits 4 and a bus refusal 2.
|
||
|
||
`mosaic grants scope --business ID` reads the chief of staff's effective scope,
|
||
its organization revision, and whether a bad value fell back to empty (Q228,
|
||
row 298). `mosaic grants scope set --business ID --revision REV` reads scope JSON
|
||
from stdin. This human-shell command is Approve for the scope change; it refuses
|
||
agent callers through the same guard and human transport as `mosaic access`.
|
||
Neither command accepts `--tester`. No Console page or chief-raised scope-change
|
||
request exists. A stale revision refuses; malformed scope input writes nothing.
|
||
|
||
The setting is `launch.grantScope` in the business organization file. It defaults
|
||
to `{}`, which covers nothing; a missing or invalid value has the same effect.
|
||
Only Jason sets the live value. The supported shapes are:
|
||
|
||
```json
|
||
{
|
||
"gitea": { "agents": ["coder"], "projects": ["app"] },
|
||
"host": { "agents": ["ops"], "hosts": ["web1"], "commands": [["systemctl", "status", "nginx"]] }
|
||
}
|
||
```
|
||
|
||
Either method may be omitted. All lists are distinct and nonempty. Every target
|
||
project, agent, host and command must be listed for its method. Command arrays
|
||
match token for token with equal lengths: `["ls"]` covers neither `["lsblk"]`
|
||
nor `["ls", "-l"]`. Host enrollment always needs per-grant Approve.
|
||
An in-scope request from the enrolled chief is closed and issued by the protected
|
||
grant executor without an inbox approval; other requests wait for Approve.
|
||
All grants retain their records and revoke path. Narrowing the scope before a
|
||
grant is spent fails that grant with `scope-changed`; existing issued grants
|
||
remain revocable. Setting `{}` removes the setting and restores per-grant Approve.
|
||
Scope changes have organization receipts and a `grant.scope` event with the
|
||
human proof and old/new digests; each automatic approval has `grant.scope.used`.
|
||
|
||
## Access (FED R5, #414)
|
||
|
||
```
|
||
mosaic access list --business ID [--json]
|
||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
|
||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||
mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N
|
||
mosaic access user remove ID --business ID --revision N
|
||
```
|
||
|
||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
|
||
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
|
||
write runs the file's checks on the new document first, bumps the revision by one, and renames
|
||
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
|
||
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
|
||
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
|
||
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
|
||
`<dataRoot>/state/access-log.jsonl`.
|
||
|
||
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
|
||
writable by group or other. If it is not, or a business file fails to load, the command refuses
|
||
with exit 3, names the file and the key path, and writes nothing.
|
||
|
||
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
|
||
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
|
||
once. Removing a provider from `console.sso` takes effect only after a Console restart.
|
||
|
||
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
|
||
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
|
||
|
||
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
|
||
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
|
||
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
|
||
at any business, names the user: revoke those first. `--business` names the business whose bus
|
||
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
|
||
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
|
||
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
|
||
|
||
## 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.
|
||
|
||
### Private tester HTTP (row 196)
|
||
|
||
`node packages/cli/src/tester-http.mjs GET /api/config` selects the instance's
|
||
console/tester.sock from its dataRoot. POST reads one bounded JSON body from stdin.
|
||
The helper supplies the fixed logical origin, reports status/text/body as JSON,
|
||
and never retries an uncertain write. It cannot navigate a browser or upgrade the
|
||
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
|
||
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
|
||
|
||
## Instance processing switch
|
||
|
||
`mosaic halt [--business ID]` and `mosaic resume [--business ID]` use the human
|
||
management path even when the CLI channel is disabled. The business chooses
|
||
proof context; the affected scope is every managed workload in the resolved
|
||
data root. Read `mosaic processing status --json` after an unknown or partial
|
||
result. Resume refuses unconfirmed stops and a stale generation. A restarted
|
||
`bus start --pm` keeps its control bus up while halted and skips the chief.
|
||
The switch does not clear other launch/Discord brakes or retry tasks. Native
|
||
diagnostic launch stays available. See packages/processing-control/README.md.
|
||
|
||
### Independent thread sessions (264e)
|
||
|
||
`mosaic session list <thread>` lists logical sessions and the saved default. `show`
|
||
reads metadata/count; `open <thread> <session>` requests that session's normal guarded
|
||
launch. Reads and `create <thread> <name> --op <key>` spend nothing and start no run.
|
||
Use `--json` for receipts and `--business <id>` as on the thread commands. A session
|
||
reference is its full ID, unambiguous ID prefix (8+ characters), or exact name in that
|
||
thread. Creating or opening a session never changes the saved default. Legacy thread
|
||
commands continue using that default.
|
||
|
||
`mosaic session clear <thread> <session> --yes --op <key>` requests safe context
|
||
replacement. The confirmation preserves old history and all old workspace files,
|
||
including dirty/untracked/unpushed work. The successor gets a fresh checkout at the
|
||
approved revision; old work is not silently copied. No forced stop is authorized.
|
||
The command waits up to 30 seconds by reading `session.operation`, then prints pending
|
||
if stop proof/capacity is outstanding. Keep the operation key: `mosaic session operation
|
||
<thread> <key> --json` reads the recorded outcome. A transport failure never triggers an
|
||
automatic second clear. Count/clear dependencies that are not installed report
|
||
unavailable; unsupported or incomplete count evidence reports unknown, never false zero.
|
||
|
||
The Console Chat thread pane offers the same chooser, New session and Clear context.
|
||
Links preserve the selected session; drafts, paging cursors and receipts belong only
|
||
to that session. Clear status can be checked with its operation key without another
|
||
write. Read-only listeners disable writes. Until the reviewed multi-user integration,
|
||
these operations retain the legacy human's own-thread authority; they grant neither
|
||
collaborator session writes nor organization-owner access to others' private context.
|
||
|
||
To address a nondefault session explicitly, use `mosaic talk --thread <thread>
|
||
--session <full-session-id> <text>`, `mosaic follow --thread <thread> --session <id>`,
|
||
or `mosaic thread read|open <thread> --session <id>`. Omitting `--session` preserves the
|
||
legacy saved-default behavior; opening a chooser session never redirects later sends.
|
||
|
||
`mosaic session cancel <thread> <clear-op-key> --yes` cancels a clear using its recorded
|
||
operation key. Before retirement this restores the old session to active without
|
||
replaying cancelled inputs. After retirement it cancels successor startup only; the
|
||
old session stays retired. A successor that already started refuses cancellation.
|
||
The Console's Check clear operation field and Cancel clear action use the same key
|
||
and require confirmation. Cancellation adds no force-stop or kill-switch authority.
|
||
Both before/after outcomes appear as `cancelled`, with `retired: null` or `true` in JSON.
|
||
If a routed message is stored but cancelled because its session is clearing/retired,
|
||
the CLI prints its ID, reports the cancellation and exits 3 without waiting for a reply.
|
||
|
||
### Organization commands
|
||
|
||
On a fresh named development instance, `mosaic org template render ID --fixture
|
||
local --output FILE`, `mosaic org validate FILE`, and `mosaic org create ID
|
||
--from FILE` prepare an organization. `mosaic org show ID [--json]` and
|
||
`mosaic org list [--json]` read it; `mosaic org update ID --from FILE
|
||
--if-revision N` requires the revision from show. Each configured project gets
|
||
its own derived root.
|
||
|
||
`mosaic org validate FILE` checks a draft by its path and prints `valid`; it
|
||
never looks up an installed organization. `mosaic org config validate ID` and
|
||
`mosaic org config resolve ID INSTANCE [--project ID]` read the installed
|
||
organization `ID` and print the same JSON as `mosaic business validate|resolve`
|
||
(`packages/organization/README.md`). A draft file in the working directory
|
||
named like an installed ID changes neither: each command reads only its own
|
||
kind of input. `mosaic org instances|instance|veto` are the role instance
|
||
commands under "Role instances" above. The config and instances commands
|
||
never use the dev-local-owner context, so they don't need a named development
|
||
instance.
|
||
|
||
`mosaic org queue init ID --local-only --accept-local-loss-risk` initializes
|
||
its checkout; `mosaic org project repo init ID PROJECT --local-only
|
||
--accept-local-loss-risk` initializes a project. For either command `--remote
|
||
URL` selects remote mode instead. These commands never push or overwrite an
|
||
existing repository. Local-only acceptance records the risk of losing the only
|
||
copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project
|
||
repo show ID PROJECT [--json]`. See `packages/organization/README.md` for the
|
||
layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
|
||
if both are supplied they must name the same ID.
|
||
|
||
`mosaic org delete ID --confirm ID --if-revision N [--json]` stops the
|
||
organization bus, moves its local organization document, history, runtime and
|
||
organization-specific secret directories into the instance's private
|
||
`deleted/` directory, and removes it from the registry. The ID may then be
|
||
reused. The deleted folder contains a receipt; `deleted/deleted.log` is an
|
||
append-only index. Remote repositories and remote credentials are untouched.
|
||
Purging deleted data and revoking remote credentials are separate operations and
|
||
are not provided by this command.
|
||
|
||
If deletion stops, the organization remains in `deleting` state and cannot
|
||
start a bus. Inspect the reported receipt and correct the refusal, then resume
|
||
with `mosaic org delete ID --confirm ID`; the revision is only required for the
|
||
first attempt. Completed moves are not rolled back. A held lifecycle lock can
|
||
mean a bus start is still in progress; if a process crashed and left its lock,
|
||
inspect it before removing it and resuming. Moves use rename and therefore
|
||
require the source and deleted folder to be on the same filesystem.
|
||
|
||
`mosaic org archive` and `mosaic org delete` stop the organization bus, so like
|
||
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
|
||
their usage checks and before any config is read.
|
||
|
||
`mosaic org restore ID --if-revision N` makes the organization active and starts
|
||
its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It
|
||
makes that verb's checks before the restore, so a stamped build or an instance
|
||
that refuses `--dev-local-owner` refuses the restore before anything changes
|
||
(exit 3). Starting a bus is a human action, so inside an agent run `org restore`
|
||
refuses like `bus start` (exit 3) before any config is read. The receipt's `bus`
|
||
names the outcome: `started` with the host pid, or `failed` with the message and
|
||
the verb that starts it (after `mosaic bus recover ID` when a host that died left a
|
||
stale lock). A bus that does not start leaves the restore in place;
|
||
the command prints the receipt and exits 3. `org restore ID --if-revision N
|
||
--no-bus` restores without starting the bus, as before, and stays open to agents.
|
||
|
||
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3):
|
||
|
||
```
|
||
mosaic org role-templates ORG [--json]
|
||
mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT [--json]
|
||
mosaic org fire ORG --name NAME [--level org|project|root] [--project P] --reason TEXT [--json]
|
||
mosaic org autonomy ORG [--json]
|
||
mosaic org autonomy ORG set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver NAME|""] [--json]
|
||
mosaic org autonomy ORG review cross-role|merge|policy|authority [--json]
|
||
```
|
||
|
||
Each sends the page's own request to the running Console's org route, as
|
||
`org reload` does, so the Console runs the same owner, `--send`, origin, body
|
||
and session checks and the same bus verbs. `hire` and `change` read the role
|
||
templates first and send the chosen template's name and sha256. `autonomy set`
|
||
needs the 64-hex `--revision` that `mosaic org autonomy ORG` prints and sends every
|
||
setting the panel sends, changing only the named ones; a stale revision is
|
||
refused `revision-conflict`. `--authority-resolver ""` clears the resolver, as
|
||
emptying the panel's field does, and `--reverse-window-hours` takes 1 to 168.
|
||
These verbs need a named instance whose Console runs with `--dev-local-owner`;
|
||
the default instance is refused with exit 3. A Console refusal exits 3 with its code and
|
||
message, an answer that never came exits 1, an unknown template exits 2. All of
|
||
these verbs, reads included, refuse inside an agent run (exit 3) after their
|
||
usage checks (exit 4) and before any config is read.
|
||
|
||
`mosaic tasks create|edit|archive` are the task board's three writes. The
|
||
arguments pass the board's own check, then go to the bus as the person
|
||
(`tasks.create`, `tasks.edit`, `tasks.archive`), so the bus runs its human,
|
||
field, digest and archive checks. Edit and archive need the task's digest from
|
||
`mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict`
|
||
(exit 2) and nothing is written. They refuse inside an agent run.
|
||
|
||
System types are installed plug-ins. `mosaic org system-types installed` prints
|
||
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
|
||
ids. The owner changes the enabled list through the existing organization file
|
||
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
|
||
shows selected, active and inactive ids. The owner replaces the selection with
|
||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||
inactive. Project settings changes are append-only and require human proof.
|
||
|
||
## Accounts
|
||
|
||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||
and takes no business selector or bus connection. It has no organization rights
|
||
verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled
|
||
account or changed authentication generation loses account authority immediately;
|
||
a legacy access.json root grant is checked independently. Organization membership
|
||
evaluation follows in later Phase M slices.
|
||
|
||
```sh
|
||
mosaic accounts init --name Operator
|
||
mosaic accounts list --json
|
||
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
|
||
mosaic accounts user disable u-0123456789ab --revision 2
|
||
mosaic accounts user enable u-0123456789ab --revision 3
|
||
mosaic accounts user signout u-0123456789ab --revision 4
|
||
mosaic accounts user remove u-0123456789ab --revision 5
|
||
```
|
||
|
||
Init creates one operator account containing every configured `console.sso`
|
||
subject and refuses if accounts.json already exists. Add mints its id and defaults
|
||
to instance role `user`; `--role operator` creates another operator. Writes require
|
||
the file revision from list. Disable, enable and signout advance `authGen`, and
|
||
the last active operator cannot be disabled or removed. Removal scans ownership
|
||
while holding the registry then accounts locks, refuses if the account owns an
|
||
organization (including archived organizations) or any registry entry lacks its
|
||
owner metadata during creation, and removes its memberships in
|
||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||
|
||
Managed accounts instances use the system units from `scripts/case2-unit.sh`.
|
||
The selected uid must use a non-login shell, be the only member of its primary
|
||
group, and own its config directory and data root. The shared Case 1/Case 2
|
||
installer writes `<dataRoot>/.deployed` as mode 0444 naming the instance.
|
||
`webui-service.sh` refuses accounts and deployed instances because its user
|
||
manager runs under a human or development seat uid. The receipt uid identifies
|
||
the invoking process; it does not attribute a human or resist tampering by that uid.
|