docs: record row 219 rebase after granted-user Console

This commit is contained in:
Rocko committed 2026-10-23 02:12:03 -05:00
1 parent 28bf34f272
commit bfa421b6cd
15 files changed
+1091 -10

No files matched your search

+23
View File
@@ -284,3 +284,26 @@ is refreshed to the same digest for the next claimant/review handoff.
Local commit only; Dewey checks mos1420 items1-3 before push/restart, and Local commit only; Dewey checks mos1420 items1-3 before push/restart, and
Sage names a gap/CLEAR after294 round4. No runs after this edit. Any further Sage names a gap/CLEAR after294 round4. No runs after this edit. Any further
red, including later lines in this test, returns to Mos through Sage. red, including later lines in this test, returns to Mos through Sage.
## Rebase round r3 after row294: sage-3250 / sage-3254
Rebased five commits from4a9c536e8..5f2ef92e5 onto
80793d824e05baaef4820f4be7790aba55971b6f, new branch
rocko/row219-on-80793d824. No conflicts and no manual product edits.
Row294's shared chat-http status refusals, chat owner-only channel-settings
transport, and serve's user rebinding/route fencing/static federation assets
remain. Row219 still forwards the selected instance over the bound caller;
its metadata route follows the listener/session bus after row294 rebinding.
r3/range-diff.txt records all five pairs: four are '='; the first is '!'
only because createChat's surrounding signature now includes owner=null.
No hunk body changed. Per-path patch IDs and row294 carry delta IDs are in
r3/path-patch-ids.json and r3/294-carry-patch-ids.json; conflict list is empty.
r3/overlap-readers.json lists exact source readers and whole tests via their
test-helper import closure, refreshed on this base. Root and r3 manifest,
base and build.patch are refreshed. Old r2 packet is historical and unchanged.
Dewey's checked mos1418 and mos1420 test edits stand; this handoff requests
rebase-only static review. No tests, Node, mutants, reader gate audit or push
performed while preparing this local rebase. Full no-carry restart waits for
Sage's CLEAR. Any new red returns to Mos through Sage before fixes.
+1 -1
View File
@@ -1 +1 @@
4a9c536e861f1d9f9a018019b3ac4519d65eccf0 80793d824e05baaef4820f4be7790aba55971b6f
+6 -6
View File
@@ -415,10 +415,10 @@ index 0d207a0a0..fa3adb866 100644
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1])); + assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
+}); +});
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index c7770726d..396b42f3c 100644 index 7980c1442..63d3ca974 100644
--- a/packages/webui/src/chat-http.mjs --- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs +++ b/packages/webui/src/chat-http.mjs
@@ -78,7 +78,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev @@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error(); (Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v; action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') { } else if (path === '/api/chat/threads') {
@@ -429,10 +429,10 @@ index c7770726d..396b42f3c 100644
} else if (match?.[2] === 'messages') { } else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error(); if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 018574ad4..65b89dab3 100644 index 1b0a41dce..5a1ed8bf1 100644
--- a/packages/webui/src/chat.mjs --- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs +++ b/packages/webui/src/chat.mjs
@@ -20,7 +20,7 @@ export function createChat({ call }) { @@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}), projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }), createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }), threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
@@ -535,7 +535,7 @@ index a6b805c1b..15066bdc6 100644
} }
}); });
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 288871d0c..d9d27a062 100644 index cd51a0b6e..56fcdd4e1 100644
--- a/packages/webui/src/serve.mjs --- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from @@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
@@ -546,7 +546,7 @@ index 288871d0c..d9d27a062 100644
import { chatHTTP } from './chat-http.mjs'; import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs'; import { processingHTTP } from './processing-http.mjs';
@@ -705,6 +706,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF @@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log }); return busSend(req, res, { send, preview: signer, log });
} }
@@ -5,10 +5,10 @@ de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/t
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs 2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs 7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
cbfd54e6b375561d1e4679ee8d78a3285f970d9a7198cec9d22a06510a36afba packages/webui/src/chat-http.mjs 39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
f49a49c6ec69783190d3f5eaf34316cb4d2c44d10d2ee9336fe1383207afefff packages/webui/src/chat.mjs 77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js 8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
a597cc4902a873fe15650dacfce14dbc897db26310a7b6a24c293287add946c8 packages/webui/src/serve.mjs 443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs 2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs 7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,17 @@
[
{
"path": "packages/webui/src/chat-http.mjs",
"baseDelta": "463a78d259c86d16e4c081bd2336f666477463b8",
"tipDelta": "463a78d259c86d16e4c081bd2336f666477463b8"
},
{
"path": "packages/webui/src/chat.mjs",
"baseDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948",
"tipDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948"
},
{
"path": "packages/webui/src/serve.mjs",
"baseDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928",
"tipDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928"
}
]
+5
View File
@@ -0,0 +1,5 @@
# Row219 rebase round r3
See ../BUILD.md rebase round section. Base80793d824; no conflicts.
Range-diff, per-path identities, row294 carry identities and exact test
reader inventory are adjacent. No tests run. Dewey checks rebase only.
+1
View File
@@ -0,0 +1 @@
80793d824e05baaef4820f4be7790aba55971b6f
+788
View File
@@ -0,0 +1,788 @@
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d76085739..11061c778 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
});
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
const INSTALLER_COMMENT = 'mosaic-installer-';
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
// thread (REQ-MSG-1).
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
@@ -598,7 +598,7 @@ export class Broker {
);
}
#threaded(business, role) {
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
}
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
#sessionHolder(session) {
@@ -6068,24 +6068,27 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
- // A thread belongs to the user who made it and to the business's one thread instance.
+ // A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
if (!s.tester && !s.user) this.#human(s);
- keys(a, ['project', 'name'], ['project', 'name']);
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
+ const instance = a.instance === undefined
+ ? (instances.length === 1 ? instances[0] : null)
+ : identifier(a.instance);
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
if (s.user) {
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
+ fail('access-refused');
}
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
- // The bus never picks between instances; the business file declares exactly one.
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
- if (!instances.length) fail('thread-instance-missing');
- if (instances.length > 1) fail('thread-instance-ambiguous');
+ // The human selects an instance; omission is compatible only with one candidate.
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -6094,13 +6097,13 @@ export class Broker {
this.#now(),
s.business,
a.project,
- instances[0],
+ instance,
this.#owner(s),
a.name,
);
// Row 264a (#1769, D3): every thread has a default session from the start.
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
- return { id: tid, instance: instances[0] };
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
+ return { id: tid, instance };
}
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// and the tester's lists only its own (row 175 listed it the business human's).
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
index 6cfbf1cd6..6d421ba19 100644
--- a/packages/bus/tests/creative.test.mjs
+++ b/packages/bus/tests/creative.test.mjs
@@ -44,15 +44,18 @@ function setup(t) {
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
+ const threads = { assistant: thread };
for (const role of Object.keys(roles)) {
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
+ if (['bean', 'quill'].includes(role))
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const count = (sql) => store.get(sql).n;
- return { call, count, store };
+ return { call, count, store, threads };
}
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
});
test('a creative instance sends only to the user and hears only from the user', (t) => {
- const { call, count } = setup(t);
+ const { call, count, threads } = setup(t);
for (const who of ['bean', 'quill']) {
call(who, 'message.send', { to: 'human', body: 'hello' });
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
for (const from of ['chief', 'pm']) {
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
}
- call('human', 'message.send', { to: who, body: 'hello' });
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
}
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index 8b31da9f9..1410ec3fa 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t =>
{ id: linked.id, name: 'work', repo: 'stack' },
]);
});
+
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
+ } }));
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
+ for (const [instance, role] of Object.entries(extra)) {
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
+ assert.equal(receipt.instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
+ ctx.launch(instance, instance, { thread: receipt.id });
+ assert.equal(ctx.claim(instance).thread, receipt.id);
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
+ const read = ctx.call(instance, 'message.receive');
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
+ { code: 'thread-required-use-thread-send' });
+ }
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
+});
+
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
+ const ctx = setup(t);
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
+ for (const instance of ['not-declared', 'pm', 'coder'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
+ assert.equal(legacy.instance, 'assistant');
+});
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
index 4dcd1ebeb..8471ddf85 100644
--- a/packages/bus/tests/user-principal.test.mjs
+++ b/packages/bus/tests/user-principal.test.mjs
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
});
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
-function setup(t, { grants = [grant()], processing = null } = {}) {
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
const store = new Store(root);
t.after(() => {
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
+ ...threadRoles,
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
test('R3 r4: a non-function access throws before the store is read', () => {
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
});
+
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
+ ctx.access.doc.grants = [grant({ projects: [project] })];
+ ctx.user('alice');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
+ assert.equal(allowed.instance, 'assistant');
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
+ assert.equal(bean.instance, 'bean');
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
+});
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index bfe55b61e..d8248284c 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -53,7 +53,7 @@
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
-// role definition is the assistant) runs once per thread, never on its own:
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
// a launch from a session or --pm refuses thread-required. Every tick
// (threadTickMs) the host asks the broker for the business's threads
// (launch op threadsState) and:
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
import { createLoader } from "../../plugins/src/loader.mjs";
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// How often the host asks for the threads' state; a poll period, not a limit.
const THREAD_TICK_MS = 2000;
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
-const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
@@ -331,7 +330,7 @@ export function createLauncher({
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
@@ -997,7 +996,7 @@ export function createLauncher({
function ending(r) {
const runs = r.runs ?? [];
const at = Date.parse(r.at);
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
}
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 2202c7753..baabd843e 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const dir = join(given.root, "config", "personas", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
- const { f, host, launcher } = await setup(t, undefined, given);
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
- const [e] = log(f);
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
+ const thread = 'persona-thread';
+ const SESSION = 'persona-session';
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
+ ...host,
+ op: message => message.op === 'threadsState'
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
+ : host.op(message),
+ }) });
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
+ await launcher.tick();
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
+ const entries = log(f);
+ assert.equal(entries.length, 2);
+ const { at, ...compactions } = entries[0];
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
+ const e = entries[1];
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
+ assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
assert.equal(runs.length, 1);
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
});
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
index 0d207a0a0..fa3adb866 100644
--- a/packages/cli/tests/thread-launch.test.mjs
+++ b/packages/cli/tests/thread-launch.test.mjs
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
+// Admission refuses before bundle construction or a sandbox is needed.
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
+ const prepared = prepare(t, { edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ doc.launch.instances.push('bean');
+ return doc;
+ } });
+ const { f } = prepared;
+ const host = await start(t, prepared);
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+ assert.deepEqual(readSessions(f.dataRoot), []);
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
+});
+
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
// bus's plug-in loader.
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
assert.deepEqual(threadRuns(f, A), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
});
+
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ const vars = { ...doc.roles.assistant.vars };
+ delete doc.roles.assistant;
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
+ doc.launch.instances = ['coder', 'bean', 'chet'];
+ return doc;
+ } });
+ const host = await start(t, prepared), { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
+ const send = async (thread, instance, body) => {
+ const message = host.call('thread.send', { thread, body });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ const reply = outcome;
+ assert.equal(reply.from_role, instance);
+ assert.equal(reply.to_role, 'human');
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
+ };
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
+ const launches = launchLog(f).filter(row => row.event === 'launch');
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
+});
+
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
+ delete doc.roles.assistant;
+ doc.launch.instances = ['coder', 'bean'];
+ return doc;
+ } });
+ let requests = [];
+ const host = await start(t, prepared, { ops: () => ({
+ lifecycleRequests: () => requests,
+ lifecycleTake: () => ({ ...requests[0], go: true }),
+ lifecycleDone: () => { requests = []; return {}; },
+ }) });
+ const { f } = prepared;
+ const project = host.call('project.create', { name: 'Writing' }).id;
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
+ for (const thread of threads) {
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
+ }
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
+ await host.launcher.tick();
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
+});
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
index 7980c1442..63d3ca974 100644
--- a/packages/webui/src/chat-http.mjs
+++ b/packages/webui/src/chat-http.mjs
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') {
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
+ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
+ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
index 1b0a41dce..5a1ed8bf1 100644
--- a/packages/webui/src/chat.mjs
+++ b/packages/webui/src/chat.mjs
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
+ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
open: ({ thread }) => channel('thread.open', { thread }),
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
index a6b805c1b..15066bdc6 100644
--- a/packages/webui/src/public/chat.js
+++ b/packages/webui/src/public/chat.js
@@ -14,7 +14,7 @@
return states.get(id);
};
const form = key => {
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
return forms.get(key);
};
class ChatError extends Error {
@@ -58,7 +58,11 @@
function creation(kind, key) {
const f = form(key), project = kind === 'project';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
+ const choices = current.instances ?? [];
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
@@ -106,7 +110,7 @@
const f = document.getElementById(keep.field.id);
if (f && !f.disabled) {
f.focus({ preventScroll: true });
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
}
@@ -126,7 +130,7 @@
paint(); return;
}
const old = current;
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
+ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
const s = kind === 'thread' ? state(id) : null;
@@ -147,6 +151,25 @@
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
}
}
+ if (kind === 'project') {
+ try {
+ const listed = await request('instances', undefined, signal);
+ if (g !== generation || out()) return;
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
+ throw new ChatError('invalid-response');
+ current.instances = listed.rows; current.instanceError = '';
+ const f = form(id);
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
+ f.instance = ''; f.instanceChosen = true;
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
+ }
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
+ } catch (err) {
+ if (err.name === 'AbortError' || out() || g !== generation) return;
+ current.instances = [];
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
+ }
+ }
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
if (kind === 'list') {
try {
@@ -179,7 +202,11 @@
if (!current || out()) return;
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
+ if (f && event.target.dataset.chatField) {
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
+ draft[field] = event.target.value;
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
+ }
});
document.addEventListener('scroll', event => {
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
@@ -232,7 +259,8 @@
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
}
});
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index cd51a0b6e..56fcdd4e1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
}
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
+ if (path === '/api/chat/instances') {
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
+ if (search) return json(res, 400, { error: 'invalid-request' });
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
+ try {
+ const value = await bus.instances();
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
+ throw new BusReadError('invalid-response');
+ const rows = Object.entries(value.instances)
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
+ return json(res, 200, { rows, at: new Date().toISOString() });
+ } catch (err) {
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
+ return json(res, busStatus(code), { error: code });
+ }
+ }
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index f3eeeca96..cc3ec093d 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
const stub = `(() => {
const real = fetch.bind(window);
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
+ window.__instances=[{instance:'assistant',name:'assistant'}];
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
window.fetch=async (url,init={})=>{
@@ -33,6 +34,7 @@ const stub = `(() => {
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
+ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
if(window.__pages.length)return data(window.__pages.shift());
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
await click('#chat-thread-form button[type=submit]');
await wait('document.querySelector("#chat-send-form")');
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
assert.equal(await b.evaluate('window.__writes.length'), 2);
});
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
+
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
+ const { b, wait, set, click, refresh } = await setup(t);
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
+ assert.match(labels[1], /\(bean\)/);
+ assert.match(labels[2], /\(chet\)/);
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
+ assert.equal(await b.evaluate('window.injected'), undefined);
+ await set('#chat-thread-name', 'Bean discussion');
+ await click('#chat-thread-form button');
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+ await set('#chat-instance', 'bean');
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
+ await click('#chat-thread-form button');
+ await wait('window.__writes.length===1');
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
+});
+
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
+ const { b, wait, set, refresh } = await setup(t);
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
+ await refresh();
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ await set('#chat-instance', 'bean');
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ await refresh();
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
+ await set('#chat-instance', 'chet');
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
+});
+
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
+ const { b, wait } = await setup(t, '?readonly');
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
+});
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
new file mode 100644
index 000000000..893c38f80
--- /dev/null
+++ b/packages/webui/tests/chat-instances.test.mjs
@@ -0,0 +1,87 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { request } from 'node:http';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { startServer } from '../src/serve.mjs';
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
+import { close } from './fixture.mjs';
+
+const path = '/api/chat/instances';
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
+const snapshot = {
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
+ instances: {
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
+ },
+};
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
+ const address = server.address(), unix = typeof address === 'string';
+ return new Promise((resolve, reject) => {
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
+ let text = '';
+ res.on('data', chunk => text += chunk);
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
+ });
+ req.on('error', reject); req.end();
+ });
+}
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
+ let calls = 0;
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
+ t.after(() => close(server));
+ const refused = await get(server);
+ assert.equal(refused.status, 401);
+ assert.equal(calls, 0);
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
+ assert.equal(calls, 1);
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
+ assert.ok([400, 405].includes(result.status));
+ }
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
+});
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
+ let reads = 0;
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
+ t.after(() => close(server));
+ const answer = await get(server);
+ assert.equal(answer.status, 200);
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
+ assert.equal(reads, 1);
+});
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
+ assert.ok(answer.status >= 400);
+ assert.doesNotMatch(answer.text, /private-path/);
+ }
+});
+
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
+ let session = null, reads = 0;
+ const touches = [];
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
+ t.after(() => close(server));
+ const headers = { host: sso.publicHost };
+ assert.equal((await get(server, { headers })).status, 401);
+ assert.equal(reads, 0);
+ session = { name: 'jason' };
+ assert.equal((await get(server, { headers })).status, 200);
+ assert.equal(reads, 1);
+ assert.deepEqual(touches, [false, false]);
+});
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
index 8c11611cf..b47ecf1a7 100644
--- a/packages/webui/tests/chat.test.mjs
+++ b/packages/webui/tests/chat.test.mjs
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
});
+
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
+ const c = await setup(t);
+ c.state.answer = { id: T, instance: 'bean' };
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
+ assert.equal(answer.status, 200);
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
+ assert.equal(answer.data.instance, 'bean');
+});
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
+ const c = await setup(t);
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
+ assert.equal(answer.status, 400, JSON.stringify(value));
+ }
+ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
+ assert.deepEqual(c.calls, []);
+});
@@ -0,0 +1,14 @@
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
@@ -0,0 +1,14 @@
packages/bus/src/broker.mjs
packages/bus/tests/creative.test.mjs
packages/bus/tests/threads.test.mjs
packages/bus/tests/user-principal.test.mjs
packages/cli/src/launcher.mjs
packages/cli/tests/launcher.test.mjs
packages/cli/tests/thread-launch.test.mjs
packages/webui/src/chat-http.mjs
packages/webui/src/chat.mjs
packages/webui/src/public/chat.js
packages/webui/src/serve.mjs
packages/webui/tests/chat-browser.test.mjs
packages/webui/tests/chat-instances.test.mjs
packages/webui/tests/chat.test.mjs
@@ -0,0 +1 @@
No conflicts. No manual resolutions or product edits. All five commits auto-rebased.
@@ -0,0 +1,131 @@
{
"method": "resolve literal relative import/URL/file paths in packages/*/tests/**/*.mjs; reverse test-helper dependency closure; no tests run",
"readers": {
"packages/webui/src/chat-http.mjs": {
"directModules": [
"packages/webui/tests/chat.test.mjs"
],
"wholeTestReaders": [
"packages/webui/tests/chat.test.mjs"
]
},
"packages/webui/src/chat.mjs": {
"directModules": [
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/tester-fixture.mjs"
],
"wholeTestReaders": [
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/proof-cli.test.mjs",
"packages/webui/tests/tester-browser.test.mjs",
"packages/webui/tests/tester-cli.test.mjs",
"packages/webui/tests/tester-listener.test.mjs"
]
},
"packages/webui/src/serve.mjs": {
"directModules": [
"packages/webui/tests/agents-browser.test.mjs",
"packages/webui/tests/agents.test.mjs",
"packages/webui/tests/assistant.test.mjs",
"packages/webui/tests/board-held.test.mjs",
"packages/webui/tests/browser-edge.test.mjs",
"packages/webui/tests/bus-browser.test.mjs",
"packages/webui/tests/bus.test.mjs",
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat-browser.test.mjs",
"packages/webui/tests/chat-instances.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/discord.test.mjs",
"packages/webui/tests/docs-browser.test.mjs",
"packages/webui/tests/editor-browser.test.mjs",
"packages/webui/tests/fixture.mjs",
"packages/webui/tests/granted-user.test.mjs",
"packages/webui/tests/history-fixture.mjs",
"packages/webui/tests/instances-browser.test.mjs",
"packages/webui/tests/instances.test.mjs",
"packages/webui/tests/limits-browser.test.mjs",
"packages/webui/tests/limits-route.test.mjs",
"packages/webui/tests/messages.test.mjs",
"packages/webui/tests/preview.test.mjs",
"packages/webui/tests/processing-browser.test.mjs",
"packages/webui/tests/processing.test.mjs",
"packages/webui/tests/project-file.test.mjs",
"packages/webui/tests/proof.test.mjs",
"packages/webui/tests/reads.test.mjs",
"packages/webui/tests/relaunch.test.mjs",
"packages/webui/tests/return-flow.test.mjs",
"packages/webui/tests/routes-browser.test.mjs",
"packages/webui/tests/routes.test.mjs",
"packages/webui/tests/send-browser.test.mjs",
"packages/webui/tests/send.test.mjs",
"packages/webui/tests/serve.test.mjs",
"packages/webui/tests/settings-form.test.mjs",
"packages/webui/tests/settings-write.test.mjs",
"packages/webui/tests/shell.test.mjs",
"packages/webui/tests/sso-browser.test.mjs",
"packages/webui/tests/sso.test.mjs",
"packages/webui/tests/tester-listener.test.mjs",
"packages/webui/tests/thread-claims-browser.test.mjs",
"packages/webui/tests/thread-claims.test.mjs",
"packages/webui/tests/usage-browser.test.mjs",
"packages/webui/tests/usage.test.mjs",
"packages/webui/tests/views.test.mjs"
],
"wholeTestReaders": [
"packages/webui/tests/agents-browser.test.mjs",
"packages/webui/tests/agents.test.mjs",
"packages/webui/tests/assistant.test.mjs",
"packages/webui/tests/board-held.test.mjs",
"packages/webui/tests/browser-edge.test.mjs",
"packages/webui/tests/browser.test.mjs",
"packages/webui/tests/bus-browser.test.mjs",
"packages/webui/tests/bus.test.mjs",
"packages/webui/tests/channel-chat.test.mjs",
"packages/webui/tests/chat-browser.test.mjs",
"packages/webui/tests/chat-instances.test.mjs",
"packages/webui/tests/chat.test.mjs",
"packages/webui/tests/conversation.test.mjs",
"packages/webui/tests/discord.test.mjs",
"packages/webui/tests/docs-browser.test.mjs",
"packages/webui/tests/editor-browser.test.mjs",
"packages/webui/tests/editor.test.mjs",
"packages/webui/tests/granted-user.test.mjs",
"packages/webui/tests/history-return-flow.test.mjs",
"packages/webui/tests/instances-browser.test.mjs",
"packages/webui/tests/instances.test.mjs",
"packages/webui/tests/limits-browser.test.mjs",
"packages/webui/tests/limits-route.test.mjs",
"packages/webui/tests/messages.test.mjs",
"packages/webui/tests/preview.test.mjs",
"packages/webui/tests/processing-browser.test.mjs",
"packages/webui/tests/processing.test.mjs",
"packages/webui/tests/project-file.test.mjs",
"packages/webui/tests/proof-cli.test.mjs",
"packages/webui/tests/proof.test.mjs",
"packages/webui/tests/reads.test.mjs",
"packages/webui/tests/relaunch.test.mjs",
"packages/webui/tests/return-flow.test.mjs",
"packages/webui/tests/routes-browser.test.mjs",
"packages/webui/tests/routes.test.mjs",
"packages/webui/tests/send-browser.test.mjs",
"packages/webui/tests/send.test.mjs",
"packages/webui/tests/serve.test.mjs",
"packages/webui/tests/settings-form.test.mjs",
"packages/webui/tests/settings-write.test.mjs",
"packages/webui/tests/shell.test.mjs",
"packages/webui/tests/sso-browser.test.mjs",
"packages/webui/tests/sso.test.mjs",
"packages/webui/tests/tester-browser.test.mjs",
"packages/webui/tests/tester-cli.test.mjs",
"packages/webui/tests/tester-listener.test.mjs",
"packages/webui/tests/thread-claims-browser.test.mjs",
"packages/webui/tests/thread-claims.test.mjs",
"packages/webui/tests/usage-browser.test.mjs",
"packages/webui/tests/usage.test.mjs",
"packages/webui/tests/views.test.mjs"
]
}
}
}
@@ -0,0 +1,72 @@
[
{
"path": "packages/bus/src/broker.mjs",
"old": "34f42b312c3d4ae9a975c5d27214414606cb9154",
"new": "34f42b312c3d4ae9a975c5d27214414606cb9154"
},
{
"path": "packages/bus/tests/creative.test.mjs",
"old": "69deed0c277da57ef36d09097f4164180e744960",
"new": "69deed0c277da57ef36d09097f4164180e744960"
},
{
"path": "packages/bus/tests/threads.test.mjs",
"old": "83597aea2f53a587b338b32a8e25e9ddac427ca1",
"new": "83597aea2f53a587b338b32a8e25e9ddac427ca1"
},
{
"path": "packages/bus/tests/user-principal.test.mjs",
"old": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3",
"new": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3"
},
{
"path": "packages/cli/src/launcher.mjs",
"old": "ef04082e6e0ce574ca3af546871838ac34a5e5ee",
"new": "ef04082e6e0ce574ca3af546871838ac34a5e5ee"
},
{
"path": "packages/cli/tests/launcher.test.mjs",
"old": "4449e824038428b8dd4e5c299cc126ab1e842b58",
"new": "4449e824038428b8dd4e5c299cc126ab1e842b58"
},
{
"path": "packages/cli/tests/thread-launch.test.mjs",
"old": "0fef61471873c35652605168dedd7dcd22cb4944",
"new": "0fef61471873c35652605168dedd7dcd22cb4944"
},
{
"path": "packages/webui/src/chat-http.mjs",
"old": "ccf9e56879a90656c7061ce0d614bfaaf04987ba",
"new": "ccf9e56879a90656c7061ce0d614bfaaf04987ba"
},
{
"path": "packages/webui/src/chat.mjs",
"old": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1",
"new": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1"
},
{
"path": "packages/webui/src/public/chat.js",
"old": "f114af96936122f620ebf0a561e3a41a0ff6c955",
"new": "f114af96936122f620ebf0a561e3a41a0ff6c955"
},
{
"path": "packages/webui/src/serve.mjs",
"old": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a",
"new": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a"
},
{
"path": "packages/webui/tests/chat-browser.test.mjs",
"old": "8cb7a6270bc8c0f2191caae713842609df0deb92",
"new": "8cb7a6270bc8c0f2191caae713842609df0deb92"
},
{
"path": "packages/webui/tests/chat-instances.test.mjs",
"old": "8018efd8ad48108293e41b77fc6c786500ef2471",
"new": "8018efd8ad48108293e41b77fc6c786500ef2471"
},
{
"path": "packages/webui/tests/chat.test.mjs",
"old": "acb49d2d659373b56409cf144f56fe6c29ffdac5",
"new": "acb49d2d659373b56409cf144f56fe6c29ffdac5"
}
]
@@ -0,0 +1,14 @@
1: d5bf46ecf ! 1: 268835a99 wip: publish unverified row 219 inspection candidate
@@ packages/webui/src/chat-http.mjs: export async function chatHTTP(req, res, { pat
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
## packages/webui/src/chat.mjs ##
-@@ packages/webui/src/chat.mjs: export function createChat({ call }) {
+@@ packages/webui/src/chat.mjs: export function createChat({ call, owner = null }) {
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
2: c7bf3622d = 2: 994860e70 docs: rebase row 219 gate packet and record run bounds
3: b97a3ff97 = 3: 0e040cf94 test: give row 219 persona fixture its explicit session
4: 7cbd839d0 = 4: 30cf10301 test: pin the compaction audit before the persona refusal
5: 5f2ef92e5 = 5: 28bf34f27 test: assert creative history in its native session store
@@ -0,0 +1 @@
28bf34f272ec8e0ad0f7155556d2c92cdb3dd5be