Files
stack/packages/cli/README.md
T
jason.woltjeandClaude Opus 5.5 fe5c68fa03 feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.

Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.

Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 12:22:50 -05:00

357 lines
18 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). Brief: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6. 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 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 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>] [--business <id>]
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.
- `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.
- `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 every
message that arrives and marks it read, and stops at the reply to its
REQUEST. No reply in time exits 1; the reply still lands in the inbox.
`--wait 0` only sends.
- `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.
## 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 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.
4. 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.
5. 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.
6. 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`); `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`. 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 to `pi` or `claude-code` and a model | `unsupported-harness` |
| its role has a contract file | `no-role-contract` |
| its model names exactly one `launch.max` family | `unknown-model-family` |
| that family has room; every running session counts, the PM's too | `capacity-full` |
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
| the bundle builds and the runner starts | `launch-failed` |
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), builds the bundle in the run directory, spawns the runner
under `unshare`, binds the outer 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.
The manifest records the Pi version from the repository's pin (the
`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
and, for Claude Code, `claude --version`.
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; `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,
`unshare` 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.
### 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 [--dir DIR] [--no-reload]` writes the unit through a temp file and
a rename, then runs `systemctl --user daemon-reload`.
- `uninstall` refuses while an instance is active.
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`
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.
## 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).