diff --git a/agents/filbert/work/queue-95/build.md b/agents/filbert/work/queue-95/build.md
new file mode 100644
index 000000000..9d66b440c
--- /dev/null
+++ b/agents/filbert/work/queue-95/build.md
@@ -0,0 +1,172 @@
+# Row 95 (#1589): the Board shows the stack's seats, and a sign-out line
+
+Filbert, 2026-10-12. Brief: `docs/plans/2026-10-12_pm-test-fixes.md`, "The
+Console's Board shows the stack's seats and a sign-out" (blob ae867dfa,
+unchanged). Darkwing reviews. Sage commits after the gate. Nothing in the
+candidate is committed, staged or pushed.
+
+Base a3f6e9e6. Nothing has landed on the manifest paths since: `git diff --stat
+a3f6e9e6..f9db19d7` on `packages/webui/src/public`, `packages/webui/tests`,
+`packages/webui/README.md` and `serve.mjs` is empty. `build.patch` applies at
+a3f6e9e6 with `git apply --index`, and `candidate-manifest.sha256` pins all 10
+files after the patch. `serve.mjs` is unchanged: the held roles come from the
+existing `/api/bus/agents` read.
+
+## What ships
+
+- **Held roles first.** The Board's first section, "Held roles", lists each
+ role a run holds on the bus, from the `agents` read: role, state (with the
+ working count), attention, address, holder run with its harness, and held
+ since. A claim with no address shows "no address". It's read with the Inbox
+ count on every Board render (the 10 s poll and Refresh). A refusal shows no
+ roles; any other failure keeps the last read, labelled with its time, as the
+ bus pages do. Focus stays on the same Message button or Retry across the
+ repaint.
+- **Message.** Each row has a Message button when the Console takes a send
+ (`send` in `/api/config`). It sets the form's role, goes to `#/inbox` and puts
+ the caret in the message box. Without `--send` there is no button, and the
+ section says messaging needs a Console started with `--send`.
+- **Show machine sessions.** A checkbox in the Board's head, off by default.
+ While it's off the page never requests `/api/board` (no first read and no
+ poll), and the projects tree, Waiting on you, Seen, All sessions and the
+ board banners are hidden. Turning it off drops the scan, the selection and
+ any open conversation, so turning it on starts from a fresh read. The choice
+ is kept in `localStorage` as `mosaic-console-board` = `{"machine":bool}`.
+- **Signed out.** Every Console fetch in `app.js` and `bus.js` passes
+ `redirect: 'manual'`: the board and conversation reads, the board's Seen and Reply POSTs,
+ `/api/config` (both), the bus and module reads, and the send. The first
+ `opaqueredirect` dispatches `mosaic-signed-out`. `app.js` sets
+ `body.signed-out`, and both scripts then:
+ - stop their timers and make no further Console fetch (each fetch helper
+ checks first, and Refresh and Pause are disabled);
+ - drop everything drawn from a read: the sessions, the inspector, any
+ conversation, the held roles, the kept bus reads and the bus page;
+ - show one line, "Signed out. Reload the page to sign in.", focused only if
+ focus was lost, never pulled out of the send form.
+
+ The send form stays with its draft and its receipt. A send answered with a
+ redirect says "Not sent … the sign-in proxy redirected it …; the draft is
+ kept". A send after sign-out says "Not sent … signed out; reload the page to
+ sign in again" and fetches nothing. A read in flight at the sign-out is
+ dropped when it lands.
+- **README**: a new section, "Board: held roles and signing out (row 95,
+ #1589)", the "Not sent" bullet's redirect case, and the new test file in
+ Verify.
+
+## Tests
+
+- New `tests/board-held.test.mjs`, two browser tests against an in-process
+ broker with pm (address `pm-run`) and coder (no address) holding claims:
+ 1. Held roles with machine sessions off: the two rows, "no address", the
+ count, the Message labels; the toggle unchecked, no `/api/board` request;
+ on, the sessions show and the setting is kept across a reload; off, the
+ sessions go and a reload reads no board; Message for each role lands in
+ `#/inbox` with that role and the caret in the message; with no send, no
+ Message button. Every request asked for `redirect: 'manual'`.
+ 2. A redirect answer on a Board read (`/api/board` on Refresh), a bus read
+ (`/api/bus/agents` on Refresh), a conversation read (`/api/conversations`
+ from History), a board POST (`/api/seen` from the inspector's Seen), a
+ module read (`/api/queue` on the Queue page) and a send. Each time: the
+ line once, no NetworkError, "did not answer" or "not read" text (the
+ footer stays visible), no sessions or held roles left, Refresh disabled,
+ and no further request on a route change, Refresh, the toggle twice or
+ the poll. The send keeps its draft and form.
+- `send-browser.test.mjs`: after its redirect-send case, the page is signed
+ out; the test checks the line and that a further send fetches nothing, then
+ reloads before its remaining cases.
+- `browser-edge.test.mjs`: with storage denied, the toggle starts off and still
+ turns on.
+- `browser.mjs`: `browser({ machine })`, default on (see choice 5).
+- `shell.test.mjs`: the 1440 short-page check is 1000 px tall (choice 6).
+
+## Choices
+
+1. **"Kept in the page's settings" is `localStorage`.** The other Board
+ filters (Hide offline, Hide seen) aren't kept across a reload, so "like the
+ other Board filters" can't mean their storage. I read it as: a checkbox with
+ the other filters, kept the way the page keeps its appearance settings
+ (`mosaic-console-appearance`). Refused storage starts it off.
+2. **No board read while off.** Hiding the sessions while still polling the
+ scan would spend a 1.3 s read every 10 s on data nobody sees. Off means no
+ request.
+3. **Message only with `--send`.** The brief says Message opens the send
+ form; the form exists only on a Console that takes a send. A button that
+ leads to a page saying "this Console only reads" is worse than a line on the
+ Board saying so.
+4. **The send form stays when signed out.** "Doesn't show stale data as
+ current" covers the reads. The draft is the person's own text, so it stays,
+ and a further Send says why it wasn't sent instead of fetching. A board
+ Reply in the inspector goes with the rest of the Board: its draft isn't
+ shown again (the README says so).
+5. **`browser()` defaults to machine sessions on.** The existing suites open 16
+ browsers in 11 test files, and most of them read the board scan. Seeding
+ the setting is one line in the harness; the alternative was a click in each. Row 95's own first test passes
+ `{ machine: false }` to see a first visit.
+6. **The shell check is 1000 px tall.** It asserts a short Board doesn't
+ scroll. With the held roles added, its two-session Board is 967 px, so 900
+ failed. The 420 px case still checks a scrolling page.
+7. **The in-flight read race.** Test 2 found that a `/api/board` read in
+ flight when a bus read signed the page out landed afterwards and redrew the
+ sessions. `accept()` and `error()` now return when signed out; mutants 10
+ and 11 cover them.
+8. **Overlap with row 77.** Row 77 (#1571, round 2 in review) also edits
+ `packages/webui/README.md`, in other sections. Whichever lands second needs
+ a rebase of that file.
+
+## Gate
+
+Run in my worktree (`a3f6e9e6` plus the candidate), sequential,
+`DOCKER_HOST` at a dead socket, 2026-10-12T21:27:35Z–21:30:19Z
+(`gate/summary.txt`):
+
+- webui 86/0, control-board 124/0;
+- every `scripts/test-*.sh` exited 0: auth 15, conductor 17, config 24,
+ discord 66, extension-package 18, foundation 44, queue 27, release 4 and
+ task 26. test-queue skipped its canonical-root part (a worktree), and
+ test-release skipped its Docker cases.
+
+The first gate (20:22:51Z–20:25:43Z, same counts, `gate1/`) ran before the
+first mutant round. That round left 9 of 35 alive (`mut/out-round1.txt`). Four
+were gaps:
+- no test redirected a conversation read, a board POST or a module read, so
+ dropping `redirect: 'manual'` from `getJSON`, from `api`'s POST or from
+ `fetchApi` survived;
+- dropping `error()`'s signed-out guard survived. Without it, a redirected
+ Board read writes "Board not read: the read failed" into the footer, which
+ stays visible when signed out, and the test didn't look there.
+
+Test 2 now redirects `/api/conversations` from History, `/api/seen` from the
+inspector's Seen and `/api/queue` on the Queue page, and checks the page for
+"not read" and "last known data". `app.js` and `bus.js` didn't change, and
+`board-held.test.mjs` is the only file whose digest moved.
+
+**Mutants** (`mut/mutants.py`, `mut/out.txt`), round 2,
+21:03:18Z–21:27:35Z: 35 edits to `app.js` and `bus.js`, each matching once,
+each run against board-held, send-browser, browser-edge and browser. 30 were
+killed and both files were restored byte for byte. The 5 survivors are
+redundant guards behind another check that holds:
+
+| Survivor | Why nothing changes |
+| --- | --- |
+| 13 `schedule` polls with the toggle off | `refresh()` returns on `!machine` (12, killed) |
+| 14 `refresh()` without its signed-out guard | `api()` throws before fetching, and `error()` returns on `err.signedOut` (11, killed) |
+| 27 bus `schedule` polls signed out | `render()` returns on `out()` (26, killed) |
+| 28 Board render without its signed-out return | the listener's `gen++` returns the render before any paint; only `last` is refilled, and nothing reads it again |
+| 29 bus view render without its signed-out return | the same `gen++` check, before the view is drawn |
+
+I kept them, so that no single check stands between a signed-out page and a
+fetch or a stale paint. Mutants 10 and 11 (choice 7) are killed.
+
+The mutant 19 run (toggle on reads nothing) times out at 900 s in both rounds.
+The run's timeout killed only `node --test`, so its file runner and browser
+were left, one per round. Darkwing saw them, and I killed both by PID at
+21:23:55Z. The `mutants.py` here starts each run in its own process group and
+kills the group on a timeout. That's the only change from the copy that
+produced `out.txt`.
+
+## Not tested here
+
+- The real Firefox NetworkError: the tests stub `fetch` to answer an
+ `opaqueredirect`, which is what Chromium and Firefox both return under
+ `redirect: 'manual'`. Live, a pod replacement at the edge is the check.
+- Live after the Console restart Mos approves (decision 109).
diff --git a/agents/filbert/work/queue-95/build.patch b/agents/filbert/work/queue-95/build.patch
new file mode 100644
index 000000000..e91811ea3
--- /dev/null
+++ b/agents/filbert/work/queue-95/build.patch
@@ -0,0 +1,790 @@
+diff --git a/packages/webui/README.md b/packages/webui/README.md
+index be26d050..cf91674c 100644
+--- a/packages/webui/README.md
++++ b/packages/webui/README.md
+@@ -186,7 +186,7 @@ On the page:
+ - Not sent shows the broker's code and keeps the draft. A redirect counts
+ as not sent: the Console never redirects this route, so only the sign-in
+ proxy does, when its session has expired. The page asks for a reload to
+- sign in again.
++ sign in again, and signs out (row 95, below).
+ - Outcome unknown says the message may have been sent, keeps the draft, and
+ has no trail to link, since no id came back. Check the role's side, or
+ the bus trail from a terminal, before sending again. A 408 counts as
+@@ -312,6 +312,47 @@ there are others. The page says what each state means. It writes nothing;
+ the server passes the rows through unchanged. How the bus derives the state
+ and its limits: `packages/bus/README.md`, "Agent state".
+
++## Board: held roles and signing out (row 95, #1589)
++
++The Board (`#/`) opens with **Held roles**: each role a run holds on the
++bus, from the same bus `agents` read as `#/agents`, including a claim with
++no address ("no address"). Each row shows the role, its state and attention
++(as in "Agent state"), the address, the holding run with its harness, and
++since when. With send on (`--send`, "Message a role"), each row has a
++Message action: it opens `#/inbox` with the send form addressed to that role
++and the caret in the message. Without send there is no action, and the
++section says so. A refused or failed first read says why and shows no roles
++rather than a guess; a failed later read keeps the last roles under a stale
++warning.
++
++The machine scan's sessions (Waiting on you, Seen, All sessions and the
++project list) sit behind **Show machine sessions**, off by default. While
++it is off the page makes no `/api/board` request and polls nothing for it.
++The choice is kept in this browser's local storage under
++`mosaic-console-board` (`{"machine":true}`), like the appearance; where
++storage is refused it starts off on each load. Turning it off drops the
++board data and closes an open conversation.
++
++Every Console fetch the page makes uses `redirect: 'manual'`. The Console
++never redirects, so a redirect answer (`opaqueredirect`) is the sign-in
++proxy's: its session has ended. The first one, on a board read, a bus read,
++a page read or a send, signs the page out:
++
++- One line says "Signed out. Reload the page to sign in." No NetworkError,
++ failure banner or stale warning is shown for it.
++- Everything drawn from a Console read is removed (the board, the held
++ roles, the bus views, an open conversation and the inspector), because
++ none of it can be refreshed. A read that was in flight is dropped when it
++ lands.
++- Refresh and Pause are disabled; nothing polls and no route change reads.
++- The send form, if open, keeps its draft and receipt so the text isn't
++ lost; a further Send is refused in the page ("signed out"). A board
++ Reply that meets the redirect is not sent, and its inspector closes with
++ the rest of the board, so its draft isn't shown again.
++- Reloading the page goes through the sign-in proxy again.
++
++How long a sign-in lasts is the edge's setting (ops-01), not the Console's.
++
+ ## Shell (row 53, #1542)
+
+ The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
+@@ -501,6 +542,18 @@ the return flow: a send from the view, a tool call and a delayed result while a
+ draft is typed, a peer message, a 4.5-million-character answer split into
+ continuation parts, and a relaunch mid-turn.
+
++Row 95's tests (`board-held.test.mjs`) seed an in-process broker with one
++claim that has an address and one that has none. They check the held roles
++with the machine sessions off by default and no `/api/board` request, the
++toggle on and off and its persistence across reloads, Message addressing
++the send form to each role, no action without send, and that every fetch
++asked for `redirect: 'manual'`. Then a redirect answer on a board read, an
++`agents` read and a send: one line, no NetworkError, nothing stale left, no
++further request on a route change, Refresh or the toggle, and the draft
++kept. `send-browser.test.mjs` also checks the sign-out after its redirect
++receipt and that a reload works again. Since the held roles lead the Board,
++the shell test's no-scroll check at 1440px uses a 1000px-tall window.
++
+ Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
+ views against an in-process broker read through its reader session, so every
+ row has the broker's own shape. `humanCall` runs against a stub CLI script.
+diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
+index 9b50235c..3587fd71 100644
+--- a/packages/webui/src/public/app.js
++++ b/packages/webui/src/public/app.js
+@@ -8,6 +8,15 @@
+ const awaiting = new Map();
+ let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
+ let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
++ // Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
++ // page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
++ let machine = false;
++ try { machine = JSON.parse(localStorage.getItem('mosaic-console-board'))?.machine === true; } catch {}
++ // Signed out (#1589): a Console fetch answered with a redirect, so the sign-in proxy turned it away.
++ // The first one sets body.signed-out (the listener below); after that no Console fetch is made.
++ const out = () => document.body.classList.contains('signed-out');
++ const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
++ const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
+ const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
+ const source = value => value ? `${esc(labels[value] || value)}` : '';
+ // Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
+@@ -97,7 +106,9 @@
+ const FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' };
+ const convURL = params => '/api/conversation?' + new URLSearchParams(params);
+ async function getJSON(path) {
+- const res = await fetch(path, { cache: 'no-store' });
++ if (out()) throw signedOut();
++ const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
++ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
+ let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
+ return { ok: res.ok, body };
+ }
+@@ -281,6 +292,7 @@
+ }
+ // Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
+ function fresh() {
++ if (!machine) { $('fresh-board').textContent = 'Machine sessions hidden'; return; }
+ const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null;
+ if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board not read: refused' : failed ? 'Board not read: the read failed' : 'Board not read yet'; return; }
+ const scan = `Board scanned ${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}${ago ? ` (${ago} ago)` : ''}`;
+@@ -288,12 +300,15 @@
+ }
+ setInterval(() => { if (data) fresh(); }, 5000);
+ async function api(path, value) {
+- const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
++ if (out()) throw signedOut();
++ const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
++ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
+ const result = await res.json();
+ if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
+ return result;
+ }
+ function accept(value) {
++ if (out()) return; // a read that was in flight when the page signed out
+ if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
+ data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
+ $('board-refused').hidden = true; document.body.classList.remove('board-refused');
+@@ -306,6 +321,7 @@
+ // its scan time. With no data, from a refusal or before the first scan, the board is drawn empty
+ // and says why, so a later failure never brings back old rows.
+ function error(err) {
++ if (err.signedOut || out()) return;
+ const refused = err.status === 403, focused = document.activeElement !== document.body;
+ failed = refused ? 'refused' : true; $('error').hidden = false;
+ if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
+@@ -327,13 +343,36 @@
+ // Clearing the board or closing a conversation can remove what had focus: give it to the page.
+ if (focused && document.activeElement === document.body) $('main').focus();
+ }
+- function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
++ function schedule() { clearTimeout(timer); if (!paused && machine && !out()) timer = setTimeout(refresh, 10000); }
++ // A board answer that arrives after the toggle went off is dropped, not drawn.
+ async function refresh() {
+- if (busy) return;
++ if (busy || !machine || out()) return;
+ busy = true; $('refresh').disabled = true;
+- try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); }
+- finally { busy = false; $('refresh').disabled = false; schedule(); }
++ try { try { const value = await api('/api/board'); if (machine) accept(value); } catch (err) { if (machine) error(err); } await pollConversation(); }
++ finally { busy = false; $('refresh').disabled = out(); schedule(); }
+ }
++ // With the toggle off, nothing from the scan stays on the page: the next time it goes on, the board
++ // starts from a fresh read rather than showing an old scan as current.
++ function hideMachine() {
++ clearTimeout(timer);
++ if (conv) closeConversation();
++ data = null; selected = null; project = null; failed = false; inspect();
++ $('error').hidden = true; $('board-refused').hidden = true; document.body.classList.remove('board-refused');
++ $('projects').innerHTML = '
Machine sessions hidden.
';
++ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
++ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
++ $('footer').textContent = 'Mosaic Stack';
++ $('status').textContent = 'Machine sessions hidden. Turn on Show machine sessions to read this machine\'s board scan.';
++ fresh();
++ }
++ function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
++ $('show-machine').onchange = e => {
++ machine = e.target.checked; showMachine();
++ try { localStorage.setItem('mosaic-console-board', JSON.stringify({ machine })); } catch {}
++ if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
++ };
++ showMachine();
++ if (!machine) hideMachine();
+ $('refresh').onclick = refresh;
+ $('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
+ $('hide-offline').onchange = $('hide-seen').onchange = render;
+@@ -373,6 +412,7 @@
+ if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
+ if (result.delivered && !paused) await refresh();
+ } catch (err) {
++ if (err.signedOut) { receipts.set(id, { error: `Signed out: ${err.message}. Not sent.` }); return; }
+ // A 4xx is a refusal before anything ran (the Console's, or the board's: see replyToRow), so
+ // nothing was sent. No answer, an unreadable one, a 5xx or a 408 (a proxy can answer it
+ // after forwarding) leaves delivery unknown.
+@@ -388,5 +428,20 @@
+ const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
+ if (next !== undefined) { e.preventDefault(); buttons[next]?.focus(); }
+ });
+- api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(error).finally(refresh);
++ // Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
++ // line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
++ document.addEventListener('mosaic-signed-out', () => {
++ if (out()) return;
++ document.body.classList.add('signed-out');
++ clearTimeout(timer); convGen++;
++ if (conv) { $('conv-log').replaceChildren(); conv = null; showConversation(false); }
++ data = null; selected = null; project = null; inspect();
++ for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
++ $('refresh').disabled = $('pause').disabled = true;
++ const line = $('signed-out');
++ line.innerHTML = '
Signed out. Reload the page to sign in.
'; line.hidden = false;
++ // Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
++ setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
++ });
++ api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
+ })();
+diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
+index 8b4bcf43..e5a8e179 100644
+--- a/packages/webui/src/public/bus.js
++++ b/packages/webui/src/public/bus.js
+@@ -24,24 +24,32 @@
+ // can show what was read before, labelled as such.
+ const last = new Map();
+ class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
++ // Row 95 (#1589): every Console fetch takes `redirect: 'manual'`. A redirect comes only from the
++ // sign-in proxy, so the session ended: app.js's listener sets body.signed-out and draws the line,
++ // and from then on no read is made. A signed-out read throws code `signed-out`, which no view shows.
++ const out = () => document.body.classList.contains('signed-out');
++ const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
++ const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
+ // `replies` takes a message id as `id`; trail takes `subject`.
+ async function fetchRead(verb, subject) {
++ if (out()) throw signedOut();
+ let res, body;
+- try {
+- res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' });
+- body = await res.json();
+- } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
++ try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
++ catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
++ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
++ try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
+ if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
+ return { rows: body.rows, at: body.at };
+ }
+ // Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
+ // error carries the module's own message, which the server has already redacted.
+ async function fetchApi(path) {
++ if (out()) throw signedOut();
++ const unreachable = () => Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true });
+ let res, body;
+- try {
+- res = await fetch(`/api/${path}`, { cache: 'no-store' });
+- body = await res.json();
+- } catch { throw Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true }); }
++ try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
++ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
++ try { body = await res.json(); } catch { throw unreachable(); }
+ if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
+ return body;
+ }
+@@ -263,7 +271,7 @@
+ const options = roles ? roles.map(r => ``).join('') : '';
+ const r = receipt;
+ const said = !r ? '' : r.kind === 'sent'
+- ? `
Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.
${repliesHtml()}`
++ ? `
Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.
Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (${txt(r.code)})`}. The draft is kept.
`
+ : `
Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.
`;
+@@ -407,6 +415,38 @@
+ const d = asks[0];
+ return `Input needed: ${txt(first(d.question))}${txt(short(d.id))}${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` +${esc(asks.length - 1)} more` : ''}`;
+ }
++ // Row 95 (#1589): the Board's first section, the stack's seats: each held role from the bus
++ // `agents` read, a claim with no address included. Message opens the send form addressed to the
++ // role, so it shows only where a send can pass (`send` in /api/config). A refusal keeps nothing;
++ // any other failure keeps the last read, labelled with its time, as the bus pages do.
++ let heldErr = null, heldHtml = null, focusSend = false;
++ function heldTable(claims) {
++ const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
++ const rows = claims.map(c => `
${txt(c.role)}
${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}
`;
++ }
++ function paintHeld(err) {
++ heldErr = err;
++ const box = $('held'), r = last.get('agents');
++ let html;
++ if (err && (refusal(err) || !r)) html = `
${refusal(err) ? `refused: ${txt(err.code)}. ` : ''}${why(err)} Console shows no held roles rather than a guess.
${retry}
`;
++ else if (!r) return; // not read yet: the skeleton stays
++ else {
++ const claims = r.rows;
++ html = `${err ? `
The last read failed. ${why(err)} These are the roles read before, ${when(r.at)}. Nothing was changed.
${retry}
` : ''}
++ ${claims.length ? heldTable(claims) : empty('No role is held on the bus right now.', 'A role shows here while a run holds its claim on the bus. Machine sessions are under Show machine sessions.')}
++
Read from the bus ${when(r.at)}.${canSend ? '' : ' Messaging a role needs a Console started with --send behind the sign-in proxy; this one only reads.'}
`;
++ }
++ $('held-count').textContent = err && (refusal(err) || !r) ? '–' : String(r.rows.length);
++ if (html === heldHtml) return; // unchanged: no repeated alert, focus stays put
++ // Keep focus on the same control across the 10 s repaint.
++ const a = document.activeElement, k = box.contains(a) ? { role: a.dataset?.messageRole, retry: a.hasAttribute?.('data-retry'), wrap: a.classList?.contains('table-wrap') } : null;
++ box.innerHTML = html; heldHtml = html;
++ if (k) {
++ const el = k.role ? [...box.querySelectorAll('[data-message-role]')].find(b => b.dataset.messageRole === k.role) : k.retry ? box.querySelector('[data-retry]') : k.wrap ? box.querySelector('.table-wrap') : null;
++ (el || $('main')).focus({ preventScroll: true });
++ }
++ }
+ async function agentsView(get) {
+ const claims = await get('agents');
+ const rows = claims.map(c => `
${txt(c.role)}
${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}
`).join('');
+@@ -616,7 +656,7 @@
+ }
+ let gen = 0, shown = null, timer;
+ const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
+- function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
++ function schedule() { clearTimeout(timer); if (!paused() && !out()) timer = setTimeout(() => render(false), 10000); }
+ // Keep focus, open and the copy status across a refresh of the same page.
+ // In the send form, the focused field and its caret too (its values come from `draft`).
+ function keep() {
+@@ -640,6 +680,7 @@
+ async function render(navigated) {
+ // A refresh due while this render reads would replace it: a navigation would lose its heading focus.
+ clearTimeout(timer);
++ if (out()) return;
+ const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
+ const g = ++gen;
+ document.body.classList.toggle('on-bus', !board);
+@@ -648,9 +689,18 @@
+ if (board) {
+ // The board page keeps its own title; the Inbox count still follows the bus.
+ if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
+- try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } }
+- catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
+- if (g === gen) schedule();
++ // The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
++ const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
++ const failed = [inbox, agents].filter(s => s.status === 'rejected').map(s => s.reason);
++ if (failed.some(err => err.code === 'signed-out')) return;
++ if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
++ if (agents.status === 'fulfilled') last.set('agents', agents.value);
++ const refused = failed.find(refusal);
++ if (refused) forget();
++ if (g !== gen) return;
++ if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
++ paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
++ sections(path); schedule();
+ return;
+ }
+ if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
+@@ -658,6 +708,7 @@
+ const live = reader(true);
+ try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
+ catch (err) {
++ if (err.code === 'signed-out') return;
+ const kept = reader(false);
+ try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
+ catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
+@@ -671,7 +722,10 @@
+ const h1 = $('bus-view').querySelector('h1');
+ document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
+ if (k) restore(k);
++ // Message on the Board (row 95) lands in the form it opened, addressed to the role.
++ else if (focusSend && $('send-body')) { $('send-body').focus(); announce(`Message to ${draft.to}`); }
+ else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
++ focusSend = false;
+ schedule();
+ }
+
+@@ -697,7 +751,9 @@
+ const item = e.target.closest('#cmdk-list [data-href]');
+ if (item) { pkGo(item.dataset.href); return; }
+ if (e.target.closest('#cmdk')) { pkOpen(); return; }
+- if (e.target.closest('#bus-view [data-retry]')) render(false);
++ const message = e.target.closest('#held [data-message-role]');
++ if (message) { draft.to = message.dataset.messageRole; focusSend = true; location.hash = '#/inbox'; return; }
++ if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) render(false);
+ });
+
+ // ---------------------------------------------------------------- toast
+@@ -774,6 +830,7 @@
+ e.preventDefault();
+ if (sending) return;
+ const to = draft.to, text = draft.body;
++ if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
+ if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
+ sending = true; receipt = null; paintSend();
+ let res = null, body = null;
+@@ -795,9 +852,24 @@
+ } else receipt = { kind: 'unknown', to };
+ sending = false; paintSend();
+ announce(receipt.kind === 'sent' ? `Sent to ${to}` : receipt.kind === 'refused' ? 'Not sent; draft kept' : 'Outcome unknown; it may have been sent; draft kept');
++ if (res?.type === 'opaqueredirect') signOut();
++ });
++ // Signed out: drop every bus read, kept ones included, and the page drawn from them. The send
++ // form stays, with its draft and receipt, so nothing typed is lost; it sends nothing more.
++ document.addEventListener('mosaic-signed-out', () => {
++ clearTimeout(timer); gen++; forget();
++ for (const el of [...$('bus-view').children]) if (el.id !== 'send-section') el.remove();
++ $('held').replaceChildren(); $('held-count').textContent = '–'; heldHtml = null;
++ sections(routeOf().split('?')[0]); paintSend();
+ });
+- // Whether this server takes a send here. The form is drawn on the next Inbox render.
+- fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(c => { if (c?.send === true) { canSend = true; if (routeOf().startsWith('/inbox')) render(false); } }).catch(() => {});
++ // Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
++ // Message buttons are drawn from the roles already read.
++ fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
++ if (c?.send !== true) return;
++ canSend = true;
++ const path = routeOf().split('?')[0];
++ if (path.startsWith('/inbox')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
++ }).catch(() => {});
+ window.addEventListener('hashchange', () => render(true));
+ $('refresh').addEventListener('click', () => render(false));
+ $('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
+diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html
+index 7faa1883..adbcce42 100644
+--- a/packages/webui/src/public/index.html
++++ b/packages/webui/src/public/index.html
+@@ -11,7 +11,8 @@
+
+
+
+-
Control board
Loading board…
++
Control board
Loading board…
++
Held roles –
The stack's seats: each role a run holds on the bus, from the bus agents read. Machine sessions are under Show machine sessions.
Reading the held roles.
+
+
Nothing to show.
The board refused the read, so Console shows nothing rather than a guess. The reason is above.
+
Conversation
+diff --git a/packages/webui/src/public/shared/app.css b/packages/webui/src/public/shared/app.css
+index de94a98a..6a1a2f2a 100644
+--- a/packages/webui/src/public/shared/app.css
++++ b/packages/webui/src/public/shared/app.css
+@@ -214,3 +214,10 @@ footer.foot{font-size:.8rem;color:var(--muted);padding:16px var(--pad);display:f
+ @media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+ @media (forced-colors:active){.badge,.btn,.chip,.panel,.wait-item,.task-card,.session-card{border:1px solid CanvasText}.badge::before{background:CanvasText}.nav-link[aria-current=page]{outline:2px solid Highlight}}
+ @media print{#toasts,.skip,nav{display:none}.panel{box-shadow:none}}
++
++/* Console Board (row 95, #1589): the held roles lead the Board and leave with it; machine sessions
++ show only under Show machine sessions; signed out, only the line and the send form stay. */
++body.on-bus #held-section,body.has-conversation #held-section{display:none}
++#held-section{margin-bottom:20px}
++body.machine-off #board-view,body.machine-off #board-tree,body.machine-off #error,body.machine-off #board-refused{display:none}
++body.signed-out #board-head,body.signed-out #held-section,body.signed-out #error,body.signed-out #board-refused,body.signed-out #conversation,body.signed-out #board-view,body.signed-out #inspector,body.signed-out #board-tree,body.signed-out #fresh,body.signed-out #bus-view>:not(#send-section){display:none}
+diff --git a/packages/webui/tests/browser-edge.test.mjs b/packages/webui/tests/browser-edge.test.mjs
+index 27813054..446c9cc2 100644
+--- a/packages/webui/tests/browser-edge.test.mjs
++++ b/packages/webui/tests/browser-edge.test.mjs
+@@ -22,6 +22,9 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
+ try {
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
+ await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
++ // Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
++ assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
++ await b.evaluate('document.querySelector("#show-machine").click()');
+ assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
+ await boardRequested;
+ holdBoard = false; boardResponse.end(JSON.stringify(index));
+diff --git a/packages/webui/tests/browser.mjs b/packages/webui/tests/browser.mjs
+index 6aa7ad77..b6a19826 100644
+--- a/packages/webui/tests/browser.mjs
++++ b/packages/webui/tests/browser.mjs
+@@ -4,7 +4,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+ import { tmpdir } from 'node:os';
+ import { join } from 'node:path';
+
+-export async function browser() {
++// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
++// so by default every page this browser opens starts with Show machine sessions on, as if the
++// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
++export async function browser({ machine = true } = {}) {
+ const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
+ const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
+ '--headless', '--no-first-run', '--no-default-browser-check',
+@@ -39,6 +42,7 @@ export async function browser() {
+ const call = (method, params) => send(method, params, sessionId);
+ await call('Page.enable');
+ await call('Runtime.enable');
++ if (machine) await call('Page.addScriptToEvaluateOnNewDocument', { source: `try { localStorage.setItem('mosaic-console-board', '{"machine":true}'); } catch {}` });
+ const evaluate = async expression => {
+ const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
+diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
+index 675c813e..6007e0fc 100644
+--- a/packages/webui/tests/send-browser.test.mjs
++++ b/packages/webui/tests/send-browser.test.mjs
+@@ -44,7 +44,7 @@ const STUB = `(() => {
+ const c = await (await real(url, init)).json();
+ return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
+ }
+- if (u !== '/api/bus/message') return real(url, init);
++ if (u !== '/api/bus/message') { window.__reads = (window.__reads || 0) + 1; return real(url, init); }
+ window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect });
+ const a = window.__answers.shift();
+ if (!a) throw new TypeError('no answer queued');
+@@ -145,6 +145,28 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
+ await send();
+ assert.equal(await receipt(), 'Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again (sign-in). The draft is kept.');
+ assert.equal(await value('#send-body'), msg);
++ // Row 95 (#1589): the redirect also signs the page out. One line says so; the form keeps its
++ // draft and receipt; nothing read before stays; no further Console fetch is made, and a Send
++ // is refused in the page.
++ await wait('!document.querySelector("#signed-out").hidden');
++ assert.equal(await text('#signed-out'), 'Signed out. Reload the page to sign in.');
++ assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), true);
++ assert.deepEqual(await b.evaluate('[...document.querySelector("#bus-view").children].map(e => e.id)'), ['send-section']);
++ assert.doesNotMatch(await b.evaluate('document.body.innerText'), /NetworkError|Failed to fetch|did not answer/);
++ assert.equal(await b.evaluate('document.querySelector("#refresh").disabled && document.querySelector("#pause").disabled'), true);
++ const reads = await b.evaluate('window.__reads');
++ await b.evaluate(`location.hash = '#/tasks', true`);
++ await send();
++ assert.equal(await receipt(), 'Not sent to coder: signed out; reload the page to sign in again (sign-in). The draft is kept.');
++ await new Promise(r => setTimeout(r, 500));
++ assert.equal(await b.evaluate('window.__reads'), reads);
++ assert.equal((await sends()).length, 3);
++ assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
++ // Reloading signs in again (here, nothing redirects) and the page works as before.
++ await b.navigate(`${base}?again#/inbox`);
++ await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
++ assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
++ await set('#send-to', 'coder'); await set('#send-body', msg);
+ // A 408 too: a proxy can answer it after forwarding.
+ for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }, { status: 408, body: { error: 'request timeout' } }]) {
+ await answer(a);
+@@ -155,7 +177,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
+ }
+ // Each Send was one POST; none was repeated after the unknown ones.
+ await new Promise(r => setTimeout(r, 500));
+- assert.equal((await sends()).length, 7);
++ assert.equal((await sends()).length, 4);
+
+ // While a send is pending, Send is disabled and a second press sends nothing; text typed
+ // meanwhile is kept when the first one succeeds.
+@@ -165,18 +187,18 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
+ assert.equal(await text('#send-form [data-send]'), 'Sending…');
+ await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
+ await set('#send-body', 'A second thought');
+- assert.equal((await sends()).length, 8);
++ assert.equal((await sends()).length, 5);
+ await b.evaluate('window.__release(), true');
+ await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
+ assert.match(await receipt(), new RegExp(`^Sent to coder .*, message ${seeded.id.slice(0, 8)}\\. Its trail\\.$`));
+ assert.equal(await value('#send-body'), 'A second thought');
+- assert.equal((await sends()).length, 8);
++ assert.equal((await sends()).length, 5);
+
+ // A plain success clears the text and keeps the role.
+ await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
+ await send();
+ assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']);
+- assert.equal(JSON.parse((await sends())[8].body).body, 'A second thought');
++ assert.equal(JSON.parse((await sends())[5].body).body, 'A second thought');
+
+ // The receipt links the message's trail, which the bus reads.
+ assert.equal(await b.evaluate('document.querySelector("#send-section .receipt a").getAttribute("href")'), `#/trail/message/${seeded.id}`);
+@@ -189,7 +211,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
+ await b.evaluate(`location.hash = '#/trail/message/no-such-message', true`);
+ await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
+ assert.match(await text('#bus-view'), /No message with id no-such-message\./);
+- assert.equal((await sends()).length, 9);
++ assert.equal((await sends()).length, 6);
+ } finally {
+ await b.close(); await close(web); await f.close(); store.close();
+ rmSync(root, { recursive: true, force: true });
+diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
+index fbea206c..2e372903 100644
+--- a/packages/webui/tests/shell.test.mjs
++++ b/packages/webui/tests/shell.test.mjs
+@@ -364,7 +364,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
+
+ assert.deepEqual(await b.evaluate('window.errors'), []);
+ assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
+- await b.viewport(1440, 900); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
++ // 1000px tall: since row 95 the Board leads with the held roles, so the short page is taller.
++ await b.viewport(1440, 1000); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
+ await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); await shot('board-1440');
+ // The side column sits between the command bar and the status footer: a short page has no scroll,
+ // and scrolled to the end of a long one the column is still below the bar.
+diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs
+new file mode 100644
+index 00000000..19b875a8
+--- /dev/null
++++ b/packages/webui/tests/board-held.test.mjs
+@@ -0,0 +1,209 @@
++// Row 95 (#1589): the Board leads with the stack's held roles, from the bus `agents` read, and
++// keeps the machine scan's sessions behind "Show machine sessions", off by default and kept in
++// the page's settings. A redirect answer on any Console fetch is the sign-in proxy's: the page
++// stops and says, in one line, to reload and sign in. The roles come from an in-process broker
++// through its reader session. A script added before the page loads records every fetch the page
++// makes, answers /api/config with send on (unless the URL has ?nosend), answers POST
++// /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does
++// under redirect: 'manual' (type opaqueredirect, status 0).
++import { test } from 'node:test';
++import assert from 'node:assert/strict';
++import { mkdtempSync, rmSync } from 'node:fs';
++import { tmpdir } from 'node:os';
++import { join } from 'node:path';
++import { Store } from '../../bus/src/store.mjs';
++import { Broker } from '../../bus/src/broker.mjs';
++import { views } from '../../bus/src/views.mjs';
++import { startServer } from '../src/serve.mjs';
++import { browser } from './browser.mjs';
++import { fixture, close } from './fixture.mjs';
++
++const businesses = {
++ demo: {
++ id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
++ roles: {
++ pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
++ cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
++ coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
++ },
++ },
++};
++
++const STUB = `(() => {
++ const real = window.fetch.bind(window);
++ window.__reqs = []; window.__answers = []; window.__redirect = [];
++ const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } });
++ window.fetch = async (url, init = {}) => {
++ const u = String(url), path = u.split('?')[0];
++ window.__reqs.push({ path, redirect: init.redirect ?? null });
++ if (window.__redirect.includes(path)) return redirect();
++ if (path === '/api/config' && !location.search.includes('nosend')) {
++ const c = await (await real(url, init)).json();
++ return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
++ }
++ if (path !== '/api/bus/message') return real(url, init);
++ const a = window.__answers.shift();
++ if (!a) throw new TypeError('no answer queued');
++ if (a.redirect) return redirect();
++ return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
++ };
++})();`;
++
++async function setup(machine) {
++ const root = mkdtempSync(join(tmpdir(), 'webui-board-held-'));
++ const store = new Store(root), b0 = new Broker({ store, businesses });
++ // pm's run has an address; coder's claim has none, which the Board still lists.
++ b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
++ b0.request(b0.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'claude-code' }), { verb: 'role.claim' });
++ const reader = b0.bindReader({ business: 'demo' });
++ const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
++ const f = await fixture(), b = await browser({ machine });
++ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
++ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
++ await b.viewport(1280);
++ const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
++ const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
++ const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
++ const reqs = () => b.evaluate('window.__reqs');
++ const settle = () => new Promise(r => setTimeout(r, 600));
++ const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
++ return { b, base, wait, text, shown, reqs, settle, done };
++}
++
++const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
++const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
++
++test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => {
++ const { b, base, wait, text, shown, reqs, settle, done } = await setup(false);
++ try {
++ // Off by default: the held roles show, the machine scan is never read.
++ await b.navigate(base);
++ await wait(HELD);
++ const rows = await b.evaluate('[...document.querySelectorAll("#held table tbody tr")].map(r => [...r.cells].map(c => c.textContent))');
++ const byRole = Object.fromEntries(rows.map(r => [r[0], r]));
++ assert.deepEqual(Object.keys(byRole).sort(), ['coder', 'pm']);
++ assert.equal(byRole.coder[3], 'no address');
++ assert.equal(byRole.pm[3], 'pm-run');
++ assert.match(byRole.coder[4], /^coder-run/);
++ assert.match(byRole.coder[1], /idle|waiting|working|unknown/);
++ assert.equal(await text('#held-count'), '2');
++ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#held [data-message-role]")].map(e => e.getAttribute("aria-label")).sort()'), ['Message coder', 'Message pm']);
++ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
++ assert.match(await text('#status'), /^Machine sessions hidden\./);
++ assert.equal(await shown('#board-view'), false);
++ assert.equal(await shown('#board-tree'), false);
++ await settle();
++ assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
++ assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
++
++ // On: the machine sessions are read and shown, and the choice is kept for the next load.
++ await b.evaluate('document.querySelector("#show-machine").click(), true');
++ await wait(SESSIONS);
++ assert.equal(await shown('#board-view'), true);
++ assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}');
++ await b.navigate(`${base}?on`);
++ await wait(SESSIONS);
++ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), true);
++ await wait(HELD);
++
++ // Off again: the sessions go, the held roles stay, and the next load reads no board.
++ await b.evaluate('document.querySelector("#show-machine").click(), true');
++ await wait(`!${SESSIONS}`);
++ assert.equal(await shown('#board-view'), false);
++ assert.equal(await shown('#held-section'), true);
++ assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":false}');
++ await b.navigate(`${base}?off`);
++ await wait(HELD);
++ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
++ await settle();
++ assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
++
++ // Message opens the send form addressed to that role, with the caret in the message.
++ for (const role of ['coder', 'pm']) {
++ await b.evaluate(`location.hash = '#/', true`);
++ await wait(HELD);
++ await b.evaluate(`document.querySelector('#held [data-message-role="${role}"]').click(), true`);
++ await wait(`location.hash === '#/inbox' && document.querySelector('#send-to')?.value === '${role}' && document.activeElement === document.querySelector('#send-body')`);
++ assert.equal(await shown('#held-section'), false);
++ }
++
++ // Without send there is no Message action; the roles still show.
++ await b.navigate(`${base}?nosend`);
++ await wait(HELD);
++ assert.equal(await b.evaluate('document.querySelectorAll("#held [data-message-role]").length'), 0);
++ assert.match(await text('#held'), /--send/);
++ // Every Console fetch the page made asked for redirect: 'manual'.
++ assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
++ } finally { await done(); }
++});
++
++test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
++ const { b, base, wait, text, shown, reqs, settle, done } = await setup(true);
++ const LINE = 'Signed out. Reload the page to sign in.';
++ const out = async () => {
++ await wait('!document.querySelector("#signed-out").hidden');
++ assert.equal(await text('#signed-out'), LINE);
++ assert.equal(await b.evaluate('document.activeElement === document.querySelector("#signed-out") || document.activeElement?.closest?.("#send-section") !== null'), true);
++ const page = await b.evaluate('document.body.innerText');
++ // No failure text from the redirected read either: the footer stays visible when signed out.
++ assert.doesNotMatch(page, /NetworkError|Failed to fetch|did not answer|Board unreachable|[Nn]ot read|last known data/);
++ assert.equal(page.split(LINE).length, 2);
++ // Nothing read before stays on the page as if current.
++ assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
++ assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0);
++ for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel);
++ assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
++ // No further Console fetch: not on a route change, a Refresh, the toggle or the poll.
++ const n = (await reqs()).length;
++ await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`);
++ await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true');
++ await settle();
++ assert.equal((await reqs()).length, n);
++ assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
++ assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
++ };
++ try {
++ // A Board read: the machine scan's /api/board, after a good first read.
++ await b.navigate(base);
++ await wait(`${SESSIONS} && ${HELD}`);
++ await b.evaluate(`window.__redirect.push('/api/board'), document.querySelector('#refresh').click(), true`);
++ await out();
++
++ // A bus read: the held roles' agents read, on a later Refresh.
++ await b.navigate(`${base}?bus`);
++ await wait(`${SESSIONS} && ${HELD}`);
++ assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
++ await b.evaluate(`window.__redirect.push('/api/bus/agents'), document.querySelector('#refresh').click(), true`);
++ await out();
++
++ // A conversation read: History on a session asks for the catalogue.
++ await b.navigate(`${base}?conv`);
++ await wait(`${SESSIONS} && ${HELD}`);
++ await b.evaluate(`window.__redirect.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
++ await out();
++
++ // A board POST: Seen in the inspector posts /api/seen.
++ await b.navigate(`${base}?seen`);
++ await wait(`${SESSIONS} && ${HELD}`);
++ await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
++ await wait('document.querySelector("#inspection [data-seen]")');
++ await b.evaluate(`window.__redirect.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
++ await out();
++
++ // A module read: the Queue page's /api/queue.
++ await b.navigate(`${base}?queue`);
++ await wait(`${SESSIONS} && ${HELD}`);
++ await b.evaluate(`window.__redirect.push('/api/queue'), location.hash = '#/queue', true`);
++ await out();
++
++ // A send: the receipt says it was not sent and keeps the draft; the form stays for the draft.
++ await b.navigate(`${base}?send#/inbox`);
++ await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
++ await b.evaluate(`(()=>{const t=document.querySelector('#send-to'),m=document.querySelector('#send-body');t.value='coder';t.dispatchEvent(new Event('input',{bubbles:true}));m.value='Kept draft';m.dispatchEvent(new Event('input',{bubbles:true}));window.__answers.push({redirect:true});document.querySelector('#send-form [data-send]').click();return true})()`);
++ await wait('document.querySelector("#send-section .receipt")');
++ assert.match(await text('#send-section .receipt'), /^Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again \(sign-in\)\. The draft is kept\./);
++ assert.equal(await b.evaluate('document.querySelector("#send-body").value'), 'Kept draft');
++ assert.equal(await shown('#send-section'), true);
++ await out();
++ } finally { await done(); }
++});
diff --git a/agents/filbert/work/queue-95/candidate-manifest.sha256 b/agents/filbert/work/queue-95/candidate-manifest.sha256
new file mode 100644
index 000000000..660912e7a
--- /dev/null
+++ b/agents/filbert/work/queue-95/candidate-manifest.sha256
@@ -0,0 +1,10 @@
+a21e4e5b274146bbc0159e7673fc303c9c37fc8757f28f38ea30f28a2eea79c7 packages/webui/README.md
+1e017d4d4c0e1326e5d4368d7215a950e89bfd7a74126208d04a0eba15c2e65f packages/webui/src/public/app.js
+59a14ac07170b2e269f966c07779c7565a9bccc253be6b68cc90a6711f87caf0 packages/webui/src/public/bus.js
+34180d9c53a25beb3fff590bf37b6b0def72103542934355de87c05b18d2bfed packages/webui/src/public/index.html
+dbffe11bbdd4226ffabb2a8bee534258a23e766259489edb4eaf929e5c1b899a packages/webui/src/public/shared/app.css
+d34ea09b02327298532da3442791035d73cc055be3927324799989d9a89ad125 packages/webui/tests/browser-edge.test.mjs
+1e70a0175f1d660f6b71449a1423ece83fbe8332108960fa9aff6f7a5e48022a packages/webui/tests/browser.mjs
+68db3ef7101267d9c5d96c1a69ce4c078bd3563c4251f5d82b1ba38c62a8b533 packages/webui/tests/send-browser.test.mjs
+5e94f3d5ca6bcbbd43926be61a7195b270687b0fdd7b054f736cfba320e5bdc1 packages/webui/tests/shell.test.mjs
+9d3bb4c9436685ad69b834c0b6849395664320b28f32fed3f31ddfbfd815253f packages/webui/tests/board-held.test.mjs
diff --git a/agents/filbert/work/queue-95/gate/control-board.txt b/agents/filbert/work/queue-95/gate/control-board.txt
new file mode 100644
index 000000000..736ef00da
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/control-board.txt
@@ -0,0 +1,132 @@
+✔ explicit request, Seen, ordinary completion and a new request have distinct attention states (4.980463ms)
+✔ attention convention ignores reasoning/quoted examples and permits leading blank lines (0.272275ms)
+✔ completed smoke replies and ordinary questions are idle, not human blockers (1.303711ms)
+✔ only an explicit first-line input request makes a finished reply waiting (0.281027ms)
+✔ tool activity, user text, errors and unfinished turns override attention text (0.217434ms)
+✔ STOP access failure is unknown, not absence, under a non-root identity (44.907294ms)
+✔ connector Task never inherits Discord routing envelopes; ordinary Task still uses user text (3.708872ms)
+✔ connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal (1.751708ms)
+✔ canonical owner identity and STOP are independent; no tmux fallback or forged registration (8.083017ms)
+✔ connector reply refusal precedes forged live tmux registration; ordinary agent still sends (0.7868ms)
+✔ server rescans connector discovery and refuses HTTP reply without transport (46.884682ms)
+✔ connector session links and linked directories are not read (1.295115ms)
+✔ newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity (2.722154ms)
+✔ CLI print uses the relaunch notice instead of old current preview (104.326813ms)
+✔ connector owner and fixed task never inherit a native relaunch notice (2.584048ms)
+✔ equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch (2.689431ms)
+✔ loadConfig: missing file throws ConfigError (1.990891ms)
+✔ loadConfig: invalid JSON throws ConfigError (0.3859ms)
+✔ loadConfig: missing dataRoot throws ConfigError (0.282817ms)
+✔ loadConfig: relative dataRoot throws ConfigError (0.245402ms)
+✔ loadConfig: valid config returns dataRoot (0.759982ms)
+✔ findNewestSession: picks the newest by mtime among two files (0.518653ms)
+✔ findNewestSession: finds files in nested subdirectories (0.343092ms)
+✔ findNewestSession: returns null for a missing dir (0.141133ms)
+✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (0.960094ms)
+✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (1.573448ms)
+✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (0.717431ms)
+✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (1.203237ms)
+✔ deriveState: full state table (0.193974ms)
+✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.371336ms)
+✔ rule: newest entry is a tool result with no assistant text after it is working (0.297966ms)
+✔ rule: a finished ordinary turn is idle, even if it says your move (0.2992ms)
+✔ task: the first user message of the session, from text blocks (0.295735ms)
+✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.287859ms)
+✔ task: no user message in the log means null (shown as unknown), never a guess (0.300942ms)
+✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.448892ms)
+✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (0.649005ms)
+✔ scan: the written record carries task, workspace and activeProject (0.647152ms)
+✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (0.675469ms)
+✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.421012ms)
+✔ registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself (1.049774ms)
+✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.463045ms)
+✔ loadRegistrations: a missing seatsDir gives empty lists (0.171517ms)
+✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (0.827255ms)
+✔ matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing) (0.355393ms)
+✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (0.905133ms)
+✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.84953ms)
+✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (0.656805ms)
+✔ scanAgent: ageSeconds is computed from the injected now (0.282529ms)
+✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.158459ms)
+✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.354265ms)
+✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.373748ms)
+✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (1.055963ms)
+✔ scan: relative boardDir throws ConfigError (0.091352ms)
+✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (65.443737ms)
+✔ CLI: missing config exits 2 with a refused: message (66.984113ms)
+✔ CLI: unknown command exits 2 (59.279253ms)
+✔ CLI: unknown --liveness value exits 2 (56.675922ms)
+✔ panesRunPi: true when any trimmed line equals 'pi' (0.299223ms)
+✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.122281ms)
+✔ tmuxIsAlive: a pane running pi is alive (0.24044ms)
+✔ tmuxIsAlive: session exists but pi has exited is not alive (0.083883ms)
+✔ tmuxIsAlive: no such tmux session is not alive (0.069633ms)
+✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.104304ms)
+✔ tmuxIsAlive: passes -L only when a socket is given (0.107395ms)
+✔ parsePanes: one pane per line, command and optional tab-separated path (0.082886ms)
+✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.07376ms)
+✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.1003ms)
+✔ loadSeen: missing file returns {} (0.175416ms)
+✔ loadSeen: invalid JSON throws ConfigError (0.224088ms)
+✔ loadSeen: a JSON array throws ConfigError (0.165393ms)
+✔ loadSeen: a non-string value throws ConfigError (0.205174ms)
+✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (0.384651ms)
+✔ markSeen: seen false deletes the key (0.347974ms)
+✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.253717ms)
+✔ markSeen: project containing '/' throws ConfigError (0.146079ms)
+✔ markSeen: non-boolean seen throws ConfigError (0.115722ms)
+✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (0.340147ms)
+✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.261968ms)
+✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (0.256389ms)
+✔ scanAgent: an error-state session with a matching mark is seen (0.258928ms)
+✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (0.673884ms)
+✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.284213ms)
+✔ taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered (0.773582ms)
+✔ taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present (0.689493ms)
+✔ taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value (1.221496ms)
+✔ isLoopbackHost: recognizes loopback hosts (1.453906ms)
+✔ isLoopbackHost: rejects non-loopback hosts (4.703991ms)
+✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (3.375558ms)
+✔ startServer: serves page, healthz, and a rescanning /api/board (39.525794ms)
+✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (7.031069ms)
+✔ startServer: /api/board returns 500 JSON with an error field when scan throws (3.290601ms)
+✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (70.342411ms)
+✔ CLI: serve rejects a non-numeric --port with exit 2 (62.465037ms)
+✔ CLI: scan still works after the async cli refactor (62.705613ms)
+✔ CLI: live serve prints its URL and answers /healthz (70.544074ms)
+✔ page.html: esc() escapes every HTML-significant character (0.728332ms)
+✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (9.412186ms)
+✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (2.383613ms)
+✔ POST /api/seen with invalid JSON returns 400 (3.31709ms)
+✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (2.384349ms)
+✔ POST /api/seen with a missing agent returns 400 (1.487727ms)
+✔ POST /api/board returns 405; PUT /api/seen returns 405 (1.886858ms)
+✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (59.53651ms)
+✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.441732ms)
+✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (0.339208ms)
+✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.225411ms)
+✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.169323ms)
+✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.390173ms)
+✔ page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped (0.743974ms)
+✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams (36.497378ms)
+✔ POST /api/reply: a registration with a tmux socket adds -L (34.056964ms)
+✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (32.762631ms)
+✔ POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called (18.671123ms)
+✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (5.812585ms)
+✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (1.563312ms)
+✔ page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped (0.677122ms)
+✔ startServer: /api/board carries taskSetBy from a live registration and null for the derived rows (3.593228ms)
+✔ page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it (0.399106ms)
+✔ Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers (9.621515ms)
+✔ Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin (40.151026ms)
+✔ conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers (6.822974ms)
+✔ every refusal code the reader can raise has an HTTP status (3.177074ms)
+✔ conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written (56.998429ms)
+ℹ tests 124
+ℹ suites 0
+ℹ pass 124
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 777.678976
diff --git a/agents/filbert/work/queue-95/gate/summary.txt b/agents/filbert/work/queue-95/gate/summary.txt
new file mode 100644
index 000000000..497b66eea
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/summary.txt
@@ -0,0 +1,13 @@
+start 2026-10-12T21:27:35Z
+webui rc=0 ℹ pass 86 ℹ fail 0
+control-board rc=0 ℹ pass 124 ℹ fail 0
+test-auth rc=0 selftest: 15 passed, 0 failed
+test-conductor rc=0 selftest: 17 passed, 0 failed
+test-config rc=0 selftest: 24 passed, 0 failed
+test-discord rc=0 discord suite: 66 passed, 0 failed
+test-extension-package rc=0 extension package selftest: 18 passed, 0 failed
+test-foundation rc=0 selftest: 44 passed, 0 failed
+test-queue rc=0 queue suite: 27 passed, 0 failed
+test-release rc=0 selftest: 4 passed, 0 failed
+test-task rc=0 selftest: 26 passed, 0 failed
+end 2026-10-12T21:30:19Z
diff --git a/agents/filbert/work/queue-95/gate/test-auth.txt b/agents/filbert/work/queue-95/gate/test-auth.txt
new file mode 100644
index 000000000..ac2287606
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-auth.txt
@@ -0,0 +1,17 @@
+OK status with missing harness credential exits 3 and still lists accounts
+OK status reports harness credential (read-only) + mosaic accounts
+OK api key material never reaches output
+OK oauth token material never reaches output
+OK unparseable credential file exits 2
+OK symlinked credential file exits 4
+OK env-side credential names reported
+OK env var values never reach output
+OK accounts without an accounts dir reports none and creates nothing
+OK accounts lists files and marks the active one
+OK loose account perms flagged in listing
+OK agent --auth with missing account file refuses (exit 4)
+OK agent --auth with non-0600 account file refuses
+OK agent --auth with invalid account name refuses
+OK auth.sh without valid config refuses
+
+selftest: 15 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-conductor.txt b/agents/filbert/work/queue-95/gate/test-conductor.txt
new file mode 100644
index 000000000..5b675d752
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-conductor.txt
@@ -0,0 +1,55 @@
+Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
+
+You are in 'detached HEAD' state. You can look around, make experimental
+changes and commit them, and you can discard any commits you make in this
+state without impacting any branches by switching back to a branch.
+
+If you want to create a new branch to retain commits you create, you may
+do so (now or later) by using -c with the switch command. Example:
+
+ git switch -c
+
+Or undo this operation with:
+
+ git switch -
+
+Turn off this advice by setting config variable advice.detachedHead to false
+
+Not currently on any branch.
+nothing to commit, working tree clean
+Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
+
+You are in 'detached HEAD' state. You can look around, make experimental
+changes and commit them, and you can discard any commits you make in this
+state without impacting any branches by switching back to a branch.
+
+If you want to create a new branch to retain commits you create, you may
+do so (now or later) by using -c with the switch command. Example:
+
+ git switch -c
+
+Or undo this operation with:
+
+ git switch -
+
+Turn off this advice by setting config variable advice.detachedHead to false
+
+OK dry-run: allowed change, exit 0, nothing committed (exit 0)
+OK dry-run committed nothing
+OK apply: allowed change exits 0 (exit 0)
+OK apply: attribution in commit subject
+OK apply: target tree clean after commit
+OK disallowed path refused (exit 1)
+OK disallowed path: target untouched
+OK syntax gate refused broken .mjs (exit 1)
+OK syntax gate: target untouched
+OK suite failure refused (exit 1)
+OK suite failure: target reverted to clean
+OK disabled policy refused (exit 2)
+OK disabled policy: target untouched
+OK failed run refused (exit 1)
+OK failed run: target untouched
+OK missing run exits 4 (exit 4)
+OK invalid policy exits 2 (exit 2)
+
+selftest: 17 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-config.txt b/agents/filbert/work/queue-95/gate/test-config.txt
new file mode 100644
index 000000000..76c05ef74
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-config.txt
@@ -0,0 +1,26 @@
+OK absent adapter defaults to pi
+OK adapter mock validates (exit 0)
+OK unsupported adapter exits 2 (exit 2)
+OK env exports adapter
+OK bootstrap creates default when absent (exit 0)
+OK bootstrap wrote config file
+OK bootstrap is idempotent on existing config (exit 0)
+OK bootstrap did not rewrite existing config
+OK validate missing config exits 3 (exit 3)
+OK malformed JSON exits 2 (exit 2)
+OK unsupported configVersion exits 2 (exit 2)
+OK unknown top-level key exits 2 (exit 2)
+OK unknown execution key exits 2 (exit 2)
+OK unsupported backend exits 2 (exit 2)
+OK unsupported environment exits 2 (exit 2)
+OK relative dataRoot exits 2 (exit 2)
+OK non-canonical dataRoot exits 2 (exit 2)
+OK filesystem root dataRoot exits 2 (exit 2)
+OK home directory dataRoot exits 2 (exit 2)
+OK dataRoot containing config dir exits 2 (exit 2)
+OK control character in provider exits 2 (exit 2)
+OK symlinked config file exits 2 (exit 2)
+OK env exports resolve correctly
+OK failed validation modified nothing
+
+selftest: 24 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-discord.txt b/agents/filbert/work/queue-95/gate/test-discord.txt
new file mode 100644
index 000000000..b013f9ffa
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-discord.txt
@@ -0,0 +1,70 @@
+toolchain: node v26.8.1
+
+OK syntax: packages/discord/src/approvals.mjs
+OK syntax: packages/discord/src/authorize.mjs
+OK syntax: packages/discord/src/binding.mjs
+OK syntax: packages/discord/src/cli.mjs
+OK syntax: packages/discord/src/connector.mjs
+OK syntax: packages/discord/src/context.mjs
+OK syntax: packages/discord/src/engine-pi.mjs
+OK syntax: packages/discord/src/errors.mjs
+OK syntax: packages/discord/src/gateway.mjs
+OK syntax: packages/discord/src/git.mjs
+OK syntax: packages/discord/src/journal.mjs
+OK syntax: packages/discord/src/notify.mjs
+OK syntax: packages/discord/src/rest.mjs
+OK syntax: packages/discord/src/setspark.mjs
+OK syntax: packages/discord/src/tools.mjs
+OK syntax: packages/discord/src/web.mjs
+OK syntax: packages/discord/bin/git-credential.mjs
+OK syntax: packages/discord/extension/tools.mjs
+OK syntax: packages/discord/tests/approvals.test.mjs
+OK syntax: packages/discord/tests/authorize.test.mjs
+OK syntax: packages/discord/tests/binding.test.mjs
+OK syntax: packages/discord/tests/connector.test.mjs
+OK syntax: packages/discord/tests/context.test.mjs
+OK syntax: packages/discord/tests/engine.test.mjs
+OK syntax: packages/discord/tests/fake-pi.mjs
+OK syntax: packages/discord/tests/gateway.test.mjs
+OK syntax: packages/discord/tests/git.test.mjs
+OK syntax: packages/discord/tests/helpers.mjs
+OK syntax: packages/discord/tests/journal.test.mjs
+OK syntax: packages/discord/tests/notify.test.mjs
+OK syntax: packages/discord/tests/recover.test.mjs
+OK syntax: packages/discord/tests/rest.test.mjs
+OK syntax: packages/discord/tests/setspark.test.mjs
+OK syntax: packages/discord/tests/tools.test.mjs
+OK syntax: packages/discord/tests/web.test.mjs
+OK syntax: packages/discord/fixtures/claim-worker.mjs
+OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
+OK syntax: scripts/discord.sh
+OK syntax: scripts/discord-service.sh
+OK packages/discord declares no dependencies
+OK no bot-token-shaped string in packages/discord
+OK fixture binding uses placeholder ids only
+OK fixture binding validates
+OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
+OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
+OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
+OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
+OK real pi with protocol vault adds reserve_id to the git verbs
+OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
+OK real pi refuses a git key on a read-only root (fail closed)
+OK real pi with the pilot flags (--no-tools) exposes no tool at all
+OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
+OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
+OK node --test packages/discord/tests/ (ℹ pass 178)
+OK scripts/discord.sh --help exits 0
+OK scripts/discord.sh check without a binding exits 4
+OK scripts/discord.sh recover without a binding exits 4
+OK scripts/discord.sh reload without a binding exits 4
+OK scripts/discord-service.sh without a command exits 4
+OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
+OK service install writes the rendered unit (0644) and leaves no temp file
+OK service install a second time reports unchanged
+OK systemd-analyze verify accepts the rendered unit
+OK service uninstall removes the unit file
+OK service install with an unknown flag exits 4
+OK service install with USER unset finishes and names the account for lingering
+
+discord suite: 66 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-extension-package.txt b/agents/filbert/work/queue-95/gate/test-extension-package.txt
new file mode 100644
index 000000000..9d0405304
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-extension-package.txt
@@ -0,0 +1,21 @@
+OK initial ordinary-file install
+OK installed tree matches canonical source
+OK installed tree has no symlinks
+OK check detects installation drift
+OK sync refuses to overwrite installation drift
+OK check detects an extra destination file
+OK check detects an extra destination directory
+OK check rejects a destination symlink
+OK sync accepts a canonical source update
+OK updated installation matches canonical source
+scripts/test-extension-package.sh: line 14: 2381477 Killed "$@" > /dev/null 2>&1
+OK forced interruption kills the replacing process
+OK next invocation recovers old consistent installation
+OK interrupted replacement rolled back
+OK sync succeeds after interruption recovery
+OK unlocked stale lock file does not block
+OK active lock refuses a concurrent sync
+OK source symlink fails closed
+OK nested second entrypoint fails closed
+
+extension package selftest: 18 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-foundation.txt b/agents/filbert/work/queue-95/gate/test-foundation.txt
new file mode 100644
index 000000000..6338e1e6d
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-foundation.txt
@@ -0,0 +1,53 @@
+toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
+
+OK syntax: scripts/foundation-inspect.mjs
+OK syntax: scripts/foundation/strict-json.mjs
+OK syntax: scripts/foundation/canonical.mjs
+OK syntax: scripts/foundation/resolve.mjs
+OK syntax: scripts/foundation/validate-record.mjs
+OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
+OK syntax: scripts/foundation/canonical.test.mjs
+OK syntax: scripts/foundation/cli.test.mjs
+OK syntax: scripts/foundation/fixtures.test.mjs
+OK syntax: scripts/foundation/resolve.test.mjs
+OK syntax: scripts/foundation/strict-json.test.mjs
+OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
+OK fixture generator runs
+OK checked-in fixtures/bundles equal a fresh generation
+OK checked-in fixtures/raw equal a fresh generation
+OK checked-in fixtures/index.json equal a fresh generation
+OK checked-in demo bundles equal a fresh generation
+OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
+OK node --test scripts/foundation/ (ℹ pass 80)
+OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
+ platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
+ node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
+ schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
+ profile column (schema-valid records only): profile-valid 510, profile-invalid 30
+ profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
+OK oracle: zero schema-column disagreements with the pinned checker
+OK oracle: strict-only profile refusals are counted and asserted
+OK demo: permitted read preview exits 0 (exit 0)
+OK demo: permitted file.change preview exits 0 (exit 0)
+OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
+OK demo: revoked registration is refused (exit 3) (exit 3)
+OK demo: message is not authority (exit 3) (exit 3)
+OK usage: no arguments exits 2 (exit 2)
+OK io: missing file exits 4 (exit 4)
+OK io: directory exits 4 (exit 4)
+OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
+OK bound: oversize fixture exits 2 (exit 2)
+OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
+OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
+OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
+OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
+OK text output starts with the disclaimer
+OK json output is valid JSON with result allowed and exactly the charter §7 fields
+OK json golden matches byte-for-byte
+OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
+OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
+OK canary never printed (bundle run and credential-file run)
+OK a non-bundle JSON file is refused at the shape gate, not read into output
+OK no field of the non-bundle file is echoed
+
+selftest: 44 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-queue.txt b/agents/filbert/work/queue-95/gate/test-queue.txt
new file mode 100644
index 000000000..78bb9c81c
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-queue.txt
@@ -0,0 +1,35 @@
+toolchain: node v26.8.1, git version 2.55.0
+
+OK syntax: packages/queue/src/cli.mjs
+OK syntax: packages/queue/src/errors.mjs
+OK syntax: packages/queue/src/io.mjs
+OK syntax: packages/queue/src/lock.mjs
+OK syntax: packages/queue/src/queue.mjs
+OK syntax: packages/queue/src/review.mjs
+OK syntax: packages/queue/src/store.mjs
+OK syntax: packages/queue/tests/commit.test.mjs
+OK syntax: packages/queue/tests/data.test.mjs
+OK syntax: packages/queue/tests/dispatch.test.mjs
+OK syntax: packages/queue/tests/helpers.mjs
+OK syntax: packages/queue/tests/lock.test.mjs
+OK syntax: packages/queue/tests/migration.test.mjs
+OK syntax: packages/queue/tests/review.test.mjs
+OK syntax: packages/queue/tests/store.test.mjs
+OK syntax: packages/queue/tests/write.test.mjs
+OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
+OK syntax: packages/queue/tests/fixtures/kill-at.mjs
+OK syntax: packages/queue/tests/fixtures/lock-child.mjs
+OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
+OK syntax: scripts/queue-commit.sh
+OK syntax: scripts/git-hooks/pre-commit
+OK syntax: scripts/mosaic
+OK queue-commit.sh, the guard and scripts/mosaic are executable
+OK packages/queue declares no dependencies
+ℹ tests 150
+ℹ pass 150
+ℹ fail 0
+OK node --test packages/queue/tests/
+OK scripts/mosaic queue help
+skip queue verify and render --check: this checkout (/home/jwoltje/filbert-scratch/r95w) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
+
+queue suite: 27 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-release.txt b/agents/filbert/work/queue-95/gate/test-release.txt
new file mode 100644
index 000000000..1d6ed0503
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-release.txt
@@ -0,0 +1,7 @@
+OK valid RELEASE resolves (exit 0)
+OK invalid RELEASE exits 1 (exit 1)
+OK missing RELEASE exits 1 (exit 1)
+OK valid RELEASE leaves image tag consistent with version
+skip state-machine cases (docker daemon unavailable)
+
+selftest: 4 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/test-task.txt b/agents/filbert/work/queue-95/gate/test-task.txt
new file mode 100644
index 000000000..cd89fe9b2
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/test-task.txt
@@ -0,0 +1,32 @@
+OK valid task validates (exit 0)
+OK unknown task key exits 2 (exit 2)
+OK unsupported taskVersion exits 2 (exit 2)
+OK invalid task id exits 2 (exit 2)
+OK empty prompt exits 2 (exit 2)
+OK NUL in expectExact exits 2 (exit 2)
+OK out-of-range timeout exits 2 (exit 2)
+OK missing mission file exits 4 (exit 4)
+OK task with valid mission validates (exit 0)
+OK invalid mission exits 2 (exit 2)
+OK validate missing task exits 4 (exit 4)
+OK validation does not modify the task file
+OK prune dry-run exits 0 (exit 0)
+OK dry-run deleted nothing
+OK prune --keep=2 --yes removes oldest (exit 0)
+OK kept exactly 2 newest runs
+OK newest run kept, oldest pruned
+OK append-only receipt written (3 entries)
+OK sessions/workspaces untouched by prune
+OK prune with invalid keep exits 4 (exit 4)
+skip adapter seam cases (docker daemon unavailable)
+skip workspace/capability cases (docker daemon unavailable)
+skip live task cases (docker unavailable)
+OK onboard without name exits 4 (non-interactive) (exit 4)
+OK onboard --name renders profile (exit 0)
+OK profile written
+OK canon structure: required filled, optional placeholdered
+OK canon sections present
+skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
+OK no agent identity on headless run
+
+selftest: 26 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate/webui.txt b/agents/filbert/work/queue-95/gate/webui.txt
new file mode 100644
index 000000000..759d79365
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate/webui.txt
@@ -0,0 +1,95 @@
+✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (7492.290142ms)
+✔ a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads (7443.490999ms)
+✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (5015.364507ms)
+Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
+✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (7076.42401ms)
+✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (10731.026078ms)
+✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (602.765774ms)
+✔ decisions route serves the roles and recent lists from the reader view (1815.613031ms)
+✔ credentials route serves the broker credential status from the reader view (826.587477ms)
+✔ replies route serves the replies to one message from the reader view (1202.275155ms)
+✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (276.321263ms)
+✔ bus refusals map to statuses and never carry a path or a stack (89.179741ms)
+✔ humanCall speaks the human transport protocol without blocking the server (2475.650074ms)
+✔ busReader takes --business, else the live bus host, else refuses (482.043377ms)
+✔ humanCall kills a transport that runs past its timeout (534.640365ms)
+✔ serve refuses a --business value that is not a business id (525.978048ms)
+✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (4701.592645ms)
+✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (5497.243708ms)
+✔ conversation view: a newer session with no readable history keeps the marker (2501.036152ms)
+✔ conversation view: seats without history say so and offer no reply (2209.329635ms)
+✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (4833.374502ms)
+✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (57170.098808ms)
+✔ preview options are all three or none, and each is checked (16.770379ms)
+✔ the CLI refuses a partial or invalid preview flag set at start (4458.074961ms)
+✔ the CLI with all three flags serves the preview mode (623.146434ms)
+✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (187.420244ms)
+✔ the header name matches without regard to case, and the value compare is exact (39.834406ms)
+✔ the public name with the header reads the bus views, and the Host and Origin are checked first (146.238762ms)
+✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (80.412946ms)
+✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (19.369922ms)
+✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (88.442643ms)
+✔ a repeated Host is 400 before any other check, in preview mode and without it (54.92512ms)
+✔ without the preview flags the public name is refused and writes still reach the board (48.983846ms)
+✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (3.60975ms)
+✔ credential state from the date alone, as the bus states it (0.383673ms)
+✔ queue: rows, one row, ids and methods, notes; no path or id from a row (3773.425083ms)
+✔ queue: a refused read fails closed with the store's text, redacted (1904.730011ms)
+✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (4442.987441ms)
+✔ business: names, vars on the allowlist, authority, credential metadata only (19.589813ms)
+✔ business: missing or invalid file refuses with the module's text, redacted (39.754666ms)
+✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (31.70911ms)
+✔ settings: release, board, loopback address, notifier binding name only (782.580405ms)
+✔ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path (42.11733ms)
+✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (4992.292654ms)
+✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (23676.580187ms)
+✔ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (10410.664684ms)
+✔ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (13022.330805ms)
+✔ startServer takes send only as a function and only with the preview mode (10.766006ms)
+✔ the CLI refuses --send without the preview flags, and without a configured bus (3757.284618ms)
+✔ the CLI with --send and a configured bus sends through the human transport and logs one line (985.404826ms)
+✔ without --send the route is read-only like every other write, and config says no send (39.754347ms)
+✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (38.596075ms)
+✔ config says send only on the public name with --send on (15.970811ms)
+✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (27.630231ms)
+✔ each request check refuses before anything is sent, and logs the refusal without the body (96.413478ms)
+✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (99.145416ms)
+✔ a crafted role name cannot forge a log line (15.768661ms)
+✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (62.896628ms)
+✔ through the human transport the request is exactly the business, message.send and the keys (484.518564ms)
+✔ against a real broker: the message is written once as a human send, and its refusals map (1809.931442ms)
+✔ loopback host and board origin fail closed (10.61809ms)
+✔ real board fixture passes through WebUI; assets and isolated seen/reply work (224.574779ms)
+✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (626.992929ms)
+✔ unreachable board reports URL; CLI rejects unsupported options (2758.214386ms)
+✔ render prints the unit without the preview flags (83.686445ms)
+✔ render carries every serve flag, the preview set included (74.488686ms)
+✔ a partial preview set is refused (152.719716ms)
+✔ a value the unit cannot carry is refused, for every flag (2654.670974ms)
+✔ the rendered unit names none of the human proof agent markers and sets no agent variable (98.265119ms)
+✔ install into --dir with --no-reload writes the file whole, then reports unchanged (317.914722ms)
+✔ install without --no-reload runs daemon-reload and nothing that starts the unit (48.220492ms)
+✔ uninstall refuses while the unit is active, and removes it once it is not (153.708374ms)
+✔ status prints the unit state and GET /healthz on the installed port (373.010192ms)
+✔ usage: an unknown verb or flag, and start or enable, exit 4 (99.465198ms)
+✔ without --send, render is the template with its placeholders filled, byte for byte (92.76238ms)
+✔ render --send with the preview set ends ExecStart with --send and changes nothing else (102.095197ms)
+✔ --send without the whole preview set is refused and writes nothing (289.885914ms)
+✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (920.823584ms)
+✔ uninstall and status take no --send (33.827526ms)
+✔ tokens.css and icons.svg are byte copies of docs/design (123.574529ms)
+✔ JetBrains Mono files match the digests in their sources file, with the OFL text (36.436083ms)
+✔ shipped strings are unchanged, and the web keeps its write boundary (5.219494ms)
+✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (8798.348056ms)
+✔ five states on Board, Inbox, Tasks, Agents and Trail (2979.48091ms)
+✔ Queue, Business and Settings in their five states, with no move button (5937.510694ms)
+✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (3122.074852ms)
+✔ the server log names no config path when the system config refuses (427.400388ms)
+ℹ tests 86
+ℹ suites 0
+ℹ pass 86
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 58444.167433
diff --git a/agents/filbert/work/queue-95/gate1/control-board.txt b/agents/filbert/work/queue-95/gate1/control-board.txt
new file mode 100644
index 000000000..b3758d53d
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/control-board.txt
@@ -0,0 +1,132 @@
+✔ explicit request, Seen, ordinary completion and a new request have distinct attention states (8.806402ms)
+✔ attention convention ignores reasoning/quoted examples and permits leading blank lines (0.462935ms)
+✔ completed smoke replies and ordinary questions are idle, not human blockers (2.187346ms)
+✔ only an explicit first-line input request makes a finished reply waiting (0.645684ms)
+✔ tool activity, user text, errors and unfinished turns override attention text (0.38779ms)
+✔ STOP access failure is unknown, not absence, under a non-root identity (64.46644ms)
+✔ connector Task never inherits Discord routing envelopes; ordinary Task still uses user text (10.512806ms)
+✔ connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal (6.889465ms)
+✔ canonical owner identity and STOP are independent; no tmux fallback or forged registration (22.042536ms)
+✔ connector reply refusal precedes forged live tmux registration; ordinary agent still sends (1.210062ms)
+✔ server rescans connector discovery and refuses HTTP reply without transport (48.132755ms)
+✔ connector session links and linked directories are not read (1.494285ms)
+✔ newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity (7.200191ms)
+✔ CLI print uses the relaunch notice instead of old current preview (123.820333ms)
+✔ connector owner and fixed task never inherit a native relaunch notice (8.902705ms)
+✔ equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch (5.595175ms)
+✔ loadConfig: missing file throws ConfigError (2.611765ms)
+✔ loadConfig: invalid JSON throws ConfigError (0.523274ms)
+✔ loadConfig: missing dataRoot throws ConfigError (1.056674ms)
+✔ loadConfig: relative dataRoot throws ConfigError (0.438189ms)
+✔ loadConfig: valid config returns dataRoot (1.22937ms)
+✔ findNewestSession: picks the newest by mtime among two files (0.822041ms)
+✔ findNewestSession: finds files in nested subdirectories (0.592264ms)
+✔ findNewestSession: returns null for a missing dir (0.235573ms)
+✔ readSession: extracts fields, collapses/truncates text, counts a truncated final line (1.243053ms)
+✔ readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them (3.636023ms)
+✔ readSession: lastError carries the assistant errorMessage only when the last assistant turn errored (1.092533ms)
+✔ findNewestSession/scan: never read sibling auth or secrets next to a sessions dir (2.252719ms)
+✔ deriveState: full state table (0.371373ms)
+✔ rule: newest entry is an assistant message with a tool call, after a question-looking text, is working (0.677895ms)
+✔ rule: newest entry is a tool result with no assistant text after it is working (0.571392ms)
+✔ rule: a finished ordinary turn is idle, even if it says your move (0.537508ms)
+✔ task: the first user message of the session, from text blocks (0.548119ms)
+✔ task: a plain-string user content is accepted, whitespace collapsed and long text capped (0.543923ms)
+✔ task: no user message in the log means null (shown as unknown), never a guess (0.577263ms)
+✔ workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null (0.856434ms)
+✔ activeProject: basename of the nearest .git directory or .git file above the workspace; none means null (1.227326ms)
+✔ scan: the written record carries task, workspace and activeProject (1.081902ms)
+✔ registration: overrides task, project and workspace; every source says registration; registered carries the launch fields; the grouping column is untouched (1.326722ms)
+✔ registration: empty task and null project/workspace leave the derived values in place; registered is still non-null (0.847956ms)
+✔ registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself (2.62111ms)
+✔ registration: no registration leaves the Gate A fields exactly as before, and registered is null (0.650101ms)
+✔ loadRegistrations: a missing seatsDir gives empty lists (0.233031ms)
+✔ loadRegistrations: one good record, one malformed JSON, one with an unknown field; a stray file under seatsDir is ignored (1.220329ms)
+✔ matchRegistration: matches by sessionsDir, and by realpath through a symlink; sessionsDir null never matches; same seat name with a different sessionsDir does not match (fleet vs repo darkwing) (0.525574ms)
+✔ scan: writes the registration override to disk; index.json carries registered and registrationErrors (1.16886ms)
+✔ scan: a relative seatsDir throws ConfigError; an omitted seatsDir behaves as before (0.380804ms)
+✔ scanAgent: waitingOnYou is true for waiting/error and false otherwise (0.758853ms)
+✔ scanAgent: ageSeconds is computed from the injected now (0.35722ms)
+✔ scanAgent: sessionFile null and state idle when sessions dir is empty but alive (0.193075ms)
+✔ discoverRepoAgents: finds agents with a sessions dir, skips those without, sorted by name (0.494791ms)
+✔ discoverFleetAgents: finds agents with a sessions dir, sorted by name, fleet tmux fields (0.499074ms)
+✔ scan: writes per-agent files and index.json, rerun overwrites, no leftover tmp files (1.168336ms)
+✔ scan: relative boardDir throws ConfigError (0.170587ms)
+✔ CLI: scan with assume-alive liveness exits 0, prints board summary, writes board files (119.711221ms)
+✔ CLI: missing config exits 2 with a refused: message (87.829152ms)
+✔ CLI: unknown command exits 2 (80.53307ms)
+✔ CLI: unknown --liveness value exits 2 (82.357108ms)
+✔ panesRunPi: true when any trimmed line equals 'pi' (0.290136ms)
+✔ panesRunPi: false for bash-only, claude, empty, or node-pi-style lines (0.087237ms)
+✔ tmuxIsAlive: a pane running pi is alive (0.287611ms)
+✔ tmuxIsAlive: session exists but pi has exited is not alive (0.089416ms)
+✔ tmuxIsAlive: no such tmux session is not alive (0.061193ms)
+✔ tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive (0.074346ms)
+✔ tmuxIsAlive: passes -L only when a socket is given (0.123467ms)
+✔ parsePanes: one pane per line, command and optional tab-separated path (0.092375ms)
+✔ tmuxInspect: reports the path of the pane running pi, not of a shell pane (0.084871ms)
+✔ tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness (0.125351ms)
+✔ loadSeen: missing file returns {} (0.177346ms)
+✔ loadSeen: invalid JSON throws ConfigError (0.296913ms)
+✔ loadSeen: a JSON array throws ConfigError (0.246988ms)
+✔ loadSeen: a non-string value throws ConfigError (0.246702ms)
+✔ markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files (0.413062ms)
+✔ markSeen: seen false deletes the key (0.365278ms)
+✔ markSeen: missing, empty, or non-string fields throw ConfigError (0.261585ms)
+✔ markSeen: project containing '/' throws ConfigError (0.162557ms)
+✔ markSeen: non-boolean seen throws ConfigError (0.140846ms)
+✔ scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou (0.374921ms)
+✔ scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true (0.381647ms)
+✔ scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error) (0.276842ms)
+✔ scanAgent: an error-state session with a matching mark is seen (0.339719ms)
+✔ scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes (0.622029ms)
+✔ scan: a corrupt seen.json makes scan throw ConfigError (fail closed) (0.36288ms)
+✔ taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered (1.209232ms)
+✔ taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present (1.098121ms)
+✔ taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value (1.365887ms)
+✔ isLoopbackHost: recognizes loopback hosts (2.141627ms)
+✔ isLoopbackHost: rejects non-loopback hosts (7.981869ms)
+✔ startServer: refuses a non-loopback host with ConfigError, never opens a socket (7.702138ms)
+✔ startServer: serves page, healthz, and a rescanning /api/board (64.103893ms)
+✔ startServer: a seatsDir registration overrides the row and index.registered reflects it (16.716069ms)
+✔ startServer: /api/board returns 500 JSON with an error field when scan throws (4.491647ms)
+✔ CLI: serve refuses a non-loopback host with exit 2 and a refused: message (96.35573ms)
+✔ CLI: serve rejects a non-numeric --port with exit 2 (86.505423ms)
+✔ CLI: scan still works after the async cli refactor (84.169033ms)
+✔ CLI: live serve prints its URL and answers /healthz (91.5463ms)
+✔ page.html: esc() escapes every HTML-significant character (0.85274ms)
+✔ POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it (13.696426ms)
+✔ POST /api/seen without a JSON content-type returns 400 and does not write a mark (4.311734ms)
+✔ POST /api/seen with invalid JSON returns 400 (5.066119ms)
+✔ POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark (4.451395ms)
+✔ POST /api/seen with a missing agent returns 400 (1.986467ms)
+✔ POST /api/board returns 405; PUT /api/seen returns 405 (3.038442ms)
+✔ CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)' (75.731001ms)
+✔ page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type (0.433961ms)
+✔ page.html: has a collapsed Seen section that lists seen rows with the shared row builder (0.366825ms)
+✔ page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note (0.211486ms)
+✔ page.html: a project header reads "N of N" only while a checkbox hides rows (0.159982ms)
+✔ page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail (0.410846ms)
+✔ page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped (0.758226ms)
+✔ POST /api/reply: runs agent-send.sh with -s from the registration, -S :control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams (34.628056ms)
+✔ POST /api/reply: a registration with a tmux socket adds -L (38.811255ms)
+✔ POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim (41.721215ms)
+✔ POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called (22.915415ms)
+✔ POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash (6.638927ms)
+✔ replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable (0.241283ms)
+✔ page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped (0.757205ms)
+✔ startServer: /api/board carries taskSetBy from a live registration and null for the derived rows (3.270206ms)
+✔ page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it (0.446774ms)
+✔ Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers (10.693905ms)
+✔ Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin (40.9639ms)
+✔ conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers (10.339324ms)
+✔ every refusal code the reader can raise has an HTTP status (1.662914ms)
+✔ conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written (78.776893ms)
+ℹ tests 124
+ℹ suites 0
+ℹ pass 124
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 1050.581652
diff --git a/agents/filbert/work/queue-95/gate1/summary.txt b/agents/filbert/work/queue-95/gate1/summary.txt
new file mode 100644
index 000000000..7d2b35a4a
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/summary.txt
@@ -0,0 +1,13 @@
+start 2026-10-12T20:22:51Z
+webui rc=0 ℹ pass 86 ℹ fail 0
+control-board rc=0 ℹ pass 124 ℹ fail 0
+test-auth rc=0 selftest: 15 passed, 0 failed
+test-conductor rc=0 selftest: 17 passed, 0 failed
+test-config rc=0 selftest: 24 passed, 0 failed
+test-discord rc=0 discord suite: 66 passed, 0 failed
+test-extension-package rc=0 extension package selftest: 18 passed, 0 failed
+test-foundation rc=0 selftest: 44 passed, 0 failed
+test-queue rc=0 queue suite: 27 passed, 0 failed
+test-release rc=0 selftest: 4 passed, 0 failed
+test-task rc=0 selftest: 26 passed, 0 failed
+end 2026-10-12T20:25:43Z
diff --git a/agents/filbert/work/queue-95/gate1/test-auth.txt b/agents/filbert/work/queue-95/gate1/test-auth.txt
new file mode 100644
index 000000000..ac2287606
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-auth.txt
@@ -0,0 +1,17 @@
+OK status with missing harness credential exits 3 and still lists accounts
+OK status reports harness credential (read-only) + mosaic accounts
+OK api key material never reaches output
+OK oauth token material never reaches output
+OK unparseable credential file exits 2
+OK symlinked credential file exits 4
+OK env-side credential names reported
+OK env var values never reach output
+OK accounts without an accounts dir reports none and creates nothing
+OK accounts lists files and marks the active one
+OK loose account perms flagged in listing
+OK agent --auth with missing account file refuses (exit 4)
+OK agent --auth with non-0600 account file refuses
+OK agent --auth with invalid account name refuses
+OK auth.sh without valid config refuses
+
+selftest: 15 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-conductor.txt b/agents/filbert/work/queue-95/gate1/test-conductor.txt
new file mode 100644
index 000000000..5b675d752
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-conductor.txt
@@ -0,0 +1,55 @@
+Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
+
+You are in 'detached HEAD' state. You can look around, make experimental
+changes and commit them, and you can discard any commits you make in this
+state without impacting any branches by switching back to a branch.
+
+If you want to create a new branch to retain commits you create, you may
+do so (now or later) by using -c with the switch command. Example:
+
+ git switch -c
+
+Or undo this operation with:
+
+ git switch -
+
+Turn off this advice by setting config variable advice.detachedHead to false
+
+Not currently on any branch.
+nothing to commit, working tree clean
+Note: switching to 'a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc'.
+
+You are in 'detached HEAD' state. You can look around, make experimental
+changes and commit them, and you can discard any commits you make in this
+state without impacting any branches by switching back to a branch.
+
+If you want to create a new branch to retain commits you create, you may
+do so (now or later) by using -c with the switch command. Example:
+
+ git switch -c
+
+Or undo this operation with:
+
+ git switch -
+
+Turn off this advice by setting config variable advice.detachedHead to false
+
+OK dry-run: allowed change, exit 0, nothing committed (exit 0)
+OK dry-run committed nothing
+OK apply: allowed change exits 0 (exit 0)
+OK apply: attribution in commit subject
+OK apply: target tree clean after commit
+OK disallowed path refused (exit 1)
+OK disallowed path: target untouched
+OK syntax gate refused broken .mjs (exit 1)
+OK syntax gate: target untouched
+OK suite failure refused (exit 1)
+OK suite failure: target reverted to clean
+OK disabled policy refused (exit 2)
+OK disabled policy: target untouched
+OK failed run refused (exit 1)
+OK failed run: target untouched
+OK missing run exits 4 (exit 4)
+OK invalid policy exits 2 (exit 2)
+
+selftest: 17 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-config.txt b/agents/filbert/work/queue-95/gate1/test-config.txt
new file mode 100644
index 000000000..76c05ef74
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-config.txt
@@ -0,0 +1,26 @@
+OK absent adapter defaults to pi
+OK adapter mock validates (exit 0)
+OK unsupported adapter exits 2 (exit 2)
+OK env exports adapter
+OK bootstrap creates default when absent (exit 0)
+OK bootstrap wrote config file
+OK bootstrap is idempotent on existing config (exit 0)
+OK bootstrap did not rewrite existing config
+OK validate missing config exits 3 (exit 3)
+OK malformed JSON exits 2 (exit 2)
+OK unsupported configVersion exits 2 (exit 2)
+OK unknown top-level key exits 2 (exit 2)
+OK unknown execution key exits 2 (exit 2)
+OK unsupported backend exits 2 (exit 2)
+OK unsupported environment exits 2 (exit 2)
+OK relative dataRoot exits 2 (exit 2)
+OK non-canonical dataRoot exits 2 (exit 2)
+OK filesystem root dataRoot exits 2 (exit 2)
+OK home directory dataRoot exits 2 (exit 2)
+OK dataRoot containing config dir exits 2 (exit 2)
+OK control character in provider exits 2 (exit 2)
+OK symlinked config file exits 2 (exit 2)
+OK env exports resolve correctly
+OK failed validation modified nothing
+
+selftest: 24 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-discord.txt b/agents/filbert/work/queue-95/gate1/test-discord.txt
new file mode 100644
index 000000000..b013f9ffa
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-discord.txt
@@ -0,0 +1,70 @@
+toolchain: node v26.8.1
+
+OK syntax: packages/discord/src/approvals.mjs
+OK syntax: packages/discord/src/authorize.mjs
+OK syntax: packages/discord/src/binding.mjs
+OK syntax: packages/discord/src/cli.mjs
+OK syntax: packages/discord/src/connector.mjs
+OK syntax: packages/discord/src/context.mjs
+OK syntax: packages/discord/src/engine-pi.mjs
+OK syntax: packages/discord/src/errors.mjs
+OK syntax: packages/discord/src/gateway.mjs
+OK syntax: packages/discord/src/git.mjs
+OK syntax: packages/discord/src/journal.mjs
+OK syntax: packages/discord/src/notify.mjs
+OK syntax: packages/discord/src/rest.mjs
+OK syntax: packages/discord/src/setspark.mjs
+OK syntax: packages/discord/src/tools.mjs
+OK syntax: packages/discord/src/web.mjs
+OK syntax: packages/discord/bin/git-credential.mjs
+OK syntax: packages/discord/extension/tools.mjs
+OK syntax: packages/discord/tests/approvals.test.mjs
+OK syntax: packages/discord/tests/authorize.test.mjs
+OK syntax: packages/discord/tests/binding.test.mjs
+OK syntax: packages/discord/tests/connector.test.mjs
+OK syntax: packages/discord/tests/context.test.mjs
+OK syntax: packages/discord/tests/engine.test.mjs
+OK syntax: packages/discord/tests/fake-pi.mjs
+OK syntax: packages/discord/tests/gateway.test.mjs
+OK syntax: packages/discord/tests/git.test.mjs
+OK syntax: packages/discord/tests/helpers.mjs
+OK syntax: packages/discord/tests/journal.test.mjs
+OK syntax: packages/discord/tests/notify.test.mjs
+OK syntax: packages/discord/tests/recover.test.mjs
+OK syntax: packages/discord/tests/rest.test.mjs
+OK syntax: packages/discord/tests/setspark.test.mjs
+OK syntax: packages/discord/tests/tools.test.mjs
+OK syntax: packages/discord/tests/web.test.mjs
+OK syntax: packages/discord/fixtures/claim-worker.mjs
+OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
+OK syntax: scripts/discord.sh
+OK syntax: scripts/discord-service.sh
+OK packages/discord declares no dependencies
+OK no bot-token-shaped string in packages/discord
+OK fixture binding uses placeholder ids only
+OK fixture binding validates
+OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
+OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
+OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
+OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
+OK real pi with protocol vault adds reserve_id to the git verbs
+OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
+OK real pi refuses a git key on a read-only root (fail closed)
+OK real pi with the pilot flags (--no-tools) exposes no tool at all
+OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
+OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
+OK node --test packages/discord/tests/ (ℹ pass 178)
+OK scripts/discord.sh --help exits 0
+OK scripts/discord.sh check without a binding exits 4
+OK scripts/discord.sh recover without a binding exits 4
+OK scripts/discord.sh reload without a binding exits 4
+OK scripts/discord-service.sh without a command exits 4
+OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
+OK service install writes the rendered unit (0644) and leaves no temp file
+OK service install a second time reports unchanged
+OK systemd-analyze verify accepts the rendered unit
+OK service uninstall removes the unit file
+OK service install with an unknown flag exits 4
+OK service install with USER unset finishes and names the account for lingering
+
+discord suite: 66 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-extension-package.txt b/agents/filbert/work/queue-95/gate1/test-extension-package.txt
new file mode 100644
index 000000000..610cece0e
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-extension-package.txt
@@ -0,0 +1,21 @@
+OK initial ordinary-file install
+OK installed tree matches canonical source
+OK installed tree has no symlinks
+OK check detects installation drift
+OK sync refuses to overwrite installation drift
+OK check detects an extra destination file
+OK check detects an extra destination directory
+OK check rejects a destination symlink
+OK sync accepts a canonical source update
+OK updated installation matches canonical source
+scripts/test-extension-package.sh: line 14: 687408 Killed "$@" > /dev/null 2>&1
+OK forced interruption kills the replacing process
+OK next invocation recovers old consistent installation
+OK interrupted replacement rolled back
+OK sync succeeds after interruption recovery
+OK unlocked stale lock file does not block
+OK active lock refuses a concurrent sync
+OK source symlink fails closed
+OK nested second entrypoint fails closed
+
+extension package selftest: 18 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-foundation.txt b/agents/filbert/work/queue-95/gate1/test-foundation.txt
new file mode 100644
index 000000000..6338e1e6d
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-foundation.txt
@@ -0,0 +1,53 @@
+toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
+
+OK syntax: scripts/foundation-inspect.mjs
+OK syntax: scripts/foundation/strict-json.mjs
+OK syntax: scripts/foundation/canonical.mjs
+OK syntax: scripts/foundation/resolve.mjs
+OK syntax: scripts/foundation/validate-record.mjs
+OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
+OK syntax: scripts/foundation/canonical.test.mjs
+OK syntax: scripts/foundation/cli.test.mjs
+OK syntax: scripts/foundation/fixtures.test.mjs
+OK syntax: scripts/foundation/resolve.test.mjs
+OK syntax: scripts/foundation/strict-json.test.mjs
+OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
+OK fixture generator runs
+OK checked-in fixtures/bundles equal a fresh generation
+OK checked-in fixtures/raw equal a fresh generation
+OK checked-in fixtures/index.json equal a fresh generation
+OK checked-in demo bundles equal a fresh generation
+OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
+OK node --test scripts/foundation/ (ℹ pass 80)
+OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
+ platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
+ node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
+ schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
+ profile column (schema-valid records only): profile-valid 510, profile-invalid 30
+ profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
+OK oracle: zero schema-column disagreements with the pinned checker
+OK oracle: strict-only profile refusals are counted and asserted
+OK demo: permitted read preview exits 0 (exit 0)
+OK demo: permitted file.change preview exits 0 (exit 0)
+OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
+OK demo: revoked registration is refused (exit 3) (exit 3)
+OK demo: message is not authority (exit 3) (exit 3)
+OK usage: no arguments exits 2 (exit 2)
+OK io: missing file exits 4 (exit 4)
+OK io: directory exits 4 (exit 4)
+OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
+OK bound: oversize fixture exits 2 (exit 2)
+OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
+OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
+OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
+OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
+OK text output starts with the disclaimer
+OK json output is valid JSON with result allowed and exactly the charter §7 fields
+OK json golden matches byte-for-byte
+OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
+OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
+OK canary never printed (bundle run and credential-file run)
+OK a non-bundle JSON file is refused at the shape gate, not read into output
+OK no field of the non-bundle file is echoed
+
+selftest: 44 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-queue.txt b/agents/filbert/work/queue-95/gate1/test-queue.txt
new file mode 100644
index 000000000..78bb9c81c
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-queue.txt
@@ -0,0 +1,35 @@
+toolchain: node v26.8.1, git version 2.55.0
+
+OK syntax: packages/queue/src/cli.mjs
+OK syntax: packages/queue/src/errors.mjs
+OK syntax: packages/queue/src/io.mjs
+OK syntax: packages/queue/src/lock.mjs
+OK syntax: packages/queue/src/queue.mjs
+OK syntax: packages/queue/src/review.mjs
+OK syntax: packages/queue/src/store.mjs
+OK syntax: packages/queue/tests/commit.test.mjs
+OK syntax: packages/queue/tests/data.test.mjs
+OK syntax: packages/queue/tests/dispatch.test.mjs
+OK syntax: packages/queue/tests/helpers.mjs
+OK syntax: packages/queue/tests/lock.test.mjs
+OK syntax: packages/queue/tests/migration.test.mjs
+OK syntax: packages/queue/tests/review.test.mjs
+OK syntax: packages/queue/tests/store.test.mjs
+OK syntax: packages/queue/tests/write.test.mjs
+OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
+OK syntax: packages/queue/tests/fixtures/kill-at.mjs
+OK syntax: packages/queue/tests/fixtures/lock-child.mjs
+OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
+OK syntax: scripts/queue-commit.sh
+OK syntax: scripts/git-hooks/pre-commit
+OK syntax: scripts/mosaic
+OK queue-commit.sh, the guard and scripts/mosaic are executable
+OK packages/queue declares no dependencies
+ℹ tests 150
+ℹ pass 150
+ℹ fail 0
+OK node --test packages/queue/tests/
+OK scripts/mosaic queue help
+skip queue verify and render --check: this checkout (/home/jwoltje/filbert-scratch/r95w) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
+
+queue suite: 27 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-release.txt b/agents/filbert/work/queue-95/gate1/test-release.txt
new file mode 100644
index 000000000..1d6ed0503
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-release.txt
@@ -0,0 +1,7 @@
+OK valid RELEASE resolves (exit 0)
+OK invalid RELEASE exits 1 (exit 1)
+OK missing RELEASE exits 1 (exit 1)
+OK valid RELEASE leaves image tag consistent with version
+skip state-machine cases (docker daemon unavailable)
+
+selftest: 4 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/test-task.txt b/agents/filbert/work/queue-95/gate1/test-task.txt
new file mode 100644
index 000000000..cd89fe9b2
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/test-task.txt
@@ -0,0 +1,32 @@
+OK valid task validates (exit 0)
+OK unknown task key exits 2 (exit 2)
+OK unsupported taskVersion exits 2 (exit 2)
+OK invalid task id exits 2 (exit 2)
+OK empty prompt exits 2 (exit 2)
+OK NUL in expectExact exits 2 (exit 2)
+OK out-of-range timeout exits 2 (exit 2)
+OK missing mission file exits 4 (exit 4)
+OK task with valid mission validates (exit 0)
+OK invalid mission exits 2 (exit 2)
+OK validate missing task exits 4 (exit 4)
+OK validation does not modify the task file
+OK prune dry-run exits 0 (exit 0)
+OK dry-run deleted nothing
+OK prune --keep=2 --yes removes oldest (exit 0)
+OK kept exactly 2 newest runs
+OK newest run kept, oldest pruned
+OK append-only receipt written (3 entries)
+OK sessions/workspaces untouched by prune
+OK prune with invalid keep exits 4 (exit 4)
+skip adapter seam cases (docker daemon unavailable)
+skip workspace/capability cases (docker daemon unavailable)
+skip live task cases (docker unavailable)
+OK onboard without name exits 4 (non-interactive) (exit 4)
+OK onboard --name renders profile (exit 0)
+OK profile written
+OK canon structure: required filled, optional placeholdered
+OK canon sections present
+skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
+OK no agent identity on headless run
+
+selftest: 26 passed, 0 failed
diff --git a/agents/filbert/work/queue-95/gate1/webui.txt b/agents/filbert/work/queue-95/gate1/webui.txt
new file mode 100644
index 000000000..a0068e372
--- /dev/null
+++ b/agents/filbert/work/queue-95/gate1/webui.txt
@@ -0,0 +1,95 @@
+✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (9433.155574ms)
+✔ a redirect answer on a Board read, a bus read and a send signs the page out: one line, nothing stale, no further reads (5507.440996ms)
+✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (6524.955107ms)
+Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
+✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (9877.584687ms)
+✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (13315.25232ms)
+✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (287.751787ms)
+✔ decisions route serves the roles and recent lists from the reader view (250.744172ms)
+✔ credentials route serves the broker credential status from the reader view (163.132809ms)
+✔ replies route serves the replies to one message from the reader view (461.99498ms)
+✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (244.818732ms)
+✔ bus refusals map to statuses and never carry a path or a stack (88.828857ms)
+✔ humanCall speaks the human transport protocol without blocking the server (3143.126699ms)
+✔ busReader takes --business, else the live bus host, else refuses (789.878043ms)
+✔ humanCall kills a transport that runs past its timeout (582.177326ms)
+✔ serve refuses a --business value that is not a business id (1572.105066ms)
+✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (6113.555172ms)
+✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (5078.995254ms)
+✔ conversation view: a newer session with no readable history keeps the marker (3277.29599ms)
+✔ conversation view: seats without history say so and offer no reply (1967.384852ms)
+✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (5530.479682ms)
+✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (56488.905683ms)
+✔ preview options are all three or none, and each is checked (13.629668ms)
+✔ the CLI refuses a partial or invalid preview flag set at start (3779.765078ms)
+✔ the CLI with all three flags serves the preview mode (543.83694ms)
+✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (81.864526ms)
+✔ the header name matches without regard to case, and the value compare is exact (11.404205ms)
+✔ the public name with the header reads the bus views, and the Host and Origin are checked first (101.839414ms)
+✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (42.212528ms)
+✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (8.210837ms)
+✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (75.634941ms)
+✔ a repeated Host is 400 before any other check, in preview mode and without it (39.813223ms)
+✔ without the preview flags the public name is refused and writes still reach the board (16.35174ms)
+✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (2.097427ms)
+✔ credential state from the date alone, as the bus states it (0.278317ms)
+✔ queue: rows, one row, ids and methods, notes; no path or id from a row (3669.920708ms)
+✔ queue: a refused read fails closed with the store's text, redacted (1310.683471ms)
+✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (5734.047113ms)
+✔ business: names, vars on the allowlist, authority, credential metadata only (27.786568ms)
+✔ business: missing or invalid file refuses with the module's text, redacted (36.782231ms)
+✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (30.05563ms)
+✔ settings: release, board, loopback address, notifier binding name only (1307.70981ms)
+✔ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path (129.567958ms)
+✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (6427.808244ms)
+✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (23932.043133ms)
+✔ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (10363.100294ms)
+✔ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (14230.141956ms)
+✔ startServer takes send only as a function and only with the preview mode (31.657799ms)
+✔ the CLI refuses --send without the preview flags, and without a configured bus (2619.884267ms)
+✔ the CLI with --send and a configured bus sends through the human transport and logs one line (1142.016334ms)
+✔ without --send the route is read-only like every other write, and config says no send (112.228634ms)
+✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (67.327123ms)
+✔ config says send only on the public name with --send on (12.088164ms)
+✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (13.393517ms)
+✔ each request check refuses before anything is sent, and logs the refusal without the body (55.576126ms)
+✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (14.254779ms)
+✔ a crafted role name cannot forge a log line (10.43217ms)
+✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (39.957833ms)
+✔ through the human transport the request is exactly the business, message.send and the keys (332.413398ms)
+✔ against a real broker: the message is written once as a human send, and its refusals map (1271.873458ms)
+✔ loopback host and board origin fail closed (10.442761ms)
+✔ real board fixture passes through WebUI; assets and isolated seen/reply work (268.01198ms)
+✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (264.197514ms)
+✔ unreachable board reports URL; CLI rejects unsupported options (3626.932419ms)
+✔ render prints the unit without the preview flags (50.295211ms)
+✔ render carries every serve flag, the preview set included (45.023949ms)
+✔ a partial preview set is refused (90.040257ms)
+✔ a value the unit cannot carry is refused, for every flag (1728.619397ms)
+✔ the rendered unit names none of the human proof agent markers and sets no agent variable (288.642192ms)
+✔ install into --dir with --no-reload writes the file whole, then reports unchanged (581.656202ms)
+✔ install without --no-reload runs daemon-reload and nothing that starts the unit (139.11428ms)
+✔ uninstall refuses while the unit is active, and removes it once it is not (832.459593ms)
+✔ status prints the unit state and GET /healthz on the installed port (1099.951478ms)
+✔ usage: an unknown verb or flag, and start or enable, exit 4 (212.379582ms)
+✔ without --send, render is the template with its placeholders filled, byte for byte (125.207505ms)
+✔ render --send with the preview set ends ExecStart with --send and changes nothing else (132.610764ms)
+✔ --send without the whole preview set is refused and writes nothing (309.927741ms)
+✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (1655.330034ms)
+✔ uninstall and status take no --send (243.45017ms)
+✔ tokens.css and icons.svg are byte copies of docs/design (2.03329ms)
+✔ JetBrains Mono files match the digests in their sources file, with the OFL text (3.276834ms)
+✔ shipped strings are unchanged, and the web keeps its write boundary (3.533697ms)
+✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (11021.104526ms)
+✔ five states on Board, Inbox, Tasks, Agents and Trail (3344.422149ms)
+✔ Queue, Business and Settings in their five states, with no move button (7800.597104ms)
+✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (3567.793363ms)
+✔ the server log names no config path when the system config refuses (426.200631ms)
+ℹ tests 86
+ℹ suites 0
+ℹ pass 86
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 57238.780105
diff --git a/agents/filbert/work/queue-95/mut/mutants.py b/agents/filbert/work/queue-95/mut/mutants.py
new file mode 100644
index 000000000..952e19df6
--- /dev/null
+++ b/agents/filbert/work/queue-95/mut/mutants.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+# Filbert's row 95 mutants (builder's own). Each edit must match exactly once. The candidate is
+# uncommitted in the worktree, so each file is restored from the bytes read before the edit, and
+# every touched file's sha256 is checked against its start at the end.
+# Usage: mutants.py
+import hashlib, os, signal, subprocess, sys
+
+WT, OUT = sys.argv[1], sys.argv[2]
+A = 'packages/webui/src/public/app.js'
+B = 'packages/webui/src/public/bus.js'
+TESTS = ['tests/board-held.test.mjs', 'tests/send-browser.test.mjs', 'tests/browser-edge.test.mjs', 'tests/browser.test.mjs']
+RD = "if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }"
+
+M = [
+ ('app getJSON without manual', A, "const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });", "const res = await fetch(path, { cache: 'no-store' });"),
+ ('app api GET without manual', A, "value === undefined ? { cache: 'no-store', redirect: 'manual' }", "value === undefined ? { cache: 'no-store' }"),
+ ('app api POST without manual', A, "body: JSON.stringify(value), redirect: 'manual' }", "body: JSON.stringify(value) }"),
+ ('app api ignores a redirect', A, "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });\n " + RD, "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });\n"),
+ ('bus fetchRead without manual', B, "=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }", "=${enc(subject)}`}`, { cache: 'no-store' }); }"),
+ ('bus fetchRead ignores a redirect', B, "catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }\n " + RD, "catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }\n"),
+ ('bus fetchApi without manual', B, "res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' });", "res = await fetch(`/api/${path}`, { cache: 'no-store' });"),
+ ('bus send without manual', B, "body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });", "body: JSON.stringify({ to, body: text }), cache: 'no-store' });"),
+ ('bus config without manual', B, "fetch('/api/config', { cache: 'no-store', redirect: 'manual' })", "fetch('/api/config', { cache: 'no-store' })"),
+ ('accept: no signed-out guard', A, " if (out()) return; // a read that was in flight when the page signed out\n", ''),
+ ('error: no signed-out guard', A, ' if (err.signedOut || out()) return;\n', ''),
+ ('refresh reads with the toggle off', A, 'if (busy || !machine || out()) return;', 'if (busy || out()) return;'),
+ ('schedule polls with the toggle off', A, 'if (!paused && machine && !out()) timer', 'if (!paused && !out()) timer'),
+ ('refresh: no signed-out guard', A, 'if (busy || !machine || out()) return;', 'if (busy || !machine) return;'),
+ ('refresh re-enables after sign-out', A, "busy = false; $('refresh').disabled = out(); schedule();", "busy = false; $('refresh').disabled = false; schedule();"),
+ ('load: no hideMachine when off', A, ' if (!machine) hideMachine();\n', ''),
+ ('setting not saved', A, " try { localStorage.setItem('mosaic-console-board', JSON.stringify({ machine })); } catch {}\n", ''),
+ ('setting defaults on', A, "?.machine === true; } catch {}", "?.machine !== false; } catch {}"),
+ ('toggle on reads nothing', A, "fresh(); refresh(); } else hideMachine();", "fresh(); } else hideMachine();"),
+ ('hideMachine keeps the rows', A, " for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();\n for (const id of ['waiting-count'", " for (const id of ['waiting-count'"),
+ ('held: no address shows blank', B, '''${c.address ? txt(c.address) : 'no address'}''', '''${c.address ? txt(c.address) : ''}'''),
+ ('held: claims with no address dropped', B, 'const claims = r.rows;', 'const claims = r.rows.filter(c => c.address);'),
+ ('Message without send', B, "${canSend ? `
${typeof c.role", "
${true ? `
${typeof c.role"),
+ ('Message keeps the old role', B, 'if (message) { draft.to = message.dataset.messageRole; focusSend = true;', 'if (message) { focusSend = true;'),
+ ('Message: no focus in the form', B, 'if (message) { draft.to = message.dataset.messageRole; focusSend = true;', 'if (message) { draft.to = message.dataset.messageRole;'),
+ ('bus render: no signed-out guard', B, " clearTimeout(timer);\n if (out()) return;\n", " clearTimeout(timer);\n"),
+ ('bus schedule polls signed out', B, "if (!paused() && !out()) timer", "if (!paused()) timer"),
+ ('board render draws after a signed-out read', B, " if (failed.some(err => err.code === 'signed-out')) return;\n", ''),
+ ('bus view render draws a signed-out read', B, " if (err.code === 'signed-out') return;\n", ''),
+ ('send after sign-out fetches', B, " if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in'", " if (false) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in'"),
+ ('send redirect does not sign out', B, " if (res?.type === 'opaqueredirect') signOut();\n", ''),
+ ('bus listener keeps the held rows', B, " $('held').replaceChildren(); $('held-count').textContent = '–'; heldHtml = null;\n", " $('held-count').textContent = '–'; heldHtml = null;\n"),
+ ('bus listener keeps the bus page', B, " for (const el of [...$('bus-view').children]) if (el.id !== 'send-section') el.remove();\n", ''),
+ ('app listener keeps the sessions', A, " for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();\n", ''),
+ ('app listener: refresh stays enabled', A, " $('refresh').disabled = $('pause').disabled = true;\n", ''),
+]
+
+env = dict(os.environ, TMPDIR=os.path.expanduser('~/filbert-scratch/tmp'), DOCKER_HOST='unix:///nonexistent.sock')
+start = {p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest() for p in (A, B)}
+out = open(OUT, 'w')
+killed = total = 0
+for name, path, old, new in M:
+ full = os.path.join(WT, path)
+ orig = open(full, 'rb').read()
+ src = orig.decode()
+ if src.count(old) != 1:
+ out.write(f'BADEDIT {name}: {src.count(old)} matches\n'); out.flush(); continue
+ open(full, 'w').write(src.replace(old, new))
+ try:
+ total += 1
+ # Its own process group: a timeout kills node --test's file runners and their browsers too.
+ p = subprocess.Popen(['node', '--test', *TESTS], cwd=os.path.join(WT, 'packages/webui'), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
+ try:
+ stdout = p.communicate(timeout=900)[0]
+ dead = p.returncode != 0
+ fails = [l.strip() for l in stdout.splitlines() if l.startswith('not ok')][:3]
+ except subprocess.TimeoutExpired:
+ os.killpg(p.pid, signal.SIGKILL); p.communicate()
+ dead, fails = True, ['timeout']
+ killed += dead
+ out.write(f"{'KILLED ' if dead else 'SURVIVED'} {name}{' <- ' + ' | '.join(fails) if fails else ''}\n"); out.flush()
+ finally:
+ open(full, 'wb').write(orig)
+end = {p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest() for p in (A, B)}
+out.write(f'{killed}/{total} killed; files restored: {start == end}\n')
diff --git a/agents/filbert/work/queue-95/mut/out-round1.txt b/agents/filbert/work/queue-95/mut/out-round1.txt
new file mode 100644
index 000000000..05da0f4e2
--- /dev/null
+++ b/agents/filbert/work/queue-95/mut/out-round1.txt
@@ -0,0 +1,36 @@
+SURVIVED app getJSON without manual
+KILLED app api GET without manual
+SURVIVED app api POST without manual
+KILLED app api ignores a redirect
+KILLED bus fetchRead without manual
+KILLED bus fetchRead ignores a redirect
+SURVIVED bus fetchApi without manual
+KILLED bus send without manual
+KILLED bus config without manual
+KILLED accept: no signed-out guard
+SURVIVED error: no signed-out guard
+KILLED refresh reads with the toggle off
+SURVIVED schedule polls with the toggle off
+SURVIVED refresh: no signed-out guard
+KILLED refresh re-enables after sign-out
+KILLED load: no hideMachine when off
+KILLED setting not saved
+KILLED setting defaults on
+KILLED toggle on reads nothing <- timeout
+KILLED hideMachine keeps the rows
+KILLED held: no address shows blank
+KILLED held: claims with no address dropped
+KILLED Message without send
+KILLED Message keeps the old role
+KILLED Message: no focus in the form
+KILLED bus render: no signed-out guard
+SURVIVED bus schedule polls signed out
+SURVIVED board render draws after a signed-out read
+SURVIVED bus view render draws a signed-out read
+KILLED send after sign-out fetches
+KILLED send redirect does not sign out
+KILLED bus listener keeps the held rows
+KILLED bus listener keeps the bus page
+KILLED app listener keeps the sessions
+KILLED app listener: refresh stays enabled
+26/35 killed; files restored: True
diff --git a/agents/filbert/work/queue-95/mut/out.txt b/agents/filbert/work/queue-95/mut/out.txt
new file mode 100644
index 000000000..b9cc11aaf
--- /dev/null
+++ b/agents/filbert/work/queue-95/mut/out.txt
@@ -0,0 +1,36 @@
+KILLED app getJSON without manual
+KILLED app api GET without manual
+KILLED app api POST without manual
+KILLED app api ignores a redirect
+KILLED bus fetchRead without manual
+KILLED bus fetchRead ignores a redirect
+KILLED bus fetchApi without manual
+KILLED bus send without manual
+KILLED bus config without manual
+KILLED accept: no signed-out guard
+KILLED error: no signed-out guard
+KILLED refresh reads with the toggle off
+SURVIVED schedule polls with the toggle off
+SURVIVED refresh: no signed-out guard
+KILLED refresh re-enables after sign-out
+KILLED load: no hideMachine when off
+KILLED setting not saved
+KILLED setting defaults on
+KILLED toggle on reads nothing <- timeout
+KILLED hideMachine keeps the rows
+KILLED held: no address shows blank
+KILLED held: claims with no address dropped
+KILLED Message without send
+KILLED Message keeps the old role
+KILLED Message: no focus in the form
+KILLED bus render: no signed-out guard
+SURVIVED bus schedule polls signed out
+SURVIVED board render draws after a signed-out read
+SURVIVED bus view render draws a signed-out read
+KILLED send after sign-out fetches
+KILLED send redirect does not sign out
+KILLED bus listener keeps the held rows
+KILLED bus listener keeps the bus page
+KILLED app listener keeps the sessions
+KILLED app listener: refresh stays enabled
+30/35 killed; files restored: True
diff --git a/agents/filbert/work/queue-95/mut/run.log b/agents/filbert/work/queue-95/mut/run.log
new file mode 100644
index 000000000..463a1007a
--- /dev/null
+++ b/agents/filbert/work/queue-95/mut/run.log
@@ -0,0 +1,2 @@
+mutants start 2026-10-12T21:03:18Z
+mutants end 2026-10-12T21:27:35Z rc=0