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,1707 @@
1
+ /**
2
+ * The Cindy invoke channel router.
3
+ *
4
+ * This is the Host's half of the DeviceLink tunnel: the phone sends
5
+ * `invoke { channel, args }` and gets one `invoke-result` back. The channel set
6
+ * is a contract — `Cindy/packages/device-link/src/allowlist.ts` — and this Host
7
+ * implements a deliberate subset of it (file-browser, the agent information
8
+ * flow, approvals, todo, goal).
9
+ *
10
+ * Everything outside that subset answers `CHANNEL_NOT_ALLOWED`, which is exactly
11
+ * what a Cindy controlled device answers for a channel it does not serve. That
12
+ * is a feature of the protocol: the controller treats it as "this device has no
13
+ * such capability" and degrades, rather than waiting for a reply that never
14
+ * comes. A channel we silently accepted and then failed would be worse — the
15
+ * phone would show a broken feature instead of an absent one.
16
+ */
17
+ import { agentKindForModel, toCindyActiveSessions, toCindySessionList, toCindySessionListRow } from './cindy-session-row.js';
18
+ import { toAgentCapabilities, toCindyPermissionMode, toDshPermissionMode, toNewMakerDefaults, toPermissionOptions, toProviderList } from './host-models.js';
19
+ import { queueItemsFromInbox } from './host-input-queue.js';
20
+ import { toAgentSkills, toAgentCommands, toAtResources } from './host-palette.js';
21
+ import { toGoalStatusPayload } from './host-goals.js';
22
+ import { hostAttachmentsOf } from './host-attachments.js';
23
+
24
+ /** The phone's own unsubscribe channel, paired with subscribe. */
25
+ const SUBSCRIBE = 'device-link:subscribe';
26
+ const UNSUBSCRIBE = 'device-link:unsubscribe';
27
+
28
+ /** Topic carrying list-level changes; one of only two this Host serves. */
29
+ export const TOPIC_SESSIONS = 'sessions';
30
+ /** Topic carrying one session's live stream. */
31
+ export const TOPIC_SESSION_PREFIX = 'session:';
32
+
33
+ /**
34
+ * The controller's goal write commands.
35
+ *
36
+ * The status read is deliberately not here: it is answered from the projection
37
+ * stream and must never resume a session.
38
+ */
39
+ export const GOAL_WRITE_CHANNELS = new Set([
40
+ 'maker:goal:set',
41
+ 'maker:goal:update',
42
+ 'maker:goal:pause',
43
+ 'maker:goal:resume',
44
+ 'maker:goal:clear',
45
+ ]);
46
+
47
+ /**
48
+ * The controller's pending-queue commands.
49
+ *
50
+ * These are what the queue panel's buttons send — 插话 (promote), 取消 (remove),
51
+ * 编辑 (update), 停止 (stop). Without them the controller shows
52
+ * `CHANNEL_NOT_ALLOWED` and the user cannot clear a stuck queue, which blocks
53
+ * sending anything at all.
54
+ */
55
+ export const QUEUE_COMMAND_CHANNELS = new Set([
56
+ 'maker:input:update-text',
57
+ 'maker:input:update-content',
58
+ 'maker:input:remove',
59
+ 'maker:input:stop',
60
+ 'maker:input:clear-session',
61
+ 'maker:input:set-expanded',
62
+ 'maker:input:set-edit-lock',
63
+ 'maker:input:set-interaction-lock',
64
+ 'maker:input:resume',
65
+ ]);
66
+
67
+ /** The controller's current `InputProjection` for one session. */
68
+ function projectionNow(capabilities, sessionId) {
69
+ const project = capabilities.inputProjection;
70
+ return typeof project === 'function'
71
+ ? project(sessionId, null)
72
+ : { sessionId, pendingQueue: [], steeringQueueClientIds: [] };
73
+ }
74
+
75
+ /**
76
+ * Answer a queue command, and tell the session's other watchers.
77
+ *
78
+ * The reply settles the controller that asked. A second screen watching the same
79
+ * session has no reason to ask again, so without this push it keeps rendering a
80
+ * row the user just deleted — the same shape of staleness the queue fold already
81
+ * caused once.
82
+ * @param request - the invoke being answered.
83
+ * @param capabilities - the per-request capabilities.
84
+ * @param sessionId - the session whose queue changed.
85
+ * @returns the reply frame.
86
+ */
87
+ function answerQueueCommand(request, capabilities, sessionId) {
88
+ const push = capabilities?.pushInputProjection;
89
+ if (typeof push === 'function') push(sessionId);
90
+ return invokeResult(request, projectionNow(capabilities, sessionId));
91
+ }
92
+
93
+ /**
94
+ * Commit one queue mutation to DSH.
95
+ *
96
+ * `control.update` is **async** — it resolves the session's agent before asking DSH to change
97
+ * the inbox — so it reports a refusal by *rejecting*, not by throwing. A `try`/`catch` around
98
+ * a call that is not awaited therefore catches nothing and the rejection floats: measured, it
99
+ * reached DSH's fail-loud unhandled-rejection handler and ended the whole `dsh web` process
100
+ * (`fatal load failure: RemoteError: queued item is no longer pending`, thrown from
101
+ * `SessionCommandController.updateQueue`) while an acceptance probe edited a queue whose item
102
+ * DSH had already admitted. The same bug also reported every refused mutation as a success,
103
+ * because `null` (the "no failure" answer) was returned unconditionally.
104
+ *
105
+ * @returns null on success, or `{ code, message }` to report.
106
+ */
107
+ async function commitQueueAction(control, sessionId, itemId, action) {
108
+ try {
109
+ await control.update({ sessionId, itemId, action });
110
+ return null;
111
+ } catch (error) {
112
+ // DSH's own code travels: `session/steer-unavailable` and
113
+ // `session/queue-item-not-found` describe the refusal precisely, and a
114
+ // generic THREW would hide the difference between "there is no such item"
115
+ // and "there is no turn to steer into".
116
+ const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'THREW';
117
+ return { code, message: String(error?.message ?? error) };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Commit one queue mutation and keep the controller's view honest either way.
123
+ *
124
+ * On **failure** the projection is still pushed. The controller promotes the row
125
+ * optimistically before asking, so a refusal it only learns about from the error
126
+ * leaves it showing a queued item as if it had been taken — and, for a steer,
127
+ * spinning on a turn that never started. The push is what tells it otherwise.
128
+ * @returns null on success, or `{ code, message }` to report.
129
+ */
130
+ async function commitQueueActionAndReconcile(capabilities, sessionId, itemId, action) {
131
+ const failure = await commitQueueAction(capabilities.queueControl, sessionId, itemId, action);
132
+ if (failure !== null) {
133
+ const push = capabilities?.pushInputProjection;
134
+ if (typeof push === 'function') push(sessionId);
135
+ }
136
+ return failure;
137
+ }
138
+
139
+ /** How long to wait for DSH's `MessageId` before answering NOT_FOUND. */
140
+ const QUEUE_ITEM_ID_WAIT_MS = 1500;
141
+ const QUEUE_ITEM_ID_POLL_MS = 50;
142
+
143
+ /**
144
+ * The durable id behind the controller's item id, waiting briefly if needed.
145
+ *
146
+ * `maker:input:enqueue` is answered from a row this Host synthesises, because
147
+ * DSH's own `queue` frame trails the commit. For that moment the item is
148
+ * addressable by the controller — it can see the row and press 取消 on it — but
149
+ * its durable `MessageId` is not yet known, and `updateQueue` is keyed on that id.
150
+ * Answering NOT_FOUND straight away made 取消 and 编辑 fail on a message the user
151
+ * had just sent, which reads as "the button is broken" rather than "try again in
152
+ * a moment". The frame is only milliseconds behind, so this waits for it.
153
+ *
154
+ * The wait is bounded and every iteration re-reads the fold, so a genuinely
155
+ * absent item still fails fast enough to keep the button responsive.
156
+ *
157
+ * @param capabilities - the per-request capabilities.
158
+ * @param sessionId - the session owning the item.
159
+ * @param controllerId - the `clientId` the controller addressed it by.
160
+ * @param waitMs - the bound, injectable so tests do not sleep.
161
+ * @returns the DSH `MessageId`, or null if it never appeared.
162
+ */
163
+ export async function resolveDshItemId(capabilities, sessionId, controllerId, waitMs = QUEUE_ITEM_ID_WAIT_MS) {
164
+ const mirror = capabilities?.queueMirror;
165
+ const deadline = Date.now() + Math.max(0, waitMs);
166
+ for (;;) {
167
+ const folded = typeof mirror?.dshItemId === 'function' ? mirror.dshItemId(sessionId, controllerId) : null;
168
+ if (folded !== null && folded !== undefined) return folded;
169
+ // The fold learns items only from pushed `queue` frames, and those are not
170
+ // guaranteed to arrive — so a fold-only answer reported NOT_FOUND for an item
171
+ // the Host itself had just accepted. The session's inbox is the authority.
172
+ const read = capabilities?.readSessionState;
173
+ if (typeof read === 'function') {
174
+ let state = null;
175
+ try {
176
+ state = await read(sessionId);
177
+ } catch {
178
+ state = null;
179
+ }
180
+ if (state !== null && state !== undefined) {
181
+ const wanted = String(controllerId);
182
+ const found = queueItemsFromInbox(state.inbox)
183
+ .find((entry) => entry.rpcId === wanted || entry.id === wanted);
184
+ if (found !== undefined) return found.id;
185
+ }
186
+ }
187
+ if (Date.now() >= deadline) return null;
188
+ await new Promise((resolve) => { setTimeout(resolve, QUEUE_ITEM_ID_POLL_MS); });
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Split the topics a controller asked for into the ones this Host serves.
194
+ *
195
+ * `session:<id>` used to be dropped here, which silently denied every live
196
+ * per-session push: the subscription succeeded, the reply looked right, and no
197
+ * stream ever arrived.
198
+ * @param value - the raw `topics` argument.
199
+ * @returns the accepted topics.
200
+ */
201
+ export function acceptTopics(value) {
202
+ if (!Array.isArray(value)) return [];
203
+ return value.filter((topic) => typeof topic === 'string'
204
+ && (topic === TOPIC_SESSIONS || (topic.startsWith(TOPIC_SESSION_PREFIX) && topic.length > TOPIC_SESSION_PREFIX.length)));
205
+ }
206
+
207
+ /** Channels this round implements; everything else is refused by omission. */
208
+ export const SUPPORTED_CHANNELS = Object.freeze([
209
+ SUBSCRIBE,
210
+ UNSUBSCRIBE,
211
+ 'local-db:sessions:list',
212
+ 'local-db:sessions:get',
213
+ 'local-db:messages:list',
214
+ 'local-db:sessions:patch-meta',
215
+ 'maker:create-session',
216
+ 'maker:send',
217
+ 'maker:input:enqueue',
218
+ 'maker:input:steer',
219
+ 'maker:list-active',
220
+ 'maker:session-in-turn',
221
+ 'maker:git-safety:get',
222
+ 'maker:goal:get-status',
223
+ ...GOAL_WRITE_CHANNELS,
224
+ ...QUEUE_COMMAND_CHANNELS,
225
+ 'maker:input:move',
226
+ 'maker:set-model',
227
+ 'maker:set-effort',
228
+ 'fs:list-dir',
229
+ 'fs:stat-path',
230
+ 'text-file:read-preview',
231
+ 'maker:get-pending-interactions',
232
+ 'maker:regenerate-title',
233
+ 'maker:get-context-usage',
234
+ 'maker:resolve-interaction',
235
+ 'maker:input:get-projection',
236
+ 'maker:list-available-agents',
237
+ 'maker:provider:list',
238
+ 'maker:get-capabilities',
239
+ 'maker:get-new-maker-defaults',
240
+ 'maker:list-agent-skills',
241
+ 'maker:list-agent-commands',
242
+ 'maker:set-plan-mode',
243
+ 'file-browser:remote-op',
244
+ 'device-link:media:fetch',
245
+ 'device-link:file-peer',
246
+ // The work-grouped history window. Gated on the controller's side by the
247
+ // `history-view-v1` capability this Host advertises in `link-accept`
248
+ // (`host-authorization.js`), so serving them is only half of turning them on.
249
+ 'local-db:messages:view',
250
+ 'local-db:messages:work-details',
251
+ 'local-db:messages:view-intent',
252
+ 'maker:set-permission-mode',
253
+ 'maker:list-desktop-commands',
254
+ 'maker:scan-at-resources',
255
+ ]);
256
+
257
+ /** Three Cindy wire aliases for the same DSH harness and capabilities. */
258
+ export const DSH_AGENT_KINDS = Object.freeze(['claude-code', 'codex', 'pi']);
259
+
260
+ /** Legacy native protocol alias; display fallback for unknown routes is Claude. */
261
+ export const DSH_AGENT_KIND = 'pi';
262
+
263
+ /**
264
+ * What this Host can do for `pi`.
265
+ *
266
+ * The phone asks for capabilities once per agent kind whenever it opens a
267
+ * device, and a refused answer leaves it without the shape it validates
268
+ * against. The lists are empty because this Host exposes no model catalog yet —
269
+ * that is a missing feature, not a broken one, and it must not cost the session
270
+ * list.
271
+ */
272
+ export const DSH_AGENT_CAPABILITIES = Object.freeze({
273
+ availableModels: [],
274
+ effortLevels: [],
275
+ permissionModes: [],
276
+ hasFastMode: false,
277
+ planModeSupported: false,
278
+ supportsSessionAgentSwitch: false,
279
+ supportsModelWindowSwitchGuard: false,
280
+ });
281
+
282
+ /** A relay result frame. `dst` is the requesting device, as the relay filled it. */
283
+ export function invokeResult(request, result) {
284
+ return { v: 1, kind: 'invoke-result', id: request.id, dst: request.src, payload: { ok: true, result } };
285
+ }
286
+
287
+ /** A refusal the controller knows how to read. */
288
+ export function invokeError(request, code = 'CHANNEL_NOT_ALLOWED', message = 'Channel is not available on DSH Host') {
289
+ return { v: 1, kind: 'invoke-result', id: request.id, dst: request.src, payload: { ok: false, error: { code, message } } };
290
+ }
291
+
292
+ /**
293
+ * The refusal codes a seam may name for an ordinary refusal.
294
+ *
295
+ * A seam knows *why* something it was asked for cannot be done — a model that offers
296
+ * no such effort, a path outside the workspace — and the controller reads those codes
297
+ * to choose its fallback. Without this, every one of them arrived as `THREW`, which
298
+ * the controller (rightly) treats as the Host crashing: the acceptance run reported
299
+ * `maker:set-effort restores effort — THREW: provider "openai-codex" model
300
+ * "gpt-5.6-sol" does not support reasoning effort "default"` for a request the Host
301
+ * had *correctly* declined.
302
+ */
303
+ export const REFUSAL_CODES = new Set(['BAD_REQUEST', 'NOT_AVAILABLE', 'NOT_FOUND', 'FORBIDDEN', 'OVERSIZE', 'CHANNEL_NOT_ALLOWED', 'PAYLOAD_TOO_LARGE', 'INTERNAL']);
304
+
305
+ /**
306
+ * An error that carries the refusal the controller should read.
307
+ * @param code - one of {@link REFUSAL_CODES}.
308
+ * @param message - what the controller shows or logs.
309
+ * @returns the error to throw from a seam.
310
+ */
311
+ export function refusalError(code, message) {
312
+ const error = new Error(message);
313
+ error.refusalCode = REFUSAL_CODES.has(code) ? code : 'INTERNAL';
314
+ return error;
315
+ }
316
+
317
+ /**
318
+ * The code to answer an error with: the refusal it names, or `THREW` for a genuine
319
+ * failure, which is the difference the controller's error handling keys on.
320
+ * @param error - whatever a seam threw.
321
+ * @returns a refusal code.
322
+ */
323
+ export function refusalCodeOf(error) {
324
+ return typeof error?.refusalCode === 'string' ? error.refusalCode : 'THREW';
325
+ }
326
+
327
+ /**
328
+ * Pull the prompt text out of a send argument.
329
+ *
330
+ * The two send paths carry different shapes:
331
+ * - `maker:send` sends a bare string or `{ type: 'user', content }`, where
332
+ * `content` is the controller's persisted message — an object carrying
333
+ * `text`, or a plain string on older paths;
334
+ * - `maker:input:enqueue` / `:steer` send the controller's own
335
+ * `QueuedRemoteMessage`, whose `text` is top level.
336
+ *
337
+ * Anything else yields nothing, and the caller refuses rather than sending an
338
+ * empty prompt.
339
+ * @param message - the raw argument.
340
+ * @returns the text to send, or null when there is none.
341
+ */
342
+ export function extractSendText(message) {
343
+ if (typeof message === 'string') return message.trim() === '' ? null : message;
344
+ if (message === null || typeof message !== 'object') return null;
345
+ // The queued-message shape, which is the composer's normal path.
346
+ if (typeof message.text === 'string') return message.text.trim() === '' ? null : message.text;
347
+ const content = message.content;
348
+ if (typeof content === 'string') return content.trim() === '' ? null : content;
349
+ if (content !== null && typeof content === 'object' && typeof content.text === 'string') {
350
+ return content.text.trim() === '' ? null : content.text;
351
+ }
352
+ return null;
353
+ }
354
+
355
+ /**
356
+ * The attachments this Host can actually serve, out of the ones the controller sent.
357
+ *
358
+ * Both forms are serveable: an absolute path on this machine, and an upload transit
359
+ * reference (`cindy-oss-attach://…`, or the legacy `xdt-oss-attach://…` the phones
360
+ * build) whose bytes this Host fetches with its own account credential. A reference is
361
+ * fetched, never handed to a filesystem as if it were a path; anything else with a
362
+ * scheme is left behind, and the send paths log what they carried.
363
+ * @param message - the controller's send argument.
364
+ * @returns the serveable attachments.
365
+ */
366
+ export function extractSendAttachments(message) {
367
+ return hostAttachmentsOf(message);
368
+ }
369
+
370
+
371
+ /**
372
+ * Build the router.
373
+ * @param options - the DSH read capabilities, the per-connection subscriber set,
374
+ * and a reader for this Host's own relay identity.
375
+ * @returns a function turning one invoke request into one result frame.
376
+ */
377
+ export function createChannelRouter({
378
+ listSessions,
379
+ getSession,
380
+ listSessionStates,
381
+ readMessages,
382
+ createSession,
383
+ sendMessage,
384
+ resolveCapabilities,
385
+ subscribers,
386
+ onSubscribe,
387
+ onUnsubscribe,
388
+ now = () => new Date(),
389
+ getDevice,
390
+ }) {
391
+ /**
392
+ * Turn states for the two polled channels, without paying for a row fold.
393
+ *
394
+ * A router built without the cheap read (tests, and any caller that only has the full
395
+ * listing) falls back to it rather than failing: the difference is cost, not answer.
396
+ */
397
+ const listStates = typeof listSessionStates === 'function' ? listSessionStates : listSessions;
398
+ /**
399
+ * Resolve the Host's write/read capabilities *per request*.
400
+ *
401
+ * They arrive from another plugin and can land after this router is built, so
402
+ * capturing them at construction would freeze an empty set — which shows up as
403
+ * `NOT_AVAILABLE` on a Host that is in fact perfectly able to serve the call.
404
+ */
405
+ const capabilitiesNow = typeof resolveCapabilities === 'function'
406
+ ? resolveCapabilities
407
+ : () => ({ readMessages, createSession, sendMessage });
408
+
409
+ /**
410
+ * The model every session falls back to, as of the last capability read.
411
+ *
412
+ * The controller asks for capabilities when it opens a device and again when a
413
+ * provider revision changes, so this is warm by the time it lists sessions —
414
+ * without the list call itself paying for a catalog read.
415
+ */
416
+ let catalogDefaultModel = null;
417
+ let agentCatalog = null;
418
+
419
+ /** This Host's relay identity, as the phone's device filter needs it. */
420
+ function deviceIdentity() {
421
+ if (typeof getDevice !== 'function') return undefined;
422
+ try {
423
+ return getDevice();
424
+ } catch {
425
+ return undefined;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Read the session rows the phone renders.
431
+ *
432
+ * `local-db:sessions:list` is also the phone's device-responsiveness probe, so
433
+ * it has to stay a single cheap read — which is why the row is folded from the
434
+ * source's one listing rather than from a per-session fetch.
435
+ *
436
+ * **And it may not block on that read at all.** Measured on a live Host (2026-09-18,
437
+ * 19:08–19:15): eighteen `invoke:local-db:sessions:list` calls aborted with
438
+ * `TimeoutError` in seven minutes while a build saturated the machine — the abort is at
439
+ * the **invoke** level (list read + title folding + row building together overran the
440
+ * deadline), so the source's own stale-serve guard never saw it, and the phone read a
441
+ * failed list as "the device is offline". The list a controller renders is satisfied by a
442
+ * few seconds' staleness, so the rows are cached and the read happens behind the answer:
443
+ * a warm cache answers instantly, and a refresh that fails leaves the previous page
444
+ * standing rather than turning a slow machine into a disconnected phone.
445
+ */
446
+ let rowsCache = null;
447
+ let rowsRefresh = null;
448
+ let rowsGeneration = 0;
449
+ /** How long a served page stays fresh enough to answer with before a refresh is kicked. */
450
+ const SESSION_ROWS_TTL_MS = 5_000;
451
+
452
+ /**
453
+ * Void the cached page because this Host just changed a row.
454
+ *
455
+ * Called from every write path that changes what a list read must say — the reply a
456
+ * controller applies is folded from the same rows, so a stale page there is not a small
457
+ * lag but an instruction to revert the user's edit.
458
+ */
459
+ function invalidateSessionRows() {
460
+ rowsGeneration += 1;
461
+ rowsCache = null;
462
+ rowsRefresh = null; // A pre-write read cannot fulfill a post-write request.
463
+ }
464
+
465
+ /** Read + fold once, sharing one flight between concurrent callers. */
466
+ function refreshSessionRows() {
467
+ if (rowsRefresh !== null) return rowsRefresh;
468
+ const generation = rowsGeneration;
469
+ const attempt = (async () => {
470
+ const items = await listSessions();
471
+ const rows = toCindySessionList(items, { now, device: deviceIdentity(), defaultModel: catalogDefaultModel, catalog: agentCatalog });
472
+ if (generation === rowsGeneration) rowsCache = { rows, at: now() };
473
+ return rows;
474
+ })();
475
+ rowsRefresh = attempt;
476
+ const settle = () => { if (rowsRefresh === attempt) rowsRefresh = null; };
477
+ attempt.then(settle, settle);
478
+ return attempt;
479
+ }
480
+
481
+ async function sessionRows() {
482
+ const cached = rowsCache;
483
+ if (cached !== null) {
484
+ if (now() - cached.at > SESSION_ROWS_TTL_MS) {
485
+ // Stale-while-revalidate: the controller gets a real page now and a fresher one on
486
+ // its next poll. A failed refresh is deliberately silent — the page above is durable.
487
+ refreshSessionRows().catch(() => {});
488
+ }
489
+ return cached.rows;
490
+ }
491
+ // Cold start: the only case where the probe must wait for a read.
492
+ return refreshSessionRows();
493
+ }
494
+
495
+ /** Mirror Cindy's native sessions:list paging contract over the cached full projection. */
496
+ function sessionListPage(rows, args) {
497
+ const rawLimit = typeof args[0] === 'number' ? args[0] : Number(args[0]);
498
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0
499
+ ? Math.min(Math.max(Math.floor(rawLimit), 1), 1_000)
500
+ : 20;
501
+ const requestedStatus = args[1];
502
+ const matching = rows.filter((row) => requestedStatus === 'active' || requestedStatus === 'archived'
503
+ ? row.status === requestedStatus
504
+ : row.status !== 'deleted');
505
+ const recent = matching.slice(0, limit);
506
+ const options = args[2] !== null && typeof args[2] === 'object' ? args[2] : {};
507
+ if (options.includePinned !== true) return recent;
508
+ const seen = new Set(recent.map((row) => row.id));
509
+ return [...recent, ...matching.filter((row) => row.pinnedAt != null && !seen.has(row.id))];
510
+ }
511
+
512
+ async function sessionRow(sessionId) {
513
+ if (typeof getSession === 'function') {
514
+ const item = await getSession(sessionId);
515
+ if (item == null || String(item.id) !== sessionId) return undefined;
516
+ return toCindySessionListRow(item, { now, device: deviceIdentity(), defaultModel: catalogDefaultModel, catalog: agentCatalog });
517
+ }
518
+ let row = (await sessionRows()).find((candidate) => candidate.id === sessionId);
519
+ if (row === undefined) {
520
+ invalidateSessionRows();
521
+ row = (await sessionRows()).find((candidate) => candidate.id === sessionId);
522
+ }
523
+ return row;
524
+ }
525
+
526
+ return async function handleInvoke(request) {
527
+ // A reply is addressed by `id` + `src`; without both there is nothing the
528
+ // relay could route, so this answers nothing rather than fabricating a frame.
529
+ if (request === null || typeof request !== 'object') return null;
530
+ if (typeof request.id !== 'string' || typeof request.src !== 'string') return null;
531
+
532
+ const channel = request?.payload?.channel;
533
+ const args = Array.isArray(request?.payload?.args) ? request.payload.args : [];
534
+
535
+ if (channel === SUBSCRIBE) {
536
+ const topics = acceptTopics(args?.[0]?.topics);
537
+ // A controller that subscribes is a controller that wants pushes; the
538
+ // subscription set is per-connection and cleared with the socket.
539
+ if (request.src && typeof onSubscribe === 'function') onSubscribe(request.src, topics);
540
+ else if (request.src) subscribers.add(request.src);
541
+ // A controller that has just attached knows nothing about the turn state,
542
+ // and its spinner clears only on an explicit terminal event — a session
543
+ // merely absent from `maker:list-active` is deliberately not read as idle.
544
+ // So the state is announced rather than left to be inferred from silence:
545
+ // without this, a refused steer or a failed prompt left the phone spinning
546
+ // with nothing on the Host to correct it.
547
+ //
548
+ // The answer is read from the **source**, not from the runtime's row cache:
549
+ // that cache is refreshed by list reads, so a controller attaching
550
+ // mid-session can be looking at a `running: true` captured before the turn
551
+ // that ended it. Announce only what the source says now, and fall back to
552
+ // the cache only when the source cannot be read at all.
553
+ const subscribing = capabilitiesNow();
554
+ const sessionIds = topics.filter((topic) => topic.startsWith(TOPIC_SESSION_PREFIX))
555
+ .map((topic) => topic.slice(TOPIC_SESSION_PREFIX.length));
556
+ // The creation pipeline awaits the sessions-topic subscription BEFORE
557
+ // sending create. Reading the corpus for that acknowledgement made cold
558
+ // listing timeouts look like failed creates that never reached this Host.
559
+ if (sessionIds.length > 0 && typeof subscribing.isSessionRunning === 'function'
560
+ && typeof subscribing.pushTurnIdle === 'function'
561
+ && typeof subscribing.pushTurnRunning === 'function') {
562
+ let live = null;
563
+ if (typeof getSession !== 'function') {
564
+ try {
565
+ const rows = await listStates();
566
+ live = Array.isArray(rows) ? rows : null;
567
+ } catch {
568
+ // Legacy sources without exact reads keep the runtime fallback.
569
+ }
570
+ }
571
+ for (const sessionId of sessionIds) {
572
+ let row = live === null ? null : live.find((candidate) => String(candidate?.id) === sessionId) ?? null;
573
+ if (typeof getSession === 'function') {
574
+ try { row = await getSession(sessionId) ?? null; } catch { row = null; }
575
+ }
576
+ const running = row === null ? subscribing.isSessionRunning(sessionId) === true : row.running === true;
577
+ if (running) subscribing.pushTurnRunning(sessionId);
578
+ else subscribing.pushTurnIdle(sessionId);
579
+ // …and the two things a push that never arrived would have carried.
580
+ //
581
+ // Measured: an app returning from the background does an unsubscribe → resubscribe
582
+ // flicker (26 subscribes / 13 unsubscribes in one session), a send landed inside it, and
583
+ // the frames carrying "accepted" and "the view changed" were discarded by the relay with
584
+ // nobody to retry them — the bubble spun forever and the transcript stayed stale until
585
+ // the app was restarted. A subscription is the one moment the client is guaranteed to be
586
+ // listening, so it is where the Host repairs what it could not deliver: the authoritative
587
+ // input projection (which retires a spinning bubble) and a view invalidation (which makes
588
+ // it re-read). Both are idempotent and cost one frame each.
589
+ if (typeof subscribing.pushInputProjection === 'function') subscribing.pushInputProjection(sessionId);
590
+ if (typeof subscribing.invalidateHistoryView === 'function') subscribing.invalidateHistoryView(sessionId);
591
+ }
592
+ }
593
+ return invokeResult(request, { subscribed: topics });
594
+ }
595
+
596
+ if (channel === UNSUBSCRIBE) {
597
+ const topics = acceptTopics(args?.[0]?.topics);
598
+ if (request.src && typeof onUnsubscribe === 'function') onUnsubscribe(request.src, topics);
599
+ else if (request.src) subscribers.delete(request.src);
600
+ return invokeResult(request, { unsubscribed: topics });
601
+ }
602
+
603
+ if (channel === 'local-db:sessions:list') {
604
+ return invokeResult(request, sessionListPage(await sessionRows(), args));
605
+ }
606
+
607
+ if (channel === 'local-db:sessions:get') {
608
+ const sessionId = String(args[0] ?? '');
609
+ // A known blank session must be readable before its first enqueue. The
610
+ // discovery list intentionally hides drafts and cannot prove absence.
611
+ const row = await sessionRow(sessionId);
612
+ // Cindy answers an unknown id with an error the controller already handles.
613
+ if (row === undefined) return invokeError(request, 'NOT_FOUND', `No DSH session ${sessionId}`);
614
+ // The row's message total is what lets the controller light its "load
615
+ // earlier" entry point at all: `hasOlderMessagesByServerCount` answers
616
+ // false for an unknown total, deliberately, so that an entry point which
617
+ // leads nowhere is never shown. Answered only here, not in the list: the
618
+ // controller polls the list, and folding every transcript per poll would
619
+ // trade a paging bug for a latency one.
620
+ const count = capabilitiesNow().countMessages;
621
+ if (typeof count === 'function') {
622
+ try {
623
+ const total = await count(sessionId);
624
+ if (Number.isFinite(total)) row._count = { messages: total };
625
+ } catch {
626
+ // An unreadable transcript leaves `_count` as the honest null it was.
627
+ }
628
+ }
629
+ return invokeResult(request, row);
630
+ }
631
+
632
+ if (channel === 'local-db:messages:list') {
633
+ const sessionId = String(args[0] ?? '');
634
+ const read = capabilitiesNow().readMessages;
635
+ if (typeof read !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot read message history yet');
636
+ const options = args[1] !== null && typeof args[1] === 'object' ? args[1] : {};
637
+ const rows = await read(sessionId, options);
638
+ // A user row in history is delivery evidence to Cindy, even if it carries
639
+ // an unknown pendingDelivery field. Keep unconsumed prompts exclusively in
640
+ // InputProjection, or a list read falsely settles the phone's queue.
641
+ return invokeResult(request, rows);
642
+ }
643
+
644
+ if (channel === 'maker:list-active') {
645
+ // Its own shape: `[{ sessionId, isTurnRunning }]`. Answering with session
646
+ // rows makes the controller skip every entry, which surfaces only as a
647
+ // running badge that never lights up.
648
+ //
649
+ // An archived or deleted session is left out: the controller does not show its
650
+ // row, so an entry for it would light the device's running badge from a session
651
+ // the user cannot open.
652
+ //
653
+ // The **cheap** read on purpose. This is the most frequently polled channel on
654
+ // this Host and it needs one boolean per session, so it must not pay for titles:
655
+ // measured 8.4s through the full row fold on a 219-session profile, against a
656
+ // handset that gives up at 15s.
657
+ const rows = await listStates();
658
+ const hidden = capabilitiesNow().sessionHidden;
659
+ const visible = typeof hidden === 'function' ? rows.filter((row) => hidden(row.id) !== true) : rows;
660
+ return invokeResult(request, toCindyActiveSessions(visible));
661
+ }
662
+
663
+ if (channel === 'maker:session-in-turn') {
664
+ // The controller's **stall watchdog** — "controlling side uses this when a
665
+ // Generating state seems stuck and no push has arrived: verify the host is
666
+ // really still running; only `false` is safe to finish on, never kill a slow
667
+ // turn that is genuinely working" (`isSessionTurnRunningFor` in the desktop
668
+ // renderer). It answers a bare boolean.
669
+ //
670
+ // Read from the **source**, not from the runtime's cached row: a missed push is
671
+ // exactly the condition being probed, and the cache is fed by those same
672
+ // pushes. This is the read the subscribe path already trusts, and the cache is
673
+ // only the fallback for when the source cannot be read at all.
674
+ const sessionId = String(args[0] ?? '');
675
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:session-in-turn needs a session id');
676
+ let running = null;
677
+ try {
678
+ const rows = await listStates(sessionId);
679
+ const row = Array.isArray(rows) ? rows.find((candidate) => String(candidate?.id) === sessionId) ?? null : null;
680
+ running = row === null ? null : row.running === true;
681
+ } catch {
682
+ running = null;
683
+ }
684
+ if (running === null) running = capabilitiesNow().isSessionRunning?.(sessionId) === true;
685
+ return invokeResult(request, running === true);
686
+ }
687
+
688
+ // Three controller aliases share one actual DSH harness. The selected kind
689
+ // is presentation-only; model routing always follows the DSH selection.
690
+ if (channel === 'maker:list-available-agents') {
691
+ return invokeResult(request, [...DSH_AGENT_KINDS]);
692
+ }
693
+
694
+ if (channel === 'maker:get-new-maker-defaults') {
695
+ const requested = typeof args[0] === 'string' ? args[0] : null;
696
+ if (requested !== null && !DSH_AGENT_KINDS.includes(requested)) {
697
+ return invokeError(request, 'NOT_AVAILABLE', `DSH Host does not offer the ${requested} alias`);
698
+ }
699
+ const readCatalog = capabilitiesNow().modelCatalog;
700
+ if (typeof readCatalog !== 'function') return invokeResult(request, {});
701
+ try {
702
+ return invokeResult(request, toNewMakerDefaults(await readCatalog()));
703
+ } catch {
704
+ // Native Cindy also answers an empty object while its defaults cache is unavailable.
705
+ return invokeResult(request, {});
706
+ }
707
+ }
708
+
709
+ if (channel === 'maker:provider:list') {
710
+ const readCatalog = capabilitiesNow().modelCatalog;
711
+ if (typeof readCatalog !== 'function') return invokeResult(request, { providers: [], providerOrder: [] });
712
+ try {
713
+ const catalog = await readCatalog();
714
+ agentCatalog = catalog;
715
+ const defaultModel = catalog?.default?.model;
716
+ catalogDefaultModel = typeof defaultModel === 'string' && defaultModel !== '' ? defaultModel : null;
717
+ invalidateSessionRows();
718
+ return invokeResult(request, toProviderList(catalog, DSH_AGENT_KINDS));
719
+ } catch {
720
+ return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host could not read its model catalog');
721
+ }
722
+ }
723
+
724
+ if (channel === 'maker:get-capabilities') {
725
+ const requested = typeof args[0] === 'string' ? args[0] : null;
726
+ if (requested !== null && !DSH_AGENT_KINDS.includes(requested)) {
727
+ return invokeError(request, 'NOT_AVAILABLE', `DSH Host does not offer the ${requested} alias`);
728
+ }
729
+ // The catalog is a live read, not a constant: it is what the picker lists,
730
+ // and answering from a frozen copy would keep offering models that this
731
+ // Host can no longer route to.
732
+ const readCatalog = capabilitiesNow().modelCatalog;
733
+ if (typeof readCatalog !== 'function') return invokeResult(request, DSH_AGENT_CAPABILITIES);
734
+ let catalog;
735
+ try {
736
+ catalog = await readCatalog();
737
+ } catch (error) {
738
+ // A failed catalog read costs the picker, never the session list.
739
+ agentCatalog = null;
740
+ catalogDefaultModel = null;
741
+ invalidateSessionRows();
742
+ return invokeResult(request, DSH_AGENT_CAPABILITIES);
743
+ }
744
+ agentCatalog = catalog;
745
+ const defaultModel = catalog?.default?.model;
746
+ catalogDefaultModel = typeof defaultModel === 'string' && defaultModel !== '' ? defaultModel : null;
747
+ invalidateSessionRows();
748
+ const capabilities = toAgentCapabilities(catalog);
749
+ // These two fields are what make the controls appear at all: the controller
750
+ // draws the permission picker only when presets are advertised, and the
751
+ // plan-mode switch only when support is — and DSH documents a missing
752
+ // `permissions` projection as "clients hide the control".
753
+ const controls = capabilitiesNow().sessionControls;
754
+ if (typeof controls?.permissionNames === 'function') {
755
+ try {
756
+ capabilities.permissionModes = toPermissionOptions(controls.permissionNames());
757
+ } catch {
758
+ capabilities.permissionModes = [];
759
+ }
760
+ }
761
+ if (typeof controls?.planModeSupported === 'function') {
762
+ try {
763
+ const supported = await controls.planModeSupported();
764
+ capabilities.planMode = { supported: supported === true };
765
+ } catch {
766
+ capabilities.planMode = { supported: false };
767
+ }
768
+ }
769
+ return invokeResult(request, capabilities);
770
+ }
771
+
772
+ if (channel === 'maker:set-plan-mode') {
773
+ const controls = capabilitiesNow().sessionControls;
774
+ if (typeof controls?.setPlanMode !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no plan mode');
775
+ const sessionId = String(args[0] ?? '');
776
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:set-plan-mode needs a session id');
777
+ try {
778
+ const outcome = await controls.setPlanMode({ sessionId, enabled: args[1] === true });
779
+ // `queued` means the switch lands at the next step rather than now. The
780
+ // controller treats a resolved call as applied, so the outcome travels
781
+ // as the answer instead of being flattened into a bare success. A seam
782
+ // that can only report "the switch was accepted" says exactly that.
783
+ if (outcome !== null && typeof outcome === 'object') {
784
+ return invokeResult(request, {
785
+ outcome: typeof outcome.outcome === 'string' ? outcome.outcome : 'accepted',
786
+ ...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
787
+ });
788
+ }
789
+ return invokeResult(request, { outcome: typeof outcome === 'string' ? outcome : 'committed' });
790
+ } catch (error) {
791
+ return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
792
+ }
793
+ }
794
+
795
+ if (channel === 'maker:set-permission-mode') {
796
+ const controls = capabilitiesNow().sessionControls;
797
+ if (typeof controls?.setPermissionMode !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no permission presets');
798
+ const sessionId = String(args[0] ?? '');
799
+ const mode = typeof args[1] === 'string' ? toDshPermissionMode(args[1]) : null;
800
+ if (sessionId === '' || mode === null) return invokeError(request, 'BAD_REQUEST', 'maker:set-permission-mode needs a supported Cindy mode');
801
+ try {
802
+ await controls.setPermissionMode({ sessionId, mode });
803
+ // The preset writes the session's knobs, so the row the controller holds
804
+ // is now stale; a fresh read is pushed for the same reason a queue
805
+ // command pushes one.
806
+ const push = capabilitiesNow().pushInputProjection;
807
+ if (typeof push === 'function') push(sessionId);
808
+ return invokeResult(request, true);
809
+ } catch (error) {
810
+ return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
811
+ }
812
+ }
813
+
814
+ if (channel === 'maker:set-model') {
815
+ const select = capabilitiesNow().selectModel;
816
+ if (typeof select !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the model');
817
+ const sessionId = String(args[0] ?? '');
818
+ const model = typeof args[1] === 'string' ? args[1] : '';
819
+ if (sessionId === '' || model === '') return invokeError(request, 'BAD_REQUEST', 'maker:set-model needs a session and a model');
820
+ // The controller sends `[sessionId, model, providerId?]`, and a richer
821
+ // `[…, providerId, null, selection]` form when it has a full selection.
822
+ const selection = args[4] !== null && typeof args[4] === 'object' ? args[4] : null;
823
+ const provider = typeof args[2] === 'string' && args[2] !== ''
824
+ ? args[2]
825
+ : (typeof selection?.provider === 'string' && selection.provider !== '' ? selection.provider : null);
826
+ const effort = typeof selection?.reasoningEffort === 'string' && selection.reasoningEffort !== ''
827
+ ? selection.reasoningEffort
828
+ : (typeof args[3] === 'string' && args[3] !== '' ? args[3] : undefined);
829
+ // A provider is passed through when the controller has one, but it is not
830
+ // required: `MobileModelOption` carries no provider id, so the Host
831
+ // resolves it from its own catalog rather than refusing the choice.
832
+ try {
833
+ const selected = await select({
834
+ sessionId,
835
+ provider: provider ?? undefined,
836
+ model,
837
+ reasoningEffort: effort,
838
+ });
839
+ // The controller applies this to the row it is showing. A second screen
840
+ // watching the same session learns the change from its next list read:
841
+ // the row-level channel that would push it belongs to the `sessions`
842
+ // topic, which a controller viewing one session does not hold.
843
+ return invokeResult(request, {
844
+ selected: selected?.selected ?? { provider, model, ...(effort === undefined ? {} : { reasoningEffort: effort }) },
845
+ });
846
+ } catch (error) {
847
+ return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
848
+ }
849
+ }
850
+
851
+ // The composer's `/` and `@` palettes.
852
+ //
853
+ // Refusing these is what the user sees as "channel is not available on DSH
854
+ // Host" while typing — the palette is part of the agent's own information
855
+ // flow, not an extra, so it answers even when a source is missing: an empty
856
+ // palette reads as "nothing to offer", which is true, while a refusal reads
857
+ // as a broken composer.
858
+ if (channel === 'maker:set-effort') {
859
+ // Effort is advertised in the capabilities (`effortLevels`, read from the
860
+ // model catalog), so the controller draws the picker and will call this.
861
+ // A Host that lists effort levels but cannot apply one is the same class of
862
+ // lie as a model catalog that cannot route.
863
+ const setEffort = capabilitiesNow().setEffort;
864
+ if (typeof setEffort !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the reasoning effort');
865
+ const sessionId = String(args[0] ?? '');
866
+ const effort = typeof args[1] === 'string' ? args[1] : '';
867
+ if (sessionId === '' || effort === '') return invokeError(request, 'BAD_REQUEST', 'maker:set-effort needs a session and an effort');
868
+ try {
869
+ await setEffort({ sessionId, effort });
870
+ // The row the controller holds is now stale, exactly as after a model or
871
+ // permission change.
872
+ const push = capabilitiesNow().pushInputProjection;
873
+ if (typeof push === 'function') push(sessionId);
874
+ return invokeResult(request, true);
875
+ } catch (error) {
876
+ return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
877
+ }
878
+ }
879
+
880
+ if (channel === 'maker:list-agent-skills') {
881
+ const palette = capabilitiesNow().palette;
882
+ if (typeof palette?.skills !== 'function') return invokeResult(request, { success: true, skills: [] });
883
+ const cwd = typeof args[1]?.workingDir === 'string' && args[1].workingDir !== '' ? args[1].workingDir : undefined;
884
+ // The session is what names the viewing scope: a preset's skills are
885
+ // registered into its own layer, so without it the registry answers for
886
+ // the global layer alone and this menu is empty on a profile that has
887
+ // skills. The controller sends the same field for its command list.
888
+ const sessionId = typeof args[1]?.sessionId === 'string' ? args[1].sessionId : '';
889
+ let summaries = [];
890
+ try {
891
+ summaries = await palette.skills({ cwd, sessionId });
892
+ } catch {
893
+ summaries = [];
894
+ }
895
+ return invokeResult(request, toAgentSkills(summaries));
896
+ }
897
+
898
+ if (channel === 'maker:list-agent-commands') {
899
+ const palette = capabilitiesNow().palette;
900
+ if (typeof palette?.commands !== 'function') return invokeResult(request, { success: true, commands: [] });
901
+ const sessionId = typeof args[1]?.sessionId === 'string' ? args[1].sessionId : '';
902
+ let descriptors = [];
903
+ try {
904
+ descriptors = await palette.commands({ sessionId });
905
+ } catch {
906
+ descriptors = [];
907
+ }
908
+ return invokeResult(request, toAgentCommands(descriptors, 'agent-builtin'));
909
+ }
910
+
911
+ if (channel === 'maker:list-desktop-commands') {
912
+ // Commands only the controlling desktop can run. This Host owns none, and
913
+ // an empty success is the right answer here — unlike the provider catalog,
914
+ // nothing downstream branches on this channel being *unsupported*.
915
+ return invokeResult(request, { success: true, commands: [] });
916
+ }
917
+
918
+ if (channel === 'maker:scan-at-resources') {
919
+ const palette = capabilitiesNow().palette;
920
+ const workingDir = typeof args[1]?.workingDir === 'string' ? args[1].workingDir : '';
921
+ if (typeof palette?.resources !== 'function' || workingDir === '') {
922
+ return invokeResult(request, { success: true, items: [] });
923
+ }
924
+ let entries = [];
925
+ try {
926
+ entries = await palette.resources({
927
+ workingDir,
928
+ query: typeof args[1]?.query === 'string' ? args[1].query : '',
929
+ cap: Number.isFinite(args[1]?.cap) ? args[1].cap : undefined,
930
+ });
931
+ } catch {
932
+ entries = [];
933
+ }
934
+ return invokeResult(request, toAtResources(entries, {
935
+ workingDir,
936
+ cap: Number.isFinite(args[1]?.cap) ? args[1].cap : undefined,
937
+ query: typeof args[1]?.query === 'string' ? args[1].query : '',
938
+ }));
939
+ }
940
+
941
+ if (channel === 'maker:create-session') {
942
+ const create = capabilitiesNow().createSession;
943
+ if (typeof create !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot create sessions yet');
944
+ const options = args[0] !== null && typeof args[0] === 'object' ? args[0] : {};
945
+ // The runtime the controller picked for this conversation arrives **here and
946
+ // nowhere else**. A new chat on the handset has no session to call
947
+ // `maker:set-model` on yet — its whole pipeline is create, getSession, enqueue,
948
+ // with no model write in between (measured in
949
+ // `apps/mobile/src/session/newSessionCreation.ts`, which never calls `setModel`).
950
+ // A Host that reads only `id`/`workingDir` therefore leaves the new session with
951
+ // no session-local selection at all, and DSH's first prompt falls back to the
952
+ // profile's `agent-default-model` — reported as
953
+ // 「新对话选了 gpt,一运行又变成 deepseek」.
954
+ //
955
+ // The names are the controller's own: the handset
956
+ // (`buildRemoteCreateSessionOptions`) and the desktop (`buildDeviceLinkCreateArgs`)
957
+ // both send `model`, an optional `providerId` (absent/blank = follow this Host's
958
+ // default route) and `effort`.
959
+ //
960
+ // `permissionMode` is the same class of field and the same class of loss, with a
961
+ // worse failure: the handset's permission options **are** this Host's own
962
+ // advertised Cindy `permissionModes` (ask / acceptEdits / bypassPermissions),
963
+ // mapped exactly onto DSH read-only / workspace-write / danger-full-access presets.
964
+ // A user who picks ask and gets the profile default of danger-full-access believes the agent is restricted while it
965
+ // holds full access. Whatever reaches the seam here is whatever the controller
966
+ // chose; the seam decides whether this Host can actually install it.
967
+ const model = typeof options.model === 'string' ? options.model.trim() : '';
968
+ const provider = typeof options.providerId === 'string' ? options.providerId.trim() : '';
969
+ const effort = typeof options.effort === 'string' ? options.effort.trim() : '';
970
+ const permission = typeof options.permissionMode === 'string' ? toDshPermissionMode(options.permissionMode) : null;
971
+ try {
972
+ // The controller may pre-allocate the session id so its optimistic row and
973
+ // route use the final id from the start; DSH's create is idempotent on a
974
+ // supplied id, so passing it through is safe and avoids a rekey.
975
+ const created = await create({
976
+ sessionId: typeof options.id === 'string' && options.id !== '' ? options.id : undefined,
977
+ cwd: typeof options.workingDir === 'string' && options.workingDir !== '' ? options.workingDir : undefined,
978
+ ...(model === '' ? {} : { model }),
979
+ ...(provider === '' ? {} : { provider }),
980
+ ...(effort === '' ? {} : { reasoningEffort: effort }),
981
+ ...(permission === null ? {} : { permissionMode: permission }),
982
+ });
983
+ // What the session is actually on, never what was asked for: DSH resolves and
984
+ // normalizes the selection, so echoing the request would claim a route the
985
+ // session may not be running. Absent means the Host named no selection, which
986
+ // is the only honest answer when the controller named none either.
987
+ const applied = created?.selection ?? null;
988
+ // A new session must be in the next list read, not five seconds later.
989
+ invalidateSessionRows();
990
+ return invokeResult(request, {
991
+ sessionId: String(created?.sessionId ?? options.id ?? ''),
992
+ // The requested platform is ignored. Echo only the applied DSH route
993
+ // (or its known default), not the controller's draft kind.
994
+ agentKind: agentKindForModel({ model: applied?.model, provider: applied?.provider, catalog: agentCatalog }),
995
+ workDir: options.workingDir ?? null,
996
+ usedProjectContext: false,
997
+ ...(applied === null ? {} : {
998
+ model: applied.model,
999
+ providerId: applied.provider,
1000
+ ...(typeof applied.reasoningEffort === 'string' && applied.reasoningEffort !== ''
1001
+ ? { effort: applied.reasoningEffort }
1002
+ : {}),
1003
+ }),
1004
+ // Present only when the preset was really installed. A mode this Host does
1005
+ // not advertise keeps the profile's preset, and saying nothing about it is
1006
+ // the honest answer — the authoritative session row reports what is in force,
1007
+ // and the seam logs the request it could not honor.
1008
+ ...(typeof created?.permissionMode === 'string' && created.permissionMode !== ''
1009
+ ? { permissionMode: toCindyPermissionMode(created.permissionMode) }
1010
+ : {}),
1011
+ });
1012
+ } catch (error) {
1013
+ // A session that was created but whose model cannot be routed must not be
1014
+ // reported as a clean success: its row would then read deepseek while the user
1015
+ // is looking at the model they picked — the silent lie this path exists to
1016
+ // avoid. The refusal travels as a code the controller already understands, and
1017
+ // its idempotent retry adopts the existing session instead of creating another.
1018
+ return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
1019
+ }
1020
+ }
1021
+
1022
+ if (channel === 'local-db:sessions:patch-meta') {
1023
+ const sessionId = String(args[0] ?? '');
1024
+ const patch = args[1] !== null && typeof args[1] === 'object' ? args[1] : {};
1025
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'local-db:sessions:patch-meta needs a session id');
1026
+
1027
+ // The controller's whole 删除 / 归档 / 置顶 / 重命名 menu arrives here, and it
1028
+ // reads the **returned row** as the truth: `useSessionListActions` applies only
1029
+ // the fields it wrote, taken from this reply. Answering with an unchanged row is
1030
+ // therefore not a refusal — it is an instruction to revert the user's edit,
1031
+ // which is how these three actions used to look like dead buttons.
1032
+ //
1033
+ // Only the title has a DSH counterpart; the other three are this Host's own
1034
+ // bookkeeping (`session-flags.js`), so the reply is built from the source row
1035
+ // with the flags folded back on — the same fold every read path uses.
1036
+ const capabilities = capabilitiesNow();
1037
+ const rename = capabilities.renameSession;
1038
+ let wrote = false;
1039
+ if (typeof rename === 'function' && typeof patch.title === 'string' && patch.title.trim() !== '') {
1040
+ await rename({ sessionId, title: patch.title.trim() });
1041
+ wrote = true;
1042
+ }
1043
+
1044
+ const writesMeta = patch.status !== undefined || patch.pinnedAt !== undefined;
1045
+ if (writesMeta) {
1046
+ const applyFlags = capabilities.applySessionFlags;
1047
+ if (typeof applyFlags !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot archive, delete or pin sessions');
1048
+ const applied = await applyFlags(sessionId, { status: patch.status, pinnedAt: patch.pinnedAt });
1049
+ wrote = true;
1050
+ // Echo only what was written, with this Host's effective value: that is the
1051
+ // same patch the controller applied optimistically, so other devices converge
1052
+ // on it too.
1053
+ const echoed = {};
1054
+ if (patch.status !== undefined) echoed.status = applied.status;
1055
+ if (patch.pinnedAt !== undefined) echoed.pinnedAt = applied.pinnedAt;
1056
+ if (typeof capabilities.publishSessionMeta === 'function') capabilities.publishSessionMeta(sessionId, echoed);
1057
+ }
1058
+
1059
+ // The write just changed what a list read must say, so the cached page is void: without
1060
+ // this the reply below would echo the pre-write row, and the controller reads an
1061
+ // unchanged row as "revert my edit" (that is how these buttons looked dead once already).
1062
+ if (wrote) invalidateSessionRows();
1063
+ const row = await sessionRow(sessionId);
1064
+ return row === undefined ? invokeError(request, 'NOT_FOUND', `No DSH session ${sessionId}`) : invokeResult(request, row);
1065
+ }
1066
+
1067
+ // The composer's send paths. `maker:send` is the direct one; an idle turn
1068
+ // uses `maker:input:enqueue` and a running turn uses `maker:input:steer`,
1069
+ // and BOTH must answer or the composer spins in one of the two states.
1070
+ if (channel === 'maker:input:enqueue' || channel === 'maker:input:steer') {
1071
+ const capabilities = capabilitiesNow();
1072
+ const sessionId = String(args[0] ?? '');
1073
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
1074
+
1075
+ // A `steer` naming an item DSH already holds is a **promotion** of that
1076
+ // queued item, not a new message: the controller sends the queued row back
1077
+ // with `removeFromQueue`. Re-sending its text would duplicate it, which is
1078
+ // exactly what a queued item's 插话 button must not do.
1079
+ const namedId = typeof args[1]?.clientId === 'string' && args[1].clientId !== '' ? args[1].clientId : null;
1080
+ if (channel === 'maker:input:steer' && namedId !== null) {
1081
+ // Attach the session first: the inbox belongs to the live agent, so
1082
+ // without this the lookup reports the item missing on a cold session and
1083
+ // the steer degrades into a second send.
1084
+ if (typeof capabilities.ensureAgent === 'function') await capabilities.ensureAgent(sessionId);
1085
+ // One shot, no wait: steering a brand-new message is the common case and
1086
+ // must not pay for a queue lookup. Only an item the Host can already
1087
+ // address is treated as a promotion.
1088
+ const dshId = await resolveDshItemId(capabilities, sessionId, namedId, 0);
1089
+ if (dshId !== null) {
1090
+ const control = capabilities.queueControl;
1091
+ if (typeof control !== 'object' || control === null) {
1092
+ return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the pending queue');
1093
+ }
1094
+ const failure = await commitQueueActionAndReconcile(capabilities, sessionId, dshId, { kind: 'steer' });
1095
+ if (failure !== null) return invokeError(request, failure.code, failure.message);
1096
+ // `mirror` only re-places an item the fold already holds, and this one may not be
1097
+ // there: the authoritative read that follows an enqueue can race DSH's splice and
1098
+ // come back empty. That is exactly how a promoted 插话 left the projection saying
1099
+ // nothing — measured as a 42-second gap (08:54:40 promoted → 08:55:22 durable)
1100
+ // with the phone's bubble abandoned in between. The upsert makes the steering id
1101
+ // true from this moment; the durable row's `retireQueuedItem` clears it again.
1102
+ //
1103
+ // The record has to carry the message **content**, because that is what the
1104
+ // controller renders: the first version restated the id with `content: []`, and the
1105
+ // bubble appeared with no text in it (「插入之后里面的文字被清空了」) while the same
1106
+ // empty item produced an empty transcript row. The fold's own record is the
1107
+ // authority when it has one; otherwise the text the controller sent with the row.
1108
+ const mirror = capabilities.queueMirror;
1109
+ const known = typeof mirror?.itemFor === 'function' ? mirror.itemFor(sessionId, namedId) : null;
1110
+ const knownContent = Array.isArray(known?.message?.content) && known.message.content.length > 0
1111
+ ? known.message.content
1112
+ : null;
1113
+ const carried = extractSendText(args[1]);
1114
+ const content = knownContent
1115
+ ?? (typeof carried === 'string' && carried !== '' ? [{ type: 'text', text: carried }] : []);
1116
+ // Keyed on DSH's own id, not the controller's: the fold is keyed that way, and an
1117
+ // upsert under the `clientId` left the original queued row in the fold beside the new
1118
+ // steering one — a duplicate the projection then reported as both `queued` and
1119
+ // `steering`. The controller's id travels as `rpcId`, which is what the projection
1120
+ // names the row by.
1121
+ const itemId = known?.id !== undefined && known?.id !== null ? String(known.id) : String(dshId);
1122
+ if (typeof mirror?.markSteering === 'function') {
1123
+ mirror.markSteering(sessionId, {
1124
+ ...(known ?? {}),
1125
+ id: itemId,
1126
+ rpcId: namedId,
1127
+ message: { ...(known?.message ?? {}), id: itemId, content },
1128
+ });
1129
+ } else {
1130
+ mirror?.mirror(sessionId, namedId, { kind: 'steer' });
1131
+ }
1132
+ // The promotion moved the row from "queued" to "steering": tell the controller
1133
+ // that, or the row it is showing disappears in the hand-off.
1134
+ capabilities.pushInputProjection?.(sessionId);
1135
+ // The controller answers a steer with a boolean and then refetches.
1136
+ return invokeResult(request, true);
1137
+ }
1138
+ }
1139
+
1140
+ const text = extractSendText(args[1]);
1141
+ const attachments = extractSendAttachments(args[1]);
1142
+ // A prompt with neither text nor a serveable attachment is nothing to run, and
1143
+ // accepting it would show the user a delivered message DSH has no counterpart
1144
+ // for. **Attachment-only is legitimate** — the phone's composer sends a photo
1145
+ // with `text: ''` — so the refusal is about having nothing at all, not about
1146
+ // having no words.
1147
+ if (text === null && attachments.length === 0) {
1148
+ return invokeError(request, 'BAD_REQUEST', `${channel} carried no text and no attachment this Host can serve`);
1149
+ }
1150
+
1151
+ // A steer needs a **live turn**, and even then DSH can refuse it: "current turn no longer
1152
+ // accepts steering" is what the phone showed when the user's tap landed as the turn moved
1153
+ // past its steerable point. Checking the session state answers the first half (a turn that
1154
+ // never started), and it cannot answer the second: the gap between our check and DSH's
1155
+ // decision is a race, and **the user's words must not be the thing that loses it**.
1156
+ //
1157
+ // So a refused steer is retried once as a normal prompt: the same intent one step later,
1158
+ // which starts the turn it could not join. Measured before this: the insert produced an
1159
+ // error string on the handset and the message the user was looking at was lost from the
1160
+ // conversation until they sent it again.
1161
+ const wantsSteer = channel === 'maker:input:steer' && capabilities.isSessionRunning?.(sessionId) === true;
1162
+ if (typeof capabilities.sendMessage !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot send prompts yet');
1163
+ // The controller's own `clientId` is the prompt identity, and DSH persists
1164
+ // it as the queued item's `rpcId` — the field the controller retires its
1165
+ // local echo on, and the field DSH dedupes a retried prompt against.
1166
+ // Sending the relay's envelope id instead left the draft in the composer
1167
+ // (so the next thing the user typed was appended to it) and made a retry
1168
+ // indistinguishable from a new message.
1169
+ const send = (mode) => capabilities.sendMessage({
1170
+ sessionId,
1171
+ text: text ?? '',
1172
+ requestId: namedId ?? request.id,
1173
+ mode,
1174
+ // A message with no serveable attachment keeps the exact shape it always
1175
+ // had: an empty list is not information, and every seam test pins it.
1176
+ ...(attachments.length > 0 ? { attachments } : {}),
1177
+ });
1178
+ let steer = wantsSteer;
1179
+ let sent;
1180
+ try {
1181
+ sent = await send(wantsSteer ? 'steer' : 'queue');
1182
+ } catch (error) {
1183
+ const message = String(error?.message ?? error);
1184
+ if (!wantsSteer || !/steer/i.test(message)) throw error;
1185
+ // The turn would not take it. Say it as a prompt instead, and remember that this was not a
1186
+ // steering row so the projection does not claim one.
1187
+ steer = false;
1188
+ sent = await send('queue');
1189
+ }
1190
+ // An attachment-only prompt whose bytes could not be fetched is refused with
1191
+ // the reason: sending it would deliver a photo the agent never saw.
1192
+ if (sent !== undefined && sent !== null && sent.ok === false) {
1193
+ return invokeError(request, sent.code ?? 'ATTACHMENT_UNAVAILABLE', sent.message ?? 'the attachment could not be fetched');
1194
+ }
1195
+
1196
+ if (steer) {
1197
+ // A **new** steering prompt (not a promotion) has to be made visible before it is
1198
+ // durable.
1199
+ //
1200
+ // DSH splices it into the running turn's next step a moment after `prompt()`
1201
+ // returns, so a projection read taken right now may legitimately say nothing about
1202
+ // it. The controller's bubble would then have nothing to hold on to and vanish —
1203
+ // 手机上「转圈转圈然后就消失了」 while the message did reach the desktop. Recording
1204
+ // it as a steering item keeps `steeringQueueClientIds` truthful from this moment,
1205
+ // and `retireQueuedItem` clears it the instant the durable row arrives.
1206
+ const controllerId = namedId ?? request.id;
1207
+ if (typeof capabilities.queueMirror?.markSteering === 'function') {
1208
+ capabilities.queueMirror.markSteering(sessionId, {
1209
+ id: controllerId,
1210
+ rpcId: controllerId,
1211
+ message: { id: controllerId, content: text === null || text === '' ? [] : [{ type: 'text', text }] },
1212
+ });
1213
+ }
1214
+ capabilities.pushInputProjection?.(sessionId);
1215
+ return invokeResult(request, true);
1216
+ }
1217
+
1218
+ // Answer with the queue DSH actually holds.
1219
+ //
1220
+ // This Host used to synthesise a `pendingQueue` row for every accepted
1221
+ // prompt. When the session was idle DSH admitted the prompt immediately and
1222
+ // never put it in the inbox, so that row described a queue entry that did
1223
+ // not exist — and nothing would ever clear it, because no queue frame is
1224
+ // emitted for an item that was never queued. The phone showed 队列中 for a
1225
+ // message the agent had already answered.
1226
+ //
1227
+ // The authoritative read is the honest answer: a row appears only if DSH is
1228
+ // really holding one. A prompt that was admitted instead shows up as a
1229
+ // durable message, which is what the controller renders anyway.
1230
+ if (typeof capabilities.readSessionState === 'function' && typeof capabilities.projectionFromItems === 'function') {
1231
+ const state = await capabilities.readSessionState(sessionId);
1232
+ if (state !== null && state !== undefined) {
1233
+ const items = queueItemsFromInbox(state.inbox);
1234
+ // Teach the fold the durable ids, so a later cancel or edit resolves
1235
+ // without waiting for a queue frame that may never come.
1236
+ capabilities.queueMirror?.adopt?.(sessionId, items);
1237
+ const held = namedId !== null
1238
+ && items.some((entry) => entry.rpcId === namedId || entry.id === namedId);
1239
+ // A prompt accepted **while a turn is running** belongs in the queue,
1240
+ // and DSH's durable splice can land a moment after `prompt` returns —
1241
+ // so that one row is shown from the acceptance itself. A prompt
1242
+ // accepted by an idle session is admitted immediately, and showing it
1243
+ // as queued is what left the phone spinning on a 队列中 row for a
1244
+ // message the agent had already answered.
1245
+ const pending = !held && namedId !== null && capabilities.isSessionRunning?.(sessionId) === true
1246
+ ? (capabilities.queuedRow?.({ clientId: namedId, text, sessionId }) ?? null)
1247
+ : null;
1248
+ // A synthesised row has to exist in the fold too, not just in this answer: the
1249
+ // controller will keep showing it, and the next thing that happens to it (插话 →
1250
+ // promotion, 编辑, 删除) resolves against the fold by that id.
1251
+ if (pending !== null && typeof capabilities.queueMirror?.markQueued === 'function') {
1252
+ capabilities.queueMirror.markQueued(sessionId, {
1253
+ id: namedId,
1254
+ rpcId: namedId,
1255
+ message: { id: namedId, content: text === null || text === '' ? [] : [{ type: 'text', text }] },
1256
+ });
1257
+ }
1258
+ return invokeResult(request, capabilities.projectionFromItems(sessionId, items, pending));
1259
+ }
1260
+ }
1261
+
1262
+ const project = capabilities.inputProjection;
1263
+ const pending = namedId === null ? null : capabilities.queuedRow?.({ clientId: namedId, text, sessionId });
1264
+ const projection = typeof project === 'function'
1265
+ ? project(sessionId, pending ?? null)
1266
+ : { sessionId, pendingQueue: pending === null ? [] : [pending], steeringQueueClientIds: [] };
1267
+ return invokeResult(request, projection);
1268
+ }
1269
+
1270
+ // Queue commands. The queue lives in DSH's durable inbox, so every mutation
1271
+ // goes to `sessionController.updateQueue` (or `cancel`) and is mirrored into
1272
+ // this Host's fold: DSH's own queue frame trails the commit, and answering a
1273
+ // removal with the pre-removal queue would put the row straight back.
1274
+ if (QUEUE_COMMAND_CHANNELS.has(channel)) {
1275
+ const capabilities = capabilitiesNow();
1276
+ const sessionId = String(args[0]?.sessionId ?? args[0] ?? '');
1277
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
1278
+ const mirror = capabilities.queueMirror;
1279
+ const control = capabilities.queueControl;
1280
+ const clientId = typeof args[1] === 'string' ? args[1] : (typeof args[1]?.clientId === 'string' ? args[1].clientId : null);
1281
+
1282
+ // Controller-owned queue UI state. The controller sets it here and reads it
1283
+ // back out of the projection, so it must be recorded rather than ignored.
1284
+ if (channel === 'maker:input:set-expanded') {
1285
+ mirror?.setExpanded(sessionId, args[1] === true);
1286
+ return answerQueueCommand(request, capabilities, sessionId);
1287
+ }
1288
+ if (channel === 'maker:input:set-edit-lock') {
1289
+ mirror?.setEditLock(sessionId, clientId, args[2] === true);
1290
+ return answerQueueCommand(request, capabilities, sessionId);
1291
+ }
1292
+ if (channel === 'maker:input:set-interaction-lock') {
1293
+ mirror?.setInteractionLock(sessionId, clientId, args[2] === true);
1294
+ return answerQueueCommand(request, capabilities, sessionId);
1295
+ }
1296
+ // `resume` reopens a paused queue. DSH's inbox is never paused — this Host
1297
+ // reports `queuePaused: false` always — so there is nothing to resume and
1298
+ // the current projection is the whole truth.
1299
+ if (channel === 'maker:input:resume') {
1300
+ return answerQueueCommand(request, capabilities, sessionId);
1301
+ }
1302
+
1303
+ if (typeof control !== 'object' || control === null) {
1304
+ return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the pending queue');
1305
+ }
1306
+
1307
+ if (channel === 'maker:input:stop') {
1308
+ // DSH cancels the active turn without dropping the pending inbox, which
1309
+ // is exactly what the controller asks for: it only ever sends
1310
+ // `keepQueue: true` (see `stopOptionsForProjection`).
1311
+ try {
1312
+ control.cancel({ sessionId });
1313
+ } catch (error) {
1314
+ return invokeError(request, 'THREW', String(error?.message ?? error));
1315
+ }
1316
+ return answerQueueCommand(request, capabilities, sessionId);
1317
+ }
1318
+
1319
+ if (channel === 'maker:input:clear-session') {
1320
+ // Emptying the queue means removing DSH's items one by one: the inbox is
1321
+ // the only owner, and `updateQueue` is the only supported mutation.
1322
+ //
1323
+ // An item DSH has already admitted is **not a failure here**, and two measured
1324
+ // behaviours depended on saying so. `updateQueue` answers such an item with
1325
+ // `session/queue-item-not-found` ("queued item is no longer pending"), which is the
1326
+ // outcome this channel exists to produce — the queue is what it should be. Returning
1327
+ // it as an error also aborted the loop, so everything after the first already-admitted
1328
+ // item stayed queued while the controller was told the clear had failed. Both showed
1329
+ // up as the acceptance suite's only two failures once refusals stopped being swallowed.
1330
+ // A genuine refusal is still reported, after the rest has been attempted.
1331
+ const ids = mirror?.itemIds(sessionId) ?? [];
1332
+ let failure = null;
1333
+ for (const itemId of ids) {
1334
+ const outcome = await commitQueueActionAndReconcile(capabilities, sessionId, itemId, { kind: 'remove' });
1335
+ if (outcome === null) continue;
1336
+ if (outcome.code === 'session/queue-item-not-found') continue;
1337
+ if (failure === null) failure = outcome;
1338
+ }
1339
+ // Only claim the queue is empty when it is: a real failure leaves the fold alone, so
1340
+ // the rows DSH still holds stay visible instead of vanishing on a false promise.
1341
+ if (failure === null) mirror?.clearSession(sessionId);
1342
+ if (failure !== null) return invokeError(request, failure.code, failure.message);
1343
+ return answerQueueCommand(request, capabilities, sessionId);
1344
+ }
1345
+
1346
+ let action;
1347
+ if (channel === 'maker:input:remove') {
1348
+ action = { kind: 'remove' };
1349
+ } else if (channel === 'maker:input:update-text' || channel === 'maker:input:update-content') {
1350
+ // `update-text` passes the new text directly; `update-content` passes a
1351
+ // whole queued row (text plus attachments). Only text is durable here.
1352
+ const text = channel === 'maker:input:update-text' ? args[2] : extractSendText(args[2]);
1353
+ if (typeof text !== 'string' || text.trim() === '') {
1354
+ return invokeError(request, 'BAD_REQUEST', `${channel} carried no text`);
1355
+ }
1356
+ action = { kind: 'edit', content: [{ type: 'text', text }] };
1357
+ } else {
1358
+ return invokeError(request, 'BAD_REQUEST', `${channel} is not a queue command`);
1359
+ }
1360
+
1361
+ if (clientId === null || clientId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs an item id`);
1362
+ // `updateQueue` is keyed on DSH's durable `MessageId`, not the `clientId`
1363
+ // the controller minted, so the id is translated — and the session is
1364
+ // attached first, because the inbox only exists on a live agent: without
1365
+ // that, a queued item on a cold session is invisible and the mutation is
1366
+ // refused as if the item had vanished.
1367
+ if (typeof capabilities.ensureAgent === 'function') await capabilities.ensureAgent(sessionId);
1368
+ const dshId = await resolveDshItemId(capabilities, sessionId, clientId);
1369
+ if (dshId === null) return invokeError(request, 'NOT_FOUND', `No queued item ${clientId}`);
1370
+ const failure = await commitQueueActionAndReconcile(capabilities, sessionId, dshId, action);
1371
+ if (failure !== null) {
1372
+ // A phone can delete a row just after the desktop admitted or removed it.
1373
+ // DSH then says the item is no longer pending; for *remove* only, that is
1374
+ // already the requested outcome. Verify against the durable inbox before
1375
+ // retiring the stale phone row, rather than turning it into a sync error.
1376
+ // Edit and steer must still report the refusal: they did not take effect.
1377
+ if (channel === 'maker:input:remove' && failure.code === 'session/queue-item-not-found'
1378
+ && typeof capabilities.readSessionState === 'function' && typeof mirror?.adopt === 'function') {
1379
+ let state = null;
1380
+ try { state = await capabilities.readSessionState(sessionId); } catch { /* Keep the original refusal. */ }
1381
+ if (state?.inbox !== null && typeof state?.inbox === 'object') {
1382
+ const items = queueItemsFromInbox(state.inbox);
1383
+ if (!items.some((item) => item.id === String(dshId) || item.rpcId === clientId)) {
1384
+ mirror.adopt(sessionId, items);
1385
+ return answerQueueCommand(request, capabilities, sessionId);
1386
+ }
1387
+ }
1388
+ }
1389
+ return invokeError(request, failure.code, failure.message);
1390
+ }
1391
+ mirror?.mirror(sessionId, clientId, action);
1392
+ return answerQueueCommand(request, capabilities, sessionId);
1393
+ }
1394
+
1395
+ if (channel === 'maker:input:move') {
1396
+ const capabilities = capabilitiesNow();
1397
+ const sessionId = String(args[0] ?? '');
1398
+ const clientId = typeof args[1] === 'string' ? args[1] : null;
1399
+ if (sessionId === '' || clientId === null) return invokeError(request, 'BAD_REQUEST', 'maker:input:move needs a session and an item id');
1400
+ // Reordering is presentation: DSH's inbox keeps its own order and a
1401
+ // reordered row would snap back on the next frame, so this Host records
1402
+ // the new order locally and says nothing it cannot keep.
1403
+ capabilities.queueMirror?.moveItem(sessionId, clientId, args[2]);
1404
+ return answerQueueCommand(request, capabilities, sessionId);
1405
+ }
1406
+
1407
+ if (channel === 'maker:send') {
1408
+ const send = capabilitiesNow().sendMessage;
1409
+ if (typeof send !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot send prompts yet');
1410
+ const sessionId = String(args[0] ?? '');
1411
+ const text = extractSendText(args[1]);
1412
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:send needs a session id');
1413
+ const sendAttachments = extractSendAttachments(args[1]);
1414
+ // Refusing beats sending an empty prompt: the controller would show the
1415
+ // message as sent while DSH had nothing to run. A photo with no caption is
1416
+ // still something to run, so only "nothing at all" is refused.
1417
+ if (text === null && sendAttachments.length === 0) {
1418
+ return invokeError(request, 'BAD_REQUEST', 'maker:send carried no text and no attachment this Host can serve');
1419
+ }
1420
+ // The controller's own `clientId` is the prompt identity on this path too.
1421
+ // DSH persists it as the message's `rpcId`, which is what the controller
1422
+ // retires its local submission echo on — send the relay's envelope id and
1423
+ // the echo never clears, so the message sits "sending" and then vanishes.
1424
+ const clientId = typeof args[1]?.clientId === 'string' && args[1].clientId !== ''
1425
+ ? args[1].clientId
1426
+ : (typeof args[2]?.clientId === 'string' && args[2].clientId !== '' ? args[2].clientId : null);
1427
+ const sent = await send({
1428
+ sessionId,
1429
+ text: text ?? '',
1430
+ requestId: clientId ?? request.id,
1431
+ ...(sendAttachments.length > 0 ? { attachments: sendAttachments } : {}),
1432
+ });
1433
+ if (sent !== undefined && sent !== null && sent.ok === false) {
1434
+ return invokeError(request, sent.code ?? 'ATTACHMENT_UNAVAILABLE', sent.message ?? 'the attachment could not be fetched');
1435
+ }
1436
+ return invokeResult(request, { accepted: true });
1437
+ }
1438
+
1439
+ // Approvals. The questions are DSH's own pending `approval/request`s, routed
1440
+ // here only while a controller is watching the asking session.
1441
+ if (channel === 'maker:get-context-usage') {
1442
+ // How full the session's context is. The controller reads the answer
1443
+ // leniently (`totalTokens`/`contextTokens`, `maxTokens`/`contextWindow`,
1444
+ // `percent`) and renders "暂无上下文数据" for anything unusable — so an unknown
1445
+ // measurement is reported as `null` rather than as a zero, which would read
1446
+ // as "empty context".
1447
+ const usage = capabilitiesNow().contextUsage;
1448
+ if (typeof usage !== 'function') return invokeResult(request, null);
1449
+ const sessionId = String(args[0] ?? '');
1450
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:get-context-usage needs a session id');
1451
+ try {
1452
+ return invokeResult(request, (await usage({ sessionId })) ?? null);
1453
+ } catch {
1454
+ // A measurement that fails costs the panel, never the session.
1455
+ return invokeResult(request, null);
1456
+ }
1457
+ }
1458
+
1459
+ if (channel === 'maker:regenerate-title') {
1460
+ // The controller sends `[{ sessionId }]` here — an object, unlike the bare
1461
+ // session-id string every other channel uses — and it reads the answer as
1462
+ // `{ title: string | null }`: this call only produces a title, and the
1463
+ // controller persists it through `local-db:sessions:patch-meta`. So "no title"
1464
+ // is a value, not an error frame.
1465
+ const sessionId = typeof args[0]?.sessionId === 'string' ? args[0].sessionId : '';
1466
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:regenerate-title needs a session id');
1467
+ const regenerate = capabilitiesNow().regenerateTitle;
1468
+ if (typeof regenerate !== 'function') return invokeResult(request, { title: null });
1469
+ try {
1470
+ return invokeResult(request, (await regenerate({ sessionId })) ?? { title: null });
1471
+ } catch {
1472
+ // A failed generation is the controller's documented "no title" outcome.
1473
+ return invokeResult(request, { title: null });
1474
+ }
1475
+ }
1476
+
1477
+ if (channel === 'maker:get-pending-interactions') {
1478
+ const list = capabilitiesNow().listPendingInteractions;
1479
+ if (typeof list !== 'function') return invokeResult(request, []);
1480
+ const sessionId = typeof args[0] === 'string' && args[0] !== '' ? args[0] : undefined;
1481
+ return invokeResult(request, list(sessionId));
1482
+ }
1483
+
1484
+ if (channel === 'maker:resolve-interaction') {
1485
+ const resolve = capabilitiesNow().resolveInteraction;
1486
+ if (typeof resolve !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot answer interactions');
1487
+ const requestId = typeof args[0] === 'string' ? args[0] : '';
1488
+ const decision = args[1] !== null && typeof args[1] === 'object' ? args[1] : {};
1489
+ if (requestId === '') return invokeError(request, 'BAD_REQUEST', 'maker:resolve-interaction needs a request id');
1490
+ // An unknown id or a decision this Host cannot map is refused rather than
1491
+ // acknowledged: the controller keeps the card open instead of believing it
1492
+ // was answered.
1493
+ return invokeResult(request, resolve(requestId, decision));
1494
+ }
1495
+
1496
+ if (channel === 'maker:goal:get-status') {
1497
+ // Answered from the session projection stream, so a status read never
1498
+ // resumes a cold session to look at it.
1499
+ const sessionId = String(args[0] ?? '');
1500
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:goal:get-status needs a session id');
1501
+ const capabilities = capabilitiesNow();
1502
+ // Read the projection itself before trusting the stream: the pushed frames
1503
+ // are not guaranteed to arrive, and a goal that exists while every status
1504
+ // read answers `null` is a card that never appears.
1505
+ if (typeof capabilities.readSessionState === 'function') {
1506
+ const state = await capabilities.readSessionState(sessionId);
1507
+ // A registered key answers authoritatively, `null` value included.
1508
+ if (state !== null && state !== undefined && state.hasGoalKey === true) {
1509
+ return invokeResult(request, toGoalStatusPayload(sessionId, state.goal ?? null) ?? null);
1510
+ }
1511
+ }
1512
+ const goalStatus = capabilities.goalStatus;
1513
+ const folded = typeof goalStatus === 'function' ? goalStatus(sessionId) : undefined;
1514
+ if (folded !== undefined) return invokeResult(request, folded ?? null);
1515
+ // Nothing could answer, and the controller reads that difference exactly:
1516
+ // `undefined` (a payload with no `result` field) means "unknown, leave the
1517
+ // card alone", while an explicit `null` means "confirmed no goal" and wipes
1518
+ // it. Answering `null` here is how a live goal disappeared from the phone:
1519
+ // the projection read times out or the key is not registered, and because a
1520
+ // goal that has not changed never pushes again, nothing brought it back.
1521
+ return invokeResult(request, undefined);
1522
+ }
1523
+
1524
+ // Goal writes. Unlike the status read above these cannot be answered from
1525
+ // the projection: DSH applies them as compare-and-set mutations on a live
1526
+ // `Agent`, so each one resolves the session. That is acceptable for an
1527
+ // action the user just took and is why the read path stays separate.
1528
+ if (GOAL_WRITE_CHANNELS.has(channel)) {
1529
+ const capabilities = capabilitiesNow();
1530
+ const write = capabilities.goalWrite;
1531
+ if (typeof write !== 'object' || write === null) {
1532
+ return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no goal service');
1533
+ }
1534
+ const sessionId = String(args[0]?.sessionId ?? args[0] ?? '');
1535
+ if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
1536
+
1537
+ let outcome;
1538
+ if (channel === 'maker:goal:set') {
1539
+ outcome = await write.set({ sessionId, objective: args[0]?.objective, limits: args[0]?.limits });
1540
+ } else if (channel === 'maker:goal:update') {
1541
+ outcome = await write.update({ sessionId, patch: args[0]?.patch });
1542
+ } else {
1543
+ // `pause` / `resume` / `clear` take the bare session id.
1544
+ outcome = await write[channel.slice('maker:goal:'.length)](sessionId);
1545
+ }
1546
+
1547
+ if (outcome?.ok !== true) {
1548
+ // The service's own code is passed through: `GOAL_ALREADY_EXISTS` and
1549
+ // `GOAL_INVALID_TRANSITION` say exactly what went wrong, and the
1550
+ // controller shows the message it is given.
1551
+ return invokeError(request, outcome?.code ?? 'THREW', outcome?.message ?? 'the goal write failed');
1552
+ }
1553
+ // The controller refetches status after a write, but a second screen
1554
+ // watching this session has no reason to — this is its signal.
1555
+ const push = capabilities.pushGoalStatus;
1556
+ if (typeof push === 'function') push(sessionId, outcome.status ?? null);
1557
+ return invokeResult(request, outcome.status ?? null);
1558
+ }
1559
+
1560
+ if (channel === 'maker:git-safety:get') {
1561
+ // Git auto-snapshot is a Cindy desktop feature that guards its Rewind
1562
+ // entry. This Host has no such feature, so the honest answer is "off" —
1563
+ // refusing instead would look like a broken setting rather than an absent
1564
+ // one, and the controller reads these three booleans positionally.
1565
+ return invokeResult(request, { autoSnapshotEnabled: false, isCustomized: false, defaultAutoSnapshotEnabled: false });
1566
+ }
1567
+
1568
+ if (channel === 'maker:input:get-projection') {
1569
+ const capabilities = capabilitiesNow();
1570
+ const sessionId = String(args[0] ?? '');
1571
+ // Read what DSH actually holds before falling back to the fold: a fold that
1572
+ // never heard a `queue` frame reports an empty queue, which is how the
1573
+ // controller came to keep a 队列中 row for a message the agent had answered.
1574
+ if (sessionId !== '' && typeof capabilities.readSessionState === 'function' && typeof capabilities.projectionFromItems === 'function') {
1575
+ const state = await capabilities.readSessionState(sessionId);
1576
+ if (state !== null && state !== undefined) {
1577
+ return invokeResult(request, capabilities.projectionFromItems(sessionId, queueItemsFromInbox(state.inbox)));
1578
+ }
1579
+ }
1580
+ const project = capabilities.inputProjection;
1581
+ // The controller requests this on every session open, so it must answer
1582
+ // even when the queue is empty: an empty projection is the honest answer,
1583
+ // and refusing it would look like a broken composer.
1584
+ if (typeof project !== 'function' || sessionId === '') {
1585
+ return invokeResult(request, { sessionId, pendingQueue: [], steeringQueueClientIds: [] });
1586
+ }
1587
+ return invokeResult(request, project(sessionId));
1588
+ }
1589
+
1590
+ // The aggregated remote file browser. One channel, dispatched by `op`; the
1591
+ // ops this Host does not serve answer `{ok:false, message}` the way the
1592
+ // reference host answers an op it does not know, rather than looking empty.
1593
+ if (channel === 'file-browser:remote-op') {
1594
+ const capabilities = capabilitiesNow();
1595
+ const op = args[0]?.op;
1596
+ // Whole-file access is Host-owned; never reinterpret nested SSH as local storage.
1597
+ if (op === 'caps' && typeof capabilities.fileUrl === 'function') {
1598
+ return invokeResult(request, { ok: true, fileRead: true });
1599
+ }
1600
+ if (op === 'fileUrl') {
1601
+ if (typeof capabilities.fileUrl !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This Host has no whole-file reader');
1602
+ return invokeResult(request, await capabilities.fileUrl({ workdir: args[0]?.workdir, relPath: args[0]?.relPath }));
1603
+ }
1604
+ // The two export ops are the controller's only way to *see* a picture that lives on
1605
+ // this machine — a file the agent drew, or any image in the file browser. The
1606
+ // reference controlled end answers `exportFileStart` immediately with a transfer id
1607
+ // and uploads to the shared staging area in the background, then `exportFileStatus`
1608
+ // reports `{state, key}`; the controller presign-gets the key itself. They are
1609
+ // Host-owned rather than browser-owned because the upload needs this runtime's
1610
+ // account session — the same reason attachment fetching lives there.
1611
+ if (op === 'exportFileStart' || op === 'exportFileStatus') {
1612
+ const start = capabilities.exportFileStart;
1613
+ const status = capabilities.exportFileStatus;
1614
+ if (op === 'exportFileStart') {
1615
+ if (typeof start !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot export files yet');
1616
+ const answered = await start({ workdir: args[0]?.workdir, relPath: args[0]?.relPath,
1617
+ ...(Object.hasOwn(args[0] ?? {}, 'maxBytes') ? { maxBytes: args[0].maxBytes } : {})
1618
+ }, { src: request.src, id: request.id });
1619
+ return answered?.ok === true ? invokeResult(request, answered) : invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'file export failed');
1620
+ }
1621
+ if (typeof status !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot export files yet');
1622
+ const answered = status({ transferId: args[0]?.transferId }, { src: request.src, id: request.id });
1623
+ return answered?.ok === true ? invokeResult(request, answered) : invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'unknown transfer');
1624
+ }
1625
+ const makeBrowser = capabilities.fileBrowser;
1626
+ const browser = typeof makeBrowser === 'function' ? makeBrowser() : undefined;
1627
+ if (browser === undefined || browser === null) {
1628
+ return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no filesystem');
1629
+ }
1630
+ try {
1631
+ return invokeResult(request, await browser(args[0]));
1632
+ } catch (error) {
1633
+ return invokeError(request, 'THREW', String(error?.message ?? error));
1634
+ }
1635
+ }
1636
+
1637
+ // The work-grouped history window. The controller only asks for these after seeing
1638
+ // `history-view-v1` in this Host's `link-accept`, and it falls back to the raw
1639
+ // `local-db:messages:list` window on any error it recognises as "no view here"
1640
+ // (`isHistoryViewUnavailable`), which is what `NOT_AVAILABLE` and
1641
+ // `UNSUPPORTED_CAPABILITY` both are.
1642
+ if (channel === 'local-db:messages:view' || channel === 'local-db:messages:work-details' || channel === 'local-db:messages:view-intent') {
1643
+ const view = capabilitiesNow().historyView;
1644
+ if (view === undefined || view === null) return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host projects no session history view');
1645
+ const sessionId = typeof args[0] === 'string' ? args[0] : '';
1646
+ const answered = channel === 'local-db:messages:view'
1647
+ ? await view.page(sessionId, args[1]?.before)
1648
+ : channel === 'local-db:messages:work-details'
1649
+ ? await view.details(sessionId, args[1], args[2]?.after)
1650
+ : await view.intent(sessionId, args[1]);
1651
+ // Pending bodies belong in InputProjection, never synthetic user history.
1652
+ return answered?.ok === true
1653
+ ? invokeResult(request, answered.result)
1654
+ : invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'history view failed');
1655
+ }
1656
+
1657
+ // A controller asking this machine for one of its files: an image the agent produced,
1658
+ // referenced by path in the transcript. The answer is a staged `ossKey` the controller
1659
+ // presign-gets itself, exactly as this Host does for a photo the phone uploaded.
1660
+ if (channel === 'device-link:file-peer') {
1661
+ const filePeer = capabilitiesNow().filePeer;
1662
+ if (typeof filePeer !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This Host has no file-peer transport');
1663
+ try {
1664
+ // Identity comes from the relay envelope, never from controller-supplied args.
1665
+ return invokeResult(request, await filePeer(request.src, args[0]));
1666
+ } catch (error) {
1667
+ const code = typeof error?.message === 'string' && /^(FILE_PEER_[A-Z_]+|INVALID_FILE_PEER_REQUEST|BAD_REQUEST|FORBIDDEN|NOT_FOUND|NOT_AVAILABLE)$/.test(error.message) ? error.message : 'FILE_PEER_FAILED';
1668
+ return invokeError(request, code, code);
1669
+ }
1670
+ }
1671
+
1672
+ if (channel === 'device-link:media:fetch') {
1673
+ const fetchMedia = capabilitiesNow().fetchLocalMedia;
1674
+ if (typeof fetchMedia !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot serve local media');
1675
+ // `skipCache` is the controller saying "the object this key named is gone, stage it
1676
+ // again": it must bypass the staging cache rather than be dropped on the floor, or a
1677
+ // deleted object would stay unmaterializable for the cache's whole lifetime.
1678
+ const answered = await fetchMedia({
1679
+ url: args[0]?.url,
1680
+ thumbnail: args[0]?.thumbnail === true,
1681
+ skipCache: args[0]?.skipCache === true,
1682
+ ...(args[0]?.prepareOnly === true ? { prepareOnly: true } : {}),
1683
+ });
1684
+ return answered?.ok === true
1685
+ ? invokeResult(request, answered.result)
1686
+ : invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'media fetch failed');
1687
+ }
1688
+
1689
+ // File reads. Both families the controller uses take an absolute path and
1690
+ // answer in its own vocabulary; the translation lives in `host-files.js`.
1691
+ if (channel === 'fs:stat-path' || channel === 'fs:list-dir' || channel === 'text-file:read-preview') {
1692
+ const files = capabilitiesNow().files;
1693
+ if (typeof files !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot read files yet');
1694
+ const reader = files();
1695
+ const path = channel === 'text-file:read-preview'
1696
+ ? (args[0]?.filePath ?? args[0]?.path)
1697
+ : args[0]?.path;
1698
+ if (typeof path !== 'string' || path.trim() === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a path`);
1699
+ if (channel === 'fs:stat-path') return invokeResult(request, await reader.statPath(path));
1700
+ if (channel === 'fs:list-dir') return invokeResult(request, await reader.listDir(path));
1701
+ return invokeResult(request, await reader.readTextPreview(path));
1702
+ }
1703
+
1704
+ // Fail closed and say so. The controller degrades on CHANNEL_NOT_ALLOWED.
1705
+ return invokeError(request);
1706
+ };
1707
+ }