Row 103, the row 100 follow-up. Built by Darkwing, packet
agents/darkwing/work/queue-103/r1/, candidate manifest
6e5ac8c0782bc595d7c0d711d457738ffb42f1ba5f38253912943dd55e55888a.
Sage approved on #1597, comment 28252; Dewey approved, comment 28260.
Host git runs through runGit: a detached group, SIGTERM at the limit,
SIGKILL after the grace, and one more SIGKILL to the group after a
signalled git closes. git-socket close() waits at most 2 s. startHost
claims bus-host/host.lock by hard link before the broker fork.
packages/cli/README.md and tests/host.test.mjs carry row 85's hunks
too; row 85 lands first.
Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CLI
Jason's front door to the bus (slice 1 row S4, #1521): mosaic inbox,
decide, tasks, agents and trail, and the bus host mosaic bus start|stop|status with its Discord notifier (row S4, #1521); the session
launcher in the host and mosaic talk, stop and launches (row S6,
#1523); and mosaic with no argument, the full-screen app (queue row 55,
#1544). Briefs: docs/plans/2026-10-04_slice-1.md, rows S4 and S6, and
docs/plans/2026-10-10_design-implementation.md, "Full-screen terminal
app". Rulings: lead decisions 70 and 77 in
docs/plans/2026-09-26_lead-decisions.md.
Every command reads through the broker. None opens the SQLite file.
scripts/mosaic # the app, on a terminal
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
scripts/mosaic bus status
node --test 'packages/cli/tests/*.test.mjs'
Human commands
mosaic inbox [--business <id>] [--json]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic 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.
inboxlists 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.--jsonkeeps its existing decision-row array.decidetakes 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. Onoutcome-unknownit does not resend: it exits 1 and asks you to checkmosaic inboxormosaic trail <id>first.trailprints rows in the broker's order (at, table, seq). A decision's trail ends with itstask_refandfollow with: mosaic trail <task>. It does not pull in the task's rows.talksends<text>to the role as a REQUEST, then polls the human's inbox every 2 s for up to--waitseconds (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 may still come, and the nextmosaic talkprints it.--wait 0only sends.talk --followkeeps printing and marking messages read until Ctrl-C, including messages with noin_reply_to.followruns that same loop without sending.--waitand--followtogether refuse. These commands consume the same human inbox; run one receiving CLI at a time. Console only reads history.launches offandonare the broker'slaunch.revokeandlaunch.restore. While off, everyrole.launchrefuses withlaunch-revoked; running sessions keep running. Stop them withstop.bus start --pmdoesn't ask the broker (launchPmskipsauthorizeLaunch): the human launches the PM, solaunches offdoesn't stop it. The other checks in "Sessions" below still apply.stopandlaunches listread<dataRoot>/bus-host/sessions.json, not the bus (see "Sessions" below).stoprefuses inside an agent run;launches listdoes not, because the file is readable to the same user anyway.
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 rswitch views (Talk, Inbox, Tasks, Agents, Trail).↑↓orj kmove, PgUp and PgDn page, Enter opens the selection whole,vopens its trail,:the command line,?help, Esc closes a box,qor 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
npicks the selected decision'snth option and runsresolveDecisioninsrc/cli.mjs, the codemosaic decideruns. The box shows the same lines and the same[y/N]question.yorYresolves; 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 as1 yes, or keys typed while a read held the screen, never answer it. Afteroutcome unknownthe 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()frompackages/conversation/src/terminal.mjs, so control characters and bidi overrides show as^[and<U+202E>. - Talk (
c) points tomosaic talkfor now. The conversation pane andstopcome in a later row. - 16 ANSI colours. Under
NO_COLORthe 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,yincluded. 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 tomosaic 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 ay.yresolves 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'
spawnSyncchild, so the screen does not redraw while a read is in flight (up to its 30 s timeout). - On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
resize redraw included) the app restores the screen, the cursor and
cooked mode. After anything but an error it then prints
(app closed with q; N decisions resolved this session); an error goes to stderr instead.
Bus host
mosaic bus start <business> [--pm]
the host; run by the unit, not by hand.
--pm also launches the business's PM
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus status [--json] host state file, socket, writer.lock
bus start does the following:
- 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 haslaunches: []andreaders: [<business>]. It getsconfig.trackers(below) when a tracker is set. - It reads the notifier config (below).
- It takes
<dataRoot>/bus-host/host.lock(0600), which holds the same state ashost.json(step 6). It writes the state to a file of its own and hard-links it in ashost.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 beforehost.lockexisted holdshost.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. Each refusal below removeshost.lockagain, so a host that didn't start leaves no file. - It forks
packages/bus/src/process.mjsand waits up to 30 s for the boot reply. If the broker answers{ok: false, error}, the host exits 3 and printsbroker refused to start: <code>. A timeout or an early exit gives exit 1. - It forks
src/notifier-process.mjsand hands it the reader capability over IPC in{op: 'start'}. A notifier refusal closes the broker, and the host exits 3. - 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 statusand 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. - It starts the session launcher (
src/launcher.mjs, "Sessions" below) and, with--pm, launches the business'slaunch.byinstance. 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 to pi or claude-code and a model |
unsupported-harness |
| its role has a contract file | no-role-contract |
a Claude Code session has CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL to log in with |
no-session-credential |
| bwrap runs here | sandbox-unavailable |
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 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 |
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
it leaves alone is a checkout-kept line in the launch log with the
project and the reason, and the launch goes on. The bundle's prompt names
each clone's path. Then the launcher builds the bundle in the run
directory, spawns the runner in its bwrap sandbox (packages/seat/README.md,
"The sandbox"), binds the outer bwrap process's pid and start time, adds the
session to sessions.json, and writes the capability as one line on the
runner's stdin. The capability is never on disk, in argv or in the
environment. When the runner exits, the launcher sends endLaunch with the
end reason from its exit code (packages/harness/README.md), which writes
session.ended, releases the role and kills the run's capabilities.
A Claude Code session logs in with CLAUDE_CODE_OAUTH_TOKEN,
ANTHROPIC_API_KEY, or ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL
(a proxy's key), from the host's environment, which the allowlist passes
on. The bus unit loads it ("Unit" below). Nothing from the host's
~/.claude reaches a session, whose HOME is its own. With none of them
set, a Claude Code launch refuses no-session-credential before anything
starts. A Pi session gets the same variables through the allowlist. A
shell the harness opens, Claude Code's Bash or Pi's bash, can read
them.
A Pi session logs in only through the variables ENV_ALLOW passes. Its
HOME is the run's own, so Pi never reads the operator's ~/.pi/agent
(auth.json, models.json, settings.json). A Pi launch has no
credential check: a Pi role on a provider whose login lives only in those
files, or whose key isn't an ENV_ALLOW variable, launches and then fails
at its first turn.
The manifest 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, --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.
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). The sandbox binds it only for a role that
holds git.push.working or review.verdict, and 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 |
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. 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 thehavelist the runner sent, so there is no bundle to send. The commit is already in one of its clones underrefs/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 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. 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 runssystemctl --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.uninstallrefuses while an instance is active.status <business>shows the unit's state and itsEnvironment, which includes the pinned claude.
The unit settings:
ExecStart=@REPO@/scripts/mosaic bus start %iRestart=on-failureRestartPreventExitStatus=2 3 4, so refusals are never retriedKillSignal=SIGTERMTimeoutStopSec=60Environment=PATH=:/usr/local/bin:/usr/bin:/bin, with node's directory first when it isn't one of thoseEnvironment=MOSAIC_CLAUDE_BIN=: the Claude Code binary that managed sessions and the manifest's version runEnvironmentFile=: the Claude Code session credential (below)
MOSAIC_CLAUDE_BIN comes from --claude PATH, or else command -v claude
in the installing shell. The path is kept as given, so the native install's
~/.local/bin/claude, a symlink into its versions directory, keeps
following its updates; reinstall to change it. It must be absolute, an
executable file, and only A-Z a-z 0-9 / . _ + -, because systemd expands
% and splits Environment= on spaces; anything else refuses with exit 1
and writes nothing. With no claude found, the unit gets a comment line
instead and sessions run claude from the unit's PATH, which in the
sandbox finds only a claude under /usr. A running host
keeps the environment it started with until it restarts.
The session credential comes from
EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env, the
one place the path is set. The file holds one line, for example
CLAUDE_CODE_OAUTH_TOKEN=<token>, mode 0600. The operator writes it;
nothing in the repository creates or reads it, and no session sees the
file itself, only the variable. The leading - lets the unit start
without it, so Pi sessions still launch and a Claude Code launch refuses
no-session-credential. To change the credential, edit or swap the file
(or this line and this paragraph), then restart the unit; a running host
keeps the environment it started with.
The unit has no network-online.target ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
One pinned copy (row 85, #1579)
The bus, Console and mosaic wrapper must run the same copy: the broker's human proof accepts only its own human-cli entry path. stack-copy.sh creates a pushed, read-only tree with pinned npm dependencies under mosaic-stack/copies/. 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. 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 one update and rollback plan is TOOLS.md, Pinned stack update. Each live switch/unit change/stop/start requires Mos's go; agents build and test fixtures only. Restarting the bus ends its sessions, so no PM turn may be in flight. Queue writes stay on the canonical scripts/mosaic.
Notifier
The notifier is a child of the host. Each poll (every 30 s) it reads the inbox through the reader capability and does two things:
- DMs. It DMs each open blocking decision once. The DM holds the
question, the options, the recommendation, what approving authorizes, the
task, and
mosaic decide <short id> <option>. - Digest. It sends one digest a day at 08:00 America/Chicago. The hour comes from the IANA zone, so daylight saving time is handled. If the host starts after 08:00 and the day has no digest yet, the digest goes at once. The digest lists the inbox. It marks each blocking decision with one of three states: "DM sent", "DM pending" or "DM refused, not retried". An empty inbox gets one line. Each message stays within Discord's 2000 characters.
The notifier only reads the bus. Its memory is the journal
<dataRoot>/notify/<business>/sent.jsonl (directory 0700, file 0600), with
one line per send attempt:
{"at": "…", "kind": "dm", "decision": "<id>", "outcome": "confirmed", "messageId": "…"}
{"at": "…", "kind": "digest", "decision": null, "day": "2026-10-08", "outcome": "unknown", "messageId": null}
- A decision, or a day's digest, counts as sent once it has a
confirmedline. - A
refusedorunknownsend is retried. After anunknownoutcome 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
atin 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 onegave-upline, 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. Anunknownoutcome (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 withmosaic decide. - On open, a final line without its newline is a torn write. The notifier
copies those bytes to
torn-<UTC stamp>.binin the same directory (0600, a new file, synced), then truncatessent.jsonlto 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:
atan ISO timestamp,kinddmordigest,decisiona string (required fordm, null fordigest),daya realYYYY-MM-DDdate (required fordigest),outcomeone ofconfirmed,refused,unknownorgave-up(gave-uponly fordm),messageIda string (required when confirmed) or null,statusan 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.jsonlis a symlink, a directory or not writable, or is not a regular 0600 file you own. The message names the path. - No Discord channel or user id goes in the journal, a log line or an error.
The Discord side is packages/discord/src/notify.mjs. It uses the
connector's REST client and the binding's tokenFile, and sends to the
binding's fixed dmRecipient, who must be one of the binding's users.
Notifier config
<dataRoot>/notify/<business>/notify.json, mode 0600, owned by you. The
journal shares the directory, so create it 0700 first (mkdir -m 0700 -p <dataRoot>/notify/<business>). A host with a binding refuses with exit 3 on
a looser directory.
{"notifyVersion": 1, "binding": "sage-seat"}
"binding": null runs the host without DMs. A missing file refuses with
exit 3, so a host never starts until someone decides whether it DMs.
Exit codes
| Code | Meaning |
|---|---|
| 0 | ok |
| 1 | failed, or outcome unknown: check before retrying |
| 2 | invalid input: unknown option, no such open decision, decision already closed, no such run, not a runner |
| 3 | refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start |
| 4 | usage |
Limits
- One broker per data root. The bus store takes
<dataRoot>/bus/writer.lock. The unit is a template per business, but only one instance can run on one data root. digest.sentis 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. stopis 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
stopresend 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. talkand 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
trackersboot case is tested once S3's broker change lands.
Not in this piece
The WebUI (S5).