# Row 150 (#1644): the human marks skipped messages read, and the Console says what talk prints Brief: `docs/plans/2026-10-15_talk-followups.md`, section "The human marks skipped messages read, and the Console says what talk prints" (blob b1c00f1285d4). Worktree base: eff8417b6 for round 1, 63c82f1c4 for round 2 (see "Round 2" at the end). Rows 142 and 146 are in both bases. ## The command ``` mosaic inbox --read [...] [--business ] [--tester] mosaic inbox --read-all [--business ] [--tester] ``` Both start with the page walk `mosaic inbox` already does: `messages` with `limit: 200` and `after`, keeping the messages with `read` false. The ids are resolved against that list. - An argument shorter than 8 characters refuses with `too-short`, as `decide` does for a decision reference. An empty argument would otherwise match every message. - An argument resolves when exactly one unread message's id starts with it, so the 8-character id `mosaic inbox` prints works, and so does a full id. - Two or more matches refuse that argument with `ambiguous` and name each match in full. Nothing is read for it. - No match and a shorter-than-full argument refuses with `no-unread-match`. - No match and a full message id (36 characters) goes to `message.read` as given. The broker decides: a message that isn't the human's refuses `message-not-found`, and one already read returns `{read: true}` again. This is how a full id from `mosaic trail` or the Console reaches the broker, and how a non-human id gets the broker's code instead of a local guess. - `--read-all` takes every message from the walk. It marks them oldest first. Every argument is tried, whatever happened to the ones before it. stdout gets one line per argument, `marked ` or `not marked : `, and then `marked N of M`. `--read-all` prints only the refusals and the count, since the walk can list hundreds. If any argument was refused, the command exits 2 after printing everything, with `N of M not marked` on stderr. Errors that aren't about one message stop the command at once with their usual exit code: an auth refusal (3), `outcome-unknown` (1), a transport failure. The per-message refusals are the codes `busExit` maps to 2. `--read` and `--read-all` together refuse (usage, 4). Either with `--json` refuses too; the JSON inbox prints only decisions today and a second JSON shape for one flag isn't worth it. `--read` with no ids refuses (usage). `--tester` with either flag refuses too: the tester's `inbox` lists the human's messages, but the tester's `message.read` looks for a message to the tester, so every id would refuse `message-not-found`. Commands that aren't `inbox` don't accept `--read` or `--read-all`; each refuses as an unknown option. ## Undelivered messages `message.read` needs a `delivered` row first, and refuses `not-recipient` otherwise. A message the human has never received has none: one that arrived while no `talk` or `follow` ran is unread and undelivered. Row 142's skipped messages are delivered, so they read at once. So on `not-recipient` the command calls `message.receive` until a batch holds the message or a batch comes back empty, then tries `message.read` once more. A second refusal is printed like any other. `message.receive` takes the oldest 100 undelivered messages per call, and `--read-all` works oldest first, so one receive usually covers the next 99 reads. The cost: every message that receive call delivers stops being printed by a later `mosaic follow` or `talk --follow`, which print only undelivered messages. That is already true of every message plain `talk` skips since row 142. The command never prints a message it receives, and one it receives but doesn't mark stays unread in `mosaic inbox`, so nothing goes missing. A message that arrives after `--read-all`'s walk can be delivered by its receive and left unread. It shows in the next `mosaic inbox`. The alternative was to receive everything before reading anything. That delivers messages a single `--read` has no reason to touch, so I didn't. ## The Console text The send section now says that a reply shows under the receipt and stays unread in `mosaic inbox` until it is marked read, and names `mosaic inbox --read`. The failure text under a receipt said `mosaic talk` prints the replies; after row 142 it prints only replies to its own message, so that line now points to `mosaic inbox` too. Both are in the send section, and both are asserted in `send-browser.test.mjs`. The comment above the send section in `bus.js`, the replies test's comments, and the `broker.mjs` comment above the `replies` verb say the same. ## Docs - `docs/TOOLS.md`: the `inbox --read` usage line, and a sentence beside `--follow` that names both flags and points to the cli README. - `packages/cli/README.md`: the usage line and an `inbox --read` entry under "Human commands" (resolution, `too-short`, `ambiguous`, `no-unread-match`, output, exit 2, the receive and what it costs, no `--json` or `--tester`). The Console sentence now says a reply to a Console send stays unread in `inbox` until it is marked read. The `--tester` exclusions name `inbox --read` and `--read-all`. - `packages/bus/src/broker.mjs`: the comment above the `replies` verb only. No code changed. ## Tests, against the brief's list All in `packages/cli/tests/mark-read.test.mjs` except the Console text. | Brief | Test | |---|---| | Marking one message | "inbox --read marks one message by the id inbox prints, delivered or not, and leaves the rest unread" (a delivered message by its 8-character id, then an undelivered one by its full id) | | An ambiguous prefix refused | "inbox --read refuses an ambiguous or short prefix, names every match, reads nothing for them and marks the rest" (also `too-short` for a 4-character and an empty argument) | | An id that isn't the human's refused, the rest still marked | "inbox --read refuses an id that is not the human's, prints its code, still marks the rest and exits 2" (`message-not-found` from the broker and `no-unread-match` locally, exit 2, "2 of 4 not marked") | | `--read-all` leaves `mosaic inbox` at 0 unread | "inbox --read-all marks every message the inbox walk lists, delivered or not, and inbox then shows 0 unread" (230 messages, more than one page and more than one receive batch) | | The new Console text | `packages/webui/tests/send-browser.test.mjs`, whose assertions now name `mosaic inbox` and `mosaic inbox --read` | Also new: "inbox --read stops on an error that is not about one message" (`outcome-unknown` exits 1 and prints nothing) and "inbox --read and --read-all refuse bad combinations; other verbs refuse them" (exit 4, no broker call). The brief lists `not-recipient` as a refusal to print. The command meets it by receiving first, so `not-recipient` prints only when the message is still undelivered after the receive loop. No test reaches that, because the real broker always delivers the message by then. ## Candidate `r1/build.patch` against BASE `eff8417b6` (sha256 `8d4f29a6ae75819662860cd561ede0658880400a96bf519bfc24ceacface0212`). `r1/candidate-manifest.sha256` (sha256 `17d118eb74ce887a1d0d37f044ed107de23c67072a9dc2203d704a01e35f9ec8`) lists 7 files: 6 changed and `packages/cli/tests/mark-read.test.mjs`, new. I applied the patch to a fresh worktree at BASE: `sha256sum -c` passed on every line and `git diff --cached --check` was clean (7 files, +204 -23). Canonical HEAD was `e7158ab14`, and `git diff eff8417b6 e7158ab14` on the 7 paths is empty. ### The public/ diff, for Mos's decision 123 go `r1/public-diff.patch` is the `packages/webui/src/public/` part of the candidate: `bus.js` only, the send-section text and the comment above it. Its sha256 is `096d922c75503db5bb67db0fb2e4c3b8be79d6d1ed9f1831ad81861ae3bfdca6`, also in `r1/public-diff.patch.sha256`. Mos gave the decision 123 go on this sha256, `096d922c75503db5bb67db0fb2e4c3b8be79d6d1ed9f1831ad81861ae3bfdca6` (sage-0560). It lands after Filbert approves (28787) and Sage's gate is green. A public/ diff with any other sha256 goes back to Mos. `mosaic inbox --json` printing decisions only (base `cli.mjs:193`, candidate line 263) is outside this row: the candidate leaves that line as it was and only refuses `--json` together with `--read` or `--read-all`. ## Checks The slot was mine from 19:22:57Z (sage-0554). This round was targeted (decision 136): `r1/gate.sh` ran each touched or adjacent test file on its own with TMPDIR `~/darkwing-scratch/r150/tmp`, no Docker. Output is in `r1/gate-run.log`, step logs in `r1/gate/`. It ran from 19:22:58 to 19:23:33 UTC and ended ROUND_PASSED. | Test file | Pass | Fail | |---|---|---| | cli mark-read (new) | 6 | 0 | | cli follow | 6 | 0 | | cli cli | 12 | 0 | | cli tester | 7 | 0 | | cli app | 30 | 0 | | cli format | 3 | 0 | | webui send-browser | 2 | 0 | 0 skipped in each. Afterwards no node or chromium process of mine had a cwd under `~/darkwing-scratch` (`sweep.sh` found 0), the r150 TMPDIR was empty, and `sha256sum -c` on the manifest passed in the worktree. Not run: the cli, bus and webui suites as a whole, the scripts/test-*.sh and mutants. Sage's gate at the landing target runs the suites. ## Round 2 (sage-0575): rebase onto 63c82f1c4 Filbert approved round 1 (28800). Row 139 then landed on `packages/bus/src/broker.mjs` (63c82f1c4, +62 -15 on that file), so the r1 manifest hash for that path no longer matches the landing target. Under the base-drift rule the candidate comes back for a round. None of the other six paths changed between eff8417b6 and 63c82f1c4, or between 63c82f1c4 and canonical HEAD `690224f4a`. ### What changed: only the base `r1/build.patch` applied to a worktree at 63c82f1c4 with `git apply --index --3way`, every file cleanly. `r2/build.patch` is `git diff --cached` of that worktree. The interdiff of r1 against r2. With the `index` and `@@` lines removed, the two patches are byte-identical (`diff` prints nothing). Those lines differ only in the broker.mjs file header, where the base blob moved and the hunk sits 47 lines lower: ``` < index 031634bb8..32c2397cb 100644 < @@ -1710,8 +1710,10 @@ export class Broker { --- > index 61ba1e4bd..f7258d656 100644 > @@ -1757,8 +1757,10 @@ export class Broker { ``` The broker hunk is still the comment above the `replies` verb and nothing else: ``` - // lists still reaches the next `mosaic talk`. An id naming no message in the business - // refuses message-not-found, as message.send does for in_reply_to; trail would return []. + // lists stays unread in `mosaic inbox` until the human marks it read (`mosaic inbox + // --read`, row 150); since row 142 `mosaic talk` prints only replies to its own message. + // An id naming no message in the business refuses message-not-found, as message.send does + // for in_reply_to; trail would return []. ``` The manifests differ on one line, broker.mjs: r1 `753c9368...`, r2 `9096792e599707c07c1394781590d4b04e8ce9ed334b9f6e3fc39b2f30141af7`. The other six hashes are the same. ### The public/ diff is unchanged `r2/public-diff.patch` is `git diff --cached -- packages/webui/src/public/` of the r2 worktree. Its sha256 is `096d922c75503db5bb67db0fb2e4c3b8be79d6d1ed9f1831ad81861ae3bfdca6`, and `cmp` against `r1/public-diff.patch` finds no difference. Mos's decision 123 go (sage-0560) still covers it. ### Chosen shape: receive, then retry (Filbert's note 3) The brief lists `not-recipient` as a refusal to print. The candidate receives first and retries the read, and I'm recording that as the chosen shape. Without it, `--read-all` can't reach 0 unread while any message was never delivered. The cost: one `message.receive` call delivers up to 100 messages, so the other received messages besides the one being marked are now delivered. They drop out of a later `mosaic follow` or `talk --follow`, which print only undelivered messages. They stay unread in `mosaic inbox`, so `inbox` still lists them. Plain `talk` has left skipped messages in the same state since row 142, and the cli README says so. ### The retry has no test (Filbert's note 2) No test reaches a second `not-recipient` from the retry after the receive loop. Filbert couldn't find a way to reach it for a human message in the current broker either. Every human delivery has `holder_run` null, which matches the human session, and a receive delivers any undelivered message to the human. So no human message reaches that branch. If one ever does, the command prints it with its code like any other per-message refusal. ### Candidate round 2 `r2/build.patch` against BASE `63c82f1c4` (sha256 `19d383466c7a628dcdb28d9353dc8e6f138f041b40d0142d56256db348aef5ed`). `r2/candidate-manifest.sha256` (sha256 `7556dda0fe75a1f76dd7423824da9c508ac48e44310740316e5f2e5d1fa34df9`) lists the same 7 files. I applied `r2/build.patch` with `git apply --index` to a second fresh worktree at 63c82f1c4: `sha256sum -c` passed on every line and `git diff --cached --check` was clean (7 files, +204 -23). ### Checks round 2 Sage granted the slot (sage-0578). Before the start, a `/proc` check found no `node --test` process with a cwd outside my scratch. `r2/gate.sh` ran each file on its own on the r2 worktree at 63c82f1c4, with TMPDIR `~/darkwing-scratch/r150/tmp` and no Docker, from 2026-10-16T19:55:19Z to 19:55:47Z, and ended ROUND_PASSED. Output is in `r2/gate-run.log`, step logs in `r2/gate/`. | Test file | Pass | Fail | |---|---|---| | cli mark-read | 6 | 0 | | bus replies | 7 | 0 | | bus messages | 4 | 0 | | bus threads | 14 | 0 | | bus hard-waits (row 139's test) | 12 | 0 | | webui send-browser | 2 | 0 | 0 skipped in each. Afterwards `sweep.sh` found no node or chromium process of mine under `~/darkwing-scratch`, the r150 TMPDIR was empty, and `sha256sum -c` on the r2 manifest passed in the worktree. Not run this round, per sage-0575: cli follow, cli, tester, app and format, which round 1 ran on eff8417b6 and whose sources didn't move; the cli, bus and webui suites; the scripts/test-*.sh; mutants. Sage's full gate at landing covers them.