# Bus, Console and mosaic CLI on one pinned copy (2026-10-12) Status: written by Sage, lead. It follows `docs/plans/BRIEF-TEMPLATE.md`. It is the fix plan in my comment 52 on Vikunja #225, which Mos asked for in sage-0094 ("Tell me the plan before anything touches the wrapper or the unit") and sage-0095 ("Keep the one-copy row brief going"). It follows queue row 77 (#1571), whose round 2 makes `webui-service.sh install --copy` refuse unless the bus runs from the same copy. Gitea #1579. ## Bus, Console and mosaic CLI on one pinned copy (#225) ### Problem On 2026-10-12 Jason ran `mosaic talk pm "Can you hear me now?"` and got `mosaic: human-required` (#225). The bus accepts a human proof only from its own `packages/bus/src/human-cli.mjs`: `verifyHuman` compares the proof process's `argv[1]` with `resolve(cliPath)` (`packages/bus/src/human.mjs:52-102`), and `cliPath` comes from the bus's own tree (`packages/bus/src/runtime.mjs:68`). The CLI spawns the human-cli of its own tree (`packages/cli/src/transport.mjs:12`), and so does the Console (`packages/webui/src/bus.mjs`). Today three things run from three trees: - the bus unit, `ExecStart=/mnt/storage/src/mosaic-stack/scripts/mosaic bus start %i`, rendered by `scripts/bus-service.sh` from `packages/cli/systemd/mosaic-bus.service.in` with REPO set to the script's own checkout; - the `mosaic` wrapper (Q81, `~/.npm-global/bin/mosaic`), which runs a linked worktree at `~/.local/share/mosaic-cli/stack`, pinned at d3ca2c22, six bus and CLI commits behind; - the Console, which runs from the checkout today and from a row 77 copy under `~/.local/share/mosaic-console//` once row 77 lands and is installed. So the proof works only from one tree at a time. The stopgap is the full path, `/mnt/storage/src/mosaic-stack/scripts/mosaic talk pm ...`, which Jason confirmed works (6b5b7aef). The bus also runs code from the moving checkout, so every landing in `packages/bus` or `packages/cli` is live at its next restart, the same problem row 77 fixes for the Console (lead decision 109). And the unit template has no `--pm` (`packages/cli/src/cli.mjs:265-279`), so no PM session ran until Mos added a temporary drop-in that sets it (sage-0096; the S7 run log, 2026-10-12T18:49Z). Accepting a second path in the proof (#225 option B) is rejected: it adds a trusted path and keeps the three trees on different code. ### Owner and reviewer Owner: Rocko. Reviewers: Darkwing and Filbert. Rocko has no Gitea token; Sage runs the review request as jarvis. This row starts when row 77 is done, and it goes ahead of row 84, since S7 comes before M1 (Q97). ### Files owned - `scripts/stack-copy.sh`, new - `scripts/webui-service.sh`, only the `copy` verb, the copies directory and the same-copy check row 77 round 2 adds - `scripts/bus-service.sh` - `packages/cli/systemd/mosaic-bus.service.in` - tests for the above, under `packages/cli/tests/` and `packages/webui/tests/`, or a new `scripts/test-stack-copy.sh` - `packages/cli/README.md`, the "Bus host" section - `packages/webui/README.md`, the service section - `docs/TOOLS.md`, the entries for the three scripts only - `agents/rocko/work/queue-/` ### What ships One copy for all three: - `scripts/stack-copy.sh copy COMMIT [--copies DIR]` makes the copy. It takes over row 77's copy code and rules: canonical checkout only (the `--allow-worktree` test override stays), the full 40-character id, a temp directory beside the target and a rename, write permission removed, and an existing copy left as it is (`exists:`). COMMIT must be reachable from both `refs/heads/refactor` and `refs/remotes/origin/refactor`, since the wrapper runs pushed code only (Q81). - The default directory is `${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies//`. `webui-service.sh copy` stays as a call into `stack-copy.sh copy`, so row 77's documented steps still work, and both scripts read the same directory. - The path list grows to what the bus host, the CLI and the Console load: row 77's list, plus `scripts/mosaic`, `adapters/`, `packages/queue/src` if any copied path imports it, `package.json` and `package-lock.json`. The script runs `npm ci` in the temp directory before the rename, so the copy carries the pi pin's `node_modules` and `pi-pin.mjs`'s lock check passes there (`packages/conversation/src/pi-pin.mjs:52-57`). Row 61 adds `packages/tasks/src` and `packages/tasks/fields.json` to what the Console loads (Darkwing, 2026-10-12), so the list carries them. The script refuses if `package.json` is missing from the temp directory, so `npm ci` can never walk up. Rocko derives the final list from the imports and records how in the packet. - Tests build a copy from a fixture repository, then from that copy: `bus start` reaches `bus host up` with a stand-in config, `cli.mjs serve` answers `/healthz` 200, and `scripts/mosaic talk` gets past the human proof against a broker started from the same copy. A path missing from the list fails the suite. The proof across copies, as tests (with the agent-environment check stubbed as `packages/bus/tests/human.test.mjs` already does, never bypassed in the code): - a human-cli from the same copy as the broker is accepted; - a human-cli from another copy, or from the checkout, is refused with `human-required`. The bus unit: - `bus-service.sh render` and `install` take `--copy ID` and `--pm`. `--copy ID` names an existing copy by its full id, and the template's `@REPO@` becomes the copy's path, so `WorkingDirectory`, `ExecStart` and `Documentation` point at it. Without `--copy` the unit renders as today, so a fresh install from the checkout still works, and `status` says the unit runs the checkout. - `--pm` renders `ExecStart=/scripts/mosaic bus start %i --pm`. A PM refusal at start exits 3, which the unit doesn't retry (`RestartPreventExitStatus=2 3 4`), so the host stays down and its journal names the refusal. The README says so. - `install` refuses while any `mosaic-bus@` instance is active, activating or deactivating, with row 77's N1 wording (Jason or Mos stops it first), and with row 77's N4 rule for a failed `systemctl` read. - `status` prints the copy id the unit runs, read from its `WorkingDirectory`, whether `--pm` is in its `ExecStart`, and whether `refactor` points at another commit. It changes nothing. - The `--claude PATH` pin (lead decision 91) is unchanged and is rendered next to `--copy` and `--pm`. - Mos's temporary drop-in, `~/.config/systemd/user/mosaic-bus@mosaic-stack.service.d/pm.conf` (sage-0096, 2026-10-12T18:53:34Z), resets `ExecStart` to add `--pm` until this row lands. A drop-in overrides the installed unit, so `status` and `check` read the effective `ExecStart` and `WorkingDirectory` from `systemctl --user show`, not from the unit file, and `status` lists any drop-in files. `install` refuses while a drop-in for `mosaic-bus@` sets `ExecStart` or `WorkingDirectory`, naming the file. The update step removes `pm.conf` before the install. The check and the wrapper: - `stack-copy.sh check` prints the tree each of the three runs: the bus unit's `WorkingDirectory`, the Console unit's `WorkingDirectory`, and the copy the `mosaic` wrapper execs. It exits 0 only when all three name the same copy, and 1 otherwise, naming each path. It changes nothing. A missing unit or wrapper counts as a mismatch, named as absent. - `stack-copy.sh wrapper ID` rewrites `~/.npm-global/bin/mosaic` to exec `/scripts/mosaic`. It keeps the Q81 lease line that sends `__lease-capability` to `mosaic-old`, and keeps the Q81 marker on line 2. It refuses unless the current file carries that marker and isn't a symlink, and it writes a temp file, then renames it. `--bin DIR` points it at a test directory. Tests cover the refusal on a foreign file and the lease line. The update step, documented once in `docs/TOOLS.md` and pointed to from both READMEs: `stack-copy.sh copy COMMIT`; stop the Console, then the bus; remove any `mosaic-bus@` drop-in that sets `ExecStart` (the first time, Mos's `pm.conf`) and run `systemctl --user daemon-reload`; `bus-service.sh install --copy ID --pm` with its flags; `webui-service.sh install --copy ID` with its flags; `stack-copy.sh wrapper ID`; start the bus, then the Console; `stack-copy.sh check`, `webui-service.sh status`, `/healthz` and `scripts/mosaic launches list` showing the PM. Rollback is the same steps with the earlier id. A bus restart ends the running sessions (`packages/cli/src/launcher.mjs:304-320`), so the docs say to do it when no PM turn is in flight. The stops, starts, installs and the wrapper rewrite are Jason's or Mos's (lead decisions 94, 102 and 103, and Mos's sage-0094: nothing touches the wrapper or the units before the row lands and Mos says go). Suites: the cli, bus and webui node suites, and every `scripts/test-*.sh`. ### Out of scope Running any step of the update on the live system: stopping, starting, installing or enabling either unit, and rewriting the real wrapper. Removing the Q81 worktree at `~/.local/share/mosaic-cli/stack` or `mosaic-old`; that's Mos's or Jason's after the first update. Pruning old copies. A health-gated activate and rollback like `scripts/release.sh`, which is D1. Any change to the human proof itself (`packages/bus/src/human.mjs`). Any change to `packages/queue`; `mosaic queue` from a copy refuses as it does from the Q81 worktree today, and queue work stays on the checkout's `scripts/mosaic`. Relaunching a PM whose session ended while the host runs. ### Gate Darkwing and Filbert approve on the row's issue, each naming the candidate manifest. The cli, bus and webui node suites and every `scripts/test-*.sh` pass on Sage's gate rerun. Before the landing Sage notifies Mos (decision 109); after it, Sage sends Mos the commit and `scripts/webui-service.sh status`, `scripts/bus-service.sh status mosaic-stack` and `/healthz`. #225 closes when Mos's first update step at the landed commit ends with `stack-copy.sh check` exiting 0, and Jason's plain `mosaic talk pm "..."` printing `sent` and then a reply.