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,1932 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ import { createRequire } from 'node:module';
3
+ import { DEFAULT_HOST_SETTINGS, SETTINGS_NAMESPACE, validateHostSettings } from './host-settings.js';
4
+ import { DshHostSource } from './dsh-host-source.js';
5
+ import { createSessionControllerSource } from './dsh-session-source.js';
6
+ import { readActiveSessionSummaries } from './dsh-active-sessions.js';
7
+ import { createMessageCounter, createMessageReader, foldSessionEvent, isHarnessNotice, promptRpcIdOf } from './dsh-message-fold.js';
8
+ import { createHistoryViewController } from './host-history-view.js';
9
+ import { createFileReader } from './host-files.js';
10
+ import { createFileBrowser } from './host-file-browser.js';
11
+ import { createAttachmentMaterializer } from './host-attachments.js';
12
+ import { createGoalWriter } from './host-goals.js';
13
+ import { startHost } from './host.js';
14
+ import { refusalError } from './cindy-channels.js';
15
+ import { hydrateImageAttachments } from './cindy-message-row.js';
16
+ import { API_PREFIX, createHostRoutes } from './host-routes.js';
17
+
18
+ export const name = 'dsh-cindy-host';
19
+ export const inject = ['settings'];
20
+
21
+ /**
22
+ * Brand a string as a settings namespace.
23
+ *
24
+ * This used to be `import { settingsNamespace } from '@deepseek-ai/dsh-settings'`, and that import
25
+ * is why the plugin could not be installed on a current DSH: the helper exists in
26
+ * `0.1.1-rc.2` but **is not exported by `0.1.5-rc.2`** (whose surface is `SettingsProvider`,
27
+ * `SettingsConflictError`, `redactSecrets`, `default`). Keeping it meant declaring an old DSH
28
+ * generation as a dependency, and installing that generation into a profile is what broke the
29
+ * host's own composition (`dsh-client-file-upload` / `dsh-session` mismatch → profile will not
30
+ * boot). See `doc/publishing.md` §5.6.
31
+ *
32
+ * The helper is a brand, not behaviour — validate, return the string — so it lives here now and
33
+ * costs nothing: the namespace is the value, the pattern is the contract.
34
+ */
35
+ const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
36
+ function settingsNamespace(value) {
37
+ if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
38
+ return value;
39
+ }
40
+ const ControllerSchema = Schema.object({ state: Schema.union(['authorized', 'revoked']), displayName: Schema.string(), grantedAt: Schema.string(), revokedAt: Schema.string(), grantRevision: Schema.natural() });
41
+ // The archive/delete/pin flags a controller writes: this Host's own answer to three
42
+ // actions DSH has no concept of. `pinnedAt: ''` is a remembered unpin (settings
43
+ // reject `null`, and an absent field cannot be told apart from "never pinned").
44
+ const SessionFlagSchema = Schema.object({ status: Schema.union(['active', 'archived', 'deleted']), pinnedAt: Schema.string() });
45
+
46
+ /**
47
+ * This bundle's settings schema.
48
+ *
49
+ * Each field restates the matching `DEFAULT_HOST_SETTINGS` entry, so a profile that has never
50
+ * written a value still resolves a complete object rather than an empty one.
51
+ */
52
+ const HostSettingsSchema = Schema.object({
53
+ transportEnabled: Schema.boolean().default(DEFAULT_HOST_SETTINGS.transportEnabled),
54
+ remoteControlEnabled: Schema.boolean().default(DEFAULT_HOST_SETTINGS.remoteControlEnabled),
55
+ controllers: Schema.dict(ControllerSchema).default({}),
56
+ deviceId: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceId),
57
+ sessionFlags: Schema.dict(SessionFlagSchema).default({}),
58
+ });
59
+
60
+ /**
61
+ * The schema DSH projects as this entry's settings surface.
62
+ *
63
+ * DSH 0.2 reads a plugin **entry's own `Config`** (`SettingsForms.describe()`, keyed by profile
64
+ * entry id) and accepts a write only to a field beneath a **volatile** node, so the root is
65
+ * marked volatile when the running schemastery supports it: one volatile root is the exact
66
+ * equivalent of the namespace-wide registration that 0.2 removed (see
67
+ * {@link createSettingsScope}).
68
+ *
69
+ * `volatile()` is **feature-detected on purpose**: schemastery `3.18.2` — the copy DSH
70
+ * `0.1.5-rc.2` resolves, and still inside this bundle's declared `^3.18.1` peer range — has no
71
+ * such method, so calling it unconditionally would throw while this module loads and take the
72
+ * whole profile down. Without it the plain schema still serves the 0.1.x `settings.register`
73
+ * path, which does not need the marker.
74
+ */
75
+ const HostSchema = typeof HostSettingsSchema.volatile === 'function' ? HostSettingsSchema.volatile() : HostSettingsSchema;
76
+
77
+ /** The plugin entry's settings surface; see {@link createSettingsScope}. */
78
+ export const Config = HostSchema;
79
+
80
+ /**
81
+ * Services that can supply the DSH session read source, in preference order.
82
+ * `sessionController` is what current DSH Web mounts; `apiProxy` was removed
83
+ * from it, so that branch is last and optional.
84
+ */
85
+ const SOURCE_SERVICES = ['sessionController', 'apiProxy'];
86
+
87
+ /**
88
+ * The plan-mode module an agent preset mounts.
89
+ *
90
+ * This deployment does **not** compose plan mode on the host plane: the Web
91
+ * bundle disables the base `plan-mode` row and each preset that wants it mounts
92
+ * this module inside its own agent context. So the presence of the service is a
93
+ * per-preset fact, and the only honest places to read it are the agent's own
94
+ * context and the preset composition.
95
+ */
96
+ const PLAN_MODE_MODULE = '@deepseek-ai/dsh-plan-mode';
97
+
98
+ /** How long a preset-composition read is reused, in milliseconds. */
99
+ const PRESET_INVENTORY_TTL_MS = 30_000;
100
+
101
+ /**
102
+ * Build the DSH read source once its supplying service exists.
103
+ *
104
+ * Both candidates are provided by *other* plugins, so `apply` cannot read them
105
+ * synchronously: doing that is what left the original bundle with a permanently
106
+ * undefined source and an empty session list. `ctx.inject` waits for the service
107
+ * and re-runs on removal, which is the only correct way to consume it.
108
+ * @param ctx - the plugin context that owns the service.
109
+ * @param serviceName - which candidate became available.
110
+ * @returns the source and the label naming its seam.
111
+ */
112
+ export function buildDshSource(ctx, serviceName, options = {}) {
113
+ if (serviceName === 'sessionController') {
114
+ const sessionController = ctx.get('sessionController');
115
+ const sessionQuery = ctx.get('sessionQuery');
116
+ /**
117
+ * The Workspace that owns a new session's directory, or undefined.
118
+ *
119
+ * Read per call and never captured: the registry is another plugin's service and may
120
+ * activate after this seam is built (the same reason every other seam here resolves
121
+ * late). `create` is DSH's own "create or reuse" for a directory, so a known folder
122
+ * reuses its record and a genuinely new one is registered — which is what makes the
123
+ * session appear where the desk expects it.
124
+ */
125
+ async function workspaceForNewSession(cwd) {
126
+ if (cwd === undefined) return undefined;
127
+ const registry = ctx.get('workspaceRegistry');
128
+ if (registry === undefined || registry === null || typeof registry.create !== 'function') return undefined;
129
+ try {
130
+ const workspace = await registry.create(cwd);
131
+ return workspace !== null && workspace !== undefined && typeof workspace.id === 'string' && workspace.id !== ''
132
+ ? workspace
133
+ : undefined;
134
+ } catch {
135
+ // A path that cannot back a Workspace — relative, missing, a file — keeps the
136
+ // session creatable; it simply will not be grouped on the desk.
137
+ return undefined;
138
+ }
139
+ }
140
+ /**
141
+ * The optional session-control services, brought up explicitly.
142
+ *
143
+ * Cordis activates a service when something *injects* it; a plain `get` on a
144
+ * registered-but-idle service answers `undefined`. Reading them that way is
145
+ * how plan mode looked uncomposed on a profile that plainly composes it — the
146
+ * same trap the original `apiProxy` seam fell into. Neither is required, so
147
+ * each keeps its own availability instead of failing the whole Host.
148
+ */
149
+ const controls = { planMode: undefined, permissionPresets: undefined, fileUploads: undefined, tokenMeter: undefined, llm: undefined, sessionTitle: undefined, skills: undefined };
150
+ ctx.inject(['planMode'], (planCtx) => {
151
+ controls.planMode = planCtx.get('planMode');
152
+ return () => { controls.planMode = undefined; };
153
+ });
154
+ ctx.inject(['permissionPresets'], (presetCtx) => {
155
+ controls.permissionPresets = presetCtx.get('permissionPresets');
156
+ return () => { controls.permissionPresets = undefined; };
157
+ });
158
+ // File attachments ride a receipt minted by the Session upload owner; without
159
+ // this service a file can still be read, but it cannot become a prompt part.
160
+ ctx.inject(['fileUploads'], (uploadCtx) => {
161
+ controls.fileUploads = uploadCtx.get('fileUploads');
162
+ return () => { controls.fileUploads = undefined; };
163
+ });
164
+ // The context meter and the model route: one measures the live session, the
165
+ // other discloses the routed model's context window. Both are optional, and
166
+ // each keeps its own absence.
167
+ ctx.inject(['tokenMeter'], (meterCtx) => {
168
+ controls.tokenMeter = meterCtx.get('tokenMeter');
169
+ return () => { controls.tokenMeter = undefined; };
170
+ });
171
+ ctx.inject(['llm'], (llmCtx) => {
172
+ controls.llm = llmCtx.get('llm');
173
+ return () => { controls.llm = undefined; };
174
+ });
175
+ // The title service owns the log-backed title fold and its provider, so the
176
+ // controller's "regenerate title" action goes through it rather than through a
177
+ // second title path of our own.
178
+ ctx.inject(['sessionTitle'], (titleCtx) => {
179
+ controls.sessionTitle = titleCtx.get('sessionTitle');
180
+ return () => { controls.sessionTitle = undefined; };
181
+ });
182
+ // The skill registry. Its answer depends on the *viewing scope*, so merely
183
+ // holding the service is not enough — see `liveAgentFor` below.
184
+ ctx.inject(['skills'], (skillCtx) => {
185
+ controls.skills = skillCtx.get('skills');
186
+ return () => { controls.skills = undefined; };
187
+ });
188
+
189
+ /**
190
+ * The viewing scope for one session's registry reads — skills, and the shape
191
+ * the command list beside them already uses.
192
+ *
193
+ * Skills live in layered registries: a preset's standing mount registers into
194
+ * that preset's layer, and a read answers for the viewing scope it is asked
195
+ * about. Reading from this Host's own context therefore names the global layer
196
+ * alone, which is how this menu came to be empty on a profile whose skills are
197
+ * all registered by a preset.
198
+ *
199
+ * The scope key is the **agent object itself**, not a tag read off its context:
200
+ * DSH's own skill tool calls `ctx.skills.list({ scope: agent, cwd })`, and the
201
+ * agent is the scope-carrier key its dispatcher couples the subject to. Asking
202
+ * `scopeOf(agent.ctx)` answers undefined — that context carries no tag — which
203
+ * is exactly how an empty menu survived the first fix attempt.
204
+ *
205
+ * A **live** agent is preferred, and a cold session is then resumed — the same
206
+ * thing the command list next to it does, for the same user action. Measured on
207
+ * this deployment: a cold session answered `skills: 0` and `commands: 6`
208
+ * (resolving the agent is what makes the difference), and the same session
209
+ * answered `skills: 6` once it was live. Both lists describe one session; two
210
+ * different answers there is not purity, it is a missing menu the user sees.
211
+ *
212
+ * A request naming **no** session is the new-task composer, and there the honest
213
+ * answer is what a new session would offer: `agentPresets.standingKeyFor()`,
214
+ * which DSH documents as "the standing scope key of one preset, for a host
215
+ * reader with no agent … ensuring the mount composes plugins but starts no
216
+ * agent, no session, and no turn". A session that *is* named but cannot be
217
+ * resolved answers nothing: inventing the default composition's skills for an
218
+ * unknown session would be a claim about a session this Host cannot see.
219
+ * @param sessionId - the session whose agent to use as a viewing scope, if any.
220
+ * @returns the viewing scope key, or undefined when none can be named.
221
+ */
222
+ async function agentScopeFor(sessionId) {
223
+ const named = typeof sessionId === 'string' && sessionId !== '';
224
+ const live = liveAgentFor(sessionId);
225
+ if (live !== undefined) return live;
226
+ if (named) {
227
+ const resolved = await sessionController.resolveAgent(sessionId);
228
+ return resolved?.error === undefined ? resolved.agent : undefined;
229
+ }
230
+ const presets = ctx.get('agentPresets');
231
+ if (typeof presets?.standingKeyFor !== 'function') return undefined;
232
+ try {
233
+ return await presets.standingKeyFor();
234
+ } catch {
235
+ // An unusable default composition is not a reason to fail a menu; the
236
+ // caller then reads the global layer.
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The live agent for one session, when this Host already has one.
243
+ *
244
+ * `ctx.agents.get(id)` answers for live agents only, which is the cheap first
245
+ * probe: it asks nothing of the session store and resumes nothing.
246
+ * @param sessionId - the session to look up.
247
+ * @returns the live agent, or undefined.
248
+ */
249
+ function liveAgentFor(sessionId) {
250
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
251
+ const agents = ctx.get('agents');
252
+ if (typeof agents?.get !== 'function') return undefined;
253
+ try {
254
+ return agents.get(sessionId) ?? undefined;
255
+ } catch {
256
+ // A registry mid-teardown answers nothing; the caller then reads the
257
+ // global layer rather than failing the palette.
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * The plan-mode controller that belongs to one agent, when one is reachable.
264
+ *
265
+ * `ctx.planMode` is unreachable from this context by composition, not by
266
+ * accident: the Web bundle disables the base row and each preset composes
267
+ * the controller inside its own agent scope. Best effort only — the command
268
+ * route below is what actually reaches a preset-owned switch.
269
+ * @param agent - the controller-resolved agent, or undefined.
270
+ * @returns the plan-mode controller for that agent, or undefined.
271
+ */
272
+ function planModeFor(agent) {
273
+ const scoped = typeof agent?.ctx?.get === 'function' ? agent.ctx.get('planMode') : undefined;
274
+ return scoped === undefined ? controls.planMode : scoped;
275
+ }
276
+
277
+ /**
278
+ * The plan-mode controller mounted inside one agent's own scope.
279
+ * @param agent - the controller-resolved agent.
280
+ * @returns the scoped controller, or undefined when the preset mounts none.
281
+ */
282
+ function planModeInAgentScope(agent) {
283
+ return typeof agent?.ctx?.get === 'function' ? agent.ctx.get('planMode') : undefined;
284
+ }
285
+
286
+ /**
287
+ * Whether the agent's own scope registers the `/plan` command.
288
+ *
289
+ * This is the switch that actually exists in this deployment: `dsh-plan-mode`
290
+ * registers `/plan` through the command registry of whatever scope it was
291
+ * mounted in, and the same command list the phone's `/` palette already reads
292
+ * answers for one exact agent. A preset that composes no plan mode registers
293
+ * no such command, so this is the honest per-session test.
294
+ * @param agent - the agent to test.
295
+ * @returns true when `/plan` resolves for that agent.
296
+ */
297
+ function planCommandAvailable(agent) {
298
+ const commands = ctx.get('commands');
299
+ if (typeof commands?.find !== 'function') return false;
300
+ try {
301
+ return commands.find(agent, 'plan') !== undefined;
302
+ } catch {
303
+ return false;
304
+ }
305
+ }
306
+
307
+ /** Preset-composition reads are reused; the answer changes only on an edit. */
308
+ let presetInventory = { at: 0, value: null };
309
+
310
+ /**
311
+ * Whether the preset a session naming no preset would compose mounts plan
312
+ * mode.
313
+ *
314
+ * Used only when nothing live can answer — a freshly restarted Host has no
315
+ * agent to inspect, and answering "unsupported" there would take the
316
+ * control away from every controller until somebody opened a session. A row
317
+ * reported `'conditional'` (an unevaluated `!!js disabled` expression)
318
+ * counts as mounted: the refusal to evaluate is not evidence of absence.
319
+ * @returns true when the default composition names the plan-mode module.
320
+ */
321
+ async function presetComposesPlanMode() {
322
+ const presets = ctx.get('agentPresets');
323
+ if (typeof presets?.compositionInventory !== 'function') return false;
324
+ const now = Date.now();
325
+ if (presetInventory.value !== null && now - presetInventory.at < PRESET_INVENTORY_TTL_MS) return presetInventory.value;
326
+ let answer = false;
327
+ try {
328
+ const inventory = await presets.compositionInventory();
329
+ const rows = Array.isArray(inventory) ? inventory : [];
330
+ const target = rows.find((preset) => preset?.isDefault === true) ?? null;
331
+ if (Array.isArray(target?.rows)) {
332
+ answer = target.rows.some((row) => row?.moduleName === PLAN_MODE_MODULE && row.enabled !== false);
333
+ }
334
+ } catch {
335
+ // A composition this Host cannot read is not evidence that it mounts
336
+ // plan mode, so the answer stays false and the control stays hidden.
337
+ answer = false;
338
+ }
339
+ presetInventory = { at: now, value: answer };
340
+ return answer;
341
+ }
342
+
343
+ /**
344
+ * Read one session's authoritative projection state.
345
+ *
346
+ * The control stream *pushes* projection changes, but a push that never
347
+ * arrives is indistinguishable from "nothing changed": that is how a queue
348
+ * came to hold a phantom row for a message the agent had already answered.
349
+ * `observeSession` computes the registered projections on demand, so this is
350
+ * the read that can be trusted. The lease is always released.
351
+ * @param sessionId - the session to observe.
352
+ * @returns the projection values this Host consumes, or null when unreadable.
353
+ */
354
+ async function readSessionStateNow(sessionId) {
355
+ if (sessionQuery === undefined || typeof sessionQuery.observeSession !== 'function') return null;
356
+ let observation;
357
+ try {
358
+ observation = await sessionQuery.observeSession(sessionId, { projectionMode: 'all' });
359
+ const values = observation?.projections?.values;
360
+ if (values === null || typeof values !== 'object') return null;
361
+ return {
362
+ inbox: Object.prototype.hasOwnProperty.call(values, 'inbox') ? values.inbox : undefined,
363
+ goal: Object.prototype.hasOwnProperty.call(values, 'goal') ? values.goal : undefined,
364
+ hasGoalKey: Object.prototype.hasOwnProperty.call(values, 'goal'),
365
+ // What the session is running on. Effort is part of a model selection
366
+ // in DSH, so changing effort means re-stating the model — and this is
367
+ // where that model comes from.
368
+ modelSelection: Object.prototype.hasOwnProperty.call(values, 'modelSelection') ? values.modelSelection : undefined,
369
+ };
370
+ } catch {
371
+ // A projection read that fails costs a fresher answer, never the call.
372
+ return null;
373
+ } finally {
374
+ try {
375
+ observation?.[Symbol.dispose]?.();
376
+ } catch {
377
+ // Releasing a lease must not mask the answer.
378
+ }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * The model one session is running on, for a write that must name it.
384
+ *
385
+ * A selection the user made is the authority; `next` is the one the coming
386
+ * request will use, and `lastUsed` is the fallback for a session whose
387
+ * pending selection was already consumed. The catalog default is the last
388
+ * resort, for a session that has never chosen.
389
+ * @param sessionId - the session to read.
390
+ * @returns `{ provider, model }`, or null when nothing can name it.
391
+ */
392
+ async function currentSelection(sessionId) {
393
+ const state = await readSessionStateNow(sessionId);
394
+ const selection = state?.modelSelection?.next ?? state?.modelSelection?.lastUsed ?? null;
395
+ if (typeof selection?.provider === 'string' && selection.provider !== ''
396
+ && typeof selection?.model === 'string' && selection.model !== '') {
397
+ return {
398
+ provider: selection.provider,
399
+ model: selection.model,
400
+ // The effort the session already runs on belongs here: changing effort means
401
+ // re-stating the whole selection, and "set it to what it already is" has to
402
+ // be recognisable as a no-op (see `setEffort`).
403
+ ...(typeof selection.reasoningEffort === 'string' && selection.reasoningEffort !== ''
404
+ ? { reasoningEffort: selection.reasoningEffort }
405
+ : {}),
406
+ };
407
+ }
408
+ try {
409
+ const catalog = await sessionController.modelCatalog();
410
+ const fallback = catalog?.default;
411
+ if (typeof fallback?.provider === 'string' && fallback.provider !== ''
412
+ && typeof fallback?.model === 'string' && fallback.model !== '') {
413
+ return { provider: fallback.provider, model: fallback.model };
414
+ }
415
+ } catch {
416
+ // A failed catalog read leaves the caller to refuse rather than guess.
417
+ }
418
+ return null;
419
+ }
420
+
421
+ /**
422
+ * The reasoning efforts the catalog declares for one model route.
423
+ *
424
+ * `null` means "this Host cannot say" — a catalog that never loaded, or a route it
425
+ * does not list — and the caller then lets DSH answer rather than inventing a
426
+ * refusal for a model it has not read about.
427
+ * @param selection - `{ provider, model }`.
428
+ * @returns the effort ids, or null when unknown.
429
+ */
430
+ async function supportedEfforts(selection) {
431
+ try {
432
+ const catalog = await sessionController.modelCatalog();
433
+ const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
434
+ const group = groups.find((entry) => entry?.id === selection.provider);
435
+ const model = (Array.isArray(group?.models) ? group.models : []).find((entry) => entry?.id === selection.model);
436
+ const efforts = Array.isArray(model?.reasoning?.efforts) ? model.reasoning.efforts : [];
437
+ const ids = efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '');
438
+ return ids.length > 0 ? ids : null;
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Install one session-local model selection, resolving the provider when the
446
+ * caller has none.
447
+ *
448
+ * The controller's model option carries no provider id — the catalog flattens
449
+ * models out of their provider groups — so a choice usually arrives with the
450
+ * model alone. This Host's own catalog is the authority on which provider
451
+ * serves it, and guessing would risk switching to a different model than the
452
+ * user picked.
453
+ *
454
+ * DSH's `session.selectModel` is the only write that installs a selection, and
455
+ * it has two effects by design: the session's coming request uses it **and** the
456
+ * profile's `agent-default-model` is updated — exactly what the desk's own model
457
+ * picker does. That is why a conversation created from the handset with gpt also
458
+ * becomes the default for sessions no picker has touched yet.
459
+ * @param request - the session, its requested provider/model, and optional effort.
460
+ * @returns DSH's normalized `{ selected }` answer.
461
+ */
462
+ async function applyModelSelection({ sessionId, provider, model, reasoningEffort }) {
463
+ let resolved = provider;
464
+ if (typeof resolved !== 'string' || resolved === '') {
465
+ const catalog = await sessionController.modelCatalog();
466
+ const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
467
+ resolved = groups.find((group) => (Array.isArray(group?.models) ? group.models : [])
468
+ .some((entry) => entry?.id === model))?.id;
469
+ }
470
+ if (typeof resolved !== 'string' || resolved === '') {
471
+ throw refusalError('NOT_AVAILABLE', "no provider in this Host's catalog serves " + model);
472
+ }
473
+ try {
474
+ return await sessionController.selectModel({
475
+ sessionId,
476
+ provider: resolved,
477
+ model,
478
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
479
+ });
480
+ } catch (error) {
481
+ // DSH refuses a route it cannot resolve with `session/model-unavailable`.
482
+ // Passed through raw that reaches the controller as `THREW`, which it reads
483
+ // as this Host crashing rather than as "that model is not usable here" — the
484
+ // same misreading the effort refusal was fixed for. Only that refusal is
485
+ // translated; anything else is a genuine failure and travels unchanged.
486
+ if (error?.code === 'session/model-unavailable') {
487
+ throw refusalError('NOT_AVAILABLE', String(error?.message ?? error));
488
+ }
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ /** The permission preset names this Host advertises, in the preset table's order. */
494
+ function advertisedPermissionNames() {
495
+ const presets = controls.permissionPresets;
496
+ return Array.isArray(presets?.names) ? [...presets.names] : [];
497
+ }
498
+
499
+ /**
500
+ * Write one permission preset onto one session.
501
+ *
502
+ * The preset is written onto the **session**; the service then drives the
503
+ * sandbox-mode and approval-policy knobs that execution reads. Shared by the
504
+ * explicit `maker:set-permission-mode` channel and the create path, so both
505
+ * install a preset the same way.
506
+ * @param request - the session to write and the preset name to install.
507
+ */
508
+ async function installPermissionMode({ sessionId, mode }) {
509
+ const presets = controls.permissionPresets;
510
+ if (typeof presets?.set !== 'function') throw new Error('permission presets are not composed on this Host');
511
+ const resolved = await sessionController.resolveAgent(sessionId);
512
+ if (resolved?.error !== undefined) throw resolved.error;
513
+ presets.set(resolved.agent.session, mode);
514
+ }
515
+
516
+ /**
517
+ * Install the permission preset a **new** conversation asked for, when this Host
518
+ * advertises it.
519
+ *
520
+ * The controller's permission vocabulary is not this Host's. `maker:get-capabilities`
521
+ * answers `permissionModes` from DSH's own preset table, and the handset coerces its
522
+ * draft into that list (`reconcileRuntimeDraftWithCapabilities`), so an advertised
523
+ * name is the ordinary case and it is installed verbatim. That matters more here than
524
+ * anywhere else on this seam: a user who picks `read-only` and silently receives the
525
+ * profile default of `danger-full-access` believes the agent is restricted while it
526
+ * holds full access.
527
+ *
528
+ * An unadvertised name — the handset's legacy fallback list (`ask`, `acceptEdits`,
529
+ * `plan`, `bypassPermissions`) when its capability read failed — is deliberately
530
+ * **not** translated and deliberately **not** refused. Translating would guess a
531
+ * privilege level on the user's behalf, and refusing would leave a phone whose
532
+ * capability read failed unable to start any conversation at all. It stays at the
533
+ * profile's preset, is logged, and the session row reports what is in force.
534
+ * @param request - the session and the preset name the controller asked for.
535
+ * @returns the installed name, or null when this Host does not advertise it.
536
+ */
537
+ async function applyPermissionMode({ sessionId, mode }) {
538
+ const advertised = advertisedPermissionNames();
539
+ if (!advertised.includes(mode)) {
540
+ // Loud rather than silent: the log is where an absorbed, unhonoured request is
541
+ // visible, and the advertised list is what makes it actionable.
542
+ ctx.logger?.warn?.(`create-session: this Host advertises ${JSON.stringify(advertised)} but the controller asked for permission mode "${mode}"; keeping the profile preset`);
543
+ return null;
544
+ }
545
+ await installPermissionMode({ sessionId, mode });
546
+ return mode;
547
+ }
548
+
549
+ /**
550
+ * The routed model's context window, when the provider discloses one.
551
+ *
552
+ * `LlmResolvedModelInfo.context` is optional — a provider that lists an id and
553
+ * nothing else leaves it absent. An absent window is reported as null rather
554
+ * than guessed from the request's `maxTokens` (that is an **output** bound, and
555
+ * using it as a context window would show the user a percentage that means
556
+ * nothing).
557
+ * @param sessionId - the session whose selection names the route.
558
+ * @returns the window in tokens, or null when unknown.
559
+ */
560
+ async function contextWindowFor(sessionId) {
561
+ const llm = controls.llm;
562
+ if (typeof llm?.resolveModelInfo !== 'function') return null;
563
+ const selection = await currentSelection(sessionId);
564
+ if (selection === null) return null;
565
+ try {
566
+ const resolved = await llm.resolveModelInfo(selection.provider, selection.model);
567
+ const window = resolved?.context?.contextWindow;
568
+ return Number.isFinite(window) && window > 0 ? window : null;
569
+ } catch {
570
+ return null;
571
+ }
572
+ }
573
+ // Message history comes from the raw log, not the projection: the projection
574
+ // carries list rows, and `sessionController.page()` would need a `throughSeq`
575
+ // the Host has no cheap way to learn.
576
+ //
577
+ // An image the phone sent comes back as a durable DSH attachment, and DSH's own
578
+ // reference for it is content-addressed — deliberately neither a path nor a URL
579
+ // ("never a filesystem path or bearer URL"), so there is nothing to hand the
580
+ // controller for rendering. `ctx.attachments.readImage` is the way back to the
581
+ // bytes, and inlining them is what makes the photo appear in the bubble instead of
582
+ // the 没有可展示的远程路径 placeholder. Read per request, so a profile whose
583
+ // attachment service activates later is picked up without a rebuild; absent, the
584
+ // rows simply keep their file chips.
585
+ //
586
+ // Counted, because "the photo does not render" has three indistinguishable causes
587
+ // from the outside — no handle on the block, no attachment service on this profile,
588
+ // or a read that failed — and the counter is what separates them.
589
+ const attachmentReads = { attempted: 0, served: 0, failed: 0 };
590
+ const readImageAttachment = async (handle) => {
591
+ const store = ctx.get('attachments');
592
+ if (store === undefined || typeof store.readImage !== 'function') {
593
+ attachmentReads.failed += 1;
594
+ return null;
595
+ }
596
+ attachmentReads.attempted += 1;
597
+ try {
598
+ // The descriptor is passed through **whole**: it is the value DSH stored, and
599
+ // the store validates what it is asked to read.
600
+ const stored = await store.readImage(handle, AbortSignal.timeout(5_000));
601
+ const data = stored?.data;
602
+ if (data === undefined || data === null) {
603
+ attachmentReads.failed += 1;
604
+ return null;
605
+ }
606
+ attachmentReads.served += 1;
607
+ return {
608
+ base64: Buffer.from(data).toString('base64'),
609
+ bytes: Number.isFinite(stored?.ref?.bytes) ? stored.ref.bytes : Buffer.from(data).length,
610
+ mediaType: typeof stored?.ref?.mediaType === 'string' ? stored.ref.mediaType : undefined,
611
+ };
612
+ } catch {
613
+ // One unreadable image costs that image, never the page.
614
+ attachmentReads.failed += 1;
615
+ return null;
616
+ }
617
+ };
618
+ const readMessages = sessionQuery === undefined
619
+ ? undefined
620
+ : createMessageReader({ readSessionLog: (sessionId) => sessionQuery.readSession(sessionId), readImageAttachment });
621
+ /**
622
+ * Turn image handles into bytes for **whatever is about to reach the phone**.
623
+ *
624
+ * One hydrator, three call sites (`messages:list` pages, history-view pages, and the live
625
+ * `messages:created` push), because "the user's photo does not render" has already happened
626
+ * once by hydration living on a single path: it was wired into `messages:list`, the
627
+ * controller moved onto the history view, and every photo went back to a path-less chip.
628
+ * A row is only renderable after this runs, so every path that hands a row to a controller
629
+ * has to run it.
630
+ */
631
+ const hydrateRows = (rows) => hydrateImageAttachments(rows, { readImage: readImageAttachment });
632
+ /**
633
+ * How many rows one session's transcript holds.
634
+ *
635
+ * The controller only lights its "load earlier" entry point when it knows the
636
+ * total and the total exceeds what it has loaded
637
+ * (`hasOlderMessagesByServerCount`: an unknown total is deliberately treated
638
+ * as "no"), and it reads that total from the session row's `_count.messages`.
639
+ * This Host used to answer `_count: null`, so the entry point never lit up.
640
+ * The count is computed on the single-session read rather than the list, which
641
+ * the controller polls: folding every transcript on every poll would trade a
642
+ * paging bug for a latency one.
643
+ */
644
+ const countMessages = sessionQuery === undefined || readMessages === undefined
645
+ ? undefined
646
+ : createMessageCounter({ reader: readMessages });
647
+ /**
648
+ * The work-grouped history window the controller prefers once it has seen
649
+ * `history-view-v1`.
650
+ *
651
+ * Grouping runs over the same cached transcript the raw window pages
652
+ * (`readMessages.all`), so the two views can never disagree about what the session
653
+ * contains — and neither of them re-reads the log to answer.
654
+ */
655
+ const historyView = readMessages === undefined
656
+ ? undefined
657
+ : createHistoryViewController({
658
+ rows: (sessionId) => readMessages.all(sessionId),
659
+ // The turn state has to be **wired** here; the controller's own default is `() => false`.
660
+ // That default is why a running turn was never marked `isStreaming`, which cost two things
661
+ // at once and looked like a client problem both times: the phone drew no live card, and
662
+ // the "running card is last" rule had nothing to detect, so the user's own messages stayed
663
+ // below their own conversation.
664
+ sessionRunning: (sessionId) => options.isSessionRunning?.(sessionId) === true,
665
+ // …and so does this: the view is what the controller actually reads, so without it a
666
+ // photo arrives as a file entry whose `imageRef` means nothing to the phone.
667
+ hydrate: hydrateRows,
668
+ });
669
+ // Bound once, not inline: the rename path has to be able to invalidate this source's
670
+ // cached title, and a property of the object literal below is not in scope here.
671
+ const sessionSource = createSessionControllerSource({
672
+ sessionController,
673
+ readActiveSessions: () => readActiveSessionSummaries(ctx),
674
+ subscribe: (name, listener) => ctx.on(name, listener),
675
+ readTitles: sessionQuery === undefined ? undefined : (ids) => readTitles(sessionQuery, ids),
676
+ readSessionMeta: sessionQuery === undefined ? undefined : (ids) => readSessionMeta(sessionQuery, ids),
677
+ readActivityEvidence: typeof readMessages?.activity === 'function' ? (sessionId) => readMessages.activity(sessionId) : undefined,
678
+ // ID lookup is not sidebar discovery. New drafts exist before they have a
679
+ // user turn and must be readable without waiting for the list cache.
680
+ readSessionSummary: typeof sessionQuery?.observeSession !== 'function' ? undefined : async (sessionId) => {
681
+ let observation;
682
+ try {
683
+ observation = await sessionQuery.observeSession(sessionId, { projectionMode: 'all', signal: AbortSignal.timeout(15_000) });
684
+ const header = observation.header;
685
+ if (header.id !== sessionId || header.cwd === undefined) return undefined;
686
+ const metadata = observation.projections?.values?.sessionListMetadata;
687
+ return {
688
+ sessionId: header.id,
689
+ createdAt: header.createdAt,
690
+ updatedAt: Math.max(header.createdAt, metadata?.lastPromptAt ?? 0),
691
+ cwd: header.cwd,
692
+ origin: header.origin,
693
+ parentSessionId: header.parentSession,
694
+ blank: metadata?.blank === true,
695
+ running: options.isSessionRunning?.(sessionId) === true,
696
+ projections: observation.projections,
697
+ };
698
+ } catch (error) {
699
+ if (error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return undefined;
700
+ throw error; // An I/O or replay failure is not evidence of absence.
701
+ } finally {
702
+ observation?.[Symbol.dispose]?.();
703
+ }
704
+ },
705
+ });
706
+ return {
707
+ kind: 'session-controller',
708
+ readMessages,
709
+ countMessages,
710
+ historyView,
711
+ // Reported through the status route: "the photo does not render" has three
712
+ // indistinguishable causes from the outside, and this separates them.
713
+ attachmentReads,
714
+ // Internal plumbing rather than a controller capability: the live push path has to inline a
715
+ // message's images before the row leaves, exactly like the two read paths do. It rides the
716
+ // seam so there is **one** hydrator (`hydrateRows`) instead of one per caller — a second
717
+ // copy is how the history view ended up serving un-hydrated rows.
718
+ hydrateImages: hydrateRows,
719
+ /**
720
+ * Create one session **the way the desk's own UI creates it**.
721
+ *
722
+ * `session.create` takes `workspaceId` **or** `cwd`, never both, and only the
723
+ * workspace form calls `workspace.attachSession()` — the registration that DSH's
724
+ * own session list groups by (the `sessionIds` array in
725
+ * `.dsh/storages/workspace.json`). Creating with a bare `cwd` therefore produces a
726
+ * session that exists, runs, streams and answers, and is **invisible in DSH
727
+ * itself**: reported from the handset as 手机上新建一个对话,dsh 上没看到, and
728
+ * measured — the phone's session was absent from that workspace's `sessionIds`
729
+ * while a Web-created session sat in it.
730
+ *
731
+ * So the requested directory is resolved to its workspace first (reusing the
732
+ * existing record for that canonical path, creating one only when the directory is
733
+ * genuinely new to DSH). A `workspaceRegistry` that cannot place the path — not
734
+ * mounted, relative, missing, a file — falls back to the bare `cwd`, which keeps
735
+ * the old behaviour: the session is created, it just is not grouped on the desk.
736
+ *
737
+ * It also installs the model the controller picked, because a new conversation
738
+ * has no other way to say it: the handset's pipeline is create, getSession,
739
+ * enqueue, and it never calls `setModel` (`newSessionCreation.ts`), so the
740
+ * runtime it chose reaches this Host **only** in these options. Creating first
741
+ * and selecting second is the only order DSH allows — a selection needs a
742
+ * session — and it is the difference between the user's pick and the profile
743
+ * default: without it the first prompt runs on `agent-default-model` and the
744
+ * handset reports 「新对话选了 gpt,一运行又变成 deepseek」.
745
+ *
746
+ * The permission preset rides the same options for the same reason, and it is the
747
+ * one where a wrong answer is dangerous rather than merely annoying: a session that
748
+ * keeps `danger-full-access` after the user asked for `read-only` reads as a
749
+ * restriction the agent does not have. Only a name this Host advertises is
750
+ * installed — see `applyPermissionMode` — and the reply names it only when it was
751
+ * really installed.
752
+ *
753
+ * A model this Host cannot route **throws** rather than being swallowed. The
754
+ * session exists either way, and the controller's create retry is idempotent
755
+ * and probes for the session before rebuilding, so the honest refusal costs one
756
+ * retry while a silent wrong model costs a conversation.
757
+ */
758
+ createSession: async (options) => {
759
+ const cwd = typeof options?.cwd === 'string' && options.cwd !== '' ? options.cwd : undefined;
760
+ const workspace = await workspaceForNewSession(cwd);
761
+ const created = await sessionController.create({
762
+ ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
763
+ ...(workspace === undefined ? (cwd === undefined ? {} : { cwd }) : { workspaceId: workspace.id }),
764
+ });
765
+ sessionSource.invalidateListing();
766
+ const installed = {};
767
+ if (typeof options?.model === 'string' && options.model !== '') {
768
+ const applied = await applyModelSelection({
769
+ sessionId: created.sessionId,
770
+ provider: options.provider,
771
+ model: options.model,
772
+ reasoningEffort: options.reasoningEffort,
773
+ });
774
+ installed.selection = applied?.selected ?? null;
775
+ }
776
+ const mode = typeof options?.permissionMode === 'string' ? options.permissionMode.trim() : '';
777
+ if (mode !== '') {
778
+ const permissionMode = await applyPermissionMode({ sessionId: created.sessionId, mode });
779
+ if (permissionMode !== null) installed.permissionMode = permissionMode;
780
+ }
781
+ return Object.keys(installed).length === 0 ? created : { ...created, ...installed };
782
+ },
783
+ /**
784
+ * Send one prompt, carrying whatever attachments this Host can serve.
785
+ *
786
+ * The controller's attachments arrive inside the message. An absolute host path
787
+ * is read through the composed filesystem, and an upload transit reference is
788
+ * fetched from the account's media staging area (`resolveAttachmentRef`, which
789
+ * this runtime supplies because the account session is its own). Both become a
790
+ * durable DSH attachment — an image part, or a file receipt.
791
+ *
792
+ * Text-bearing prompts are never refused for an attachment that could not be
793
+ * carried: the text still goes, and the answer says what was left out. A prompt
794
+ * that is **only** an attachment is the one case that must fail loudly, because
795
+ * there is nothing left to send — accepting it would show the user a delivered
796
+ * photo that the agent never saw.
797
+ */
798
+ sendMessage: async ({ sessionId, text, requestId, mode, attachments, resolveAttachmentRef, releaseAttachmentRef }) => {
799
+ const incoming = Array.isArray(attachments) ? attachments : [];
800
+ const body = typeof text === 'string' ? text : '';
801
+ let parts = [];
802
+ let dropped = [];
803
+ let consumed = [];
804
+ if (incoming.length > 0) {
805
+ const fileSystem = ctx.get('fs');
806
+ const materializer = createAttachmentMaterializer({
807
+ fileSystem,
808
+ fileUploads: controls.fileUploads,
809
+ resolveAttachmentRef,
810
+ });
811
+ const resolved = await sessionController.resolveAgent(sessionId);
812
+ if (resolved?.error !== undefined) throw resolved.error;
813
+ ({ parts, dropped, consumed } = await materializer.materialize({
814
+ agent: resolved.agent,
815
+ attachments: incoming,
816
+ signal: AbortSignal.timeout(30_000),
817
+ }));
818
+ }
819
+ if (parts.length === 0 && incoming.length > 0 && body.trim() === '') {
820
+ return {
821
+ ok: false,
822
+ code: 'ATTACHMENT_UNAVAILABLE',
823
+ message: `this Host could not fetch the attachment: ${dropped.map((entry) => entry.reason).join(', ') || 'unknown reason'}`,
824
+ attachmentsDropped: dropped,
825
+ };
826
+ }
827
+ const content = [
828
+ ...(body !== '' ? [{ type: 'text', text: body }] : []),
829
+ ...parts,
830
+ ];
831
+ const result = await sessionController.prompt({
832
+ requestId,
833
+ sessionId,
834
+ // An idle turn queues; a running turn steers. Both are the same DSH
835
+ // primitive, which is why one capability serves both channels.
836
+ mode: mode === 'steer' ? 'steer' : 'queue',
837
+ content,
838
+ // `signal` is REQUIRED here, not optional: the service calls
839
+ // `signal.throwIfAborted()` on entry, so omitting it throws a TypeError
840
+ // that the controller never sees — it just keeps spinning. The timeout is
841
+ // the honest bound: acceptance is fast, and a prompt still unaccepted
842
+ // after 30s is a failure, not a slow success.
843
+ }, AbortSignal.timeout(30_000));
844
+ // The staged object was a **transit** copy, and its bytes are now a durable
845
+ // DSH attachment. Releasing it after the prompt has landed keeps the staging
846
+ // area from accumulating an orphan per photo; a failure here is never worth
847
+ // failing a prompt that already succeeded, so it is best-effort by contract.
848
+ if (typeof releaseAttachmentRef === 'function') {
849
+ for (const ossKey of consumed) void releaseAttachmentRef(ossKey).catch(() => undefined);
850
+ }
851
+ return { ok: true, result, attachmentsServed: parts.length, attachmentsDropped: dropped };
852
+ },
853
+ /**
854
+ * Rename, and drop the cached title the same moment.
855
+ *
856
+ * The row this answers with is built from the list read, which serves titles from a
857
+ * cache (`dsh-session-source.js`); without this the controller would read back the
858
+ * **old** title for up to the cache's TTL and look like the rename failed. A rename
859
+ * is the only title change this Host causes itself, so it is the only one that needs
860
+ * telling.
861
+ */
862
+ renameSession: async ({ sessionId, title }) => {
863
+ const result = await sessionController.rename({ sessionId, title });
864
+ sessionSource.invalidateTitle?.(sessionId);
865
+ return result;
866
+ },
867
+ /**
868
+ * The controller's queue commands.
869
+ *
870
+ * DSH keeps the pending queue in the session's durable inbox, so a queued
871
+ * message is not something this Host can edit on its own: `updateQueue` is
872
+ * the only supported mutation, and it is compare-and-set on the item id
873
+ * the controller already holds.
874
+ */
875
+ queueControl: {
876
+ /**
877
+ * Commit one pending-queue mutation.
878
+ *
879
+ * `updateQueue` mutates the **live agent's** inbox and refuses outright
880
+ * when there is none — with the message "queued item is no longer
881
+ * pending", which reads as a race and is really "this session is not
882
+ * attached". So the agent is resolved first, exactly as the goal writes
883
+ * do; without it every cancel and edit failed on a session nobody had
884
+ * opened, even though the item was plainly still queued.
885
+ */
886
+ update: async ({ sessionId, itemId, action }) => {
887
+ const resolved = await sessionController.resolveAgent(sessionId);
888
+ if (resolved?.error !== undefined) throw resolved.error;
889
+ return sessionController.updateQueue({ sessionId, itemId, action });
890
+ },
891
+ // Cancels the active turn **without** dropping the pending inbox, which
892
+ // is exactly what the controller asks for when it stops with a queue.
893
+ cancel: ({ sessionId }) => sessionController.cancel({ sessionId }),
894
+ },
895
+ /**
896
+ * How full one session's context is.
897
+ *
898
+ * A **read**, so it never resumes a session to answer: `ctx.sessions` holds
899
+ * live sessions only, and a cold session answers `null` (the controller
900
+ * renders that as "暂无上下文数据") rather than paying a resume for a number
901
+ * the user only glances at. That is the same rule the goal read follows.
902
+ *
903
+ * The total comes from `ctx.tokenMeter.measure(session)`; the window comes
904
+ * from the routed model, and is omitted when the provider does not disclose
905
+ * one — the controller then shows the raw count instead of a percentage it
906
+ * would have to invent.
907
+ * @param request - the session to measure.
908
+ * @returns `{ totalTokens, maxTokens?, percent? }`, or null when unknown.
909
+ */
910
+ contextUsage: async ({ sessionId }) => {
911
+ const meter = controls.tokenMeter;
912
+ if (typeof meter?.measure !== 'function') return null;
913
+ const sessions = ctx.get('sessions');
914
+ const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
915
+ if (session === undefined) return null;
916
+ let measurement;
917
+ try {
918
+ measurement = meter.measure(session);
919
+ } catch {
920
+ return null;
921
+ }
922
+ const totalTokens = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
923
+ if (totalTokens === null) return null;
924
+ const window = await contextWindowFor(sessionId);
925
+ if (window === null || window <= 0) return { totalTokens };
926
+ return { totalTokens, maxTokens: window, percent: Math.min(100, Math.max(0, (totalTokens / window) * 100)) };
927
+ },
928
+ /**
929
+ * Regenerate one session's title.
930
+ *
931
+ * The controller's contract is generate-only — it persists whatever it gets
932
+ * through `local-db:sessions:patch-meta` — and it renders `{ title: null }` as
933
+ * "nothing to name it with". So a service that is absent, a session that is
934
+ * cold, and a provider that fails all answer the same way, and none of them is
935
+ * an error frame.
936
+ *
937
+ * A cold session is deliberately **not** resumed: this is a decoration on a
938
+ * session the user has open, and waking an agent to name it would be a side
939
+ * effect nobody asked for (the rule the goal and context reads follow too).
940
+ * @param request - the session whose title should be regenerated.
941
+ * @returns `{ title }`, null when there is none.
942
+ */
943
+ regenerateTitle: async ({ sessionId }) => {
944
+ const titles = controls.sessionTitle;
945
+ if (typeof titles?.refresh !== 'function') return { title: null };
946
+ const sessions = ctx.get('sessions');
947
+ const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
948
+ if (session === undefined) return { title: null };
949
+ const snapshot = await titles.refresh(session, AbortSignal.timeout(25_000));
950
+ const title = typeof snapshot?.title === 'string' && snapshot.title !== '' ? snapshot.title : null;
951
+ return { title };
952
+ },
953
+ /**
954
+ * The model catalog the controller's picker reads.
955
+ *
956
+ * A capability read is the only way it learns this Host's models, and it
957
+ * asks once per device: an empty catalog does not look like a missing
958
+ * feature, it looks like a Host with nothing to offer.
959
+ */
960
+ modelCatalog: () => sessionController.modelCatalog(),
961
+ /**
962
+ * Make sure a session has a live agent, and say whether it does.
963
+ *
964
+ * The inbox — the pending queue — is a property of the **live agent**. A
965
+ * cold session's projection snapshot carries no inbox at all, so a queue
966
+ * that is plainly durable reads as empty until something attaches the
967
+ * session, and a mutation is refused with "queued item is no longer
968
+ * pending". Reading or changing the queue therefore begins by attaching.
969
+ */
970
+ ensureAgent: async (sessionId) => {
971
+ const resolved = await sessionController.resolveAgent(sessionId);
972
+ return resolved?.error === undefined;
973
+ },
974
+ /**
975
+ * Read one session's authoritative projection state.
976
+ *
977
+ * The control stream *pushes* projection changes, but a push that never
978
+ * arrives is indistinguishable from "nothing changed": that is how a queue
979
+ * came to hold a phantom row for a message the agent had already answered.
980
+ * `observeSession` computes the registered projections on demand, so this is
981
+ * the read that can be trusted. The lease is always released.
982
+ */
983
+ readSessionState: readSessionStateNow,
984
+ /** Apply the controller's model choice to one session. */
985
+ selectModel: async ({ sessionId, provider, model, reasoningEffort }) =>
986
+ applyModelSelection({ sessionId, provider, model, reasoningEffort }),
987
+ /**
988
+ * Change one session's reasoning effort.
989
+ *
990
+ * Effort is part of a **model selection** in DSH
991
+ * (`SessionSelectModelRequest extends ModelSelection`), so the write has to
992
+ * name the model it applies to. The session's own selection is the
993
+ * authority — re-stating it is not a model switch, and picking the catalog
994
+ * default here would silently move a session onto another model as a side
995
+ * effect of changing effort.
996
+ */
997
+ setEffort: async ({ sessionId, effort }) => {
998
+ const selection = await currentSelection(sessionId);
999
+ if (selection === null) throw new Error('this Host cannot name the model this session runs on');
1000
+ // Re-stating the effort a session already runs on is a no-op, not a write.
1001
+ //
1002
+ // This is not just an optimisation: DSH validates the effort against the model
1003
+ // *route*, and a route that lists no `default` rejects the word outright
1004
+ // ("provider \"openai-codex\" model \"gpt-5.6-sol\" does not support reasoning
1005
+ // effort \"default\"") while the session is demonstrably running on it. The
1006
+ // controller restores the value it read from the row, so that combination is
1007
+ // the *ordinary* path — and it used to surface as the Host crashing.
1008
+ if (selection.reasoningEffort === effort) return selection;
1009
+ const supported = await supportedEfforts(selection);
1010
+ if (supported !== null && !supported.includes(effort)) {
1011
+ throw refusalError('NOT_AVAILABLE', `model ${selection.model} offers no reasoning effort "${effort}"`);
1012
+ }
1013
+ return sessionController.selectModel({ sessionId, ...selection, reasoningEffort: effort });
1014
+ },
1015
+ /**
1016
+ * The composer's `/` and `@` palettes.
1017
+ *
1018
+ * All three sources are other plugins' services and may be absent, so each
1019
+ * answers with an empty envelope rather than refusing: an empty palette is
1020
+ * honest ("nothing to offer"), while a refusal here reads to the controller
1021
+ * as a broken composer.
1022
+ */
1023
+ palette: {
1024
+ /**
1025
+ * The skills the human-facing `/` menu can offer.
1026
+ *
1027
+ * **The scope is the whole point.** `ctx.skills.list()` reads the *global*
1028
+ * layer when no scope is given, and an agent preset's skills are registered
1029
+ * into that preset's own layer — so listing from the host context answered
1030
+ * "no skills" on a profile that plainly has them (the phone's `/` menu was
1031
+ * empty while its command list was not).
1032
+ *
1033
+ * The scope comes from the session's own agent, which is the only way a
1034
+ * preset's layer is visible at all — see `agentScopeFor`.
1035
+ */
1036
+ skills: async ({ cwd, sessionId }) => {
1037
+ const skills = controls.skills;
1038
+ if (skills === undefined || typeof skills.list !== 'function') return [];
1039
+ const scope = await agentScopeFor(sessionId);
1040
+ return skills.list({
1041
+ ...(scope === undefined ? {} : { scope }),
1042
+ ...(cwd === undefined ? {} : { cwd }),
1043
+ });
1044
+ },
1045
+ commands: async ({ sessionId }) => {
1046
+ const commands = ctx.get('commands');
1047
+ if (commands === undefined || typeof commands.list !== 'function') return [];
1048
+ const resolved = await sessionController.resolveAgent(sessionId);
1049
+ // A cold session still has a command catalog; the agent is only what
1050
+ // DSH keys it by, so an unresolvable session yields no commands rather
1051
+ // than an error the palette would show as broken.
1052
+ return resolved?.error === undefined ? commands.list(resolved.agent) : [];
1053
+ },
1054
+ resources: async ({ workingDir, query, cap }) => {
1055
+ const fileSystem = ctx.get('fs');
1056
+ if (fileSystem === undefined || typeof fileSystem.listDir !== 'function') return [];
1057
+ const resolved = typeof fileSystem.resolve === 'function' ? await fileSystem.resolve(workingDir) : workingDir;
1058
+ const listed = await fileSystem.listDir(resolved);
1059
+ return (Array.isArray(listed) ? listed : []).map((entry) => ({
1060
+ name: String(entry?.name ?? ''),
1061
+ kind: entry?.type === 'directory' || entry?.kind === 'dir' ? 'dir' : 'file',
1062
+ })).filter((entry) => entry.name !== '');
1063
+ },
1064
+ },
1065
+ /**
1066
+ * The session controls the composer offers: plan mode and the permission
1067
+ * preset.
1068
+ *
1069
+ * Both are other plugins' services and may be absent, and their absence is
1070
+ * meaningful: DSH documents that a missing `permissions` projection means
1071
+ * "clients hide the control". So the capability list and the channels are
1072
+ * built from what is composed, never from what this Host wishes were.
1073
+ */
1074
+ sessionControls: {
1075
+ /**
1076
+ * Whether this Host can switch plan mode for a session.
1077
+ *
1078
+ * Plan mode is preset-owned here, so the question is about the
1079
+ * composition a session gets, and it is answered from evidence in order
1080
+ * of strength: a host-plane service if one exists, a live agent whose
1081
+ * context carries it, then the default preset's composition rows. Only
1082
+ * when none of those can be read does this answer false — and false
1083
+ * hides the control, which is the same silence DSH itself uses for a
1084
+ * missing `permissions` projection.
1085
+ * @returns true when a plan-mode switch exists to be driven.
1086
+ */
1087
+ async planModeSupported() {
1088
+ if (typeof controls.planMode?.set === 'function') return true;
1089
+ const agents = ctx.get('agents');
1090
+ if (typeof agents?.list === 'function') {
1091
+ try {
1092
+ if (agents.list().some((agent) => planModeFor(agent) !== undefined || planCommandAvailable(agent))) return true;
1093
+ } catch {
1094
+ // A registry mid-teardown answers nothing; fall through to the
1095
+ // composition read rather than treating it as "unsupported".
1096
+ }
1097
+ }
1098
+ return presetComposesPlanMode();
1099
+ },
1100
+ /**
1101
+ * Switch plan mode for one session.
1102
+ *
1103
+ * The command registry is the real switch here. Plan mode is composed by
1104
+ * the agent's preset inside its own scope, so `ctx.planMode` is not
1105
+ * reachable from the Host; the `/plan` command that preset registered is,
1106
+ * and it is the exact path the Web composer takes. Its own answer travels
1107
+ * back to the controller rather than being flattened into a success, so a
1108
+ * switch that only lands at the next step is not reported as already on.
1109
+ * @param request - the session to switch and the requested state.
1110
+ * @returns the outcome word, or the command's own result.
1111
+ */
1112
+ async setPlanMode({ sessionId, enabled }) {
1113
+ const resolved = await sessionController.resolveAgent(sessionId);
1114
+ if (resolved?.error !== undefined) throw resolved.error;
1115
+ const agent = resolved.agent;
1116
+ const scoped = planModeInAgentScope(agent);
1117
+ if (typeof scoped?.set === 'function') return scoped.set(agent, enabled === true);
1118
+ if (typeof controls.planMode?.set === 'function') return controls.planMode.set(agent, enabled === true);
1119
+
1120
+ const commands = ctx.get('commands');
1121
+ // A preset that composes no plan mode registers no `/plan`, so the
1122
+ // refusal is per session and about composition, not about this Host.
1123
+ if (typeof commands?.execute !== 'function' || !planCommandAvailable(agent)) {
1124
+ throw new Error('this session composes no plan mode');
1125
+ }
1126
+ const execution = await commands.execute(
1127
+ agent,
1128
+ enabled === true ? '/plan' : '/plan off',
1129
+ [],
1130
+ // The registry's contract takes a caller-owned signal; an unarmed
1131
+ // switch is instant, so a still-unsettled one after 30s is a failure.
1132
+ AbortSignal.timeout(30_000),
1133
+ );
1134
+ if (execution === undefined) throw new Error('the plan command did not resolve');
1135
+ const result = execution?.result;
1136
+ if (result?.kind === 'error') throw new Error(String(result.text ?? 'plan mode switch failed'));
1137
+ return {
1138
+ // The command answers with human text ("Plan mode on." vs "applies
1139
+ // from the next step"), not with DSH's outcome word, so claiming
1140
+ // `committed` here would be a guess. What is known is that the
1141
+ // switch was accepted, and the text says when it lands.
1142
+ outcome: 'accepted',
1143
+ message: typeof result?.text === 'string' ? result.text : undefined,
1144
+ };
1145
+ },
1146
+ /** The advertised preset names, in the preset table's order. */
1147
+ permissionNames: advertisedPermissionNames,
1148
+ setPermissionMode: installPermissionMode,
1149
+ },
1150
+ /**
1151
+ * The aggregated remote file browser the controller's file screen uses.
1152
+ *
1153
+ * A factory rather than an instance: the filesystem service can be replaced
1154
+ * when its plugin reloads, and the reader has to come from whatever is
1155
+ * composed at the moment of the call — the same lesson as every other seam
1156
+ * in this Host.
1157
+ */
1158
+ fileBrowser: () => {
1159
+ const fileSystem = ctx.get('fs');
1160
+ return fileSystem === undefined ? undefined : createFileBrowser({ fileSystem });
1161
+ },
1162
+ source: sessionSource,
1163
+ };
1164
+ }
1165
+
1166
+ const apiProxy = ctx.get('apiProxy');
1167
+ if (apiProxy === undefined) {
1168
+ return { kind: 'api-proxy', readMessages: undefined, createSession: undefined, sendMessage: undefined, source: undefined };
1169
+ }
1170
+ // The legacy adapter is loaded **lazily, and its absence is not fatal**.
1171
+ //
1172
+ // Measured on a clean install (a tarball into a fresh DSH_HOME): a static import here dragged
1173
+ // `@deepseek-ai/dsh-host-apiproxy` in as a hard requirement, its transitive
1174
+ // `@deepseek-ai/dsh-agent-presets` resolved to a copy that does not export `InvalidPresetIdError`,
1175
+ // and **the plugin failed to load at all** — the whole profile would not boot, on a dependency
1176
+ // the current DSH never even uses (it mounts `sessionController`, not `apiProxy`). An optional
1177
+ // capability must not be able to break mounting, so it is required at the moment it is needed
1178
+ // and a failure degrades to "this seam serves nothing" instead of "the tree does not load".
1179
+ try {
1180
+ const require = createRequire(import.meta.url);
1181
+ const { InProcessApiClient, toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy');
1182
+ return { kind: 'api-proxy', readMessages: undefined, createSession: undefined, sendMessage: undefined, source: new DshHostSource(new InProcessApiClient(toFetchHandler(apiProxy))) };
1183
+ } catch {
1184
+ return { kind: 'api-proxy', readMessages: undefined, createSession: undefined, sendMessage: undefined, source: undefined };
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * How long this Host may run with a clean boundary record before that is itself suspicious.
1190
+ *
1191
+ * A boundary that never fires and a boundary that was never installed look identical from the
1192
+ * outside, and the second one is how a whole `dsh web` process dies. So `handlerErrors: []` is
1193
+ * not automatically good news: past this uptime, an empty record means "nothing has exercised
1194
+ * the boundaries yet", which is a fact worth reading rather than a green light.
1195
+ */
1196
+ export const BOUNDARY_SILENCE_MS = 10 * 60_000;
1197
+
1198
+ /**
1199
+ * The boundary verdict, as one word plus the numbers behind it.
1200
+ * @param options - contained errors, uptime, and the silence threshold.
1201
+ * @returns `{ verdict, contained, uptimeMs }` where verdict is `recovered` | `silent` | `starting`.
1202
+ */
1203
+ export function boundaryVerdict({ contained = [], uptimeMs = 0, silenceMs = BOUNDARY_SILENCE_MS } = {}) {
1204
+ const count = Array.isArray(contained) ? contained.length : 0;
1205
+ if (count > 0) return { verdict: 'recovered', contained: count, uptimeMs };
1206
+ return { verdict: uptimeMs >= silenceMs ? 'silent' : 'starting', contained: 0, uptimeMs };
1207
+ }
1208
+
1209
+ /**
1210
+ * The diagnostics block the status route serves.
1211
+ *
1212
+ * Extracted from `apply` with explicit inputs for one measured reason: the first version read
1213
+ * `sessionSource` from a scope it does not exist in, which threw a `ReferenceError`, and the
1214
+ * status route answers a throwing producer by **omitting `diagnostics` entirely** (its own
1215
+ * test pins that, because the settings page must still load). So a single stale reference
1216
+ * silently removed every counter this Host's bug hunts depend on — no error, no gap in the
1217
+ * JSON, just a shorter body. Here the inputs cannot be guessed wrong, a test calls it, and
1218
+ * each volatile field is read through {@link field} so one broken producer degrades that field
1219
+ * instead of the block.
1220
+ *
1221
+ * @param options - the runtime, the seam kind, the current source seam, and the listing reader.
1222
+ * @returns the diagnostics object, with every key present.
1223
+ */
1224
+ export function buildDiagnostics({ runtime, sourceKind, seam, listingDiagnostics, boundaries }) {
1225
+ const field = (read, fallback) => {
1226
+ try {
1227
+ return read();
1228
+ } catch {
1229
+ return fallback;
1230
+ }
1231
+ };
1232
+ return {
1233
+ dataSource: sourceKind,
1234
+ transfers: field(() => runtime?.getTransferDiagnostics?.() ?? null, null),
1235
+ projectionRunning: runtime?.projectionRunning === true,
1236
+ projectedSessions: field(() => (runtime ? runtime.model.list().length : 0), 0),
1237
+ // What a controller actually asked for and what it got back, with the
1238
+ // asking device. Without attribution, polling from another linked
1239
+ // computer reads as if it came from the phone the user is holding.
1240
+ recentInvokes: field(() => (runtime ? runtime.getInvokeLog().slice(-20) : []), []),
1241
+ // Refusals outlive the success ring: a controller polling a transcript
1242
+ // evicts everything else within seconds, and the one refused channel is
1243
+ // the only entry that explains "host failed to serve this channel".
1244
+ recentRefusals: field(() => (runtime ? runtime.getRefusalLog().slice(-30) : []), []),
1245
+ // What went out, and to how many controllers. A push to zero watchers and
1246
+ // a push the controller dropped look identical from the desk, so the
1247
+ // destination count is what makes "the phone never updated" attributable.
1248
+ recentPushes: field(() => (runtime ? runtime.getPushLog().slice(-20) : []), []),
1249
+ // Monotonic per-channel totals. The ring churns during a turn, so a count
1250
+ // taken from it is not evidence that a push happened.
1251
+ pushTotals: field(() => (runtime ? runtime.getPushTotals() : {}), {}),
1252
+ // The same for the request direction: which channels have ever been asked
1253
+ // for, and which of those answers were refusals. `recentInvokes` is a
1254
+ // forty-entry ring and a polling controller evicts it within seconds, so
1255
+ // without this "did the phone ever call X" is unanswerable.
1256
+ invokeTotals: field(() => (runtime ? runtime.getInvokeTotals() : {}), {}),
1257
+ refusalTotals: field(() => (runtime ? runtime.getRefusalTotals() : {}), {}),
1258
+ // The phone's own reading of itself, with arrival times. A controller has no log of its
1259
+ // own: it reports by invoking an unknown channel whose name carries the reading, so every
1260
+ // entry here is a refusal **by design**. Kept apart from the rings above because ordinary
1261
+ // polling evicts them within seconds, and "the app was stuck for 96s starting at 10:37:06"
1262
+ // is exactly the finding that must survive until someone looks.
1263
+ phoneDiagnostics: field(() => (runtime ? runtime.getPhoneDiagnostics() : []), []),
1264
+ // How the transcript's image inlining is actually doing: `attempted` counts the
1265
+ // image handles the reader was handed, `served` the ones whose bytes came back.
1266
+ // A photo that renders as a file chip is one of {no handle on the block, no
1267
+ // attachment service on this profile, a failed read} and only these numbers tell
1268
+ // them apart. `served: 0, attempted: 0` with a photo in the transcript means the
1269
+ // handle never reached the reader at all.
1270
+ attachmentReads: seam?.attachmentReads ?? { attempted: 0, served: 0, failed: 0 },
1271
+ // How the automatic reconnection is doing. "The Host vanished from the phone" and
1272
+ // "the Host was briefly offline and came back on its own" look identical from the
1273
+ // handset, and only this tells them apart.
1274
+ reconnect: field(() => (runtime ? runtime.getReconnectState() : { attempts: 0, pending: false, lastReason: null }), null),
1275
+ // What had to be given up to fit one device-link frame. The relay drops an
1276
+ // oversized frame outright, so a degraded page and a page that never arrived
1277
+ // are indistinguishable from the handset — this is where they differ.
1278
+ frameBudget: field(
1279
+ () => (runtime && typeof runtime.getFrameBudget === 'function'
1280
+ ? runtime.getFrameBudget()
1281
+ : { limitBytes: null, refusals: 0, recentRefusals: [], degradations: [] }),
1282
+ null,
1283
+ ),
1284
+ // Errors this Host caught at its own boundaries. Should stay empty: each entry is an
1285
+ // exception that would otherwise have left the whole `dsh web` process through DSH's
1286
+ // fail-loud unhandled-rejection handler.
1287
+ handlerErrors: field(() => (runtime && typeof runtime.getHandlerErrors === 'function' ? runtime.getHandlerErrors() : []), []),
1288
+ /**
1289
+ * The boundary record, with a verdict rather than just a list.
1290
+ *
1291
+ * `recovered` means the plugin caught something that would otherwise have ended the whole
1292
+ * `dsh web` process — good news, and worth reading. `silent` means the process has been up
1293
+ * past {@link BOUNDARY_SILENCE_MS} without a single boundary firing, which is **not**
1294
+ * automatically healthy: a boundary that is never exercised and one that was never
1295
+ * installed are indistinguishable from here, and `installed` is what tells them apart.
1296
+ */
1297
+ boundaries: field(() => {
1298
+ const contained = runtime && typeof runtime.getHandlerErrors === 'function' ? runtime.getHandlerErrors() : [];
1299
+ const uptimeMs = runtime && typeof runtime.getUptimeMs === 'function' ? runtime.getUptimeMs() : 0;
1300
+ return {
1301
+ ...boundaryVerdict({ contained, uptimeMs }),
1302
+ installed: Array.isArray(boundaries?.installed) ? [...boundaries.installed] : [],
1303
+ };
1304
+ }, { verdict: 'unknown', contained: 0, uptimeMs: 0, installed: [] }),
1305
+ // How the session listing is doing. `staleServes > 0` means a controller was answered
1306
+ // from the previous read because the live one missed its deadline — an absorbed
1307
+ // degradation, and the difference between a rendered list and the reported spinner.
1308
+ listing: field(() => (typeof listingDiagnostics === 'function' ? listingDiagnostics() : null), null),
1309
+ // Rows this Host deliberately did not show the phone. Hidden is not dropped: the count is how
1310
+ // a projection decision stays distinguishable from a lost message.
1311
+ suppressedNotices: field(() => (runtime && typeof runtime.getSuppressedNotices === 'function' ? runtime.getSuppressedNotices() : 0), 0),
1312
+ // The topics controllers currently hold. An empty set means the phone
1313
+ // never subscribed — the single most likely reason a live reply or a todo
1314
+ // card never reaches it.
1315
+ subscriptions: field(() => (runtime ? runtime.getSubscriptions() : { devices: [], sessions: [] }), { devices: [], sessions: [] }),
1316
+ };
1317
+ }
1318
+
1319
+ /**
1320
+ * The capability provider the channel layer reads.
1321
+ *
1322
+ * Written as explicit fields rather than a spread of the seam's output so that
1323
+ * `kind`/`source` stay out of the channel layer — but that means a capability
1324
+ * the seam produces can be built and never forwarded, which is exactly what
1325
+ * happened to `queueControl`: every queue button answered NOT_AVAILABLE while
1326
+ * the implementation sat right there. `dsh-plugin.test.js` now asserts that
1327
+ * every capability the seam builds arrives here.
1328
+ * @param sources - the capabilities one source seam produced.
1329
+ * @returns the provider `startHost` resolves per request.
1330
+ */
1331
+ export function capabilityProvider(sources) {
1332
+ return {
1333
+ readMessages: sources.readMessages,
1334
+ countMessages: sources.countMessages,
1335
+ // The work-grouped history window the controller reaches for once it has seen
1336
+ // `history-view-v1`; without this forwarding the three channels would answer
1337
+ // NOT_AVAILABLE while the controller sat right here (the `queueControl` failure).
1338
+ historyView: sources.historyView,
1339
+ attachmentReads: sources.attachmentReads,
1340
+ // Internal (the live push path), but it crosses the same seam, so it is forwarded here: the
1341
+ // invariant test in `test/dsh-plugin.test.js` requires every seam key to be reachable, and a
1342
+ // key that exists but is reachable from nowhere is exactly the bug class this repo keeps
1343
+ // closing.
1344
+ hydrateImages: sources.hydrateImages,
1345
+ createSession: sources.createSession,
1346
+ sendMessage: sources.sendMessage,
1347
+ renameSession: sources.renameSession,
1348
+ files: sources.files,
1349
+ goalWrite: sources.goalWrite,
1350
+ queueControl: sources.queueControl,
1351
+ modelCatalog: sources.modelCatalog,
1352
+ regenerateTitle: sources.regenerateTitle,
1353
+ contextUsage: sources.contextUsage,
1354
+ selectModel: sources.selectModel,
1355
+ setEffort: sources.setEffort,
1356
+ readSessionState: sources.readSessionState,
1357
+ ensureAgent: sources.ensureAgent,
1358
+ palette: sources.palette,
1359
+ sessionControls: sources.sessionControls,
1360
+ fileBrowser: sources.fileBrowser,
1361
+ };
1362
+ }
1363
+
1364
+ /**
1365
+ * Build the controller's goal writer from the two contexts that supply it.
1366
+ *
1367
+ * Both services come from other plugins and activate independently, so this
1368
+ * reads them off the injected contexts rather than closing over anything: the
1369
+ * first version referenced a `buildDshSource` local, which threw
1370
+ * `sessionController is not defined` at call time — a failure no unit test of
1371
+ * the writer could see, because it only exists once the wiring runs.
1372
+ * @param sourceCtx - the context that carries `sessionController`.
1373
+ * @param goalCtx - the context that carries `goals`.
1374
+ * @returns the writer the goal channels call.
1375
+ */
1376
+ export function buildGoalWrite(sourceCtx, goalCtx) {
1377
+ const controller = sourceCtx.get('sessionController');
1378
+ return createGoalWriter({
1379
+ goals: goalCtx.get('goals'),
1380
+ resolveAgent: typeof controller?.resolveAgent === 'function'
1381
+ ? (sessionId) => controller.resolveAgent(sessionId)
1382
+ : undefined,
1383
+ });
1384
+ }
1385
+
1386
+ /**
1387
+ * Read creation times and working directories from the session headers.
1388
+ *
1389
+ * `SessionSummary` carries neither `createdAt` nor a durable cwd, but the phone's
1390
+ * session row requires a creation time.
1391
+ */
1392
+ export async function readSessionMeta(sessionQuery, sessionIds) {
1393
+ const records = await sessionQuery.listSessions();
1394
+ const wanted = new Set(sessionIds);
1395
+ const meta = new Map();
1396
+ for (const record of Array.isArray(records) ? records : []) {
1397
+ const header = record?.header;
1398
+ if (header === undefined || !wanted.has(header.id)) continue;
1399
+ meta.set(header.id, {
1400
+ createdAt: Number.isFinite(header.createdAt) ? new Date(header.createdAt).toISOString() : undefined,
1401
+ cwd: typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : null,
1402
+ });
1403
+ }
1404
+ return meta;
1405
+ }
1406
+
1407
+ /**
1408
+ * Fold a batch title read into a `Map<sessionId, title>`.
1409
+ *
1410
+ * The nesting is the trap: `readTitleSnapshots` answers
1411
+ * `{ sessionId, status, value }`, where `value` is a `SessionTitleObservation`
1412
+ * whose `title` is a `SessionTitleSnapshot` — so the text is at
1413
+ * `value.title.title`, two levels below a field that already reads like a title.
1414
+ * Reading `value.title` as a string silently yields an object, which fails a
1415
+ * `typeof === 'string'` guard and leaves every session unnamed.
1416
+ *
1417
+ * A rejected entry is one session's failure, not the batch's: titles are row
1418
+ * decoration, so the rest still come through.
1419
+ */
1420
+ export function foldTitleSnapshots(results) {
1421
+ const titles = new Map();
1422
+ const rows = Array.isArray(results) ? results : [];
1423
+ for (const row of rows) {
1424
+ if (row === null || typeof row !== 'object' || row.status !== 'fulfilled') continue;
1425
+ const snapshot = row.value?.title;
1426
+ const text = typeof snapshot?.title === 'string' ? snapshot.title : null;
1427
+ if (typeof row.sessionId === 'string' && text !== null && text.trim() !== '') {
1428
+ titles.set(row.sessionId, text.trim());
1429
+ }
1430
+ }
1431
+ return titles;
1432
+ }
1433
+
1434
+ async function readTitles(sessionQuery, sessionIds) {
1435
+ return foldTitleSnapshots(await sessionQuery.readTitleSnapshots(sessionIds));
1436
+ }
1437
+
1438
+ /**
1439
+ * The settings write for the archive/delete/pin flags.
1440
+ *
1441
+ * **`mutate` with one `set` op, never `update`.** `update` deep-merges its patch into
1442
+ * the namespace's user section, so it cannot express a removal: un-archiving a session
1443
+ * writes "no flags for this session", which merges into nothing and leaves the archived
1444
+ * flag still on disk — where the next settings commit re-seeds it and the session
1445
+ * silently stays hidden. Measured on the live Host: archived → restored → *still*
1446
+ * `archived`, with the stale flag reloaded from the file. A path-addressed `set`
1447
+ * replaces the field exactly, which is what a snapshot write means.
1448
+ *
1449
+ * @param settings - the `settings` service.
1450
+ * @param ns - this bundle's namespace, as registered.
1451
+ * @returns the writer the runtime persists through.
1452
+ */
1453
+ export function sessionFlagsWriter(settings, ns) {
1454
+ return (sessionFlags) => settings.mutate(ns, [{ op: 'set', path: ['sessionFlags'], value: sessionFlags }]);
1455
+ }
1456
+
1457
+ /**
1458
+ * The settings seam, for both DSH generations this bundle supports.
1459
+ *
1460
+ * **0.1.x**: `settings.register(ns, schema, options)` publishes a plugin's settings and returns
1461
+ * a live scope (`get`/`watch`/`update`/`replace`). The registration is *also* what makes
1462
+ * `update(ns, …)` and `mutate(ns, …)` accept this namespace — their write path looks the
1463
+ * namespace up in the provider's registration table.
1464
+ *
1465
+ * **0.2 removed `register`.** The host now reads the plugin **entry's own `Config`** schema
1466
+ * (`SettingsForms.describe()`, keyed by **profile entry id**), resolves it, and hands the value
1467
+ * to `apply(ctx, config)`; `update`/`mutate` address that same entry id. This bundle's row is
1468
+ * `- id: dsh-cindy-host` (see `cordis.patch.yml`), so `SETTINGS_NAMESPACE` names the same
1469
+ * surface under both models — only the way the plugin obtains it differs. Hence one seam with
1470
+ * two branches instead of two code paths through the rest of the plugin.
1471
+ *
1472
+ * The returned object always has the 0.1.x scope shape, so every consumer below is
1473
+ * generation-agnostic.
1474
+ *
1475
+ * @param ctx - the plugin context that owns the `settings` service.
1476
+ * @param entryId - the namespace (0.1.x) / profile entry id (0.2) this bundle writes.
1477
+ * @param projected - the host-resolved `Config` value; only 0.2 supplies it.
1478
+ * @returns the settings scope described above.
1479
+ */
1480
+ export function createSettingsScope(ctx, entryId, projected) {
1481
+ const { settings } = ctx;
1482
+ if (typeof settings.register === 'function') {
1483
+ return settings.register(entryId, HostSchema, { base: DEFAULT_HOST_SETTINGS, applies: 'live', validate: validateHostSettings });
1484
+ }
1485
+ if (projected === undefined) throw new Error('dsh-cindy-host: the host resolved no Config for this entry, so there is nowhere to read settings from');
1486
+ // Volatile Config is updated WITHOUT remounting the plugin. Keep a detached
1487
+ // snapshot and re-read the settings owner's resolved value after its event;
1488
+ // copying apply(config) once left the relay disabled after the UI enabled it.
1489
+ const snapshot = (value) => {
1490
+ const next = JSON.parse(JSON.stringify({ ...DEFAULT_HOST_SETTINGS, ...value }));
1491
+ validateHostSettings(next);
1492
+ return next;
1493
+ };
1494
+ let current = snapshot(projected);
1495
+ const watchers = new Set();
1496
+ const report = (error) => ctx.logger?.warn?.(`dsh-cindy-host settings: a watcher failed and was contained: ${String(error)}`);
1497
+ const publish = (next) => {
1498
+ if (JSON.stringify(current) === JSON.stringify(next)) return;
1499
+ current = next;
1500
+ for (const callback of [...watchers]) {
1501
+ try {
1502
+ // Runtime updates are asynchronous. A synchronous try/catch alone does
1503
+ // not contain a rejection from starting or stopping the relay.
1504
+ Promise.resolve(callback(next)).catch(report);
1505
+ } catch (error) {
1506
+ report(error);
1507
+ }
1508
+ }
1509
+ };
1510
+ let refreshing = false;
1511
+ const refresh = () => {
1512
+ if (refreshing) return; // describe may itself emit document-updated.
1513
+ refreshing = true;
1514
+ try {
1515
+ const descriptor = settings.describe().find((row) => row.ns === entryId);
1516
+ if (descriptor !== undefined) publish(snapshot(descriptor.value));
1517
+ } finally {
1518
+ refreshing = false;
1519
+ }
1520
+ };
1521
+
1522
+ return {
1523
+ get: () => current,
1524
+ refresh,
1525
+ watch: (callback) => {
1526
+ watchers.add(callback);
1527
+ return () => watchers.delete(callback);
1528
+ },
1529
+ update: async (patch) => {
1530
+ await settings.update(entryId, patch);
1531
+ refresh(); // Use resolved values, not an invented shallow merge.
1532
+ },
1533
+ replace: async (section) => {
1534
+ await settings.replace(entryId, section);
1535
+ refresh();
1536
+ },
1537
+ };
1538
+ }
1539
+
1540
+
1541
+ /**
1542
+ * Present one interaction to Cindy and the downstream DSH UI, settling on the first answer.
1543
+ * The downstream branch receives a private abort signal so the losing local card is dismissed;
1544
+ * without that cancellation contract a Promise.race would leak a parked waterfall listener.
1545
+ */
1546
+ export async function arbitrateInteraction({ request, next, begin, settleLocal }) {
1547
+ const handle = await begin();
1548
+ if (handle === null) return next();
1549
+
1550
+ const downstreamAbort = new AbortController();
1551
+ const upstreamSignal = request?.signal;
1552
+ const downstreamSignal = upstreamSignal === undefined
1553
+ ? downstreamAbort.signal
1554
+ : AbortSignal.any([upstreamSignal, downstreamAbort.signal]);
1555
+ let cancellable = false;
1556
+ try {
1557
+ request.signal = downstreamSignal;
1558
+ cancellable = request.signal === downstreamSignal;
1559
+ } catch {
1560
+ try {
1561
+ Object.defineProperty(request, 'signal', { value: downstreamSignal, configurable: true, writable: true });
1562
+ cancellable = request.signal === downstreamSignal;
1563
+ } catch {
1564
+ cancellable = false;
1565
+ }
1566
+ }
1567
+ // Never start a branch we cannot dismiss; keep the existing Cindy answer path instead.
1568
+ if (!cancellable) return handle.answered;
1569
+
1570
+ const controller = Promise.resolve(handle.answered).then((value) => ({ kind: 'controller', value }));
1571
+ const local = Promise.resolve().then(next).then(
1572
+ (value) => ({ kind: 'local', value }),
1573
+ (error) => ({ kind: 'local-error', error }),
1574
+ );
1575
+ const first = await Promise.race([controller, local]);
1576
+ if (first.kind === 'local-error') return (await controller).value;
1577
+ if (first.kind === 'local') {
1578
+ settleLocal(handle.requestId, first.value);
1579
+ return first.value;
1580
+ }
1581
+ downstreamAbort.abort(new Error('interaction settled by Cindy'));
1582
+ void local.then(() => undefined, () => undefined);
1583
+ return first.value;
1584
+ }
1585
+
1586
+ export function apply(ctx, config) {
1587
+ const scope = createSettingsScope(ctx, settingsNamespace(SETTINGS_NAMESPACE), config);
1588
+ let runtime;
1589
+ // The source can arrive before OR after the runtime: `startHost` awaits a
1590
+ // Cindy session before resolving, while `ctx.inject` fires the moment the DSH
1591
+ // service activates. Holding it here and re-applying after the await covers
1592
+ // both orders — dropping it when `runtime` was still undefined is what left
1593
+ // the projection permanently stopped in the first wiring attempt.
1594
+ let currentSource;
1595
+ /**
1596
+ * The whole seam output, forwarded as one object.
1597
+ *
1598
+ * This used to be a hand-maintained field list, and it was the bug twice:
1599
+ * `queueControl` was built and left out (every queue button answered
1600
+ * NOT_AVAILABLE), and then `setEffort` was built, forwarded through
1601
+ * `capabilityProvider`, and left out here — so the phone's effort picker
1602
+ * answered NOT_AVAILABLE on a Host that advertises four effort levels.
1603
+ * `capabilityProvider` is the single place that decides what the channel layer
1604
+ * may see, so the seam output travels whole.
1605
+ */
1606
+ let currentSeam = {};
1607
+ /**
1608
+ * Every listener this plugin hands to the host, and the entry point that wraps it.
1609
+ *
1610
+ * Cordis's `emit` dispatch has no `try`/`catch`, and its `waterfall` hands a listener's
1611
+ * returned promise straight back to DSH — so one throw or rejection from any of these is not
1612
+ * a degraded plugin, it is a dead `dsh web`: DSH's boot registers an unhandled-rejection
1613
+ * handler that writes `fatal load failure` to stderr and calls `process.exit(1)`. Measured
1614
+ * once already (`RemoteError: queued item is no longer pending` took the whole desktop down).
1615
+ *
1616
+ * So each registration goes through {@link guarded}, which names it and contains whatever it
1617
+ * throws. `test/plugin-boundaries.test.js` scans this file and fails when a `ctx.on(`
1618
+ * registration does not go through it — an unwrapped boundary has to be a test failure rather
1619
+ * than something a reviewer has to notice.
1620
+ */
1621
+ const guardedListeners = [];
1622
+
1623
+ /** Record one contained boundary failure, before or after the runtime exists. */
1624
+ const earlyBoundaryErrors = [];
1625
+ function noteBoundaryError(where, error) {
1626
+ if (runtime != null && typeof runtime.noteHandlerError === 'function') {
1627
+ runtime.noteHandlerError(where, error);
1628
+ return;
1629
+ }
1630
+ earlyBoundaryErrors.push({
1631
+ at: new Date().toISOString(),
1632
+ where,
1633
+ message: (error instanceof Error ? error.message : String(error)).slice(0, 400),
1634
+ });
1635
+ if (earlyBoundaryErrors.length > 20) earlyBoundaryErrors.shift();
1636
+ }
1637
+
1638
+ /**
1639
+ * Wrap one host-facing listener in the plugin's single guarded entry point.
1640
+ * @param label - what this listener is, for the diagnostics record.
1641
+ * @param listener - the listener as written.
1642
+ * @returns a listener that cannot let its failure escape.
1643
+ */
1644
+ function guarded(label, listener) {
1645
+ guardedListeners.push(label);
1646
+ return (...args) => {
1647
+ try {
1648
+ const result = listener(...args);
1649
+ if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
1650
+ // A waterfall listener's rejection is the dangerous shape: DSH awaits what it
1651
+ // returns, so a rejection here arrives as an unhandled one at the process level.
1652
+ return result.catch((error) => {
1653
+ noteBoundaryError(label, error);
1654
+ return undefined;
1655
+ });
1656
+ }
1657
+ return result;
1658
+ } catch (error) {
1659
+ noteBoundaryError(label, error);
1660
+ return undefined;
1661
+ }
1662
+ };
1663
+ }
1664
+
1665
+ /** The boundary record for diagnostics: what is installed, and what it has contained. */
1666
+ function boundaryState() {
1667
+ const contained = runtime != null && typeof runtime.getHandlerErrors === 'function'
1668
+ ? runtime.getHandlerErrors()
1669
+ : earlyBoundaryErrors;
1670
+ return { installed: [...guardedListeners], contained: contained.slice() };
1671
+ }
1672
+
1673
+ /**
1674
+ * The current source's listing diagnostics, or a null answer.
1675
+ *
1676
+ * Held here rather than read from `currentSeam`: the first version reached for the
1677
+ * session source by name from inside `getDiagnostics`, where it is out of scope, and the
1678
+ * resulting `ReferenceError` made the status route drop **its whole diagnostics block**
1679
+ * (that route answers a throwing producer by omitting the field, so the settings page still
1680
+ * loads). One stale reference, and every counter a bug hunt needs was gone with no error to
1681
+ * show for it. The seam output also travels whole into the channel layer, which is asserted
1682
+ * by an invariant test — a diagnostics accessor is not a capability and does not belong there.
1683
+ */
1684
+ let currentListingDiagnostics = () => null;
1685
+ /** The controller's file reads, once a filesystem service is composed. */
1686
+ let currentFiles;
1687
+ /** The controller's goal writes, once a goal service is composed. */
1688
+ let currentGoalWrite;
1689
+ // Which seam supplied the source, for the status route's diagnostics. Without
1690
+ // it a silent `none` looks identical to "this profile has no sessions yet".
1691
+ let sourceKind = 'none';
1692
+
1693
+ if (typeof scope.refresh === 'function') {
1694
+ ctx.on('settings/document-updated', guarded('settings-document-updated', (namespace) => {
1695
+ if (namespace === SETTINGS_NAMESPACE) scope.refresh();
1696
+ }));
1697
+ }
1698
+
1699
+ ctx.effect(async () => {
1700
+ runtime = await startHost(undefined, scope.get(), {
1701
+ // A provider, not a snapshot: these callbacks come from the injected
1702
+ // session service, which activates after `startHost` already awaited a
1703
+ // Cindy session. Capturing them here would freeze an empty set and make a
1704
+ // capable Host answer NOT_AVAILABLE forever.
1705
+ resolveCapabilities: () => capabilityProvider({
1706
+ ...currentSeam,
1707
+ // The two capabilities that come from *other* injections rather than
1708
+ // from the seam: the filesystem, and the goal service's writer.
1709
+ files: currentFiles,
1710
+ goalWrite: currentGoalWrite,
1711
+ }),
1712
+ // The controller's archive/delete/pin writes go straight to this plugin's
1713
+ // settings section, so the whole flag store reloads with the process.
1714
+ persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
1715
+ });
1716
+ // Settings can change while startHost awaits authentication. Reconcile the
1717
+ // latest snapshot after the runtime exists instead of dropping that update.
1718
+ await runtime.updateSettings(scope.get());
1719
+ if (currentSource !== undefined) await runtime.setSource(currentSource);
1720
+ return () => runtime.stop();
1721
+ }, 'dsh-cindy-host runtime');
1722
+ ctx.effect(() => scope.watch((settings) => runtime?.updateSettings(settings)), 'dsh-cindy-host settings');
1723
+
1724
+ // Attach the DSH read source whenever a supplier activates, and detach it if
1725
+ // that supplier goes away — a reload of the session API must not leave the
1726
+ // projection reading a dead service.
1727
+ for (const serviceName of SOURCE_SERVICES) {
1728
+ ctx.inject([serviceName], (sourceCtx) => {
1729
+ const built = buildDshSource(sourceCtx, serviceName, {
1730
+ // Read lazily: the runtime resolves after this seam is built, and the state changes on
1731
+ // every turn boundary.
1732
+ isSessionRunning: (sessionId) => runtime?.isSessionRunning?.(sessionId) === true,
1733
+ });
1734
+ // The filesystem is a separate service; the controller's file reads are
1735
+ // only wired when this profile composes one.
1736
+ const fileSystem = sourceCtx.get('fs');
1737
+ if (fileSystem !== undefined) {
1738
+ const reader = createFileReader({ fileSystem });
1739
+ currentFiles = () => reader;
1740
+ }
1741
+ // Goal writes need a live `Agent`, which only this controller can hand out.
1742
+ // `goals` is a separate plugin that may activate later, so it is injected
1743
+ // rather than read here: a missing goal service must leave the channels
1744
+ // answering NOT_AVAILABLE, not make this Host look like it has no source.
1745
+ sourceCtx.inject(['goals'], (goalCtx) => {
1746
+ currentGoalWrite = buildGoalWrite(sourceCtx, goalCtx);
1747
+ return () => {
1748
+ currentGoalWrite = undefined;
1749
+ };
1750
+ });
1751
+ currentSource = built.source;
1752
+ // The seam output travels whole; see `currentSeam`.
1753
+ currentSeam = built;
1754
+ sourceKind = built.kind;
1755
+ // Bound next to the source it describes, because this is the only scope that has it.
1756
+ currentListingDiagnostics = () => (typeof built.source?.listDiagnostics === 'function' ? built.source.listDiagnostics() : null);
1757
+ // The runtime may not have resolved yet; it applies the pending source
1758
+ // after its own await, and picks up the readers at construction.
1759
+ void runtime?.setSource(currentSource);
1760
+ // The input queue and jobs arrive as an AsyncIterable, not a query:
1761
+ // consume it for as long as the service is composed and fold each frame
1762
+ // into the runtime, which is what `maker:input:get-projection` reads.
1763
+ const controllerAbort = new AbortController();
1764
+ void (async () => {
1765
+ try {
1766
+ for await (const frame of sessionController.control(controllerAbort.signal)) {
1767
+ runtime?.applyControlFrame(frame);
1768
+ }
1769
+ } catch {
1770
+ // A broken control stream costs the queue panel, never the session.
1771
+ }
1772
+ })();
1773
+ sourceCtx.effect(() => () => controllerAbort.abort(), `dsh-cindy-host: ${built.kind} control stream`);
1774
+ sourceCtx.effect(() => () => {
1775
+ if (sourceKind === built.kind) sourceKind = 'none';
1776
+ currentSource = undefined;
1777
+ currentSeam = {};
1778
+ currentListingDiagnostics = () => null;
1779
+ currentFiles = undefined;
1780
+ void runtime?.setSource(undefined);
1781
+ }, `dsh-cindy-host: ${built.kind} source`);
1782
+ });
1783
+ }
1784
+
1785
+ // The live session stream. `session/event` fires for every appended event, so
1786
+ // it is the push path's source: fold the event into the controller's message
1787
+ // rows and hand them to whoever is watching that session.
1788
+ ctx.effect(() => ctx.on('session/event', guarded('session-event', async (session, event) => {
1789
+ // This handler runs inside Cordis's own dispatch, which has no `try`/`catch`: a throw
1790
+ // from here does not fail this plugin, it fails the process — DSH's boot installs an
1791
+ // unhandled-rejection handler that writes `fatal load failure` and calls `exit(1)`. The
1792
+ // work below reads DSH's session events and folds them, so it is exactly where an
1793
+ // unexpected event shape surfaces; it must never be able to take the desktop down.
1794
+ await handleSessionEvent(session, event);
1795
+ })), 'dsh-cindy-host: live session stream');
1796
+
1797
+ /** Fold one DSH session event into the push path and the transcript cache. */
1798
+ async function handleSessionEvent(session, event) {
1799
+ if (!runtime || typeof runtime.pushSessionMessage !== 'function') return;
1800
+ const sessionId = session?.header?.id;
1801
+ if (typeof sessionId !== 'string' || sessionId === '') return;
1802
+ // DSH's own turn boundaries are the authority for the phone's spinner.
1803
+ //
1804
+ // The projection's running flag is *not*: `api-session/status` reports
1805
+ // `running: false` for a moment between a prompt being accepted and its turn
1806
+ // really starting, and announcing that as the end produced a `done` **31 ms
1807
+ // after a live turn began** — the controller finalized its streaming rows and
1808
+ // stopped its timer while the agent was still generating. `turn/start` and
1809
+ // `turn/end` are the boundaries that mean what the phone reads into them.
1810
+ if (event?.type === 'turn/start' && typeof runtime.pushTurnRunning === 'function') {
1811
+ runtime.pushTurnRunning(sessionId);
1812
+ } else if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') {
1813
+ runtime.pushTurnIdle(sessionId);
1814
+ }
1815
+ // A real boundary needs no fallback, and arming one here is not harmless: this
1816
+ // handler runs *after* the boundary was announced, so it re-armed the very
1817
+ // timer the boundary had just cancelled and sent a duplicate `done` 1.5s later.
1818
+ const boundaryArrived = event?.type === 'turn/start' || event?.type === 'turn/end';
1819
+ // The prompt is now a durable message, so DSH's inbox no longer holds it and
1820
+ // the controller's optimistic queue row has to go — otherwise it shows
1821
+ // "队列中" for a message the agent already answered.
1822
+ const rpcId = promptRpcIdOf(event);
1823
+ if (rpcId !== null && typeof runtime.retireQueuedItem === 'function') runtime.retireQueuedItem(sessionId, rpcId);
1824
+ // The transcript cache is kept current for **every** session, watched or not.
1825
+ //
1826
+ // It is what makes paging cheap (`createMessageReader`): one full read seeds it,
1827
+ // and each appended event extends it, so a "load earlier" page costs O(page)
1828
+ // instead of re-reading and re-folding a whole session log — measured at 216 ms
1829
+ // warm and 2.3 s during a live turn on the 17 MB conversation the user reported
1830
+ // as 加载更早消息…每次只加载一点点. Skipping unwatched sessions would be wrong: the
1831
+ // cache's whole value is that it never needs re-reading, and the controller
1832
+ // reaches for a session's history through a fresh subscription, not through a
1833
+ // stream this process was already following.
1834
+ if (currentSeam !== undefined && currentSeam !== null && typeof currentSeam.readMessages === 'function'
1835
+ && typeof currentSeam.readMessages.noteEvent === 'function') {
1836
+ currentSeam.readMessages.noteEvent(sessionId, event);
1837
+ currentSource?.invalidateActivityEvidence?.(sessionId);
1838
+ }
1839
+ // A harness notice is hidden from the phone (the fold returns no rows for it), and the count is
1840
+ // what keeps that hiding honest: "there was nothing to show" and "the Host dropped a message"
1841
+ // must not look alike from the desk.
1842
+ if (isHarnessNotice(event) && typeof runtime.noteSuppressedNotice === 'function') runtime.noteSuppressedNotice();
1843
+ // Nothing to do unless a controller is watching; the fold is not free.
1844
+ if (runtime.watchersFor(sessionId) === 0) return;
1845
+ // Awaited, because `pushSessionMessage` inlines the row's images first: a photo has to be
1846
+ // renderable in the frame that carries it, not one read later.
1847
+ for (const row of foldSessionEvent(event, { sessionId })) await runtime.pushSessionMessage(sessionId, row);
1848
+ // A durable message is a second witness that something happened, so it also
1849
+ // schedules the one check that makes sure the spinner cannot outlive the
1850
+ // turn it was watching — unless this event *was* the boundary.
1851
+ if (!boundaryArrived && typeof runtime.reconcileTurnState === 'function') runtime.reconcileTurnState(sessionId);
1852
+ }
1853
+
1854
+ // DSH raises approvals as a waterfall and the chain's terminal answerer is
1855
+ // fail-closed. This answerer only serves questions for sessions a Cindy
1856
+ // controller is watching; in every other case it calls `next()` so the local
1857
+ // UI — or the fail-closed default — decides. Swallowing a question the user
1858
+ // could have answered at the desk would be worse than not answering at all.
1859
+ ctx.effect(() => ctx.on('approval/request', guarded('approval-request', async (req, next) => {
1860
+ if (!runtime || typeof runtime.beginApproval !== 'function') return next();
1861
+ const sessionId = req?.agent?.session?.header?.id;
1862
+ return arbitrateInteraction({
1863
+ request: req,
1864
+ next,
1865
+ begin: () => runtime.beginApproval({
1866
+ sessionId: typeof sessionId === 'string' ? sessionId : '',
1867
+ toolName: typeof req?.toolName === 'string' ? req.toolName : '',
1868
+ reason: req?.reason,
1869
+ callId: req?.callId,
1870
+ signal: req?.signal,
1871
+ }),
1872
+ settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
1873
+ });
1874
+ // `prepend` and `global` are both REQUIRED, and this is the difference between
1875
+ // a phone that can answer a card and one that never sees one.
1876
+ //
1877
+ // `global`: DSH dispatches these waterfalls at the **agent's** scope
1878
+ // (`scopeTarget(agent, agent)`), and Cordis admits a listener to a filtered
1879
+ // dispatch only when it is global, untagged, or tagged with the dispatch key or
1880
+ // an ancestor (`EventsService.dispatch` → `hook.global || !filter || …`).
1881
+ //
1882
+ // `prepend`: the Web bundle composes `dsh-api-remotes`, which registers its own
1883
+ // listener on this same event and **parks the chain** while it waits for a
1884
+ // browser client to answer (`forwardWaterfall` holds a `Promise.withResolvers()`
1885
+ // and never calls `next()` until that remote answers). Registered after it, this
1886
+ // answerer never got a turn: the tool call simply hung, the session stayed
1887
+ // `running`, and the phone showed nothing. Going first means a Cindy controller
1888
+ // answers when one is watching; when none is, `next()` hands the question back
1889
+ // to the browser path unchanged.
1890
+ }), { prepend: true, global: true }), 'dsh-cindy-host: approval answerer');
1891
+
1892
+ // The `ask_user` tool blocks on a different waterfall from approvals, and the
1893
+ // controller renders it as its own card. Leaving it unanswered does not degrade
1894
+ // the conversation — it stops it: the tool call waits for a human who never saw
1895
+ // the question. Same rule as approvals: only claim questions for a session a
1896
+ // controller is watching, and pass the rest down the chain.
1897
+ ctx.effect(() => ctx.on('user-questions/request', guarded('user-questions-request', async (request, next) => {
1898
+ if (!runtime || typeof runtime.beginUserQuestion !== 'function') return next();
1899
+ const sessionId = request?.agent?.session?.header?.id;
1900
+ return arbitrateInteraction({
1901
+ request,
1902
+ next,
1903
+ begin: () => runtime.beginUserQuestion({
1904
+ sessionId: typeof sessionId === 'string' ? sessionId : '',
1905
+ questions: request?.questions,
1906
+ signal: request?.signal,
1907
+ }),
1908
+ settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
1909
+ });
1910
+ // Same `prepend` + `global` requirement as the approval answerer above, and the
1911
+ // same reason: this waterfall is agent-scoped and the Web bundle's remote
1912
+ // forwarding listener parks ahead of a late registration.
1913
+ }), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
1914
+
1915
+ // The settings page reaches the Host through this route. `webServer` is absent
1916
+ // in headless compositions, so the row is injected optionally: the runtime and
1917
+ // its settings still work, only the browser surface is missing.
1918
+ const routes = createHostRoutes({
1919
+ getRuntime: () => runtime,
1920
+ getDiagnostics: () => buildDiagnostics({ runtime, sourceKind, seam: currentSeam, listingDiagnostics: currentListingDiagnostics, boundaries: boundaryState() }),
1921
+ // The Host's own relay identity outlives the credential: a phone links to the
1922
+ // *device id*, so it is remembered here and reused when the credential is gone.
1923
+ getSettings: () => scope.get(),
1924
+ rememberDeviceId: (deviceId) => scope.update({ deviceId }),
1925
+ });
1926
+ ctx.inject(['webServer'], (webCtx) => {
1927
+ webCtx.effect(
1928
+ () => webCtx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler: routes.handle }),
1929
+ 'dsh-cindy-host: settings-page API',
1930
+ );
1931
+ });
1932
+ }