diff --git a/BUILD-LOG.md b/BUILD-LOG.md
index e77a9dc0..d8e2c253 100644
--- a/BUILD-LOG.md
+++ b/BUILD-LOG.md
@@ -3192,3 +3192,37 @@ never opened on the Discord side, as expected. Row 25 is live: records,
approval request, button, and the approval recorded in SetSpark. The
service-side approver validation gap went to the SetSpark lead (see
DEFERRED).
+
+## 2026-09-26: CHAT-02 Console (#1507, row 5, Dewey, Filbert, Sage)
+
+The WebUI can now show a seat's conversation, read-only, through the two
+CHAT-02 board routes. History opens it from the Waiting card, the table
+row and the inspector. The view pages through the whole branch without
+clipping. Untrusted text is set only with textContent, and control and
+bidi characters are drawn as visible marks. On the last page it polls
+with the follow cursor and replies through the existing /api/reply path.
+The WebUI proxy passes only the two conversation routes' query strings
+upstream.
+
+Review: Filbert asked for changes on revision 1 (24b046af). The blocker,
+B1: after a reconcile, "Reload conversation" reopened on the default
+branch without saying so. Revision 2 (d06de6a7) keeps the branch, adds a
+`gone` marker when that branch no longer exists, and takes notes N1 to
+N3. Filbert approved it in review 160dd68d, with 188/188 including two
+probes of his own. Sage checked the ten pins on the staged index: webui,
+conversation, control-board and seat tests 186/186 serial. The eight
+suites are green, and the chat-00, chat-01 and chat-01c checks exit 0.
+Dewey's mutation runs caught 17 of 17.
+
+Deviation from brief §2.3 item 6, accepted by Filbert (who wrote the
+item) and recorded by Sage: a relaunched seat shows the `newer` marker
+("Open the newest session"), not `reconcile`. The relaunch writes a new
+session file, while the old one stays readable, so nothing needs
+reconciling. Item 6's wording was too narrow.
+
+Also in this commit: Filbert's CHAT-02 backend reproduction scripts,
+referenced by his backend review (a5beb6d9), and Darkwing's two
+test-only route notes. Filbert's idsDigest note waits in
+agents/dewey/work/chat-02/FOLLOWUPS.md. Still due: a WebUI restart,
+because the running proxy predates these routes, then the brief §4 live
+check.
diff --git a/agents/dewey/work/chat-02/CONSOLE-r1-24b046af.md b/agents/dewey/work/chat-02/CONSOLE-r1-24b046af.md
new file mode 100644
index 00000000..919e5168
--- /dev/null
+++ b/agents/dewey/work/chat-02/CONSOLE-r1-24b046af.md
@@ -0,0 +1,161 @@
+# CHAT-02 Console: review packet (#1507, row 5)
+
+Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.2, §2.3
+and §4. Base: `3a209eea` on `refactor`, which includes the backend commit
+`a5beb6d9`. The commits after `a4d38a3d` touch only `packages/ledger` and
+records. Nothing here is committed; the candidate is the working tree,
+pinned by the hashes below.
+
+Reviewer (Sage's order): Filbert, all ten files.
+
+## 1. Candidate hashes
+
+```
+77039b18845c913dfcbfaf4cd6854ed97865a14eb762773af6f6d19cb704fc11 packages/webui/README.md
+68a054ee61f033f200a5d53b16b3274cbfb89d8bd065c8a049d5f804d51fe0a7 packages/webui/src/public/app.js
+b972e2f7f22dafbbf7425773c0715875f9bbcf795af1e5f66a2d9dc394c77047 packages/webui/src/public/index.html
+8747b83d16d93d880cf1502267aefc3e48f02e7b5b364177c6fb492320f63024 packages/webui/src/public/live.css
+1187a98f52e937f30dc0fbbb3d83445feff7c0a38d3535252af1c7e7e704b7b3 packages/webui/src/serve.mjs
+0477f66d5caf9d8f76ba3fb14d3f122f124d6701259f3d7447da639f69982525 packages/webui/tests/serve.test.mjs
+9ed68e39904181ba37961d22eea200b024344ef5a7de06bd7c2e8a41ebb1b0e0 packages/webui/tests/conversation.test.mjs
+b312c8a192a7a92f0642ee977b136a2ef5909b55632a4d0f669f4ca06e30eee0 packages/webui/tests/history-fixture.mjs
+0918aeeaac89cac3d6977ad2e106d299c623e8f2ac6af631ab116ae7f14d3013 packages/webui/tests/history-return-flow.test.mjs
+105d87ec3394589afb5c5a43230dd0a43752fcac05d6558b6205013a611a69b2 packages/control-board/tests/serve.test.mjs
+```
+
+The last three webui tests are new files; the rest are diffs against the
+base (`git diff 3a209eea -- packages/webui packages/control-board/tests`).
+The `packages/ledger` edits in the shared tree belong to another seat and
+are not part of this candidate.
+
+## 2. What it does
+
+- **Entry points.** A History button on each seat's Waiting card, table row
+ and inspector opens a read-only conversation view in place of the board.
+ Back returns focus to the button that opened it.
+- **Rendering.** The view reads the whole branch through
+ `/api/conversation`, page by page, and joins fragments and continuation
+ parts by block. Nothing is clipped. Tool calls, tool results and thinking
+ are collapsed `details`; redacted thinking says "not available".
+- **Untrusted text.** Session content is set only with `textContent`.
+ Markdown stays as source. C0 controls and DEL show as Unicode control
+ pictures (`␛`, `␇`, `␡`); bidi overrides and isolates show as `[U+202E]`
+ and similar. Newlines and tabs stay.
+- **Polling.** On the last page the view keeps the `follow` cursor and reads
+ from it on each board refresh (every 10 s; Pause stops it). It scrolls only
+ when the reader was already near the end.
+- **Nothing switches silently.** Three markers, each with a button:
+ - `branch`: `view.defaultBranch` differs from the open branch. "Open the
+ latest branch" reopens without a branch parameter.
+ - `newer`: the board row's session id changed after the view opened, which
+ is a relaunch. "Open the newest session."
+ - `reconcile`: a refusal with `reconcile: true`. The view keeps what it
+ showed, stops polling and offers "Reload conversation".
+- **Session picker.** Lists every catalogue row for the seat, newest first;
+ unavailable ones say why. Non-Pi harnesses and seats without files say so
+ and show no reply form.
+- **Reply.** The view's form uses the same `/api/reply` path, draft map,
+ pending-send lock and receipts as the inspector. A delivered send clears
+ the box only if its text is unchanged.
+- **Proxy.** `webui/src/serve.mjs` adds `GET /api/conversations` and
+ `/api/conversation`. Only those two carry their query string upstream; the
+ board validates it. Upstream status and body pass through unchanged.
+- **Age** is unchanged from `42c08d52` (no Age lines in the diff).
+
+## 3. Choices to review
+
+1. **The relaunch marker is `newer`, not `reconcile`.** Brief §2.3 item 6
+ says "shows the reconcile marker". A new session file does not change the
+ open file, so the reader has nothing to refuse and the open view is still
+ accurate. The view shows a separate `newer` marker instead, and the test
+ asserts it, the kept file, the absence of the new file's text and the
+ draft. `reconcile` is kept for refusals (tested in `conversation.test.mjs`
+ with a same-inode rewrite).
+2. **Relaunch detection uses the board's `sessionId`** for the newest
+ readable conversation only. An older session opened from the picker never
+ shows `newer`, because it was never the board's current session.
+3. **The conversation form has its own `conv-form` and `conv-receipt`
+ classes.** My first draft reused `reply-form` and `receipt`. The hidden
+ view sits earlier in the DOM, so `querySelector(".reply-form")` in four
+ existing browser tests found it first and failed; those are the four
+ failures Filbert saw in the shared tree during the backend review. The
+ inspector's classes are unchanged. CSS rules list both classes, and the
+ submit handler matches `.reply-form, #conv-form`.
+4. **Heading focus ring.** Opening the view focuses its heading
+ (`tabindex="-1"`). The shared `:focus-visible` rule draws its ring, as it
+ does for the inspector title. A synthetic `click()` counts as keyboard
+ focus, so the screenshots show the ring; a real mouse click does not. I
+ kept it for keyboard users.
+5. **Darkwing's two R2 notes** on the backend routes are taken here, as Sage
+ offered: the refusal-status test now scans every `.mjs` in
+ `packages/conversation/src` and pins the whole `REFUSAL_STATUS` object.
+ Test-only; `serve.mjs` is unchanged from `a5beb6d9`.
+
+## 4. Evidence
+
+### 4.1 Suites and contract checks
+
+- In `/tmp/dewey-chat02/overlay-console`, a `git archive` of `3a209eea`
+ with only the ten files overlaid: conversation 29, control-board 124,
+ webui 13 and seat 19, 185 of 185 pass.
+- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
+ `chat-01c/check.mjs` all exit 0 there.
+- The shared tree gives the same 185/185.
+
+### 4.2 Acceptance map (brief §4)
+
+| Item | Test |
+|---|---|
+| §2.2 hostile-render fixture | `conversation.test.mjs` test 1: ` [click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 evil';
++const HOSTILE = ' [click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil';
+ const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END';
+
+ test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => {
+@@ -53,11 +53,11 @@
+ await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)');
+ const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent');
+ assert.ok(shown.startsWith('Here: [click](javascript:window.injected=3) link ␛[31mRED'), shown);
+- assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+202E]evil'), shown);
++ assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown);
+ assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6));
+ assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0);
+ assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false);
+- assert.equal(await b.evaluate('document.body.textContent.includes("\\u001b") || document.body.textContent.includes("\\u202e")'), false);
++ assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false);
+ await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()');
+ assert.equal(await b.evaluate('typeof window.injected'), 'undefined');
+ assert.equal(await b.evaluate('location.href'), href);
+@@ -84,7 +84,7 @@
+ }
+
+ // Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button.
+- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()');
++ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
+ await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")');
+ assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
+ await b.evaluate('document.querySelector("#conv-back").click()');
+@@ -125,16 +125,61 @@
+ await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")');
+ assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
+ assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
+- // The fork becomes the default leaf again before the view reopens.
++ // The fork becomes the default leaf again. A same-inode rewrite then refuses the next check.
+ log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') }));
+- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()');
++ writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"'));
++ await b.evaluate('document.querySelector("#refresh").click()');
++ await wait('document.querySelector("[data-marker=reconcile]")');
++ // Reload stays on the branch the view was on, and still says the conversation went elsewhere.
++ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
++ await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")');
++ await wait('document.querySelector("[data-marker=branch]")');
++ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
++ // "Open the latest branch" takes the default.
++ await b.evaluate('document.querySelector("[data-conv-action=latest]").click()');
+ await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")');
+- assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
++ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
+ assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null);
++ // The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so.
++ writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n'));
++ await b.evaluate('document.querySelector("#refresh").click()');
++ await wait('document.querySelector("[data-marker=reconcile]")');
++ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
++ await wait('document.querySelector("[data-marker=gone]")');
++ assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/);
++ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
++ assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
++ } finally { if (b) await b.close(); await f.close(); }
++});
++
++test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => {
++ const f = await historyFixture();
++ let b;
++ try {
++ const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot);
++ log.add(user('Question'), at(-120));
++ log.add(assistant('OLD_ANSWER'), at(-110));
++ b = await browser(); await b.viewport(1440, 1000);
++ const wait = waiter(b);
++ await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
++ await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
++ await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
++ // The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last.
++ const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) });
++ await b.evaluate('document.querySelector("#refresh").click()');
++ await wait('document.querySelector("[data-marker=newer]")');
++ await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
++ await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)');
++ assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/);
++ // Once the new file has entries, the same button opens it.
++ log2.add(user('NEW_QUESTION'), at(1));
++ await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
++ await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")');
++ assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
+ } finally { if (b) await b.close(); await f.close(); }
+ });
+
+-test('conversation view: seats without history say so and offer no reply', { timeout: 60000 }, async () => {
++test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => {
+ const f = await historyFixture();
+ let b;
+ try {
diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-dark.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-dark.png
new file mode 100644
index 00000000..c5438112
Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-dark.png differ
diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-light.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-light.png
new file mode 100644
index 00000000..66b96e6a
Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-light.png differ
diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-dark.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-dark.png
new file mode 100644
index 00000000..4c7e0ec5
Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-dark.png differ
diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-light.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-light.png
new file mode 100644
index 00000000..ee247c77
Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-light.png differ
diff --git a/agents/filbert/work/chat-02-backend-review-evidence/branch.mjs b/agents/filbert/work/chat-02-backend-review-evidence/branch.mjs
new file mode 100644
index 00000000..c8c862a0
--- /dev/null
+++ b/agents/filbert/work/chat-02-backend-review-evidence/branch.mjs
@@ -0,0 +1,19 @@
+import { mkdirSync, writeFileSync, appendFileSync, mkdtempSync, realpathSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createReader } from "../../../../packages/conversation/src/reader.mjs";
+const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
+const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true });
+const f = join(dir, "s.jsonl");
+const L = (o) => JSON.stringify(o) + "\n";
+const msg = (id, parentId, role, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: role === "assistant" ? { role, content: [{ type: "text", text }], stopReason: "stop" } : { role, content: text } });
+writeFileSync(f, L({ type: "session", id: "sess", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + msg("a", null, "user", "hi") + msg("b", "a", "assistant", "hello"));
+const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] });
+const conv = reader.catalogue().conversations[0].conversation;
+const p1 = reader.open({ conversation: conv });
+console.log("open branch", p1.page.branch, "entries", p1.page.entries.map(e => e.id + "@" + e.branch));
+appendFileSync(f, msg("c", "b", "user", "more") + msg("d", "c", "assistant", "ok"));
+const p2 = reader.next({ cursor: p1.follow.id, conversation: conv, branch: p1.page.branch });
+console.log("follow ok", p2.ok, "page branch", p2.page?.branch, "entries", p2.page?.entries.map(e => e.id + "@" + e.branch), "view", JSON.stringify(p2.view?.branches));
+const p3 = reader.open({ conversation: conv, branch: p1.page.branch });
+console.log("reopen with first branch id:", JSON.stringify(p3.refusal ?? p3.page.branch));
diff --git a/agents/filbert/work/chat-02-backend-review-evidence/bridge.mjs b/agents/filbert/work/chat-02-backend-review-evidence/bridge.mjs
new file mode 100644
index 00000000..661ef058
--- /dev/null
+++ b/agents/filbert/work/chat-02-backend-review-evidence/bridge.mjs
@@ -0,0 +1,15 @@
+import { mkdirSync, writeFileSync, mkdtempSync, realpathSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createReader } from "../../../../packages/conversation/src/reader.mjs";
+const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
+const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true });
+const L = (o) => JSON.stringify(o) + "\n";
+const msg = (id, parentId, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: { role: "user", content: text } });
+// a -> b is one branch. X (lost, malformed) was a child of a: a fork. y -> X.
+writeFileSync(join(dir, "s.jsonl"), L({ type: "session", id: "sess", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + msg("a", null, "root") + msg("b", "a", "ON THE OTHER BRANCH") + '{"type":"message","id":"X","parentId":"a","timest\n' + msg("y", "X", "leaf"));
+const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] });
+const conv = reader.catalogue().conversations[0].conversation;
+const p = reader.open({ conversation: conv });
+console.log("branches", JSON.stringify(p.view.branches));
+for (const e of p.page.entries) console.log(e.branch, e.role, JSON.stringify(e.content[0].text).slice(0, 90));
diff --git a/agents/filbert/work/chat-02-backend-review-evidence/fork.mjs b/agents/filbert/work/chat-02-backend-review-evidence/fork.mjs
new file mode 100644
index 00000000..945da6cc
--- /dev/null
+++ b/agents/filbert/work/chat-02-backend-review-evidence/fork.mjs
@@ -0,0 +1,24 @@
+import { mkdirSync, writeFileSync, appendFileSync, mkdtempSync, realpathSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createReader } from "../../../../packages/conversation/src/reader.mjs";
+const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
+const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true });
+const f = join(dir, "s.jsonl");
+const L = (o) => JSON.stringify(o) + "\n";
+const m = (id, parentId, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: { role: "user", content: text } });
+writeFileSync(f, L({ type: "session", id: "s", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + m("a", null, "A") + m("b", "a", "B") + m("c", "b", "C"));
+const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] });
+const conv = reader.catalogue().conversations[0].conversation;
+const t = (p) => p.ok ? `${p.page.branch} [${p.page.entries.map((e) => e.content[0].text).join(",")}] default=${p.view.defaultBranch} branches=${p.view.branches.map((b) => b.branch).join("|")}` : JSON.stringify(p.refusal);
+let main = reader.open({ conversation: conv }); console.log("1 open", t(main));
+// Pi navigates back to b and continues: fork from the middle of main.
+appendFileSync(f, m("x", "b", "X") + m("y", "x", "Y"));
+let fm = reader.next({ cursor: main.follow.id, conversation: conv, branch: "main" }); console.log("2 main follow", t(fm));
+let dx = reader.open({ conversation: conv }); console.log("3 default open", t(dx));
+// Pi goes back to c on main and continues; then resetLeaf starts a new root.
+appendFileSync(f, m("d", "c", "D") + m("r", null, "R"));
+console.log("4 main follow", t(reader.next({ cursor: fm.follow.id, conversation: conv, branch: "main" })));
+console.log("5 b.x follow", t(reader.next({ cursor: dx.follow.id, conversation: conv, branch: dx.page.branch })));
+console.log("6 open b.x", t(reader.open({ conversation: conv, branch: "b.x" })));
+console.log("7 open default", t(reader.open({ conversation: conv })));
diff --git a/agents/filbert/work/chat-02-backend-review-evidence/perm.mjs b/agents/filbert/work/chat-02-backend-review-evidence/perm.mjs
new file mode 100644
index 00000000..ec1df993
--- /dev/null
+++ b/agents/filbert/work/chat-02-backend-review-evidence/perm.mjs
@@ -0,0 +1,15 @@
+import { mkdirSync, writeFileSync, mkdtempSync, realpathSync, chmodSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createReader } from "../../../../packages/conversation/src/reader.mjs";
+const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
+const mk = (seat) => { const d = join(proj, ".pi", "state", seat, "sessions"); mkdirSync(d, { recursive: true }); writeFileSync(join(d, "s.jsonl"), JSON.stringify({ type: "session", id: "s", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + "\n"); return d; };
+const good = mk("good"), bad = mk("bad");
+chmodSync(join(proj, ".pi", "state", "bad"), 0o000);
+const root = (seat, dir) => ({ seat, project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null });
+const reader = createReader({ roots: [root("good", good), root("bad", bad)] });
+try { const c = reader.catalogue(); console.log("catalogue ok", c.conversations.length, JSON.stringify(c.refusedRoots)); }
+catch (e) { console.log("catalogue THREW", e.code, e.message); }
+try { console.log("open", JSON.stringify(reader.open({ conversation: "pi-" + "0".repeat(32) }).refusal)); }
+catch (e) { console.log("open THREW", e.code); }
+chmodSync(join(proj, ".pi", "state", "bad"), 0o755);
diff --git a/agents/filbert/work/chat-02-console-review-2026-09-26.md b/agents/filbert/work/chat-02-console-review-2026-09-26.md
new file mode 100644
index 00000000..974d8442
--- /dev/null
+++ b/agents/filbert/work/chat-02-console-review-2026-09-26.md
@@ -0,0 +1,264 @@
+# CHAT-02 Console, revision 1: Filbert's code review
+
+Reviewer: Filbert, 2026-09-26. Requested by Dewey, assigned by Sage (#1507,
+row 5). Measured against brief R4 (`agents/dewey/work/chat-02/BRIEF.md`) §2.2,
+§2.3 and §4, and Sage's D1–D4.
+
+Candidate: packet `agents/dewey/work/chat-02/CONSOLE.md`, sha256
+`24b046af…cd9ccd`, ten files on base `3a209eea`. All ten pins verify in the
+shared tree and in my overlay.
+
+**Verdict: changes requested.** One blocking finding (B1): "Reload
+conversation" after a reconcile moves a view that sits on an older branch to
+the default branch without saying so. The rest of the candidate is sound, and
+I accept all five §3 choices. The fix is small, and I'll re-review only the
+delta.
+
+## 1. What I ran
+
+- **Overlay.** A detached worktree at `3a209eea` (`/tmp/fb-console-wt`), with
+ only the ten pinned files overlaid (`sha256sum -c` clean) and
+ `node_modules` symlinked.
+- **Suites.** Running `node --test --test-concurrency=1` over the
+ conversation, control-board, webui and seat tests gives 185/185. The
+ `chat-00`, `chat-01` and `chat-01c` `check.mjs` scripts each exit 0.
+- **Screens.** I regenerated test 1's screens with `WEBUI_EVIDENCE` at the
+ pinned hashes. The four PNGs have the same dimensions as Dewey's
+ (305×3315 at 320, 1425×1586 at 1440), and I looked at 320-dark and
+ 1440-light. They show the long answer in full, the tool call and result,
+ inert hostile text (with ␛, ␇ and `[U+202E]` visible), the line-5 notice
+ and the reconcile marker. Test 1 asserts no horizontal overflow at 320 and
+ 1440 in both modes.
+- **Two probes of my own,** in a scratch test file in the overlay (not the
+ shared tree). Both results are below: B1 and N1.
+
+## 2. Against the brief
+
+- **§2.2 rendering.**
+ - Every session string goes through `node()`, which sets `textContent`
+ after `inert()`. No session value reaches `innerHTML`. The existing
+ card, table and inspector templates gain only `historyButton(r)`, and
+ both values it interpolates go through `esc()`.
+ - Tool calls, tool results, thinking and compaction are closed
+ `details`. Thinking is hidden by default, and unavailable thinking
+ says so.
+ - The hostile fixture matches R1, in both assistant text and tool output.
+ The assertions cover the absence of any element, any `on*` attribute and
+ any navigation.
+ - Age is untouched. Reply keeps the board path, and the view polls.
+- **§2.3 items 1–7** are in `history-return-flow.test.mjs`, with no
+ injection and no Refresh. Item 6 is covered with `newer` in place of
+ `reconcile` (see §3.1).
+- **Proxy.** `/api/conversations` and `/api/conversation` are GET-only and
+ forward their query string unchanged. `/api/board` gets no query. The Host
+ and Origin checks run first, the JSON-only response rule still holds, and
+ so does `redirect: 'error'`. The webui serve test pins each of these.
+- **§4.**
+ - The suites and checks are green.
+ - The screens are present, and I reproduced them at the pins.
+ - The live check is still open: it needs the commit and a WebUI restart,
+ as the packet's §6 says.
+
+## 3. The five choices Dewey asked about
+
+1. **`newer` instead of `reconcile` for a relaunch: accept.**
+ - In this code, `reconcile` means "the source refused, polling stopped".
+ A relaunch refuses nothing. The open file is still accurate, and
+ polling on it should go on.
+ - A separate marker with its own action is the better reading of item 6's
+ intent, which is to keep the file and never switch silently.
+ - I wrote that line in my R2 review, and the wording was too narrow.
+ Sage should record the deviation against brief §2.3 item 6, in the
+ BUILD-LOG entry or as a brief erratum, so the brief and the test agree.
+2. **Detection only for the newest readable conversation: accept.** An
+ older session is an explicit choice from the picker, so it has nothing
+ newer to warn about. There is one latent edge in the matching; see N1.
+3. **The `conv-form` and `conv-receipt` classes: accept.**
+ - The cause is right: the hidden view comes earlier in the DOM, so a
+ bare `.reply-form` query found it.
+ - The four browser tests pass in the overlay.
+ - Dewey's "forms share the reply-form class" mutant is caught.
+ - The CSS lists both class pairs, and the submit handler matches
+ `.reply-form, #conv-form`.
+4. **The heading focus ring: accept.** The heading uses `tabindex="-1"` and
+ the shared `:focus-visible` rule, the same as the inspector title. The
+ ring shows in the screens only because a synthetic `click()` counts as
+ keyboard focus.
+5. **Darkwing's R2 notes, test-only: accept.**
+ - Only `packages/control-board/tests/serve.test.mjs` changes under
+ control-board (`git diff 3a209eea --stat`), so the board's `serve.mjs`
+ is unchanged.
+ - The refusal scan now reads every `.mjs` in `packages/conversation/src`,
+ and the pin covers the whole `REFUSAL_STATUS` object, all 16 codes.
+
+## 4. Blocking
+
+**B1. After a reconcile, Reload silently switches the branch.**
+- **Where.** The reconcile marker's button is
+ `{ id: 'reopen', label: 'Reload conversation' }`. The click handler calls
+ `openConversation(conv.row, conv.id)`, which reads `convURL({ id })` with
+ no branch, so the server returns the default branch. `openConversation`
+ resets the markers, and `apply()` raises `branch` only when
+ `view.defaultBranch !== view.branch`. On the default branch, that is
+ never.
+- **Probe A.** I opened a view, then a fork made another leaf the default,
+ and the `branch` marker showed. That part is correct. Next I did a
+ same-inode rewrite that keeps both branches, which triggered a reconcile,
+ and clicked Reload.
+ - Result: turns `[["User","QUESTION"],["Assistant","FORK_ANSWER"]]` and
+ no markers.
+ - The reader was on `MAIN_ANSWER`'s branch. They now see the fork's
+ branch, and nothing on screen says so.
+- **Why it blocks.**
+ - The branch marker's own text promises "This view stays on the branch it
+ opened".
+ - Test 2's assertion is named "no silent switch".
+ - Brief §2.1 says nothing switches silently.
+- **It is reachable in normal use, not only on a rewrite.** Cursors are held
+ in board memory with a 10-minute TTL (`reader.mjs:204`), so either of
+ these produces the reconcile:
+ - a board restart (`cursor-unknown`);
+ - Pause held for more than ten minutes (`cursor-expired`).
+- **Suggested fix.**
+ - Reload reopens with the branch it was on: `convURL({ id, branch })`.
+ - If the board answers `unknown-branch`, open the default and show a
+ marker that says the old branch is gone.
+ - Add a test in probe A's shape: fork, a refusal, Reload, then assert the
+ open branch's text or a marker. Also run a mutant that drops the branch
+ from Reload.
+ - The "Open the latest branch" button shares the `reopen` id. It should
+ keep going to the default, so the two buttons need different ids.
+
+## 5. Nonblocking
+
+**N1. The board and the catalogue disagree on "newest".**
+- **The mismatch.** The board row's `sessionId` comes from the newest file
+ by mtime (`scan.mjs:49–58`). The catalogue sorts by the last entry's
+ timestamp and puts `null` last (`reader.mjs:262`).
+ `openConversation` takes `readable[0]` as "the board's session" and, for
+ the "Open the newest session" action, again as "the newest".
+- **Probe B.** A relaunch file holding only its header:
+ - `newer` shows. Clicking "Open the newest session" reopens the **old**
+ file, clears the marker and records the new `sessionId`.
+ - After a message then lands in the new file, the view is still on the
+ old file and has no marker. It never returns.
+- **Why it doesn't block.** Real Pi doesn't write a header-only file. Its
+ `_persist` creates the file with `wx` only once an assistant message
+ exists (`session-manager.js:739–766`), so header, user and assistant land
+ together. The new file's last timestamp is then newer, and the two rules
+ agree.
+- **Cheap guard.** After the `newest` action, if `choice.conversation`
+ equals the conversation that was open, keep the marker and say the newer
+ session isn't readable yet.
+
+**N2. Raw bidi controls in the source.**
+- **Where.** `app.js:88` builds `BIDI` from raw U+202A, U+202E, U+2066 and
+ U+2069 characters inside the regex literal. `conversation.test.mjs`'s
+ `HOSTILE` string holds a raw U+202E.
+- **Why it matters.** They behave correctly, but a raw override in a diff is
+ exactly what a reviewer can't see; this is the Trojan Source pattern.
+- **Fix.** Write `/[--]/g` and `'evil'`. The
+ behaviour is identical.
+
+**N3. `inert()` coverage.**
+- **What's missing.** It leaves out:
+ - LRM, RLM and ALM (U+200E, U+200F, U+061C);
+ - the C1 controls U+0080–U+009F, which include the single-byte CSI
+ U+009B.
+- **Why it's minor.** In a browser, the marks can only nudge neutral
+ characters next to them, and C1 has no effect. Take it or leave it. If
+ taken, the fixture gains one of each.
+
+## 6. Scratch
+
+- **Worktree.** `/tmp/fb-console-wt` stays in place for the delta re-review.
+ Its only additions are the probe file
+ `packages/webui/tests/zz-filbert-probe.test.mjs` and the symlinked
+ `node_modules`, and the ten pins still verify. Screens are in
+ `/tmp/fb-console-ev`.
+- **Nothing live was touched.** I didn't use the shared tree's served WebUI
+ or any live process.
+- No commit or push.
+
+## Revision 2: delta re-review
+
+Candidate: packet `CONSOLE.md` `d06de6a7…72d2`, and
+`evidence/console/r2-delta.patch` `c1d65628…5866`. Three files changed:
+
+- `app.js` `3c7f2f4c…0d6e`
+- `conversation.test.mjs` `52c2c663…a4a5`
+- `README.md` `5a1a4de7…acc4`
+
+The other seven pins are unchanged from revision 1.
+
+**Chain.** In my overlay, the patch applies in reverse to revision 1's ten
+pins (all ten verify). Applied forward again, it gives the three revision 2
+hashes. In both the overlay and the shared tree, all ten revision 2 pins
+verify.
+
+**Runs.** Suites 188/188: Dewey's 186, plus my probes A and B, kept for the
+run. The chat-00, chat-01 and chat-01c checks exit 0.
+
+**B1: fixed.**
+- The reconcile button is now `reload` and reopens with
+ `{ branch: c.branch }`. "Open the latest branch" is now `latest`, which
+ reopens without a branch. `newest` passes `from` and `sessionId`.
+- Probe A, rerun: after the rewrite, Reload shows
+ `[QUESTION, MAIN_ANSWER]` with the `branch` marker. Revision 1 showed the
+ fork with no marker.
+- On `unknown-branch`, the view falls back to the default and raises
+ `gone`. The fallback runs only when a branch was asked for, and the second
+ fetch checks the generation.
+- The fork test now covers:
+ - Reload keeping `main`;
+ - `latest` taking the fork;
+ - a fork rewritten away, giving `gone` with no reconcile marker.
+- Dewey's four new mutants for this (reload drops its branch, latest keeps
+ the open branch, gone not reopened, gone reopened silently) are all
+ caught.
+
+**N1: taken.**
+- `newerUnread` is set when `newest` lands on the conversation already open.
+ The view then keeps the old `sessionId`, so `newer` stays and says the new
+ history isn't readable yet.
+- Probe B, rerun: the marker survives the click and a later poll.
+- Dewey's test 3 adds the second click once the new file has an entry, and
+ that click opens it.
+
+**N2: taken.** A scan of `app.js` and both conversation test files finds no
+raw C1, LRM, RLM, ALM, bidi override or isolate, U+2028, U+2029 or ESC
+characters.
+
+**N3: taken.**
+- `UNSEEN` covers U+0080–U+009F, U+061C, U+200E, U+200F, U+202A–U+202E and
+ U+2066–U+2069, printed as `[U+XXXX]` and padded to four digits.
+- The fixture asserts `[U+009B]31mCSI [U+200E]mark [U+202E]evil`, and the
+ page check rejects all four raw characters.
+
+**Nit, no action needed.** Test 4's declaration lost a space
+(`reply',{ timeout`).
+
+**Screens.** I didn't regenerate revision 2's screens. Test 1 asserts the
+new visible text at the pins, and that overflow check passed in my run.
+
+**Verdict: approve** revision 2. These are the exact ten files:
+
+| File | sha256 |
+|---|---|
+| `packages/webui/README.md` | `5a1a4de7…acc4` |
+| `packages/webui/src/public/app.js` | `3c7f2f4c…0d6e` |
+| `packages/webui/src/public/index.html` | `b972e2f7` (unchanged) |
+| `packages/webui/src/public/live.css` | `8747b83d` (unchanged) |
+| `packages/webui/src/serve.mjs` | `1187a98f` (unchanged) |
+| `packages/webui/tests/serve.test.mjs` | `0477f66d` (unchanged) |
+| `packages/webui/tests/conversation.test.mjs` | `52c2c663…a4a5` |
+| `packages/webui/tests/history-fixture.mjs` | `b312c8a1` (unchanged) |
+| `packages/webui/tests/history-return-flow.test.mjs` | `0918aeea` (unchanged) |
+| `packages/control-board/tests/serve.test.mjs` | `105d87ec` (unchanged) |
+
+**Still open, and not part of this verdict:**
+- the brief §4 live check, after the commit and a WebUI restart;
+- Sage recording the `newer` deviation from §2.3 item 6.
+
+**Scratch.** I removed the probe file and verified the pins, then removed
+the worktree `/tmp/fb-console-wt`. No commit or push.
diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md
index d974cab6..a9649a55 100644
--- a/docs/SESSIONS.md
+++ b/docs/SESSIONS.md
@@ -403,3 +403,8 @@ are never rewritten or removed; corrections are new entries.
2026-09-26T21:42:17Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1509 row 25 live check | Jason's button approval passed (DEC-010 request 2, 21:38:58Z). Approver validation gap sent to the SetSpark lead (ac03938d), who does not own shared-signals; owner and DEC-009 go to Jason. Lead decision 19.
2026-09-26T21:54:38Z | Filbert (T3 Claude Code, thread 9cb9731e) | #1506 Gate F follow-up (notes 1-3) code review | Manifest 382f5bb0 (three pins, clean a4d38a3d worktree): approve. Ledger 51/51; rethrow mutation killed; CLI non-SQLite path prints generic message, exit 1. Live diagnostic 15th traced (shape only): content-engine agent header with spaces in Sage thread, counted human. Review 6fd693b6. Reported to Darkwing and Sage. No commit or push.
2026-09-26T21:57:51Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1506 Gate F follow-up commit | Darkwing notes 1-3 (manifest 382f5bb0, pins and patch verified), Filbert approve 6fd693b6. Ledger 51/51, eight suites green on the index. Header-role gap noted in DEFERRED.
+2026-09-26T22:05:18Z | Sage (T3 Claude Code, thread 1ef1e4f8) | pkill recovery | Claude sessions dropped 22:02:32Z. Darkwing (A1, two untracked files) and Dewey (Console mutation pass) resumed with state; Filbert idle, Rocko (codex) and SetSpark lead unaffected or self-recovered. Discord connector 954332 and board 1042473 still up.
+2026-09-26T22:23:02Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-02 Console review, packet 24b046af (#1507 row 5) | changes requested: B1 Reload after reconcile silently switches branch (probe reproduced); N1–N3 nonblocking; 185/185, chat checks 0; review agents/filbert/work/chat-02-console-review-2026-09-26.md
+2026-09-26T22:51:14Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-02 Console revision 2 delta re-review, packet d06de6a7 (#1507 row 5) | approved: B1 fixed (probe A rerun keeps branch), N1–N3 taken; 186/186 plus probes, chat checks 0; ten pins in review file
+2026-09-26T22:51:39Z | Dewey (T3 Claude Code, thread 84fb346c) | CHAT-02 Console (#1507 row 5), revisions 1 and 2 | approved by Filbert (review 160dd68d): packet CONSOLE.md d06de6a7, ten pins; B1 Reload-branch fix and N1–N3 taken; 186/186 on overlay 3a209eea and shared tree, chat checks 0, mutants 17/17; uncommitted, handed to Sage for commit; live check waits on commit and WebUI restart
+2026-09-26T23:05:11Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1507 CHAT-02 Console commit | Dewey r2 d06de6a7, Filbert approve 160dd68d; ten pins verified; 186/186 serial, eight suites and chat checks green on the index. §2.3 item 6 deviation (newer, not reconcile) recorded. WebUI restart and live check next.
diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md
index 3ea426f3..51672057 100644
--- a/docs/plans/DEFERRED.md
+++ b/docs/plans/DEFERRED.md
@@ -154,6 +154,13 @@ at every gate. Started 2026-09-12 during the control board MVP.
Sage asked the SetSpark lead to use a one-word role. The rule stays as
it is. (2026-09-26, #1506)
+- **A nested `node --test` passes even when its tests fail.** A child
+ `node --test` inherits NODE_TEST_CONTEXT from its parent and exits 0
+ whatever its results. Darkwing found it building queue A1 and fixed that
+ path with `env -u NODE_TEST_CONTEXT`, plus a test. Other suites that
+ start `node --test` from inside a test run have not been checked, so
+ they may be hiding failures. (2026-09-26, #1508)
+
## Queue
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs
index e1f5e18a..6ab004d8 100644
--- a/packages/control-board/tests/serve.test.mjs
+++ b/packages/control-board/tests/serve.test.mjs
@@ -1104,12 +1104,21 @@ test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin
});
test("every refusal code the reader can raise has an HTTP status", () => {
- const src = ["reader.mjs", "pi.mjs", "safe-fs.mjs"].map((f) => readFileSync(join(pkgRoot, "..", "conversation", "src", f), "utf8")).join("\n");
+ // Every source file in the reader package, so a refusal added in a new file is seen too.
+ const dir = join(pkgRoot, "..", "conversation", "src");
+ const src = readdirSync(dir).filter((f) => f.endsWith(".mjs")).map((f) => readFileSync(join(dir, f), "utf8")).join("\n");
const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1]));
codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason
assert.ok(codes.size >= 15, [...codes].join(" "));
assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
+ // The statuses themselves, so a changed value fails here even where no route test reaches it.
+ assert.deepEqual(REFUSAL_STATUS, {
+ "unknown-conversation": 404, "unknown-branch": 404, unavailable: 404,
+ "cursor-unknown": 409, "cursor-expired": 409, "cursor-foreign": 409, "source-replaced": 409, "incomplete-header": 409,
+ "unsafe-path": 403, "foreign-project": 403, unreadable: 403, "unknown-actor": 403,
+ "unsupported-harness": 422, "not-a-pi-session": 422, "too-large": 422, "unsupported-purpose": 422,
+ });
});
test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => {
diff --git a/packages/webui/README.md b/packages/webui/README.md
index ea12352d..7af161b0 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -38,6 +38,19 @@ bytes. Do not expose either unauthenticated server through a public proxy.
with a warning. There are no automatic action retries.
- Palette and appearance use the existing brand tokens and persist in this
browser when local storage is available. No settings screen is added.
+- History opens a read-only conversation view for a seat, from its Waiting
+ card, its table row or its inspector (#1507, CHAT-02). It shows the whole
+ branch with nothing clipped. Tool calls, tool results and thinking start
+ collapsed. Session text is always shown as text: Markdown stays as source,
+ and terminal controls, bidi controls and marks show as visible symbols. Reply in the view
+ uses the same board reply path as the inspector.
+- The view checks for new entries on the same ten-second refresh, and Pause
+ stops it. It never switches on its own. A fork, a newer session for the seat
+ or a rewritten file each shows a marker with a button to open the other
+ history. Reloading after a rewrite keeps the branch the view was on; if that
+ branch is gone, the view opens the latest one and says so. Session lists
+ older sessions for the seat. Only repository Pi seats
+ have history; other harnesses say so.
Drafts and receipts stay in page memory, including across refresh, inspector
changes and a stale registration. Reloading or closing the page loses them.
@@ -47,11 +60,13 @@ delivery may be unknown: inspect the seat before sending again.
## Data and boundaries
-GET `/api/board` and POST `/api/seen` and `/api/reply` proxy only the existing
-board paths. POST bytes and upstream status/JSON are preserved. GET `/api/config`
-returns the configured board URL for the page's error message. No scanner,
-registration, session reader or transport is implemented here. No control-board,
-seat, fleet, comms or root package files are changed.
+GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
+`/api/seen` and `/api/reply` proxy only those board paths. Only the two
+conversation routes carry their query string; the board validates it. POST
+bytes and upstream status/JSON are preserved. GET `/api/config` returns the
+configured board URL for the page's error message. No scanner, registration,
+session reader or transport is implemented here. The session reader is
+`packages/conversation`, served by the board.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
@@ -67,6 +82,7 @@ or mockup session hierarchy is presented as live data.
node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
+WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -83,6 +99,13 @@ on 330 rendered samples across ten palettes and three modes. Layouts are checked
at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the
dense table; the page itself must not overflow.
+Conversation tests run the real board routes over temporary repository-layout
+session files. They cover full-length answers, collapsed tools and thinking,
+hostile content rendered inert, malformed-line and reconcile markers, forks, and
+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.
+
No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance.
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index b4246cfc..b862a985 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -52,14 +52,15 @@
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
return ``;
}
+ const historyButton = r => ``;
const openButton = r => ``;
function cards(rows) {
if (!rows.length) return '