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 <[email protected]>
This commit is contained in:
2026-10-12 16:31:43 -05:00
co-authored by Claude Opus 5.5
parent f9db19d7af
commit 27eda72309
31 changed files with 2237 additions and 0 deletions
+172
View File
@@ -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).
+790
View File
@@ -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 ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
// 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 <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${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 = '<p class="muted">Machine sessions hidden.</p>';
+ 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 = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; 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 => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
const r = receipt;
const said = !r ? '' : r.kind === 'sent'
- ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${repliesHtml()}`
+ ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${out() ? '' : repliesHtml()}`
: r.kind === 'refused'
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
: `<p class="receipt failed">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.</p>`;
@@ -407,6 +415,38 @@
const d = asks[0];
return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
}
+ // 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 => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.holder_run)}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
+ return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
+ }
+ function paintHeld(err) {
+ heldErr = err;
+ const box = $('held'), r = last.get('agents');
+ let html;
+ if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
+ else if (!r) return; // not read yet: the skeleton stays
+ else {
+ const claims = r.rows;
+ html = `${err ? `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} These are the roles read before, ${when(r.at)}. Nothing was changed.</p>${retry}</div>` : ''}
+ ${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.')}
+ <p class="small muted">Read from the bus ${when(r.at)}.${canSend ? '' : ' Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}</p>`;
+ }
+ $('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 => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).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 <details> 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 @@
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
-<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
+<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
+<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
<div id="error" class="banner err" role="alert" hidden></div>
<div id="board-refused" class="empty" hidden><b>Nothing to show.</b><p>The board refused the read, so Console shows nothing rather than a guess. The reason is above.</p></div>
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
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(); }
+});
@@ -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
@@ -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 <socket> 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 <host>: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 <socket> (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
@@ -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
@@ -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
@@ -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 <new-branch-name>
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 <new-branch-name>
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <socket> 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 <host>: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 <socket> (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
@@ -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
@@ -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
@@ -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 <new-branch-name>
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 <new-branch-name>
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <worktree> <out.txt>
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) : '<span class="muted">no address</span>'}''', '''${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, "</td>${canSend ? `<td>${typeof c.role", "</td>${true ? `<td>${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')
@@ -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
+36
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
mutants start 2026-10-12T21:03:18Z
mutants end 2026-10-12T21:27:35Z rc=0