@hachej/boring-ui-kit 0.4.18 → 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/shared-chat/index.d.ts +4 -0
  2. package/dist/shared-chat/index.js +42 -31
  3. package/dist/shared-chat/room.d.ts +41 -0
  4. package/dist/shared-chat/room.js +596 -0
  5. package/dist/shared-chat/socket.js +1 -0
  6. package/dist/shared-chat/source.d.ts +12 -0
  7. package/package.json +2 -2
  8. package/r/agent-target.json +4 -4
  9. package/r/app-shell.json +9 -9
  10. package/r/board-viewer.json +5 -5
  11. package/r/button.json +1 -1
  12. package/r/diagram-viewer.json +1 -1
  13. package/r/dock.json +1 -1
  14. package/r/documents.json +12 -12
  15. package/r/feedback-preview.json +1 -1
  16. package/r/feedback.json +6 -6
  17. package/r/file-tree.json +5 -5
  18. package/r/highlights.json +3 -3
  19. package/r/html-viewer.json +3 -3
  20. package/r/looks-viewer.json +1 -1
  21. package/r/maquette-viewer.json +2 -2
  22. package/r/markdown-editor.json +3 -3
  23. package/r/mockup-viewer.json +1 -1
  24. package/r/pi-ambient.json +9 -9
  25. package/r/pi-app.json +20 -20
  26. package/r/pi-artifacts.json +1 -1
  27. package/r/pi-chat-approval.json +1 -1
  28. package/r/pi-chat-attachments.json +1 -1
  29. package/r/pi-chat-markdown.json +1 -1
  30. package/r/pi-chat-mentions.json +2 -2
  31. package/r/pi-chat-pickers.json +1 -1
  32. package/r/pi-chat-plugins.json +11 -11
  33. package/r/pi-chat-question.json +1 -1
  34. package/r/pi-chat-slash.json +1 -1
  35. package/r/pi-chat-subagent.json +1 -1
  36. package/r/pi-chat-voice.json +2 -2
  37. package/r/pi-chat.json +6 -6
  38. package/r/pi-workspace.json +4 -4
  39. package/r/placed-agent.json +4 -4
  40. package/r/provider-setup.json +2 -2
  41. package/r/record-viewer.json +1 -1
  42. package/r/registry.json +171 -166
  43. package/r/requests-board.json +1 -1
  44. package/r/shared-chat.json +19 -13
  45. package/r/task-list-viewer.json +2 -2
  46. package/r/version-history.json +3 -3
  47. package/r/viewer-code.json +2 -2
  48. package/r/viewer-frame.json +5 -5
  49. package/r/viewer-html.json +4 -4
  50. package/r/viewer-image.json +2 -2
  51. package/r/viewer-markdown.json +5 -5
  52. package/r/viewer-pdf.json +3 -3
  53. package/r/viewers.json +6 -6
  54. package/r/windows.json +1 -1
@@ -0,0 +1,596 @@
1
+ import { randomUUID } from '@hachej/boring-files/platform';
2
+ import { ChannelRequestError } from './error.js';
3
+ const words = {
4
+ en: { stalled: 'Still working on it…', retry: 'Try again', actions: { try: 'Try', later: 'Later', validate: 'Approve' }, titles: { main: 'Team', admin: 'Admin' } },
5
+ fr: { stalled: 'Toujours en train de répondre…', retry: 'Réessayer', actions: { try: 'Essayer', later: 'Plus tard', validate: 'Valider' }, titles: { main: 'Équipe', admin: 'Admin' } },
6
+ };
7
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ const str = (value, fallback = '') => typeof value === 'string' ? value : fallback;
9
+ const list = (value) => Array.isArray(value) ? value : [];
10
+ const strings = (value) => list(value).filter((item) => typeof item === 'string');
11
+ const id = (value) => typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
12
+ const stepStatus = (value) => value === 'running' || value === 'failed' ? value : 'done';
13
+ function member(raw) {
14
+ if (!isObject(raw) || typeof raw.id !== 'string')
15
+ return undefined;
16
+ const kind = raw.kind === 'agent' || raw.kind === 'admin' ? raw.kind : 'person';
17
+ const role = typeof raw.role === 'string' ? raw.role : undefined;
18
+ // The room names an agent's role by its slug (`pm`, `worker`); the view reads `agent:<name>`.
19
+ const normalised = kind === 'agent' ? `agent:${(role ?? raw.id).replace(/^agent:/, '')}`
20
+ : role === 'owner' || role === 'validator' || role === 'member' || role === 'admin' ? role : undefined;
21
+ return { id: raw.id, name: str(raw.name, raw.id), kind, ...(normalised ? { role: normalised } : {}), ...(typeof raw.roleLabel === 'string' ? { roleLabel: raw.roleLabel } : {}),
22
+ ...(typeof raw.avatarUrl === 'string' ? { avatarUrl: raw.avatarUrl } : {}), ...(raw.presence === 'here' || raw.presence === 'away' ? { presence: raw.presence } : {}) };
23
+ }
24
+ export async function createRoomSource(options) {
25
+ const base = new URL(options.url, globalThis.location?.href);
26
+ base.pathname = base.pathname.replace(/\/$/, '') + '/';
27
+ const request = options.fetch ?? (request => globalThis.fetch(request));
28
+ const now = options.now ?? Date.now;
29
+ const text = { ...words[options.locale ?? 'en'], ...options.words };
30
+ const stallMs = options.stallMs ?? 8000;
31
+ const url = (path) => new URL(path, base);
32
+ async function rpc(op, input = {}, door = '_room') {
33
+ let response;
34
+ try {
35
+ response = await request(new Request(url(`${door}/${op}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }));
36
+ }
37
+ catch (error) {
38
+ throw new ChannelRequestError('network', error instanceof Error ? error.message : 'Channel connection was lost', true);
39
+ }
40
+ const body = await response.json().catch(() => undefined);
41
+ if (isObject(body) && isObject(body.error) && typeof body.error.code === 'string') {
42
+ throw new ChannelRequestError(body.error.code, str(body.error.message, body.error.code), body.error.retryable === true, { refused: true });
43
+ }
44
+ if (!response.ok || body === undefined)
45
+ throw new ChannelRequestError('invalid-response', `Room ${op} returned an invalid response (${response.status})`, false);
46
+ return body;
47
+ }
48
+ // The web door never enrols anyone: a person's first visit is the explicit `_channel/join` (the directory-role admission check, the same on
49
+ // either engine), before any `_room/*` call. A refusal is `no-access`: the host shows a calm « no access » state, not an error.
50
+ const noAccess = (error) => {
51
+ if (error instanceof ChannelRequestError && error.refused && ['not-found', 'forbidden', 'unauthorized'].includes(error.code))
52
+ throw new ChannelRequestError('no-access', error.message, false, { refused: true });
53
+ throw error;
54
+ };
55
+ if (options.join !== false)
56
+ await rpc('join', { idempotencyKey: randomUUID() }, '_channel').catch(noAccess);
57
+ const boot = await rpc('me').catch(noAccess);
58
+ const me = isObject(boot) && typeof boot.space === 'string' && boot.space ? member(boot.me) : undefined;
59
+ if (!me || !isObject(boot) || typeof boot.space !== 'string')
60
+ throw new ChannelRequestError('invalid-response', 'Room me returned an invalid response', false);
61
+ const space = boot.space;
62
+ const convs = new Map();
63
+ const conv = (name) => {
64
+ let found = convs.get(name);
65
+ if (!found) {
66
+ found = { list: [], cursor: undefined, cursorEpoch: -1, loaded: false, loading: undefined, writing: new Map(), waiting: new Map() };
67
+ convs.set(name, found);
68
+ }
69
+ return found;
70
+ };
71
+ const owner = new Map();
72
+ const clientIds = new Map();
73
+ const subscribers = new Map();
74
+ const inflight = new Map();
75
+ const emit = (name, event) => { for (const listener of [...(subscribers.get(name) ?? [])])
76
+ listener.event(structuredClone(event)); };
77
+ // ---- time: the door's items carry none yet, so a message is stamped when this source first sees it, in entry order ----
78
+ const stamps = new Map();
79
+ let lowId = Infinity, highId = -Infinity, lowMs = 0, highMs = 0;
80
+ function stamp(entries) {
81
+ const fresh = [...new Set(entries)].filter(entry => !stamps.has(entry)).sort((a, b) => a - b);
82
+ if (!fresh.length)
83
+ return;
84
+ const time = now();
85
+ if (!stamps.size)
86
+ fresh.forEach((entry, index) => stamps.set(entry, time - (fresh.length - 1 - index)));
87
+ else {
88
+ const below = fresh.filter(entry => entry < lowId).reverse();
89
+ below.forEach((entry, index) => stamps.set(entry, lowMs - 1 - index));
90
+ let next = Math.max(time, highMs + 1);
91
+ for (const entry of fresh.filter(entry => entry > highId))
92
+ stamps.set(entry, next++);
93
+ // An entry between two known ones (a read that skipped it) takes its lower neighbour's time: the order is the entry's.
94
+ for (const entry of fresh.filter(entry => !stamps.has(entry))) {
95
+ let at = lowMs;
96
+ for (const [known, ms] of stamps)
97
+ if (known < entry && ms >= at)
98
+ at = ms;
99
+ stamps.set(entry, at);
100
+ }
101
+ }
102
+ for (const entry of fresh) {
103
+ const ms = stamps.get(entry);
104
+ if (entry < lowId) {
105
+ lowId = entry;
106
+ lowMs = ms;
107
+ }
108
+ if (entry > highId) {
109
+ highId = entry;
110
+ highMs = ms;
111
+ }
112
+ }
113
+ }
114
+ const iso = (entry, given) => {
115
+ if (typeof given === 'number' && Number.isFinite(given))
116
+ return new Date(given).toISOString();
117
+ if (typeof given === 'string' && Number.isFinite(Date.parse(given)))
118
+ return given;
119
+ return new Date(stamps.get(entry) ?? now()).toISOString();
120
+ };
121
+ const after = () => new Date(Math.max(now(), highMs + 1)).toISOString();
122
+ // ---- item to message ----
123
+ const actionsOf = (raw) => list(raw).flatMap((action) => {
124
+ if (typeof action === 'string')
125
+ return [{ id: action, label: text.actions[action] ?? action, ...(action === 'later' ? { variant: 'secondary' } : {}) }];
126
+ if (isObject(action) && typeof action.id === 'string')
127
+ return [{ id: action.id, label: str(action.label, action.id), ...(action.variant === 'primary' || action.variant === 'secondary' ? { variant: action.variant } : {}) }];
128
+ return [];
129
+ });
130
+ function convert(name, item) {
131
+ if (!isObject(item))
132
+ return undefined;
133
+ const entry = id(item.id);
134
+ if (entry === undefined)
135
+ return undefined;
136
+ if (item.kind === 'removed') {
137
+ const target = id(item.target);
138
+ return target === undefined ? undefined : { retract: target };
139
+ }
140
+ stamp([entry]);
141
+ const at = iso(entry, item.at);
142
+ const author = str(item.author);
143
+ const base = { id: String(entry), conversationId: name, at };
144
+ const mine = clientIds.get(entry);
145
+ if (item.kind === 'message') {
146
+ const speaker = author.startsWith('platform:') ? str(item.for) : author;
147
+ const gone = item.removed === true;
148
+ if (!speaker)
149
+ return { ...base, author: 'platform:room', kind: 'system', text: str(item.text) };
150
+ return { ...base, author: speaker, ...(typeof item.via === 'string' ? { via: item.via } : {}),
151
+ ...(gone ? { removed: { by: '', at } } : { text: str(item.text) }), mentions: strings(item.mentions), ...(id(item.replyTo) !== undefined ? { replyTo: String(item.replyTo) } : {}),
152
+ ...(gone ? {} : { attachments: list(item.attachments).filter(isObject).filter(file => typeof file.path === 'string').map(file => ({ path: str(file.path) })) }),
153
+ ...(gone ? {} : { steps: list(item.steps).filter(isObject).map((step) => ({ label: str(step.label), status: stepStatus(step.status) })) }),
154
+ ...(mine ? { clientId: mine } : {}) };
155
+ }
156
+ if (item.kind === 'system')
157
+ return { ...base, author: 'platform:room', kind: 'system', text: str(item.line) };
158
+ if (item.kind === 'pin')
159
+ return { ...base, author, card: { requestId: `pin:${entry}`, kind: 'pin', title: str(item.title), line: str(item.line) } };
160
+ if (item.kind === 'question') {
161
+ const options = strings(item.options);
162
+ return { ...base, author, text: str(item.prompt), question: { callId: str(item.callId), prompt: str(item.prompt), options, allowFreeText: item.allowFreeText !== false || options.length === 0, answered: item.answered === true } };
163
+ }
164
+ if (item.kind === 'card') {
165
+ const card = item.card;
166
+ if (!isObject(card))
167
+ return undefined; // The card's state is not in the room yet (R5): nothing to show, and never a bare request id.
168
+ const since = card.since;
169
+ const shown = { requestId: str(item.requestId), kind: 'request', title: str(card.title), line: str(card.line), ...(typeof card.state === 'string' ? { state: card.state } : {}),
170
+ ...(typeof since === 'number' ? { since: new Date(since).toISOString() } : typeof since === 'string' ? { since } : {}), actions: actionsOf(card.actions) };
171
+ return { ...base, author, card: shown };
172
+ }
173
+ return undefined; // A kind a newer host adds is not drawn, never refused.
174
+ }
175
+ const sameMessage = (a, b) => JSON.stringify({ ...a, clientId: undefined }) === JSON.stringify({ ...b, clientId: undefined });
176
+ // ---- the cache: messages by entry id, so history pages and resumes are slices ----
177
+ function put(c, message) {
178
+ const entry = Number(message.id);
179
+ const index = c.list.findIndex(existing => Number(existing.id) >= entry);
180
+ if (index >= 0 && c.list[index].id === message.id) {
181
+ const kept = c.list[index];
182
+ const merged = { ...message, ...(message.clientId || !kept.clientId ? {} : { clientId: kept.clientId }) };
183
+ c.list[index] = merged;
184
+ return merged;
185
+ }
186
+ if (index < 0)
187
+ c.list.push(message);
188
+ else
189
+ c.list.splice(index, 0, message);
190
+ return message;
191
+ }
192
+ function markRemoved(c, target) {
193
+ const index = c.list.findIndex(message => message.id === String(target));
194
+ const found = c.list[index];
195
+ if (!found || found.removed)
196
+ return undefined;
197
+ const { text: _text, attachments: _attachments, steps: _steps, card: _card, question: _question, ...rest } = found;
198
+ c.list[index] = { ...rest, removed: { by: '', at: found.at } };
199
+ return c.list[index];
200
+ }
201
+ /** A reply to a question answers it: the question card shows as answered, in place. */
202
+ function noteReply(c, name, message) {
203
+ if (!message.replyTo)
204
+ return;
205
+ const index = c.list.findIndex(existing => existing.id === message.replyTo);
206
+ const asked = c.list[index];
207
+ if (!asked?.question || asked.question.answered)
208
+ return;
209
+ c.list[index] = { ...asked, question: { ...asked.question, answered: true } };
210
+ emit(name, { type: 'message', message: c.list[index] });
211
+ }
212
+ // ---- the writing state: an open wake draws the agent's message, in place, until its answer lands ----
213
+ const clientIdOf = (w) => `wake:${w.wake.id}#${w.segment}`;
214
+ const placeholder = (w) => ({ id: `wake:${w.wake.id}`, conversationId: w.wake.conversation, author: w.wake.agent, at: w.at, streaming: true, clientId: clientIdOf(w),
215
+ ...(w.stalled ? { card: { requestId: `wake:${w.wake.id}`, kind: 'request', title: text.stalled, line: '', actions: [{ id: 'retry', label: text.retry }] } } : { text: w.text }), steps: w.steps.map(step => ({ ...step })) });
216
+ function arm(w) {
217
+ clearTimeout(w.timer);
218
+ w.timer = setTimeout(() => {
219
+ if (!conv(w.wake.conversation).writing.has(w.wake.id) || w.stalled)
220
+ return;
221
+ w.stalled = true;
222
+ emit(w.wake.conversation, { type: 'message', message: placeholder(w) });
223
+ }, stallMs);
224
+ }
225
+ /** Opens the writing message of a wake (once). `shown: false` when the caller returns it itself, as a history read does. */
226
+ function wake(raw, shown = true) {
227
+ if (!isObject(raw) || typeof raw.id !== 'string' || typeof raw.agent !== 'string')
228
+ return undefined;
229
+ const name = str(raw.conversation, 'main'), c = conv(name);
230
+ const found = c.writing.get(raw.id) ?? c.waiting.get(raw.id);
231
+ if (found)
232
+ return found;
233
+ const w = { wake: { id: raw.id, agent: raw.agent, conversation: name }, segment: 0, text: '', steps: [], stalled: false, timer: undefined, at: after() };
234
+ c.writing.set(raw.id, w);
235
+ arm(w);
236
+ if (shown)
237
+ emit(name, { type: 'message', message: placeholder(w) });
238
+ return w;
239
+ }
240
+ /** A frame of an open wake: the writing message grows in place. The frame may name its conversation; else the wake's cause entry does. */
241
+ function frame(raw) {
242
+ const wakeId = str(raw.wakeId);
243
+ const name = typeof raw.conversationId === 'string' ? raw.conversationId : owner.get(Number.parseInt(wakeId, 10)) ?? (subscribers.has('main') ? 'main' : [...subscribers.keys()][0]);
244
+ if (!name)
245
+ return;
246
+ const c = conv(name);
247
+ const agent = wakeId.slice(wakeId.indexOf(':') + 1);
248
+ const w = c.writing.get(wakeId) ?? wake({ id: wakeId, agent, conversation: name });
249
+ if (!w || c.waiting.has(wakeId))
250
+ return;
251
+ const wasStalled = w.stalled;
252
+ w.stalled = false;
253
+ arm(w);
254
+ if (raw.type === 'delta') {
255
+ const piece = str(raw.text);
256
+ w.text += piece;
257
+ emit(name, wasStalled ? { type: 'message', message: placeholder(w) } : { type: 'delta', messageId: placeholder(w).id, conversationId: name, text: piece });
258
+ }
259
+ else {
260
+ const label = str(raw.label), status = stepStatus(raw.status);
261
+ let index = w.steps.findIndex(step => step.label === label && step.status === 'running');
262
+ if (index < 0) {
263
+ w.steps.push({ label, status });
264
+ index = w.steps.length - 1;
265
+ }
266
+ else
267
+ w.steps[index] = { label, status };
268
+ emit(name, wasStalled ? { type: 'message', message: placeholder(w) } : { type: 'step', messageId: placeholder(w).id, conversationId: name, index, step: { label, status } });
269
+ }
270
+ }
271
+ /**
272
+ * An agent's own entry takes its writing message's place (same clientId, so the thread swaps them). A question does the same and parks
273
+ * the wake: the agent now waits for a person, so nothing says it is working and nothing times out. The answer wakes it again.
274
+ */
275
+ function settle(c, message) {
276
+ if (message.kind === 'system' || message.removed || !message.author.startsWith('agent:'))
277
+ return message;
278
+ const w = [...c.writing.values()].find(candidate => candidate.wake.agent === message.author);
279
+ if (!w) {
280
+ if (!message.question)
281
+ for (const [key, parked] of c.waiting)
282
+ if (parked.wake.agent === message.author)
283
+ c.waiting.delete(key);
284
+ return message;
285
+ }
286
+ const adopted = { ...message, clientId: clientIdOf(w) };
287
+ clearTimeout(w.timer);
288
+ c.writing.delete(w.wake.id);
289
+ if (message.question) {
290
+ w.segment++;
291
+ w.text = '';
292
+ w.steps = [];
293
+ w.stalled = false;
294
+ c.waiting.set(w.wake.id, w);
295
+ }
296
+ return adopted;
297
+ }
298
+ /** A person's answer to a question: the parked agent works again, and a later frame (or this one) draws a new writing message. */
299
+ function resume(c, answer) {
300
+ const asked = answer.replyTo ? c.list.find(message => message.id === answer.replyTo) : undefined;
301
+ if (!asked?.question)
302
+ return;
303
+ for (const [key, w] of c.waiting)
304
+ if (w.wake.agent === asked.author) {
305
+ c.waiting.delete(key);
306
+ w.at = after();
307
+ c.writing.set(key, w);
308
+ arm(w);
309
+ emit(w.wake.conversation, { type: 'message', message: placeholder(w) });
310
+ }
311
+ }
312
+ // ---- reading ----
313
+ function absorb(name, c, items) {
314
+ const entries = items.flatMap(item => isObject(item) && id(item.id) !== undefined ? [id(item.id)] : []);
315
+ for (const entry of entries)
316
+ owner.set(entry, name);
317
+ stamp(entries.filter(entry => !stamps.has(entry)));
318
+ const out = [];
319
+ for (const item of items) {
320
+ const converted = convert(name, item);
321
+ if (!converted)
322
+ continue;
323
+ if ('retract' in converted) {
324
+ const gone = markRemoved(c, converted.retract);
325
+ if (gone)
326
+ out.push(gone);
327
+ continue;
328
+ }
329
+ out.push(put(c, converted));
330
+ }
331
+ return out;
332
+ }
333
+ async function read(name, c) {
334
+ const epoch = socketEpoch;
335
+ const from = c.loaded ? c.cursor : undefined;
336
+ const body = await rpc('history', { conversationId: name, ...(from === undefined ? {} : { after: from }) });
337
+ if (!isObject(body))
338
+ throw new ChannelRequestError('invalid-response', 'Room history returned an invalid response', false);
339
+ for (const message of absorb(name, c, list(body.items)))
340
+ if (c.loaded)
341
+ noteReply(c, name, message);
342
+ const through = id(body.cursor);
343
+ if (through !== undefined) {
344
+ c.cursor = Math.max(c.cursor ?? 0, through);
345
+ c.cursorEpoch = epoch;
346
+ }
347
+ c.loaded = true;
348
+ // Open wakes, when the host says them: a reload redraws the writing message, and a wake that ended elsewhere clears.
349
+ if (Array.isArray(body.wakes)) {
350
+ const open = body.wakes.filter(isObject).filter(w => w.conversation === name || (w.conversation === undefined && name === 'main'));
351
+ for (const raw of open)
352
+ wake({ ...raw, conversation: name }, false);
353
+ for (const [key, w] of c.writing)
354
+ if (!open.some(raw => raw.id === w.wake.id)) {
355
+ clearTimeout(w.timer);
356
+ c.writing.delete(key);
357
+ }
358
+ }
359
+ }
360
+ const sync = (name) => {
361
+ const c = conv(name);
362
+ return c.loading ??= read(name, c).finally(() => { c.loading = undefined; });
363
+ };
364
+ // ---- the socket: one per source, shared by its subscribers, resumed by cursor ----
365
+ let socket, opening, retry, attempt = 0, socketEpoch = 0, live = false;
366
+ const announce = (status) => { for (const set of subscribers.values())
367
+ for (const listener of [...set])
368
+ listener.status?.(status); };
369
+ function onFrame(raw) {
370
+ if (!isObject(raw))
371
+ return;
372
+ if (raw.type === 'message') {
373
+ const name = str(raw.conversationId);
374
+ if (!subscribers.has(name) && !convs.has(name))
375
+ return;
376
+ const c = conv(name);
377
+ const entry = isObject(raw.item) ? id(raw.item.id) : undefined;
378
+ if (entry === undefined)
379
+ return;
380
+ owner.set(entry, name);
381
+ // My own message may reach me by the socket before its HTTP answer: it takes the clientId of the send waiting for it.
382
+ if (isObject(raw.item) && raw.item.mine === true && !clientIds.has(entry)) {
383
+ const match = [...inflight].find(([, sent]) => sent.conversationId === name && isObject(raw.item) && sent.text === raw.item.text);
384
+ if (match) {
385
+ clientIds.set(entry, match[0]);
386
+ inflight.delete(match[0]);
387
+ }
388
+ }
389
+ // A frame of a socket that has been up since the last read keeps the resume cursor current; after a reconnect the next read does.
390
+ if (c.cursorEpoch === socketEpoch)
391
+ c.cursor = Math.max(c.cursor ?? 0, entry);
392
+ const converted = convert(name, raw.item);
393
+ if (!converted)
394
+ return;
395
+ if ('retract' in converted) {
396
+ const gone = markRemoved(c, converted.retract);
397
+ if (gone)
398
+ emit(name, { type: 'message', message: gone });
399
+ return;
400
+ }
401
+ const settled = settle(c, converted);
402
+ const before = c.list.find(existing => existing.id === settled.id);
403
+ const stored = put(c, settled);
404
+ if (!before || !sameMessage(before, stored))
405
+ emit(name, { type: 'message', message: stored });
406
+ noteReply(c, name, stored);
407
+ resume(c, stored);
408
+ }
409
+ else if (raw.type === 'delta' || raw.type === 'step')
410
+ frame(raw);
411
+ else if (raw.type === 'wake') {
412
+ const name = str(raw.conversationId, 'main');
413
+ wake({ id: str(raw.wakeId, str(raw.id)), agent: str(raw.agent), conversation: name });
414
+ }
415
+ else if (raw.type === 'presence') {
416
+ const members = list(raw.members).filter(isObject).flatMap((m) => typeof m.id === 'string' && (m.presence === 'here' || m.presence === 'away') ? [{ id: m.id, presence: m.presence }] : []);
417
+ for (const name of subscribers.keys())
418
+ emit(name, { type: 'presence', members });
419
+ }
420
+ else if (raw.type === 'members') {
421
+ const name = str(raw.conversationId);
422
+ emit(name, { type: 'members', conversationId: name, members: list(raw.members).flatMap(m => { const found = member(m); return found ? [found] : []; }) });
423
+ }
424
+ }
425
+ function connect() {
426
+ if (!subscribers.size || socket)
427
+ return;
428
+ announce('reconnecting');
429
+ const target = url(`spaces/${encodeURIComponent(space)}/live`);
430
+ target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
431
+ let current;
432
+ try {
433
+ current = (options.open ?? (target => new WebSocket(target)))(target);
434
+ }
435
+ catch {
436
+ lost();
437
+ return;
438
+ }
439
+ socket = current;
440
+ current.onopen = () => { if (current !== socket)
441
+ return; clearTimeout(opening); attempt = 0; live = true; socketEpoch++; announce('live'); };
442
+ current.onmessage = event => {
443
+ if (current !== socket || typeof event.data !== 'string')
444
+ return;
445
+ try {
446
+ onFrame(JSON.parse(event.data));
447
+ }
448
+ catch { /* a malformed frame is dropped; the entries are the truth and a resume reads them */ }
449
+ };
450
+ // A socket that fails to open may report an error and no close (and close() on it reports the error again): either ends it.
451
+ const ended = () => { if (current === socket) {
452
+ socket = undefined;
453
+ live = false;
454
+ clearTimeout(opening);
455
+ lost();
456
+ } };
457
+ opening = setTimeout(() => { if (current === socket && current.readyState !== 1) {
458
+ try {
459
+ current.close();
460
+ }
461
+ catch { /* already closed */ }
462
+ ended();
463
+ } }, options.openMs ?? 10000);
464
+ current.onerror = () => { if (current.readyState === 1)
465
+ current.close();
466
+ else
467
+ ended(); };
468
+ current.onclose = ended;
469
+ }
470
+ function lost() {
471
+ if (!subscribers.size)
472
+ return;
473
+ announce('offline');
474
+ clearTimeout(retry);
475
+ retry = setTimeout(connect, Math.min(30000, (options.reconnectMs ?? 1000) * 2 ** attempt++));
476
+ }
477
+ // A phone coming out of a tunnel says so: reconnect now instead of waiting out the backoff.
478
+ const nudge = () => { if (socket || !subscribers.size)
479
+ return; clearTimeout(retry); attempt = 0; connect(); };
480
+ let listening = false;
481
+ const listen = (on) => {
482
+ if (on === listening || typeof globalThis.addEventListener !== 'function')
483
+ return;
484
+ listening = on;
485
+ if (on)
486
+ globalThis.addEventListener('online', nudge);
487
+ else
488
+ globalThis.removeEventListener('online', nudge);
489
+ };
490
+ const dropEmpty = () => {
491
+ if ([...subscribers.values()].some(set => set.size))
492
+ return;
493
+ listen(false);
494
+ clearTimeout(retry);
495
+ clearTimeout(opening);
496
+ const current = socket;
497
+ socket = undefined;
498
+ live = false;
499
+ current?.close();
500
+ for (const c of convs.values())
501
+ for (const w of [...c.writing.values(), ...c.waiting.values()])
502
+ clearTimeout(w.timer);
503
+ };
504
+ // ---- the ChannelSource ----
505
+ const conversationsOf = async () => {
506
+ const rows = list(await rpc('conversations')).filter(isObject).filter(row => typeof row.id === 'string');
507
+ return Promise.all(rows.map(async (row) => {
508
+ const name = str(row.id), kind = str(row.kind, name);
509
+ const people = await rpc('members', { conversationId: name }).then(body => list(body).flatMap(m => member(m)?.id ?? []), () => []);
510
+ return { id: name, title: text.titles[name] ?? name, kind: kind === 'main' || kind === 'admin' ? kind : 'direct', members: people };
511
+ }));
512
+ };
513
+ const own = (message) => message.author === me.id;
514
+ const source = {
515
+ me, space, engine: 'room',
516
+ cursor: name => convs.get(name)?.cursor,
517
+ conversations: conversationsOf,
518
+ members: async (name) => list(await rpc('members', { conversationId: name })).flatMap(m => member(m) ?? []),
519
+ history: async (name, opts) => {
520
+ const c = conv(name);
521
+ await sync(name);
522
+ const end = opts.before ? c.list.findIndex(message => message.id === opts.before) : c.list.length;
523
+ if (end < 0)
524
+ throw new ChannelRequestError('cursor', 'Unknown history cursor', false);
525
+ const start = Math.max(0, end - opts.limit);
526
+ const page = c.list.slice(start, end).map(message => structuredClone(message));
527
+ // The writing messages belong to the newest page.
528
+ const writing = opts.before ? [] : [...c.writing.values()].map(placeholder);
529
+ return { messages: [...page, ...writing], ...(start > 0 && c.list[start] ? { before: c.list[start].id } : {}) };
530
+ },
531
+ send: async (input) => {
532
+ inflight.set(input.clientId, { conversationId: input.conversationId, text: input.text });
533
+ try {
534
+ const replyTo = input.replyTo === undefined ? undefined : Number(input.replyTo);
535
+ const body = await rpc('send', { conversationId: input.conversationId, text: input.text, mentions: input.mentions,
536
+ ...(replyTo !== undefined && Number.isSafeInteger(replyTo) ? { replyTo } : {}), idempotencyKey: input.clientId });
537
+ const entry = isObject(body) && isObject(body.entry) ? body.entry : undefined, data = entry && isObject(entry.data) ? entry.data : undefined, entryId = entry ? id(entry.id) : undefined;
538
+ if (!entry || !data || entryId === undefined)
539
+ throw new ChannelRequestError('invalid-response', 'Room send returned an invalid response', false);
540
+ const name = str(isObject(body) ? body.conversation : undefined, input.conversationId);
541
+ clientIds.set(entryId, input.clientId);
542
+ owner.set(entryId, name);
543
+ const c = conv(name);
544
+ const message = convert(name, { kind: 'message', id: entryId, author: data.author, via: data.via, text: data.text, mentions: data.mentions, replyTo: data.replyTo, attachments: data.attachments, steps: data.steps, mine: true, removed: false });
545
+ if (!message || 'retract' in message)
546
+ throw new ChannelRequestError('invalid-response', 'Room send returned an invalid response', false);
547
+ const stored = put(c, { ...message, clientId: input.clientId });
548
+ noteReply(c, name, stored);
549
+ resume(c, stored);
550
+ // The wakes this message opened are in the answer: the agent's writing message shows now, before any frame (B2).
551
+ if (isObject(body) && Array.isArray(body.wakes))
552
+ for (const raw of body.wakes)
553
+ wake({ ...(isObject(raw) ? raw : {}), conversation: name });
554
+ return structuredClone(stored);
555
+ }
556
+ finally {
557
+ inflight.delete(input.clientId);
558
+ }
559
+ },
560
+ action: async (input) => {
561
+ if (input.actionId === 'retry' && input.requestId.startsWith('wake:')) {
562
+ await rpc('retry', { conversationId: input.conversationId, wakeId: input.requestId.slice(5) });
563
+ return;
564
+ }
565
+ await rpc('action', { ...input, idempotencyKey: randomUUID() });
566
+ },
567
+ remove: async (messageId) => {
568
+ const name = [...convs].find(([, c]) => c.list.some(message => message.id === messageId))?.[0];
569
+ if (!name)
570
+ throw new ChannelRequestError('not-found', 'No such message', false, { refused: true });
571
+ await rpc('remove', { conversationId: name, messageId: Number(messageId), idempotencyKey: randomUUID() });
572
+ // The door broadcasts no item for a removal: the remover's own screen is updated from the answer.
573
+ const gone = markRemoved(conv(name), Number(messageId));
574
+ if (gone)
575
+ emit(name, { type: 'message', message: gone });
576
+ },
577
+ canRemove: message => !message.pending && !message.removed && message.kind !== 'system' && !message.question && !message.card
578
+ && (own(message) || ((me.role === 'owner' || me.kind === 'admin') && !message.author.startsWith('agent:') && !message.author.startsWith('platform:'))),
579
+ subscribe: (name, onEvent, onStatus) => {
580
+ const listener = { event: onEvent, status: onStatus };
581
+ let set = subscribers.get(name);
582
+ if (!set) {
583
+ set = new Set();
584
+ subscribers.set(name, set);
585
+ }
586
+ set.add(listener);
587
+ listen(true);
588
+ if (socket)
589
+ onStatus?.(live ? 'live' : 'offline');
590
+ else
591
+ connect();
592
+ return () => { subscribers.get(name)?.delete(listener); dropEmpty(); };
593
+ },
594
+ };
595
+ return source;
596
+ }
@@ -8,6 +8,7 @@ const cardAction = z.object({ id: z.string(), label: z.string(), variant: z.enum
8
8
  // An unknown `kind` from a newer host reads as absent (a plain message, a request card), never as a refused event.
9
9
  const message = z.object({ id: z.string(), conversationId: z.string(), author: z.string(), at: z.string(), via: z.string().optional(), text: z.string().optional(),
10
10
  kind: z.enum(['system']).optional().catch(undefined),
11
+ question: z.object({ callId: z.string(), prompt: z.string(), options: z.array(z.string()), allowFreeText: z.boolean(), answered: z.boolean() }).optional(),
11
12
  card: z.object({ requestId: z.string(), kind: z.enum(['request', 'pin']).optional().catch(undefined), title: z.string(), line: z.string(), since: z.string().optional(), state: z.string().optional(), action: cardAction.optional(), actions: z.array(cardAction).optional() }).optional(),
12
13
  mentions: z.array(z.string()).optional(), replyTo: z.string().optional(), attachments: z.array(z.object({ path: z.string(), title: z.string().optional() })).optional(), steps: z.array(step).optional(), streaming: z.boolean().optional(),
13
14
  removed: z.object({ by: z.string(), at: z.string() }).optional(), clientId: z.string().optional() });
@@ -36,6 +36,17 @@ export type Card = {
36
36
  };
37
37
  /** The card's choices in order: `actions`, else the single `action`, else none. */
38
38
  export declare function cardActions(card: Pick<Card, 'action' | 'actions'>): readonly CardAction[];
39
+ /**
40
+ * An agent's question to the people in the room (`ask_user`): `prompt`, the choices as buttons and, when `allowFreeText`, a free reply.
41
+ * A reply to the message (`replyTo`) answers it; `answered` turns the buttons off.
42
+ */
43
+ export type Question = {
44
+ callId: string;
45
+ prompt: string;
46
+ options: string[];
47
+ allowFreeText: boolean;
48
+ answered: boolean;
49
+ };
39
50
  export type Step = {
40
51
  label: string;
41
52
  detail?: string;
@@ -54,6 +65,7 @@ export type ChannelMessage = {
54
65
  text?: string;
55
66
  kind?: 'system';
56
67
  card?: Card;
68
+ question?: Question;
57
69
  mentions?: string[];
58
70
  replyTo?: string;
59
71
  attachments?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -134,7 +134,7 @@
134
134
  }
135
135
  },
136
136
  "peerDependencies": {
137
- "@hachej/boring-files": "^0.4.18",
137
+ "@hachej/boring-files": "^0.4.20",
138
138
  "@earendil-works/pi-durable": "~1.2.0",
139
139
  "react": "^19.0.0",
140
140
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.18",
8
- "@hachej/boring-feedback@^0.4.18",
7
+ "@hachej/boring-ui-kit@^0.4.20",
8
+ "@hachej/boring-feedback@^0.4.20",
9
9
  "@earendil-works/pi-durable@~1.2.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.18/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.18/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.20/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.20/r/pi-chat.json",
16
16
  "button",
17
17
  "card",
18
18
  "message",