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,505 @@
1
+ /**
2
+ * Track DSH's input queue and answer `maker:input:get-projection`.
3
+ *
4
+ * DSH publishes queue and job state as an `AsyncIterable` of control frames from
5
+ * `sessionController.control()` — a baseline followed by replacements — so the
6
+ * answer is a fold over that stream, not a query. This module keeps the latest
7
+ * state per session.
8
+ *
9
+ * The controller's shape is `InputProjection`
10
+ * (`apps/mobile/src/session/types.ts`), and its reader is defensive: an entry is
11
+ * kept only when it carries `clientId`, `text`, `persistedContent`, `model`,
12
+ * `workingDir`, `createOpts`, and `chatMessage.role === 'user'`
13
+ * (`apps/mobile/src/session/inputProjection.ts`). Anything less is dropped, so a
14
+ * partially-known queue degrades to a shorter queue rather than to a broken
15
+ * composer — which is why this Host emits only entries it can fill honestly.
16
+ */
17
+
18
+ /** The one agent kind this Host presents; the controller's union is closed. */
19
+ const DSH_AGENT_KIND = 'pi';
20
+ const DSH_MODEL = 'pi';
21
+ const DSH_EFFORT = 'default';
22
+ const DSH_PERMISSION_MODE = 'default';
23
+
24
+ /**
25
+ * The session's working directory, from whichever row shape was handed in.
26
+ *
27
+ * The queue tracker is given a **source** row, whose field is `cwd`
28
+ * (`dsh-session-source.js`), while the row the controller sees on the wire
29
+ * calls the same value `workingDir` (`cindy-session-row.js`). Reading only one
30
+ * name silently emptied every projection: `toQueuedRemoteMessage` dropped DSH's
31
+ * real queue entries, and `queuedRowFromController` returned null, so the item a
32
+ * controller had just sent was missing from its own `maker:input:enqueue`
33
+ * answer. The composer therefore never settled — the draft stayed put and the
34
+ * next send repeated the same text — and the queue panel showed nothing.
35
+ * @param session - a session row in either shape.
36
+ * @returns the working directory, or null when neither name carries one.
37
+ */
38
+ function workingDirOf(session) {
39
+ if (typeof session?.workingDir === 'string' && session.workingDir !== '') return session.workingDir;
40
+ if (typeof session?.cwd === 'string' && session.cwd !== '') return session.cwd;
41
+ return null;
42
+ }
43
+
44
+ /** Flatten a DSH queued message's content blocks into display text. */
45
+ function textOf(content) {
46
+ const parts = [];
47
+ for (const part of Array.isArray(content) ? content : []) {
48
+ if (part === null || typeof part !== 'object') continue;
49
+ if (part.type === 'text' && typeof part.text === 'string') parts.push(part.text);
50
+ }
51
+ return parts.join('\n');
52
+ }
53
+
54
+ /**
55
+ * Build the controller's queued-message row for one DSH queue entry.
56
+ *
57
+ * Every field below is read by the controller's validator, so an entry is only
58
+ * emitted when the session supplies the working directory it needs.
59
+ * @param item - a DSH `SessionQueuedItem`.
60
+ * @param session - the owning session row (`{ id, title, workingDir }`).
61
+ * @returns the row, or null when it cannot be filled honestly.
62
+ */
63
+ export function toQueuedRemoteMessage(item, session) {
64
+ const workingDir = workingDirOf(session);
65
+ const id = item?.id === undefined || item?.id === null ? null : String(item.id);
66
+ // `rpcId` is the identity the **controller** minted for this prompt, and it is
67
+ // the field its own submission echo retires on. Reporting DSH's `MessageId`
68
+ // instead made the optimistic row and the confirmed row look like two
69
+ // different messages: the composer kept the draft, so the next thing the user
70
+ // typed was appended to the unsent text, and both went out as one message.
71
+ const clientId = typeof item?.rpcId === 'string' && item.rpcId !== '' ? item.rpcId : id;
72
+ if (clientId === null || workingDir === null) return null;
73
+
74
+ const content = Array.isArray(item?.message?.content) ? item.message.content : [];
75
+ const text = textOf(content);
76
+ const createOpts = {
77
+ agentKind: DSH_AGENT_KIND,
78
+ workingDir,
79
+ model: DSH_MODEL,
80
+ effort: DSH_EFFORT,
81
+ permissionMode: DSH_PERMISSION_MODE,
82
+ fastMode: false,
83
+ };
84
+
85
+ return {
86
+ clientId,
87
+ text,
88
+ persistedContent: JSON.stringify(content),
89
+ files: [],
90
+ agentReferences: [],
91
+ model: DSH_MODEL,
92
+ effort: DSH_EFFORT,
93
+ permissionMode: DSH_PERMISSION_MODE,
94
+ workingDir,
95
+ createOpts,
96
+ // The controller's validator requires a user-role chat message.
97
+ chatMessage: { role: 'user', clientId, content },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Build the controller's queued row for a message this Host just accepted.
103
+ *
104
+ * `maker:input:enqueue` hands the controller's own row in and expects the new
105
+ * projection back with it present. DSH's queue frame arrives over the control
106
+ * stream asynchronously, so the freshly accepted item is folded in here: the
107
+ * `prompt` call already returned `accepted`, which is what makes showing it
108
+ * accurate rather than optimistic guesswork.
109
+ * @param input - the controller's clientId and text, plus the owning session.
110
+ * @returns the row, or null when it cannot be filled.
111
+ */
112
+ export function queuedRowFromController({ clientId, text, session }) {
113
+ const workingDir = workingDirOf(session);
114
+ if (typeof clientId !== 'string' || clientId === '' || workingDir === null) return null;
115
+ const value = typeof text === 'string' ? text : '';
116
+ const createOpts = {
117
+ agentKind: DSH_AGENT_KIND,
118
+ workingDir,
119
+ model: DSH_MODEL,
120
+ effort: DSH_EFFORT,
121
+ permissionMode: DSH_PERMISSION_MODE,
122
+ fastMode: false,
123
+ };
124
+ return {
125
+ clientId,
126
+ text: value,
127
+ persistedContent: JSON.stringify([{ type: 'text', text: value }]),
128
+ files: [],
129
+ agentReferences: [],
130
+ model: DSH_MODEL,
131
+ effort: DSH_EFFORT,
132
+ permissionMode: DSH_PERMISSION_MODE,
133
+ workingDir,
134
+ createOpts,
135
+ chatMessage: { role: 'user', clientId, content: [{ type: 'text', text: value }] },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Fold the control stream into per-session queue state.
141
+ * @returns the tracker the runtime feeds and the channel reads.
142
+ */
143
+ export function createInputQueueTracker() {
144
+ /** sessionId -> SessionQueuedItem[] */
145
+ const queues = new Map();
146
+ /** sessionId -> SessionJob[] */
147
+ const jobs = new Map();
148
+ /**
149
+ * sessionId -> controller-owned queue UI state.
150
+ *
151
+ * The controller sends `set-expanded` / `set-edit-lock` / `set-interaction-lock`
152
+ * as real invokes and then reads them back out of the projection, so this Host
153
+ * is their source of truth: they are queue state, not renderer state. They live
154
+ * in memory only, which is honest — a lock is held by a screen that is open now.
155
+ */
156
+ const ui = new Map();
157
+
158
+ function uiFor(sessionId) {
159
+ let state = ui.get(sessionId);
160
+ if (state === undefined) {
161
+ state = { expanded: false, editLocks: new Set(), interactionLocks: new Set() };
162
+ ui.set(sessionId, state);
163
+ }
164
+ return state;
165
+ }
166
+
167
+ /**
168
+ * The queued item a **controller** is addressing.
169
+ *
170
+ * The controller knows a queued row by the `clientId` it minted (which DSH
171
+ * persists as the item's `rpcId`), while `updateQueue` is keyed on the durable
172
+ * `MessageId`. They are different values — passing the controller's id
173
+ * straight to the service looks up an item that does not exist, and matching
174
+ * only on `id` here made every removal and promotion silently miss.
175
+ */
176
+ function itemOf(sessionId, controllerId) {
177
+ const wanted = String(controllerId);
178
+ return (queues.get(sessionId) ?? []).find((item) => {
179
+ const id = item?.id === undefined || item?.id === null ? null : String(item.id);
180
+ const rpcId = typeof item?.rpcId === 'string' && item.rpcId !== '' ? item.rpcId : id;
181
+ return rpcId === wanted || id === wanted;
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Apply a committed queue mutation to the fold.
187
+ *
188
+ * DSH's own `queue` frame follows the commit, but the controller is answered
189
+ * from this fold **immediately**: answering with the pre-mutation queue would
190
+ * show a row the user just deleted, and the composer would put it back.
191
+ */
192
+ function mirror(sessionId, controllerId, action) {
193
+ const items = queues.get(sessionId);
194
+ if (items === undefined) return;
195
+ const target = itemOf(sessionId, controllerId);
196
+ if (target === undefined) return;
197
+ const id = String(target.id);
198
+ if (action?.kind === 'remove') {
199
+ const next = items.filter((item) => String(item?.id) !== id);
200
+ if (next.length > 0) queues.set(sessionId, next);
201
+ else queues.delete(sessionId);
202
+ return;
203
+ }
204
+ if (action?.kind === 'steer') {
205
+ queues.set(sessionId, items.map((item) => (String(item?.id) === id ? { ...item, placement: 'steering' } : item)));
206
+ return;
207
+ }
208
+ if (action?.kind === 'edit') {
209
+ queues.set(sessionId, items.map((item) => (String(item?.id) === id
210
+ ? { ...item, message: { ...item.message, content: action.content } }
211
+ : item)));
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Upsert one prompt into the fold with a placement, preserving everything else.
217
+ *
218
+ * `mirror` can only re-place an item the fold already holds, and a *fresh* prompt is in
219
+ * no frame we have seen yet: DSH splices it into the inbox a moment after `prompt()`
220
+ * returns, so an authoritative read taken right then can legitimately come back empty —
221
+ * measured, that is exactly how a promoted 插话 left the projection saying nothing about
222
+ * the message the user was looking at ("we still lost it": 08:54:29 enqueued, 08:54:40
223
+ * promoted, 08:55:22 durable, with `steeringQueueClientIds` empty the whole time).
224
+ * Anything the controller is showing therefore has to be insertable by id.
225
+ *
226
+ * @param sessionId - the session.
227
+ * @param item - `{ id, rpcId?, message? }` for the prompt.
228
+ * @param placement - `'queued'` or `'steering'`.
229
+ */
230
+ function markItem(sessionId, item, placement) {
231
+ if (typeof sessionId !== 'string' || sessionId === '') return;
232
+ const id = item?.id === undefined || item?.id === null ? null : String(item.id);
233
+ if (id === null) return;
234
+ const items = queues.get(sessionId) ?? [];
235
+ const others = items.filter((entry) => String(entry?.id) !== id);
236
+ queues.set(sessionId, [...others, { ...item, id, placement }]);
237
+ }
238
+
239
+ /**
240
+ * Record a prompt this Host has just accepted as a **steering** item.
241
+ *
242
+ * Without this the projection says nothing about the message the user just sent, so the
243
+ * controller retires its own bubble and nothing replaces it — reported as
244
+ * 「在我的手机端它直接转圈转圈然后就消失了」. The entry is stored under the controller's own
245
+ * id and is removed by `retireQueuedItem` the moment the durable row lands.
246
+ *
247
+ * @param sessionId - the session the prompt went to.
248
+ * @param item - `{ id, rpcId, message }` for the accepted prompt.
249
+ */
250
+ function markSteering(sessionId, item) {
251
+ markItem(sessionId, item, 'steering');
252
+ }
253
+
254
+ /** Record a prompt this Host has just accepted as **queued** (a running turn holds it). */
255
+ function markQueued(sessionId, item) {
256
+ markItem(sessionId, item, 'queued');
257
+ }
258
+
259
+ /** Reorder one queued item, as `maker:input:move` asks. */
260
+ function moveItem(sessionId, controllerId, targetIndex) {
261
+ const items = queues.get(sessionId);
262
+ if (items === undefined) return;
263
+ const target = itemOf(sessionId, controllerId);
264
+ if (target === undefined) return;
265
+ const id = String(target.id);
266
+ const from = items.findIndex((item) => String(item?.id) === id);
267
+ if (from === -1) return;
268
+ const next = [...items];
269
+ const [moved] = next.splice(from, 1);
270
+ const to = Math.max(0, Math.min(Number.isInteger(targetIndex) ? targetIndex : from, next.length));
271
+ next.splice(to, 0, moved);
272
+ queues.set(sessionId, next);
273
+ }
274
+
275
+ /** Rebuild one session's queue state. */
276
+ function setItems(sessionId, items) {
277
+ if (Array.isArray(items) && items.length > 0) queues.set(sessionId, items);
278
+ else queues.delete(sessionId);
279
+ }
280
+
281
+ /**
282
+ * Apply one `SessionControlFrame`.
283
+ *
284
+ * `baseline` replaces everything (it is the stream's starting point);
285
+ * `queue`/`jobs` replace one session's slice. `projection` frames carry model
286
+ * projections, which this Host does not serve, and are ignored.
287
+ */
288
+ function apply(frame) {
289
+ if (frame === null || typeof frame !== 'object') return;
290
+ if (frame.type === 'baseline') {
291
+ queues.clear();
292
+ jobs.clear();
293
+ for (const [sessionId, items] of Object.entries(frame.value?.queues ?? {})) {
294
+ if (Array.isArray(items)) queues.set(sessionId, items);
295
+ }
296
+ for (const [sessionId, entries] of Object.entries(frame.value?.jobs ?? {})) {
297
+ if (Array.isArray(entries)) jobs.set(sessionId, entries);
298
+ }
299
+ return;
300
+ }
301
+ if (frame.type === 'queue' && typeof frame.sessionId === 'string') {
302
+ setItems(frame.sessionId, frame.items);
303
+ return;
304
+ }
305
+ if (frame.type === 'jobs' && typeof frame.sessionId === 'string') {
306
+ if (Array.isArray(frame.jobs) && frame.jobs.length > 0) jobs.set(frame.sessionId, frame.jobs);
307
+ else jobs.delete(frame.sessionId);
308
+ }
309
+ }
310
+
311
+ /** The queued entries DSH is holding for one session. */
312
+ function queueFor(sessionId) {
313
+ return queues.get(sessionId) ?? [];
314
+ }
315
+
316
+ /** The background jobs DSH is running for one session. */
317
+ function jobsFor(sessionId) {
318
+ return jobs.get(sessionId) ?? [];
319
+ }
320
+
321
+ /**
322
+ * Build the controller's `InputProjection` for one session.
323
+ *
324
+ * Only the queue is projected. The error/recovery fields describe a turn that
325
+ * failed inside the controller's own agent; DSH reports those as session
326
+ * events instead, so claiming them here would be invention. They are emitted
327
+ * as the controller's own empty values.
328
+ * @param sessionId - the session to project.
329
+ * @param session - the owning session row, for `workingDir`.
330
+ * @param pending - a row to fold in as queued when DSH's own frame has not
331
+ * arrived yet (the just-accepted `enqueue`).
332
+ * @returns the projection the controller normalizes.
333
+ */
334
+ function projectionFor(sessionId, session, pending = null, items = null) {
335
+ // `items` lets a caller project a queue DSH actually reported, instead of the
336
+ // fold — the two disagree exactly when the fold never heard a frame.
337
+ const source = Array.isArray(items) ? items : queueFor(sessionId);
338
+ const pendingQueue = [];
339
+ const steeringQueueClientIds = [];
340
+ const seen = new Set();
341
+ for (const item of source) {
342
+ const row = toQueuedRemoteMessage(item, session);
343
+ if (row === null) continue;
344
+ seen.add(row.clientId);
345
+ // DSH next-step (steering) is still an inbox wait, not a consumed message.
346
+ // Cindy supports the same id in both fields: the body survives re-entry
347
+ // and its phase is 'sending', with queue actions disabled until delivery.
348
+ if (item?.placement === 'queued' || item?.placement === 'steering') pendingQueue.push(row);
349
+ if (item?.placement === 'steering') steeringQueueClientIds.push(row.clientId);
350
+ }
351
+ if (pending !== null && !seen.has(pending.clientId)) pendingQueue.push(pending);
352
+
353
+ const state = uiFor(sessionId);
354
+ return {
355
+ sessionId,
356
+ pendingQueue,
357
+ steeringQueueClientIds,
358
+ queuePaused: false,
359
+ queueExpanded: state.expanded,
360
+ queueInteractionLocks: [...state.interactionLocks],
361
+ queueEditLocks: [...state.editLocks],
362
+ queueAbortPending: false,
363
+ error: null,
364
+ errorReason: null,
365
+ toolLoop: null,
366
+ recovery: null,
367
+ errorRetryText: null,
368
+ credentialSwitchWait: null,
369
+ autoResumePending: null,
370
+ continuationTurnClientId: null,
371
+ };
372
+ }
373
+
374
+ function clear() {
375
+ queues.clear();
376
+ jobs.clear();
377
+ ui.clear();
378
+ }
379
+
380
+ return {
381
+ apply,
382
+ queueFor,
383
+ jobsFor,
384
+ projectionFor,
385
+ clear,
386
+ /** Whether DSH is holding this item; decides steer-promotion vs a new send. */
387
+ hasItem: (sessionId, itemId) => itemOf(sessionId, itemId) !== undefined,
388
+ /**
389
+ * The durable `MessageId` behind the controller's id.
390
+ *
391
+ * `updateQueue` is keyed on this, while the controller addresses a row by the
392
+ * `clientId` it minted — so every mutation has to be translated or DSH looks
393
+ * up an item that does not exist (and answers "queued item is no longer
394
+ * pending", which reads like a race rather than a wrong id).
395
+ */
396
+ dshItemId: (sessionId, controllerId) => {
397
+ const found = itemOf(sessionId, controllerId);
398
+ return found === undefined ? null : String(found.id);
399
+ },
400
+ /** Fold a queue mutation this Host already committed to DSH. */
401
+ mirror,
402
+ moveItem,
403
+ setExpanded: (sessionId, expanded) => { uiFor(sessionId).expanded = expanded === true; },
404
+ setEditLock: (sessionId, clientId, locked) => {
405
+ const locks = uiFor(sessionId).editLocks;
406
+ if (locked === true) locks.add(String(clientId));
407
+ else locks.delete(String(clientId));
408
+ },
409
+ setInteractionLock: (sessionId, lockId, locked) => {
410
+ const locks = uiFor(sessionId).interactionLocks;
411
+ if (locked === true) locks.add(String(lockId));
412
+ else locks.delete(String(lockId));
413
+ },
414
+ /** Drop every queued item for one session, as `maker:input:clear-session` asks. */
415
+ clearSession: (sessionId) => { setItems(sessionId, []); },
416
+ /**
417
+ * Replace one session's queue with items an authoritative read returned.
418
+ *
419
+ * This is how a durable id becomes known without waiting for a `queue` frame:
420
+ * the read already carries it, so a cancel or edit issued moments later
421
+ * resolves instead of reporting NOT_FOUND.
422
+ */
423
+ adopt: (sessionId, items) => { setItems(sessionId, items); },
424
+ /** Record one just-accepted steering prompt, preserving the rest of the queue. */
425
+ markSteering: (sessionId, item) => { markSteering(sessionId, item); },
426
+ /** Record one just-accepted queued prompt, preserving the rest of the queue. */
427
+ markQueued: (sessionId, item) => { markQueued(sessionId, item); },
428
+ /**
429
+ * The fold's own record for one item, by the controller's id or DSH's.
430
+ *
431
+ * Needed because the pending projection renders the message **content**: an upsert
432
+ * carrying only an id produced an empty bubble (「插入之后里面的文字被清空了」).
433
+ * Callers that re-place an item preserve what the fold
434
+ * already knew instead of restating it from memory.
435
+ */
436
+ itemFor: (sessionId, itemId) => queueFor(sessionId).find(
437
+ (item) => String(item?.id) === String(itemId) || String(item?.rpcId ?? '') === String(itemId),
438
+ ) ?? null,
439
+ /** Every queued item id, newest fold first — used to clear a queue honestly. */
440
+ itemIds: (sessionId) => (queues.get(sessionId) ?? []).map((item) => String(item?.id)),
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Read the pending queue out of DSH's `inbox` projection value.
446
+ *
447
+ * A faithful port of the controller's own `queueItemsFromInbox`
448
+ * (`dsh-api-session-controller/lib/index.js`), because this is the **only**
449
+ * reliable source: the control stream pushes a `queue` frame when the inbox
450
+ * changes, but that frame is not guaranteed to reach us, and a fold cannot tell
451
+ * "the queue is empty" from "I never heard". Answering the controller from a
452
+ * stale fold left a phantom 队列中 row — the message had been answered, and the
453
+ * row describing it never went away.
454
+ *
455
+ * The value is `{ 'next-turn': Message[], 'next-step': Message[] }`, where each
456
+ * message carries its own id and, for a browser-submitted prompt, an `rpcId` —
457
+ * the controller's own id for it.
458
+ *
459
+ * @param inbox - the projection value, or anything else.
460
+ * @returns the controller's queued rows, in DSH's own order.
461
+ */
462
+ export function queueItemsFromInbox(inbox) {
463
+ if (inbox === null || typeof inbox !== 'object') return [];
464
+ const items = [];
465
+ for (const message of Array.isArray(inbox['next-turn']) ? inbox['next-turn'] : []) {
466
+ items.push(queueItemOf(message, 'queued'));
467
+ }
468
+ for (const message of Array.isArray(inbox['next-step']) ? inbox['next-step'] : []) {
469
+ const placement = message?.source?.kind === 'user' ? 'steering' : 'context';
470
+ items.push(queueItemOf(message, placement));
471
+ }
472
+ return items.filter((item) => item !== null);
473
+ }
474
+
475
+ /** One queued row, or null when the message cannot be addressed. */
476
+ function queueItemOf(message, placement) {
477
+ const id = message?.id === undefined || message?.id === null ? null : String(message.id);
478
+ if (id === null) return null;
479
+ const source = message?.source;
480
+ const rpcId = source?.kind === 'user' && typeof source.rpcId === 'string' && source.rpcId !== ''
481
+ ? source.rpcId
482
+ : undefined;
483
+ return {
484
+ id,
485
+ placement,
486
+ ...(rpcId === undefined ? {} : { rpcId }),
487
+ message: { id, content: Array.isArray(message?.content) ? message.content : [] },
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Read the goal projection value out of an `observeSession` snapshot.
493
+ *
494
+ * Same reasoning as the queue: the projection stream is a convenience that may
495
+ * never speak, while this read is authoritative — a goal existed, the status
496
+ * channel answered `null` for twenty seconds straight.
497
+ * @param projections - a `ProjectionSnapshot`, or anything else.
498
+ * @returns the goal projection value, or undefined when the snapshot has none.
499
+ */
500
+ export function goalFromProjections(projections) {
501
+ const values = projections?.values;
502
+ if (values === null || typeof values !== 'object') return undefined;
503
+ if (!Object.prototype.hasOwnProperty.call(values, 'goal')) return undefined;
504
+ return values.goal ?? null;
505
+ }