dsh-cindy-host 0.1.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 (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * `local-db:messages:view` — the work-grouped history window the controller prefers.
3
+ *
4
+ * Why this channel exists at all: the phone's raw transcript path pages **20 rows at a
5
+ * time** (`MESSAGE_FETCH_PAGE_SIZE`) and, on a full-page sync, deliberately drops the
6
+ * older history it cannot prove is contiguous (`setLatestMessageWindow` +
7
+ * `sessionWindowCoverage` in `apps/mobile/src/session/remoteSessionStore.ts`). The result
8
+ * is 「每次重新进入会话都重新拉取、丢掉之前翻出来的历史」 — the client's own rule, and the
9
+ * reason the reference controlled end serves a *history view* instead: it returns
10
+ * **work items** with their own cursor, so the controller keeps a projection it can trust
11
+ * across re-entries rather than guessing at raw row continuity.
12
+ *
13
+ * Contract (read off `@cindy/maker-shared/message-window`, the desktop reader
14
+ * `apps/desktop/src/main/localDb/ipc/historyViewReader.ts`, and the phone's transport):
15
+ *
16
+ * ```
17
+ * page(sessionId, before?) → { version: 1, items, nextCursor, hasMore }
18
+ * details(sessionId, ref, after?) → { version: 1, messages, nextCursor, hasMore }
19
+ * intent(sessionId, refs) → true
20
+ * ```
21
+ *
22
+ * - **A page's `items` are chronological** (oldest → newest); `nextCursor` is the
23
+ * **oldest** id of that page (`firstId(items[0])`) and is passed back as `before` to
24
+ * walk further back. `hasMore` is what lights the controller's "load older".
25
+ * - A `work` item carries a `HistoryWorkSummary`; a `messages` item is a plain row the
26
+ * controller renders on its own.
27
+ * - `details` reads the inclusive range `firstMessageId…lastMessageId`, one
28
+ * `HISTORY_DETAIL_PAGE_BYTES` page at a time, with `after` = the last row already sent.
29
+ * - `intent` replaces the set of expanded work keys wholesale; it is advisory.
30
+ * - An error whose text matches `isHistoryViewUnavailable` sends the controller back to
31
+ * the raw window. `UNSUPPORTED_CAPABILITY` is therefore how this Host *deliberately*
32
+ * declines a session it cannot project (a transcript past the scan budget) — the same
33
+ * lever the reference uses.
34
+ *
35
+ * Grouping here is by **DSH turn boundary as the controller can see it**: a row whose role
36
+ * is `user` starts a work group, and everything up to the next one belongs to it. The
37
+ * desktop groups more finely (sealed answers and their own activity runs, from row shapes
38
+ * this Host does not have), but the contract is about the *page*, not the grouping: a
39
+ * coarser work item is still a work item, and `preview`/`children` are optional.
40
+ *
41
+ * @module dsh-cindy-host/host-history-view
42
+ */
43
+ import {
44
+ HISTORY_DETAIL_PAGE_BYTES,
45
+ HISTORY_PAGE_BYTES,
46
+ HISTORY_PAGE_ITEMS,
47
+ HISTORY_VIEW_VERSION,
48
+ } from './host-history-view-limits.js';
49
+
50
+ /** One history page's byte ceiling is the contract's, not this Host's choice. */
51
+ export { HISTORY_DETAIL_PAGE_BYTES, HISTORY_PAGE_BYTES, HISTORY_PAGE_ITEMS, HISTORY_VIEW_VERSION };
52
+
53
+ /**
54
+ * Longest transcript this Host will project — **no longer a ceiling**.
55
+ *
56
+ * Kept only so the removed refusal is visible where it used to be: the budget did not save the
57
+ * read (it had already happened and been cached) and refusing poisoned the controller's view
58
+ * permanently. See the comment in `transcriptFor` and ADR-0007's decision evolution. Nothing
59
+ * reads this constant; a future "this is too big" rule must find a retryable answer instead.
60
+ */
61
+ export const HISTORY_SCAN_MAX_ROWS = 20_000;
62
+
63
+ /** FNV-1a over the group's rows: the revision changes whenever the group's body does. */
64
+ function revisionOf(rows) {
65
+ let hash = 2166136261;
66
+ for (const row of rows) {
67
+ const text = JSON.stringify(row);
68
+ for (let index = 0; index < text.length; index += 1) {
69
+ hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
70
+ }
71
+ }
72
+ return hash >>> 0;
73
+ }
74
+
75
+ /** A row's own key in the controller's vocabulary. */
76
+ function keyOf(row) {
77
+ return row.clientId || row.id;
78
+ }
79
+
80
+ /** The id a page's cursor is built from: a work item's first row, or a row itself. */
81
+ export function firstIdOf(item) {
82
+ return item.type === 'work' ? item.summary.firstMessageId : item.messages[0]?.id ?? '';
83
+ }
84
+
85
+ /**
86
+ * Rows one work item may carry before the run is split.
87
+ *
88
+ * A work item is *collapsed* until the controller expands it, so an item that swallows a
89
+ * whole turn hides the conversation: measured on this session, grouping a turn as one item
90
+ * produced a page of twenty collapsed items with **zero** readable rows, one of them
91
+ * carrying 329 rows — 「进入会话,只显示一号折叠的会话,然后每次展开十来分钟,又要继续展开」.
92
+ * The cap keeps every expansion to roughly one detail page.
93
+ */
94
+ export const WORK_ITEM_MAX_ROWS = 40;
95
+
96
+ /**
97
+ * Whether a row is the plan card rather than agent activity.
98
+ *
99
+ * A `TodoWrite` row is a **card**, not a step: the controller lifts those rows into their
100
+ * own top-level item (the desktop projection's own tests list them as
101
+ * `['message', 'work_group', 'todo', 'message']`). Leaving one inside an activity run hides
102
+ * it behind an expansion — which is what happened as soon as the phone switched to this
103
+ * view: 「我没有看到你刚刚这个小任务的 to do」, because the plan rows only ever arrived
104
+ * inside collapsed segments.
105
+ */
106
+ function isTodoRow(row) {
107
+ return row?.role === 'tool_use' && row?.content?.toolName === 'TodoWrite';
108
+ }
109
+
110
+ /** Whether a row is agent *activity* (folded into a work item) rather than prose or a card. */
111
+ function isActivityRow(row) {
112
+ if (isTodoRow(row)) return false;
113
+ return row?.role === 'thinking' || row?.role === 'tool_use' || row?.role === 'tool_result';
114
+ }
115
+
116
+ /**
117
+ * Group one transcript (in either order) into work items and readable rows.
118
+ *
119
+ * The split follows the renderer contract, not a turn's extent:
120
+ *
121
+ * - a **user prompt** and an **assistant answer** stay top-level `messages` items, because
122
+ * those are what a person reads — the desktop's projection keeps them visible too
123
+ * (`['message', 'work_group', 'todo', 'message']` in its own tests);
124
+ * - the **activity in between** (thinking, tool calls, tool results) collapses into `work`
125
+ * items, split every {@link WORK_ITEM_MAX_ROWS} rows so one expansion is one page.
126
+ *
127
+ * @param rows - the transcript rows, **newest first** (the reader's order).
128
+ * @param options - `running` says whether the session is in a turn right now; `null` (the
129
+ * default) derives it: a transcript that does not end in an answer is still being written.
130
+ * @returns items in **chronological** order, oldest first.
131
+ */
132
+ export function groupHistoryItems(rows, { running = null, maxWorkRows = WORK_ITEM_MAX_ROWS } = {}) {
133
+ const chronological = [...rows].reverse();
134
+ const items = [];
135
+ let run = [];
136
+
137
+ /** Emit the buffered activity run as work items, newest one marked when it is live. */
138
+ const flushRun = (streaming) => {
139
+ while (run.length > 0) {
140
+ const chunk = maxWorkRows > 0 ? run.slice(0, maxWorkRows) : run;
141
+ run = run.slice(chunk.length);
142
+ items.push(workItem(chunk, streaming && run.length === 0));
143
+ }
144
+ };
145
+
146
+ for (const row of chronological) {
147
+ if (isActivityRow(row)) {
148
+ run.push(row);
149
+ continue;
150
+ }
151
+ // Prose ends the run before it, and stays readable itself.
152
+ flushRun(false);
153
+ items.push({ type: 'messages', key: keyOf(row), messages: [row] });
154
+ }
155
+ const derivedRunning = running === null ? chronological[chronological.length - 1]?.role !== 'assistant' : running === true;
156
+ flushRun(derivedRunning);
157
+ return items;
158
+ }
159
+
160
+ /** Rows of the live tail a streaming work item shows without being expanded. */
161
+ export const WORK_PREVIEW_ROWS = 5;
162
+
163
+ /**
164
+ * One work item for a run of activity rows.
165
+ *
166
+ * @param run - the run's rows, in log order.
167
+ * @param streaming - whether this run is the one still being written.
168
+ * @returns the item the controller renders.
169
+ */
170
+ function workItem(run, streaming) {
171
+ const first = run[0];
172
+ const last = run[run.length - 1];
173
+ const summary = {
174
+ key: `work-${keyOf(first)}`,
175
+ anchorClientId: keyOf(first),
176
+ firstMessageId: first.id,
177
+ lastMessageId: last.id,
178
+ startedAtMs: Date.parse(first.createdAt) || 0,
179
+ endedAtMs: Date.parse(last.createdAt) || 0,
180
+ isStreaming: streaming,
181
+ messageCount: run.length,
182
+ toolCount: run.filter((row) => row.role === 'tool_use').length,
183
+ revision: `${last.id}:${run.length}:${revisionOf(run)}`,
184
+ };
185
+ // A running group gets a **preview**: the tail the controller can show inside the
186
+ // collapsed row, which is the difference between 「正在干活」 and 「卡住了」.
187
+ //
188
+ // This is the contract's own field (`HistoryWorkSummary.preview`, "Existing detail
189
+ // endpoint can read just the visible desktop tail") and the reference sets it for every
190
+ // streaming group. Without it, folding activity into work items meant a long turn showed
191
+ // nothing but a spinner: measured, the phone sat on 「一直在思考中转圈」 for minutes while
192
+ // this Host pushed 100+ rows that were all inside a collapsed group.
193
+ if (streaming && run.length > 0) {
194
+ const tail = run.slice(Math.max(0, run.length - WORK_PREVIEW_ROWS));
195
+ const previewFirst = tail[0];
196
+ const previewLast = tail[tail.length - 1];
197
+ summary.preview = {
198
+ key: `preview-${summary.key}`,
199
+ anchorClientId: keyOf(previewFirst),
200
+ firstMessageId: previewFirst.id,
201
+ lastMessageId: previewLast.id,
202
+ startedAtMs: Date.parse(previewFirst.createdAt) || 0,
203
+ endedAtMs: Date.parse(previewLast.createdAt) || 0,
204
+ isStreaming: true,
205
+ messageCount: tail.length,
206
+ toolCount: tail.filter((row) => row.role === 'tool_use').length,
207
+ revision: `${previewLast.id}:${tail.length}:${revisionOf(tail)}`,
208
+ };
209
+ }
210
+ return { type: 'work', key: summary.key, summary };
211
+ }
212
+
213
+ /** UTF-8 size of one item, which is what the page budgets count. */
214
+ function itemBytes(item) {
215
+ try {
216
+ return Buffer.byteLength(JSON.stringify(item), 'utf8');
217
+ } catch {
218
+ return 0;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Build the reader behind the three view channels.
224
+ *
225
+ * @param options - `rows` (the transcript reader's whole-transcript accessor),
226
+ * `sessionRunning` (whether the session is in a turn right now, for `isStreaming`),
227
+ * and the page/scan limits.
228
+ * @returns `{ page, details, intent, expandedFor }`.
229
+ */
230
+ export function createHistoryViewController({
231
+ rows,
232
+ sessionRunning = () => false,
233
+ pageItems = HISTORY_PAGE_ITEMS,
234
+ pageBytes = HISTORY_PAGE_BYTES,
235
+ detailBytes = HISTORY_DETAIL_PAGE_BYTES,
236
+ hydrate,
237
+ } = {}) {
238
+ /**
239
+ * Turn the served rows' image handles into bytes, in place.
240
+ *
241
+ * A view page is a **second** read path, and the reason this hook exists: image hydration was
242
+ * wired into `local-db:messages:list` alone, so when the controller moved onto this view
243
+ * (`history-view-v1`) every user photo silently went back to a path-less file chip — the
244
+ * 「发送的照片看不到」 regression, on a path nothing hydrated. Anything that serves rows to the
245
+ * phone has to run this.
246
+ *
247
+ * Called **after** the page's items are chosen, so only the rows being served pay for reading
248
+ * image bytes, and the page's own byte budget still counts text only (the pictures carry their
249
+ * own budget inside the hydrator).
250
+ *
251
+ * @param servedRows - the rows of the page about to be returned.
252
+ */
253
+ async function hydrateServed(servedRows) {
254
+ if (typeof hydrate !== 'function' || servedRows.length === 0) return;
255
+ await hydrate(servedRows);
256
+ }
257
+
258
+ /** `sessionId` → the work keys the controller currently has expanded. */
259
+ const expanded = new Map();
260
+
261
+ /** The transcript for one session, or a refusal the controller understands. */
262
+ async function transcriptFor(sessionId) {
263
+ if (typeof sessionId !== 'string' || sessionId === '') {
264
+ return { ok: false, code: 'BAD_REQUEST', message: 'history view needs a session' };
265
+ }
266
+ if (typeof rows !== 'function') {
267
+ return { ok: false, code: 'NOT_AVAILABLE', message: 'this DSH Host cannot project session history' };
268
+ }
269
+ let all;
270
+ try {
271
+ all = await rows(sessionId);
272
+ } catch {
273
+ return { ok: false, code: 'NOT_FOUND', message: 'this session has no readable transcript' };
274
+ }
275
+ if (!Array.isArray(all)) return { ok: false, code: 'INTERNAL', message: 'the transcript could not be read' };
276
+ // Long transcripts are **served**, not refused.
277
+ //
278
+ // This was `all.length > scanMaxRows → UNSUPPORTED_CAPABILITY`, on the reasoning that the
279
+ // scan would time out on the phone and the controller recognises that code as "no view
280
+ // here". Both halves are wrong, and the cost is permanent:
281
+ //
282
+ // - it saves no scan. `rows` is the reader's transcript (`readMessages.all`, i.e.
283
+ // `ensureTranscript`), so the full read has **already happened and been cached** before
284
+ // this function is entered; `page()` below only windows that array in O(page).
285
+ // - it does not degrade, it kills. The controller's `historyViewController.refresh()`
286
+ // returns immediately **forever** once its error matches `UNSUPPORTED_CAPABILITY`
287
+ // (`packages/maker-shared/src/historyViewController.ts:95`), and only `reset()` clears
288
+ // that error — while re-entry does not reliably reset, and the row count is a permanent
289
+ // property of the session, so a long session's view is dead for the life of the screen.
290
+ // `maker:history-view-changed` is the controller's only refresh trigger, so every push
291
+ // we send lands on that dead path.
292
+ //
293
+ // `UNSUPPORTED_CAPABILITY` and `CHANNEL_NOT_ALLOWED` are therefore reserved for "this Host
294
+ // has no projection capability at all" — a deployment fact — and never for a property of
295
+ // one session. See ADR-0007's decision evolution.
296
+ return { ok: true, rows: all };
297
+ }
298
+
299
+ /**
300
+ * One page of work items, newest window by default.
301
+ * @param sessionId - the session.
302
+ * @param before - a cursor from a previous page: the oldest id that page returned.
303
+ */
304
+ async function page(sessionId, before = undefined) {
305
+ const read = await transcriptFor(sessionId);
306
+ if (read.ok !== true) return read;
307
+ const items = groupHistoryItems(read.rows, {
308
+ running: typeof sessionRunning === 'function' ? sessionRunning(sessionId) === true : null,
309
+ });
310
+
311
+ // `before` names a row inside the older part of the transcript: everything at or after
312
+ // it has already been sent, so the next page starts strictly older than that item.
313
+ let end = items.length;
314
+ if (typeof before === 'string' && before !== '') {
315
+ const index = items.findIndex((item) => firstIdOf(item) === before
316
+ || (item.type === 'work' && item.summary.lastMessageId === before));
317
+ if (index >= 0) end = index;
318
+ else {
319
+ // A cursor this Host cannot place: answer the oldest page we have rather than the
320
+ // newest one again, which is what re-serving the same rows would amount to.
321
+ end = Math.min(items.length, pageItems);
322
+ }
323
+ }
324
+ const selected = [];
325
+ let bytes = 1024;
326
+ for (let index = end - 1; index >= 0; index -= 1) {
327
+ const size = itemBytes(items[index]);
328
+ if (selected.length > 0 && (selected.length >= pageItems || bytes + size > pageBytes)) break;
329
+ selected.unshift(items[index]);
330
+ bytes += size;
331
+ }
332
+ const hasMore = selected.length > 0 && end - selected.length > 0;
333
+ await hydrateServed(selected.flatMap((item) => (item.type === 'messages' ? item.messages ?? [] : [])));
334
+ return {
335
+ ok: true,
336
+ result: {
337
+ version: HISTORY_VIEW_VERSION,
338
+ items: selected,
339
+ hasMore,
340
+ nextCursor: hasMore && selected.length > 0 ? firstIdOf(selected[0]) : null,
341
+ },
342
+ };
343
+ }
344
+
345
+ /**
346
+ * The rows of one work range, one page at a time.
347
+ * @param sessionId - the session.
348
+ * @param ref - the controller's `HistoryWorkReference` (its `firstMessageId`/`lastMessageId`).
349
+ * @param after - the last row id already sent, for the next page.
350
+ */
351
+ async function details(sessionId, ref, after = undefined) {
352
+ const read = await transcriptFor(sessionId);
353
+ if (read.ok !== true) return read;
354
+ const firstId = typeof ref?.firstMessageId === 'string' ? ref.firstMessageId : '';
355
+ const lastId = typeof ref?.lastMessageId === 'string' ? ref.lastMessageId : '';
356
+ if (firstId === '' || lastId === '') return { ok: false, code: 'BAD_REQUEST', message: 'a work range needs both endpoints' };
357
+ // The transcript is newest first; the range is inclusive in log order.
358
+ const chronological = [...read.rows].reverse();
359
+ const from = chronological.findIndex((row) => row.id === firstId);
360
+ const to = chronological.findIndex((row) => row.id === lastId);
361
+ if (from < 0 || to < 0 || to < from) return { ok: false, code: 'NOT_FOUND', message: 'this work range is no longer in the transcript' };
362
+ const range = chronological.slice(from, to + 1);
363
+ let start = 0;
364
+ if (typeof after === 'string' && after !== '') {
365
+ const index = range.findIndex((row) => row.id === after);
366
+ if (index < 0) return { ok: false, code: 'BAD_REQUEST', message: 'the detail cursor is outside this work range' };
367
+ start = index + 1;
368
+ }
369
+ const messages = [];
370
+ let bytes = 1024;
371
+ for (let index = start; index < range.length; index += 1) {
372
+ const row = range[index];
373
+ const size = itemBytes(row);
374
+ if (messages.length > 0 && bytes + size > detailBytes) break;
375
+ messages.push(row);
376
+ bytes += size;
377
+ }
378
+ const lastSent = messages[messages.length - 1];
379
+ const hasMore = messages.length > 0 && start + messages.length < range.length;
380
+ await hydrateServed(messages);
381
+ return {
382
+ ok: true,
383
+ result: {
384
+ version: HISTORY_VIEW_VERSION,
385
+ messages,
386
+ hasMore,
387
+ nextCursor: hasMore && lastSent !== undefined ? lastSent.id : null,
388
+ },
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Replace the controller's expanded work set.
394
+ * @param sessionId - the session.
395
+ * @param refs - the expanded summaries, as the controller sends them.
396
+ */
397
+ async function intent(sessionId, refs) {
398
+ if (typeof sessionId !== 'string' || sessionId === '') return { ok: false, code: 'BAD_REQUEST', message: 'history intent needs a session' };
399
+ if (!Array.isArray(refs) || refs.length > 100) return { ok: false, code: 'BAD_REQUEST', message: 'invalid expanded work groups' };
400
+ const keys = [];
401
+ for (const ref of refs) {
402
+ const key = typeof ref?.key === 'string' ? ref.key.replace(/^preview-/, '') : '';
403
+ if (key === '') return { ok: false, code: 'BAD_REQUEST', message: 'an expanded work group needs a key' };
404
+ keys.push(key);
405
+ }
406
+ if (keys.length === 0) expanded.delete(sessionId);
407
+ else expanded.set(sessionId, new Set(keys));
408
+ return { ok: true, result: true };
409
+ }
410
+
411
+ /** What the controller has expanded for one session (advisory; used by the status route). */
412
+ function expandedFor(sessionId) {
413
+ return [...(expanded.get(sessionId) ?? new Set())];
414
+ }
415
+
416
+ return { page, details, intent, expandedFor };
417
+ }