Files
stack/packages/cli
jason.woltjeandClaude Opus 5.5 390d437ba2 feat(tasks): the adapter reads a project tree (#1555, row 62)
With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.

Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.

Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); 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, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.

Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:29:13 -05:00
..

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>] [--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.

The app

mosaic

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

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

Bus host

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

bus start does the following:

  1. It reads the system config through scripts/mosaic-config.mjs validate, the business file and the role files, and builds the broker's {op: 'boot'} message (src/config.mjs). The message has launches: [] and readers: [<business>]. It gets config.trackers (below) when a tracker is set.
  2. It reads the notifier config (below).
  3. It 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:

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

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

Notifier config

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

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

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

Exit codes

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

Limits

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

Not in this piece

The WebUI (S5).