--- a/packages/webui/src/cli.mjs
+++ b/packages/webui/src/cli.mjs
@@ -86 +86,3 @@
- bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
+ // Row 278: this owner/reader read belongs to the Console, not the shared
+ // role views or the tester adapter. The bus decides caller eligibility.
+ bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') };
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -64 +64 @@
-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
+const BUS_VERBS = ['inbox', 'tasks', 'agents', 'thread.claims', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
@@ -71,0 +72 @@
+ if (verb === 'thread.claims' && search) return json(res, 400, { error: 'invalid-request', message: 'thread.claims takes no arguments' });
@@ -737,0 +739,2 @@
+ // Owner/reader metadata is never a tester read, even with a supplied bus.
+ if (tester && verb === 'thread.claims') return json(res, 403, { error: 'tester-refused' });
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -271,0 +272 @@
+ let configRead = Promise.resolve();
@@ -471,0 +473,18 @@
+ // Row 278: a claims refusal belongs to this section. Only sign-out reaches
+ // the global handler; a 403 here must not forget the successful role read.
+ async function threadClaimsSection(get) {
+ await configRead;
+ if (isTester) return '';
+ const title = '
Thread claims
';
+ try {
+ const claims = await get('thread.claims');
+ if (out()) { last.delete('thread.claims'); throw signedOut(); }
+ if (!claims.every(c => c && ['thread', 'holder_run', 'harness', 'at'].every(k => typeof c[k] === 'string'))) throw new ReadError('invalid-response', '');
+ const rows = claims.map(c => `| ${txt(c.thread)} | ${txt(c.holder_run)} | ${txt(c.harness)} | ${when(c.at)} |
`).join('');
+ return `${title}${claims.length ? `${['Thread', 'Holder run', 'Harness', 'Claimed at'].map(h => `| ${h} | `).join('')}
${rows}
` : empty('No thread claim is held right now.', 'A thread appears while a run holds its claim on the bus.')}`;
+ } catch (err) {
+ if (err.code === 'signed-out') throw err;
+ last.delete('thread.claims');
+ return ``;
+ }
+ }
@@ -473,0 +493 @@
+ const threadSection = await threadClaimsSection(get);
@@ -477,0 +498 @@
+ ${threadSection}
@@ -1296 +1317 @@
- fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
+ configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
--- /dev/null
+++ b/packages/webui/tests/thread-claims.test.mjs
@@ -0,0 +1,68 @@
+// Row 278 (#1783): Console-owned read adapter and the listener boundary.
+// All listeners and config are fixtures; no live bus or host configuration.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmp, fixture } from '../../cli/tests/helpers.mjs';
+import { startServer } from '../src/serve.mjs';
+import { startTesterSocket } from '../src/tester-socket.mjs';
+import { testerRequest } from '../../cli/src/tester-http.mjs';
+import { BusReadError } from '../src/bus.mjs';
+import { close } from './fixture.mjs';
+
+const rows = [{ thread: '11111111-1111-4111-8111-111111111111', instance: 'assistant', holder_run: 'thread-run', harness: 'pi', at: '2026-01-01T00:00:00Z' }];
+
+test('row278 HTTP read returns held claims without send and refuses arguments before the bus', async t => {
+ const calls = [];
+ const bus = { 'thread.claims': async arg => { calls.push(arg); return rows; } };
+ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
+ t.after(() => close(web));
+ const url = `http://127.0.0.1:${web.address().port}/api/bus/thread.claims`;
+ const result = await fetch(url);
+ assert.equal(result.status, 200); assert.equal(result.headers.get('cache-control'), 'no-store');
+ const body = await result.json(); assert.deepEqual(body.rows, rows); assert.ok(body.at);
+ assert.deepEqual(calls, [undefined]);
+ for (const query of ['?thread=one', '?subject=one', '?limit=1', '?unknown=']) {
+ const r = await fetch(url + query); assert.equal(r.status, 400); assert.equal((await r.json()).error, 'invalid-request');
+ }
+ assert.equal((await fetch(url, { method: 'POST' })).status, 405);
+ assert.equal(calls.length, 1);
+});
+
+test('row278 HTTP read preserves bus refusal and rejects a malformed result', async t => {
+ let value = new BusReadError('human-required');
+ const web = await startServer({ port: 0, bus: { 'thread.claims': async () => { if (value instanceof Error) throw value; return value; } } });
+ t.after(() => close(web));
+ const url = `http://127.0.0.1:${web.address().port}/api/bus/thread.claims`;
+ let r = await fetch(url); assert.equal(r.status, 403); assert.equal((await r.json()).error, 'human-required');
+ value = {};
+ r = await fetch(url); assert.equal(r.status, 502); assert.equal((await r.json()).error, 'invalid-response');
+ value = [];
+ r = await fetch(url); assert.equal(r.status, 200); assert.deepEqual((await r.json()).rows, []);
+});
+
+test('row278 tester socket refuses thread claims explicitly with zero bus calls', async t => {
+ const dataRoot = tmp(t, 'claims-tester-'); let calls = 0;
+ const web = await startTesterSocket({ dataRoot, bus: { 'thread.claims': async () => { calls++; return rows; } } });
+ t.after(() => close(web));
+ const r = await testerRequest({ dataRoot, path: '/api/bus/thread.claims' });
+ assert.equal(r.status, 403); assert.equal(r.body.error, 'tester-refused'); assert.equal(calls, 0);
+});
+
+test('row278 Console adapter calls argument-free thread.claims through the normal transport', t => {
+ const root = tmp(t, 'claims-cli-'), f = fixture(root), log = join(root, 'calls.jsonl'), hook = join(root, 'hooks.mjs');
+ writeFileSync(log, '');
+ const serveSource = `export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){await o.bus['thread.claims']();return {address:()=>({port:0}),close:()=>{}};}`;
+ const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,...args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,tester:!!o.cli})+'\\n');return [];};`;
+ writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
+ if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
+ if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
+ return next(url,context);
+ }});`);
+ const r = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--business', 'acme', '--port', '0'],
+ { env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
+ assert.equal(r.status, 0, r.stdout + r.stderr);
+ assert.deepEqual(readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse), [{ verb: 'thread.claims', args: [], tester: false }]);
+});
--- /dev/null
+++ b/packages/webui/tests/thread-claims-browser.test.mjs
@@ -0,0 +1,132 @@
+// Row 278: real Console rendering and HTTP with mutable bus-answer stand-ins.
+// Broker claim/release semantics are covered by row 277; this tests the reader.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { startServer } from '../src/serve.mjs';
+import { BusReadError } from '../src/bus.mjs';
+import { fixture, close } from './fixture.mjs';
+import { browser } from './browser.mjs';
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
+import { testerBrowser } from './tester-browser-fixture.mjs';
+import { tmp } from '../../cli/tests/helpers.mjs';
+
+const AT = '2026-01-01T00:00:00Z';
+const THREAD = '11111111-1111-4111-8111-111111111111';
+const claim = { thread: THREAD, holder_run: 'thread-run', harness: 'pi', at: AT };
+async function setup(t) {
+ const f = await fixture(); t.after(() => f.close());
+ const state = { rows: [], failure: null, offline: false, calls: [], hold: false, release: null };
+ t.after(() => { state.hold = false; state.release?.(); });
+ const role = { role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT, state: 'idle', inputNeeded: [] };
+ const read = (verb, answer) => async () => {
+ state.calls.push(verb);
+ if (state.offline) throw new BusReadError('outcome-unknown');
+ if (verb === 'thread.claims' && state.failure) throw new BusReadError(state.failure);
+ if (verb === 'thread.claims' && state.hold) await new Promise(resolve => { state.release = resolve; });
+ return typeof answer === 'function' ? answer() : answer;
+ };
+ const bus = { agents: read('agents', [role]), inbox: read('inbox', []), messages: read('messages', { messages: [], unread: 0 }), 'thread.claims': read('thread.claims', () => state.rows) };
+ const web = await startServer({ port: 0, board: f.boardURL, bus }); t.after(() => close(web));
+ const b = await browser(); t.after(() => b.close());
+ const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}throw Error(${JSON.stringify(expression)})})()`);
+ await b.navigate(`http://127.0.0.1:${web.address().port}/#/agents`);
+ await wait(`!!document.querySelector('#b-thread-claims')`);
+ const roleHtml = () => b.evaluate(`document.querySelector('[aria-labelledby="b-claims"]').outerHTML`);
+ const refresh = () => b.evaluate(`document.querySelector('#refresh').click(),true`);
+ return { b, state, wait, roleHtml, refresh };
+}
+
+test('row278 browser lists held claims and release preserves role rows and totals', async t => {
+ const { b, state, wait, roleHtml, refresh } = await setup(t);
+ const before = await roleHtml();
+ assert.match(await b.evaluate(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent`), /No thread claim is held/);
+ state.rows = [claim]; await refresh();
+ await wait(`document.querySelector('.s1-thread-claims tbody')?.textContent.includes('${THREAD}')`);
+ assert.deepEqual(await b.evaluate(`Array.from(document.querySelectorAll('.s1-thread-claims thead th'),e=>e.textContent)`), ['Thread', 'Holder run', 'Harness', 'Claimed at']);
+ assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1');
+ assert.equal(await roleHtml(), before);
+ assert.equal(await b.evaluate(`document.querySelector('.s1-thread-claims time').dateTime`), AT);
+ state.rows = []; await refresh();
+ await wait(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent.includes('No thread claim is held')`);
+ assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0);
+ assert.equal(await roleHtml(), before);
+});
+
+test('row278 browser claims 403 stays local and role section remains byte-equal', async t => {
+ const { b, state, wait, roleHtml, refresh } = await setup(t);
+ const before = await roleHtml(); state.rows = [claim]; await refresh();
+ await wait(`!!document.querySelector('.s1-thread-claims tbody tr')`);
+ for (const code of ['human-required', 'not-configured', 'outcome-unknown']) {
+ state.failure = code; await refresh();
+ await wait(`document.querySelector('[aria-labelledby="b-thread-claims"] code')?.textContent===${JSON.stringify(code)}`);
+ assert.equal(await roleHtml(), before, code);
+ assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1');
+ assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0, 'failed read drops claims only');
+ }
+ state.offline = true; await refresh();
+ await wait(`!!document.querySelector('.s1-stale') && document.querySelector('[aria-labelledby="b-thread-claims"] code')?.textContent==='not-read'`);
+ assert.equal(await roleHtml(), before, 'other reads may use the kept role section');
+ assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0, 'a later stale repaint must not recover refused claims');
+});
+
+test('row278 browser renders only four escaped claim fields and never instance or other extras', async t => {
+ const { b, state, wait, refresh } = await setup(t);
+ const hostile = '
';
+ state.rows = [{ ...claim, thread: hostile, holder_run: '', harness: 'pi',
+ instance: 'NEVER_INSTANCE', name: 'NEVER_THREAD_NAME', body: 'NEVER_MESSAGE_BODY', address: 'NEVER_ADDRESS' }];
+ await refresh(); await wait(`document.querySelector('.s1-thread-claims tbody')?.textContent.includes('onerror')`);
+ const text = await b.evaluate(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent`);
+ assert.ok(text.includes(hostile)); assert.ok(text.includes('')); assert.ok(text.includes('pi'));
+ assert.doesNotMatch(text, /NEVER_INSTANCE|NEVER_THREAD_NAME|NEVER_MESSAGE_BODY|NEVER_ADDRESS/);
+ assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr > *').length`), 4);
+ assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims script,.s1-thread-claims img,.s1-thread-claims b').length`), 0);
+ assert.equal(await b.evaluate(`!!window.claimInjected`), false);
+});
+
+test('row278 browser sign-out removes claims and prevents reuse of cached answers', async t => {
+ const { b, state, wait, refresh } = await setup(t);
+ state.rows = [claim]; await refresh(); await wait(`!!document.querySelector('.s1-thread-claims tbody tr')`);
+ await b.evaluate(`window.claimFetch=fetch;window.fetch=(url,options)=>url==='/api/bus/thread.claims'?Promise.resolve(new Response(JSON.stringify({error:'sign-in required'}),{status:401})):window.claimFetch(url,options);true`);
+ await refresh();
+ await wait(`document.body.classList.contains('signed-out') && !document.querySelector('#b-thread-claims')`);
+ const calls = state.calls.length; await refresh(); assert.equal(state.calls.length, calls);
+ // Simulate returning to the route while the bus is unavailable. A cache that
+ // survived sign-out would repopulate the held thread from reader(false).
+ state.offline = true;
+ await b.evaluate(`window.fetch=window.claimFetch;document.body.classList.remove('signed-out');location.hash='#/agents?after-signout=1';true`);
+ await wait(`document.querySelector('#bus-view h1')?.textContent==='The read failed'`);
+ assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), new RegExp(THREAD));
+});
+
+test('row278 tester browser keeps role rows without requesting the owner claims section', async t => {
+ const dataRoot = tmp(t, 'claims-view-'), calls = [], reads = [];
+ const web = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: {
+ agents: async () => [{ role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT }],
+ messages: async () => ({ messages: [], unread: 0 }),
+ 'thread.claims': async () => { calls.push('thread.claims'); return [claim]; },
+ } });
+ t.after(() => close(web));
+ const b = await testerBrowser(web); t.after(() => b.close());
+ b.on('Fetch.requestPaused', ({ request }) => { if (new URL(request.url).pathname === '/api/bus/thread.claims') reads.push(request.url); });
+ await b.navigate(TESTER_ORIGIN + '/#/agents');
+ await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(document.querySelector('#tester-banner')&&document.querySelector('.s1-agents tbody tr'))return true;await new Promise(r=>setTimeout(r,30));}throw Error('tester Agents view did not render')})()`);
+ assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1');
+ assert.equal(await b.evaluate(`document.querySelector('#b-thread-claims')===null`), true);
+ assert.deepEqual(calls, []);
+ assert.deepEqual(reads, []);
+});
+
+test('row278 browser discards a late claims answer after sign-out', async t => {
+ const { b, state, wait, refresh } = await setup(t);
+ await b.evaluate(`window.claimFetch=fetch;window.fetch=async(url,options)=>{const r=await window.claimFetch(url,options);if(url==='/api/bus/thread.claims'){const json=r.json.bind(r);r.json=async()=>{const body=await json();window.lateClaimsFinished=true;return body;};}return r;};true`);
+ state.rows = [claim]; state.hold = true; await refresh();
+ for (let n = 0; !state.release && n < 100; n++) await new Promise(resolve => setTimeout(resolve, 10));
+ assert.equal(typeof state.release, 'function');
+ await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')),true`);
+ state.release(); state.hold = false;
+ await wait(`window.lateClaimsFinished && document.body.classList.contains('signed-out') && !document.querySelector('#b-thread-claims')`);
+ state.offline = true;
+ await b.evaluate(`window.fetch=window.claimFetch;document.body.classList.remove('signed-out');location.hash='#/agents?late-answer=1';true`);
+ await wait(`document.querySelector('#bus-view h1')?.textContent==='The read failed'`);
+ assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), new RegExp(THREAD));
+});
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -384,0 +385,9 @@
+The same view has a separate **Thread claims** section (row 278, #1783).
+It reads `thread.claims` and shows thread id, holder run, harness and claim time,
+in the bus's order. Thread names, messages, instance and address are not shown.
+An empty read has an empty-state line. A refused or failed read clears only
+this section's kept claims and shows its reason; the held roles and their
+count stay intact. Sign-out clears the claims with the other bus reads.
+The read needs no `--send`. The bus permits human and reader sessions;
+the tester listener refuses it with HTTP 403 `tester-refused` before any bus call.
+
--- a/packages/webui/tests/shell.test.mjs
+++ b/packages/webui/tests/shell.test.mjs
@@ -97 +97 @@
- const rows = { inbox: [], tasks: [], agents: [], trail: [] };
+ const rows = { inbox: [], tasks: [], agents: [], trail: [], 'thread.claims': [] };
@@ -101 +101 @@
- return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail') } };
+ return { rows, hold, fail: code => { fail = code; }, bus: { inbox: verb('inbox'), tasks: verb('tasks'), agents: verb('agents'), trail: verb('trail'), 'thread.claims': verb('thread.claims') } };