From 27eda72309853eb5485c4b66bafaafca1651a67d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 16:31:43 -0500 Subject: [PATCH] filbert: row 95 build packet (#1589), manifest 246c0557 Candidate on base a3f6e9e6, not committed here. Gate green 21:27:35Z-21:30:19Z; mutants 30/35 killed, 5 equivalent guards explained in build.md. Co-Authored-By: Claude Opus 5.5 --- agents/filbert/work/queue-95/build.md | 172 ++++ agents/filbert/work/queue-95/build.patch | 790 ++++++++++++++++++ .../work/queue-95/candidate-manifest.sha256 | 10 + .../work/queue-95/gate/control-board.txt | 132 +++ agents/filbert/work/queue-95/gate/summary.txt | 13 + .../filbert/work/queue-95/gate/test-auth.txt | 17 + .../work/queue-95/gate/test-conductor.txt | 55 ++ .../work/queue-95/gate/test-config.txt | 26 + .../work/queue-95/gate/test-discord.txt | 70 ++ .../queue-95/gate/test-extension-package.txt | 21 + .../work/queue-95/gate/test-foundation.txt | 53 ++ .../filbert/work/queue-95/gate/test-queue.txt | 35 + .../work/queue-95/gate/test-release.txt | 7 + .../filbert/work/queue-95/gate/test-task.txt | 32 + agents/filbert/work/queue-95/gate/webui.txt | 95 +++ .../work/queue-95/gate1/control-board.txt | 132 +++ .../filbert/work/queue-95/gate1/summary.txt | 13 + .../filbert/work/queue-95/gate1/test-auth.txt | 17 + .../work/queue-95/gate1/test-conductor.txt | 55 ++ .../work/queue-95/gate1/test-config.txt | 26 + .../work/queue-95/gate1/test-discord.txt | 70 ++ .../queue-95/gate1/test-extension-package.txt | 21 + .../work/queue-95/gate1/test-foundation.txt | 53 ++ .../work/queue-95/gate1/test-queue.txt | 35 + .../work/queue-95/gate1/test-release.txt | 7 + .../filbert/work/queue-95/gate1/test-task.txt | 32 + agents/filbert/work/queue-95/gate1/webui.txt | 95 +++ agents/filbert/work/queue-95/mut/mutants.py | 79 ++ .../filbert/work/queue-95/mut/out-round1.txt | 36 + agents/filbert/work/queue-95/mut/out.txt | 36 + agents/filbert/work/queue-95/mut/run.log | 2 + 31 files changed, 2237 insertions(+) create mode 100644 agents/filbert/work/queue-95/build.md create mode 100644 agents/filbert/work/queue-95/build.patch create mode 100644 agents/filbert/work/queue-95/candidate-manifest.sha256 create mode 100644 agents/filbert/work/queue-95/gate/control-board.txt create mode 100644 agents/filbert/work/queue-95/gate/summary.txt create mode 100644 agents/filbert/work/queue-95/gate/test-auth.txt create mode 100644 agents/filbert/work/queue-95/gate/test-conductor.txt create mode 100644 agents/filbert/work/queue-95/gate/test-config.txt create mode 100644 agents/filbert/work/queue-95/gate/test-discord.txt create mode 100644 agents/filbert/work/queue-95/gate/test-extension-package.txt create mode 100644 agents/filbert/work/queue-95/gate/test-foundation.txt create mode 100644 agents/filbert/work/queue-95/gate/test-queue.txt create mode 100644 agents/filbert/work/queue-95/gate/test-release.txt create mode 100644 agents/filbert/work/queue-95/gate/test-task.txt create mode 100644 agents/filbert/work/queue-95/gate/webui.txt create mode 100644 agents/filbert/work/queue-95/gate1/control-board.txt create mode 100644 agents/filbert/work/queue-95/gate1/summary.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-auth.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-conductor.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-config.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-discord.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-extension-package.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-foundation.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-queue.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-release.txt create mode 100644 agents/filbert/work/queue-95/gate1/test-task.txt create mode 100644 agents/filbert/work/queue-95/gate1/webui.txt create mode 100644 agents/filbert/work/queue-95/mut/mutants.py create mode 100644 agents/filbert/work/queue-95/mut/out-round1.txt create mode 100644 agents/filbert/work/queue-95/mut/out.txt create mode 100644 agents/filbert/work/queue-95/mut/run.log 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.

${out() ? '' : repliesHtml()}` + : r.kind === 'refused' + ? `

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)}` : ''}${attention(c)}${c.address ? txt(c.address) : 'no address'}${txt(c.holder_run)}${c.harness ? `${txt(c.harness)}` : ''}${when(c.at)}${canSend ? `${typeof c.role === 'string' && SAFE.test(c.role) ? `` : ''}` : ''}`).join(''); ++ return `
${cols.map(h => ``).join('')}${canSend ? '' : ''}${rows}
${h}Message
`; ++ } ++ function paintHeld(err) { ++ heldErr = err; ++ const box = $('held'), r = last.get('agents'); ++ let html; ++ if (err && (refusal(err) || !r)) html = ``; ++ else if (!r) return; // not read yet: the skeleton stays ++ else { ++ const claims = r.rows; ++ html = `${err ? `` : ''} ++ ${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)}` : ''}${attention(c)}${txt(c.holder_run)}${txt(c.harness)}${c.address ? `${txt(c.address)}` : ''}${when(c.at)}`).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.
+ + +