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
package/src/host.js ADDED
@@ -0,0 +1,2497 @@
1
+ /**
2
+ * The Cindy Host runtime: one Cindy session, one DeviceLink relay socket, one
3
+ * projected DSH session list, and the status the settings card renders.
4
+ *
5
+ * Everything here is driven by the 连接手机 switch. When it is off there is no
6
+ * socket, no projection, and no accepted controller — turning it off tears all
7
+ * three down rather than idling them.
8
+ *
9
+ * Wire facts (all from Cindy's own protocol packages, none invented):
10
+ * - the relay authenticates the socket from the bearer token and fixes the
11
+ * account; `Envelope.src` is filled by the relay, so frames that reach us
12
+ * are already same-account;
13
+ * - `hello` must declare `remoteControlEnabled`, because the relay only routes
14
+ * `link-open`/`invoke` to a target that advertised it;
15
+ * - `hello-ack` carries the only identity the relay gives us: `deviceId` and
16
+ * `userId` (there is no pairing token anywhere in the protocol);
17
+ * - same-account devices arrive as `presence-changed` snapshots;
18
+ * - the client is expected to send `ping` every ~20s and treat consecutive
19
+ * missing `pong`s as a dead socket.
20
+ */
21
+ import WebSocket from 'ws';
22
+ import { realpath as realpathImpl, stat as statImpl } from 'node:fs/promises';
23
+ import { extname as extnameImpl, resolve as resolvePathImpl } from 'node:path';
24
+ import { restoreSession } from './auth-session.js';
25
+ import { createChannelRouter, invokeError } from './cindy-channels.js';
26
+ import { createApprovalRegistry } from './host-approvals.js';
27
+ import { createInputQueueTracker, queuedRowFromController } from './host-input-queue.js';
28
+ import { createProjectionTracker, toGoalStatusPayload } from './host-goals.js';
29
+ import { ReadOnlyProjection } from './projection.js';
30
+ import { SessionReadModel } from './session-read-model.js';
31
+ import { ProjectionReadModelSink } from './projection-read-model-sink.js';
32
+ import { publishLifecycle, runningStatusEvent, turnEventFor } from './session-publisher.js';
33
+ import { acceptLink } from './host-authorization.js';
34
+ import { AuthorizationPolicy } from './authorization-policy.js';
35
+ import { HostStatus } from './host-status.js';
36
+ import { DEFAULT_HOST_SETTINGS } from './host-settings.js';
37
+ import { createSessionFlags } from './session-flags.js';
38
+ import { MAX_ATTACHMENT_BYTES } from './host-attachments.js';
39
+ import { createMediaRefResolver, createMediaReleaser, createMediaUploader, mediaApiBaseUrl, isAttachmentOssRef } from './host-media.js';
40
+ import { createLocalMediaFetcher, createLocalFileResolver, isBlockedMediaPath, isInsideDirectory, mimeForMediaPath } from './host-media-fetch.js';
41
+ import { createFilePeerManager } from './host-file-peer.js';
42
+ import { createFileIceLoader } from './host-file-ice.js';
43
+ import { createLocalFileUrl } from './host-file-reference.js';
44
+ import { FILE_PEER_MAX_BYTES } from './file-peer-protocol.js';
45
+ import { createFileMediaUploader } from './host-media.js';
46
+ import { createExportJobs } from './host-export-jobs.js';
47
+ import { createExportOwner } from './host-export-owner.js';
48
+ import { createTransferDiagnostics } from './host-transfer-diagnostics.js';
49
+ import { normalizeAuthBaseUrl } from './cindy-login-flow.js';
50
+ import { RECONNECT_STABLE_RESET_MS, computeReconnectDelayMs } from './host-reconnect.js';
51
+ import { MAX_FRAME_BYTES, fitInvokeResultToFrame, frameByteLength } from './host-frame-budget.js';
52
+
53
+ /** Mainland DeviceLink relay. The settings card never guesses this value. */
54
+ export const RELAY_WS_URL = 'wss://device-link.cindy.com.cn/api/device-link/ws';
55
+
56
+ /** Relay envelope version this Host speaks (Cindy `PROTOCOL_VERSION`). */
57
+ export const PROTOCOL_VERSION = 1;
58
+
59
+ /** Application-level heartbeat period, mirroring Cindy's client. */
60
+ export const HEARTBEAT_INTERVAL_MS = 20_000;
61
+
62
+ /**
63
+ * Consecutive missed `pong`s that mark the socket dead.
64
+ *
65
+ * Cindy's client uses 3 ("连续 2 个周期无 pong 视为僵死" in its doc, `pongMissLimit: 3`
66
+ * in its defaults), and — more to the point — it clears that counter on **any** inbound
67
+ * frame. A host that counted only `pong` would declare a busy relay dead; see
68
+ * `handleFrame`.
69
+ */
70
+ export const HEARTBEAT_MISS_LIMIT = 3;
71
+
72
+ export const HOST_DEVICE_NAME = 'DSH Host';
73
+ export const HOST_PLATFORM = 'desktop';
74
+ export const HOST_APP_VERSION = '0.1.0';
75
+
76
+ /** Default socket factory; tests inject their own. */
77
+ function openRelaySocket(session, url = RELAY_WS_URL) {
78
+ return new WebSocket(url, { headers: { Authorization: `Bearer ${session.accessToken}` } });
79
+ }
80
+
81
+ /**
82
+ * Derive the account device directory from the relay's WebSocket URL.
83
+ * @param relayUrl - the `…/api/device-link/ws` endpoint.
84
+ * @returns the REST device-list URL on the same host.
85
+ */
86
+ export function deviceListUrl(relayUrl = RELAY_WS_URL) {
87
+ return `${relayUrl.replace(/^ws/, 'http').replace(/\/ws$/, '')}/devices`;
88
+ }
89
+
90
+ /**
91
+ * Read the account's device directory.
92
+ *
93
+ * `presence-changed` only reaches us for devices that come online while we are
94
+ * connected, so a device that links before us (or that never broadcasts) would
95
+ * otherwise be a bare `deviceId`. This endpoint is the documented way to name it
96
+ * (`Cindy/packages/device-link/src/protocol.ts`: `GET /api/device-link/devices`).
97
+ * It is read-only; no device is created, changed, or removed.
98
+ */
99
+ async function fetchDeviceDirectory(session, relayUrl) {
100
+ const response = await fetch(deviceListUrl(relayUrl), { headers: { Authorization: `Bearer ${session.accessToken}`, accept: 'application/json' } });
101
+ if (!response.ok) throw new Error('Cindy device directory request failed');
102
+ return response.json();
103
+ }
104
+
105
+ /**
106
+ * Start the Cindy Host runtime.
107
+ *
108
+ * The returned runtime starts in whatever state `settings` describes: with
109
+ * `transportEnabled` false nothing connects until `updateSettings` turns it on.
110
+ * @param source - optional DSH read source; without one the projection is skipped.
111
+ * @param settings - resolved `dsh-cindy-host` settings section.
112
+ * @param options - injectable session resolver, socket factory, clock, and timers.
113
+ * @returns the runtime handle used by the plugin, the routes, and the tests.
114
+ */
115
+ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS, options = {}) {
116
+ const {
117
+ resolveSession = restoreSession,
118
+ openSocket = openRelaySocket,
119
+ listDevices = fetchDeviceDirectory,
120
+ relayUrl = RELAY_WS_URL,
121
+ now = () => new Date(),
122
+ heartbeatMs = HEARTBEAT_INTERVAL_MS,
123
+ heartbeatMissLimit = HEARTBEAT_MISS_LIMIT,
124
+ setInterval: setIntervalImpl = setInterval,
125
+ clearInterval: clearIntervalImpl = clearInterval,
126
+ setTimeout: setTimeoutImpl = setTimeout,
127
+ clearTimeout: clearTimeoutImpl = clearTimeout,
128
+ fetch: fetchImpl = fetch,
129
+ maxAttachmentBytes = MAX_ATTACHMENT_BYTES,
130
+ } = options;
131
+
132
+ /**
133
+ * The controller's uploaded attachments, fetched with this Host's own credential.
134
+ *
135
+ * Built here rather than in the plugin seam because the account session is *this*
136
+ * runtime's: it is the one the relay socket authenticated with, and a second
137
+ * `restoreSession()` call would refresh — and rotate — the same refresh token
138
+ * concurrently, which is the failure that once cost a real login. `getSession`
139
+ * reads the live session when the fetch happens, so a reconnect that refreshed the
140
+ * token is picked up for free.
141
+ */
142
+ const mediaBase = mediaApiBaseUrl(relayUrl);
143
+
144
+ /**
145
+ * Refresh this runtime's account session because a media call was refused with 401.
146
+ *
147
+ * The session above is read **once** at startup (and again when the relay reconnects), so a
148
+ * credential refreshed outside this process — the settings card, a tool, another Host — is
149
+ * invisible here and every signed request keeps failing. Measured 2026-09-18: after the
150
+ * stored session was refreshed, the in-app 导出 path still answered `presign-put answered 401`
151
+ * while the same stored token worked from a fresh process.
152
+ *
153
+ * Two deliberate constraints, both learned the hard way:
154
+ * - **One refresh at a time.** Concurrent `restoreSession()` calls rotate the same refresh
155
+ * token; that race is what once cost a real login (see the comment on `mediaOptions`).
156
+ * - **A cooldown.** A stream of 401s must not turn into a stream of refreshes: the second
157
+ * failure within `SESSION_REFRESH_COOLDOWN_MS` is reported, not retried.
158
+ * @returns whether the in-memory session is now a refreshed one.
159
+ */
160
+ let credentialOperations = Promise.resolve();
161
+ function serializeCredentialOperation(action) {
162
+ const next = credentialOperations.then(action);
163
+ credentialOperations = next.catch(() => {});
164
+ return next;
165
+ }
166
+ let sessionRefreshInFlight = null;
167
+ let sessionRefreshedAt = 0;
168
+ const SESSION_REFRESH_COOLDOWN_MS = 30_000;
169
+ function refreshSessionForMedia() {
170
+ if (sessionRefreshInFlight !== null) return sessionRefreshInFlight;
171
+ if (Date.now() - sessionRefreshedAt < SESSION_REFRESH_COOLDOWN_MS) return Promise.resolve(false);
172
+ const callerGeneration = generation;
173
+ const capturedOwner = exportOwner.capture();
174
+ const capturedCredential = exportOwner.getCredential(capturedOwner);
175
+ const attempt = serializeCredentialOperation(async () => {
176
+ if (stopped || callerGeneration !== generation) return false;
177
+ if (capturedOwner) {
178
+ const renewed = await exportOwner.refreshCredential(capturedCredential);
179
+ sessionRefreshedAt = Date.now();
180
+ return renewed !== null;
181
+ }
182
+ const result = await restoreSession();
183
+ sessionRefreshedAt = Date.now();
184
+ if (stopped || callerGeneration !== generation || exportOwner.capture()) return false;
185
+ if (result?.ok === true && result.session !== undefined) {
186
+ session = result.session;
187
+ return true;
188
+ }
189
+ return false;
190
+ }).catch(() => { sessionRefreshedAt = Date.now(); return false; });
191
+ sessionRefreshInFlight = attempt;
192
+ void attempt.then(() => { if (sessionRefreshInFlight === attempt) sessionRefreshInFlight = null; });
193
+ return attempt;
194
+ }
195
+
196
+ const mediaOptions = {
197
+ apiBaseUrl: mediaBase,
198
+ getSession: () => session,
199
+ fetchImpl,
200
+ maxBytes: maxAttachmentBytes,
201
+ onUnauthorized: refreshSessionForMedia,
202
+ };
203
+ const resolveAttachmentRef = createMediaRefResolver(mediaOptions);
204
+ const releaseAttachmentRef = createMediaReleaser(mediaOptions);
205
+ /**
206
+ * The **outbound** half: staging a file from this machine so a controller can download it.
207
+ *
208
+ * An image the agent produced lives on this machine, and the controller cannot read this
209
+ * machine's disk — the reference controlled end uploads it into the shared staging area
210
+ * and answers with the key (`apps/desktop/src/main/device-link/mediaFetch.ts`,
211
+ * `file-browser/device-op.ts`). Both the file browser's `exportFileStart` and
212
+ * `device-link:media:fetch` ride this one uploader, and both use this runtime's account
213
+ * session for the same reason attachment fetching does.
214
+ */
215
+ const uploadMedia = createMediaUploader(mediaOptions);
216
+ // Streamed workdir Export follows Cindy's inclusive 2GiB ceiling.
217
+ const FILE_EXPORT_MAX_BYTES = 2 * 1024 * 1024 * 1024;
218
+ /**
219
+ * The controller asking this machine to hand over one of its files.
220
+ *
221
+ * The chat-image path: a picture the agent produced is referenced by path in the
222
+ * transcript, and the controller resolves it through `device-link:media:fetch`. Same
223
+ * uploader as the file browser's export ops, stricter request handling
224
+ * (`host-media-fetch.js`): a containment root is mandatory and both sides are realpath'd.
225
+ */
226
+ const fetchLocalMedia = createLocalMediaFetcher({ uploader: uploadMedia });
227
+ const resolvePeerFile = createLocalFileResolver({ maxBytes: FILE_PEER_MAX_BYTES });
228
+ const fileUrl = createLocalFileUrl({ resolveFile: resolvePeerFile });
229
+
230
+ /**
231
+ * Resolve one controller-named path inside its workdir.
232
+ *
233
+ * `relPath` is resolved against the workdir and **realpath-checked on both sides**, so
234
+ * neither `..` nor a symlink inside the workspace can reach outside it. The never-serve
235
+ * list is the same one media fetching uses: a workdir can be as broad as a home
236
+ * directory, so containment alone is not a secret boundary.
237
+ */
238
+ async function resolveInsideWorkdir(workdir, relPath) {
239
+ if (typeof workdir !== 'string' || workdir.trim() === '') return { ok: false, code: 'BAD_REQUEST', message: 'file export needs a workdir' };
240
+ if (typeof relPath !== 'string' || relPath.trim() === '') return { ok: false, code: 'BAD_REQUEST', message: 'file export needs a relPath' };
241
+ if (/^[A-Za-z]:[\\/]/.test(relPath) || relPath.startsWith('/') || relPath.startsWith('\\')) {
242
+ return { ok: false, code: 'BAD_REQUEST', message: 'relPath must be relative to the workdir' };
243
+ }
244
+ let real;
245
+ let realRoot;
246
+ try {
247
+ realRoot = await realpathImpl(workdir);
248
+ real = await realpathImpl(resolvePathImpl(workdir, relPath));
249
+ } catch {
250
+ return { ok: false, code: 'NOT_FOUND', message: 'the requested file does not exist' };
251
+ }
252
+ if (!isInsideDirectory(real, realRoot)) {
253
+ return { ok: false, code: 'FORBIDDEN', message: 'the requested file is outside the workdir' };
254
+ }
255
+ if (isBlockedMediaPath(real)) {
256
+ return { ok: false, code: 'FORBIDDEN', message: 'the requested file is not served to controllers' };
257
+ }
258
+ let info;
259
+ try {
260
+ info = await statImpl(real);
261
+ } catch {
262
+ return { ok: false, code: 'NOT_FOUND', message: 'the requested file could not be read' };
263
+ }
264
+ if (typeof info?.isDirectory === 'function' && info.isDirectory()) {
265
+ return { ok: false, code: 'BAD_REQUEST', message: 'the requested path is a directory' };
266
+ }
267
+ if (Number.isFinite(info?.size) && info.size > FILE_EXPORT_MAX_BYTES) {
268
+ return { ok: false, code: 'OVERSIZE', message: `the file is ${info.size} bytes, over the ${FILE_EXPORT_MAX_BYTES} byte export limit` };
269
+ }
270
+ return { ok: true, real, info };
271
+ }
272
+
273
+ /**
274
+ * Start one file export: answer with a transfer id, upload behind the reply.
275
+ * @param input - `{ workdir, relPath }` as the controller sent them.
276
+ * @returns `{ ok: true, transferId, size, mtimeMs }` or a refusal.
277
+ */
278
+ const startFileExport = (input, request) => fileExports.start(input, request);
279
+ const fileExportStatus = (input, request) => fileExports.status(input, request);
280
+
281
+ const model = new SessionReadModel();
282
+ /** Devices following the list-level `sessions` topic. */
283
+ const subscribers = new Set();
284
+ /** Devices following one session's live stream: `sessionId -> Set<deviceId>`. */
285
+ const sessionSubscribers = new Map();
286
+ /**
287
+ * Sessions a controller has attached to at least once in this process.
288
+ *
289
+ * The distinction this exists for: *currently watching* and *belongs to the
290
+ * phone* are not the same thing. A user who starts a turn from the phone and
291
+ * then looks away unsubscribes the session topic, and a question the agent asks
292
+ * in that window used to be handed straight to the Web bundle's remote
293
+ * forwarder — which parks it waiting for a browser. Measured: `pending []` while
294
+ * unwatched, still `[]` after re-subscribing, session `running` forever, and no
295
+ * card anywhere the user was looking. A session this Host has served to a
296
+ * controller is therefore claimed for that controller's user for the rest of the
297
+ * process, and stays answerable from the phone's pending list.
298
+ */
299
+ const remoteOwnedSessions = new Set();
300
+ /**
301
+ * The session metadata DSH has no concept of: archived, deleted, pinned.
302
+ *
303
+ * Owned by the runtime rather than the plugin because it is per-process state the
304
+ * channel layer and the row projection must agree on, and it is persisted through
305
+ * the settings section the plugin passes in as `persistSessionFlags`.
306
+ */
307
+ const sessionFlags = createSessionFlags({ initial: settings?.sessionFlags, persist: options.persistSessionFlags });
308
+ /** Questions DSH is waiting on, answered by whichever controller is watching. */
309
+ const approvals = createApprovalRegistry({
310
+ now,
311
+ // How long a controller has to answer a card before the asker is released with
312
+ // "cancelled". Two minutes was the default and it is the wrong budget for the
313
+ // surface this Host serves: the phone's card has no countdown, the user may be
314
+ // away from it, and the path this replaces (the Web bundle's remote forwarder)
315
+ // waits indefinitely — so a short Host timeout is strictly worse than the
316
+ // reference. Half an hour with cancellation as the escape hatch
317
+ // (`maker:input:stop` settles the card immediately) mirrors the reference while
318
+ // still bounding a forgotten turn.
319
+ timeoutMs: 30 * 60_000,
320
+ // Push the dismissal to every watcher, not just the controller that
321
+ // answered: the card is gone for all of them.
322
+ onDismissed: ({ sessionId, requestId }) => {
323
+ pushSessionUpdate(sessionId, 'maker:interaction-dismissed', { sessionId, requestId });
324
+ },
325
+ });
326
+ /** DSH's input queue and jobs, folded from the control stream. */
327
+ const inputQueue = createInputQueueTracker();
328
+ /** Session projections off the same stream — notably `goal`. */
329
+ const projections = createProjectionTracker();
330
+ const acceptedControllers = new Set();
331
+ const policy = new AuthorizationPolicy(settings);
332
+ const status = new HostStatus({ now });
333
+
334
+ let current = settings;
335
+ let source = initialSource;
336
+ let active = false;
337
+ let connecting = null; // Generation owning the in-flight connect, not a global latch.
338
+ let session = null;
339
+ let socketSession = null;
340
+ let boundSocketGeneration = -1;
341
+ let ws = null;
342
+ let projection = null;
343
+ let heartbeat = null;
344
+ let pongMisses = 0;
345
+ /**
346
+ * The reconnect ladder: how many attempts have failed in a row, and the one pending
347
+ * retry timer. `reconnectAttempt` is forgotten after `markConnectionStable`, so a
348
+ * Host that flaps for a week still retries in a second once the link is healthy.
349
+ */
350
+ let reconnectAttempt = 0;
351
+ let reconnectTimer = null;
352
+ let stableTimer = null;
353
+ /** Why the last retry was scheduled, for the status route and the card's message. */
354
+ let reconnectReason = null;
355
+ // Bumped by every teardown and every new connect: any async continuation or
356
+ // socket callback that still carries an older generation has been superseded
357
+ // and must not touch the runtime.
358
+ let generation = 0;
359
+ let stopped = false;
360
+ // Peer connections never outlive the relay/account authorization that created them.
361
+ const transferDiagnostics = createTransferDiagnostics({ now: () => Number(now()) });
362
+ let fileExports;
363
+ const exportOwner = createExportOwner({
364
+ ...options.exportCredentialOptions, now: () => Number(now()),
365
+ onSession: (next) => { session = next; },
366
+ onInvalidate: () => { void fileExports?.invalidateOwner(); transferDiagnostics.clear(); },
367
+ });
368
+ const uploadExport = createFileMediaUploader({
369
+ ...options.fileExportUploadOptions, apiBaseUrl: mediaBase, fetchImpl,
370
+ getCredential: (owner) => exportOwner.getCredential(owner),
371
+ refreshCredential: async (captured) => {
372
+ const owner = exportOwner.capture();
373
+ if (!owner || exportOwner.getCredential(owner) !== captured) return null;
374
+ const ok = await refreshSessionForMedia();
375
+ const next = exportOwner.getCredential(owner);
376
+ return ok && next !== captured ? next : null;
377
+ },
378
+ });
379
+ fileExports = createExportJobs({
380
+ ...options.fileExportJobOptions, resolveSource: resolveInsideWorkdir, upload: uploadExport,
381
+ captureOwner: () => exportOwner.capture(), isOwnerCurrent: (owner) => exportOwner.isCurrent(owner),
382
+ isControllerAllowed: (src) => policy.canAccept(src), now: () => Number(now()),
383
+ identify: (facts) => transferDiagnostics.identify(facts),
384
+ emit: (event) => transferDiagnostics.record(event),
385
+ });
386
+ const filePeer = createFilePeerManager({
387
+ ...options.filePeerOptions,
388
+ identify: (facts) => transferDiagnostics.identify({ ...facts, owner: exportOwner.capture() }),
389
+ onEvent: (event) => { if (exportOwner.capture()) transferDiagnostics.record(event); },
390
+ resolveFile: resolvePeerFile,
391
+ isAllowed: (peer) => !stopped && active && ws?.readyState === 1 && session !== null && policy.canAccept(peer),
392
+ loadIceServers: options.filePeerOptions?.loadIceServers ?? createFileIceLoader(mediaOptions),
393
+ });
394
+ /** Devices we already asked the directory to name in this connection. */
395
+ const directoryAsked = new Set();
396
+ /**
397
+ * Frames this Host built but refused to send because they exceeded the relay's
398
+ * per-frame ceiling.
399
+ *
400
+ * A refusal here is a bug in the reply path rather than a routine outcome: message
401
+ * pages are degraded to fit before this point, and everything else this Host
402
+ * answers is small. It is recorded because the alternative — a frame the relay
403
+ * silently drops — is indistinguishable from a Host that never answered.
404
+ */
405
+ const frameRefusals = [];
406
+ const FRAME_REFUSAL_LOG_LIMIT = 20;
407
+ let frameRefusalTotal = 0;
408
+ /** Replies that had to be degraded (or refused) to fit one frame, newest last. */
409
+ const frameDegradations = [];
410
+
411
+ /**
412
+ * Errors this Host caught at its own boundaries, newest last.
413
+ *
414
+ * This is not bookkeeping for its own sake: **an unhandled rejection is fatal to the whole
415
+ * `dsh web` process.** DSH installs a fail-loud handler
416
+ * (`@deepseek-ai/dsh-app-boot`: `proc.on('unhandledRejection', …)` → stderr
417
+ * `fatal load failure` → `proc.exit(1)`), and Cordis dispatches listeners with no
418
+ * `try`/`catch` of its own (`events.emit` → `this.dispatch(...).map(cb => cb(...))`). So a
419
+ * single throw from our code does not degrade this Host — it takes the desktop's DSH down
420
+ * with it. Measured once: the process vanished mid-turn at 17:30:23 with `turn/end
421
+ * reason=interrupted`, no Windows crash record, and a two-minute gap before the user
422
+ * restarted it. Every boundary below therefore catches, and what it caught is readable
423
+ * here instead of only in a terminal scrollback.
424
+ */
425
+ const handlerErrors = [];
426
+ const HANDLER_ERROR_LIMIT = 20;
427
+ /**
428
+ * Rows this Host decided the phone should not see, counted.
429
+ *
430
+ * Today that is one thing: a harness notice (a background job finishing, written by the
431
+ * `tool-jobs` plugin) used to be projected as a **user bubble**, so the user read a command
432
+ * line as if they had said it. Hiding it is a projection decision; counting it is what keeps
433
+ * the decision from being indistinguishable from a dropped message.
434
+ */
435
+ let suppressedNotices = 0;
436
+ /** When this Host started, for the diagnostics liveness verdict. */
437
+ const startedAtMs = now().getTime();
438
+
439
+ /** Record one error caught at a Host boundary (bounded, never throws). */
440
+ function recordHandlerError(where, error) {
441
+ try {
442
+ const message = error instanceof Error ? error.message : String(error);
443
+ handlerErrors.push({
444
+ at: now().toISOString(),
445
+ where,
446
+ message: message.slice(0, 400),
447
+ stack: (error instanceof Error && typeof error.stack === 'string' ? error.stack : '').split('\n').slice(0, 6).join('\n').slice(0, 1200),
448
+ });
449
+ if (handlerErrors.length > HANDLER_ERROR_LIMIT) handlerErrors.splice(0, handlerErrors.length - HANDLER_ERROR_LIMIT);
450
+ } catch {
451
+ // A recorder that can throw is worse than no recorder.
452
+ }
453
+ }
454
+
455
+ function send(frame) {
456
+ if (ws === null || typeof ws.send !== 'function') return;
457
+ // The relay **rejects** a frame over `MAX_FRAME_BYTES` outright — it does not
458
+ // truncate it, and the controller waiting on that frame is left until its own
459
+ // deadline. So an oversized frame is refused here, where it is observable, rather
460
+ // than handed to a socket that will drop it. Message pages never reach this point
461
+ // oversized: `fitInvokeResultToFrame` degrades them first.
462
+ const bytes = frameByteLength(frame);
463
+ if (bytes > MAX_FRAME_BYTES) {
464
+ frameRefusals.push({ at: now().toISOString(), kind: typeof frame?.kind === 'string' ? frame.kind : null, bytes });
465
+ if (frameRefusals.length > FRAME_REFUSAL_LOG_LIMIT) frameRefusals.splice(0, frameRefusals.length - FRAME_REFUSAL_LOG_LIMIT);
466
+ frameRefusalTotal += 1;
467
+ return;
468
+ }
469
+ try {
470
+ ws.send(JSON.stringify(frame));
471
+ } catch {
472
+ // A send that throws belongs to the socket's own error path.
473
+ }
474
+ }
475
+
476
+ function startHeartbeat() {
477
+ stopHeartbeat();
478
+ pongMisses = 0;
479
+ if (!(heartbeatMs > 0)) return;
480
+ heartbeat = setIntervalImpl(() => {
481
+ if (ws === null) return;
482
+ pongMisses += 1;
483
+ if (pongMisses > heartbeatMissLimit) {
484
+ // Consecutive missed pongs mean a socket that looks open and routes nothing.
485
+ // Drop it and retry with backoff — the card's manual reconnect stays available,
486
+ // but a person at the desk must never be the mechanism that brings the Host back.
487
+ void dropAndRetry('与 Cindy relay 的心跳已丢失').catch((error) => recordHandlerError('heartbeat-drop', error));
488
+ return;
489
+ }
490
+ send({ v: PROTOCOL_VERSION, kind: 'ping' });
491
+ }, heartbeatMs);
492
+ if (typeof heartbeat?.unref === 'function') heartbeat.unref();
493
+ }
494
+
495
+ function stopHeartbeat() {
496
+ if (heartbeat !== null) clearIntervalImpl(heartbeat);
497
+ heartbeat = null;
498
+ pongMisses = 0;
499
+ }
500
+
501
+ /**
502
+ * Start the DSH projection when both a source and the switch are present.
503
+ *
504
+ * Factored out because the source can arrive *after* the runtime: the DSH
505
+ * services that supply it (`sessionController`) are provided by another plugin
506
+ * and may activate later, so `apply` cannot read them synchronously.
507
+ */
508
+ async function ensureProjection() {
509
+ if (projection !== null || source === undefined || source === null) return;
510
+ projection = new ReadOnlyProjection(new ProjectionReadModelSink(model, (item) => {
511
+ // List-level pushes are recorded through the same tally as session-scoped
512
+ // ones: a running badge on the session list depends on them, so they must
513
+ // be answerable from diagnostics too.
514
+ const record = (channel, watchers, payload) => recordPush(null, channel, watchers, payload);
515
+ if (ws !== null) publishLifecycle(ws, subscribers, item, record);
516
+ publishTurnEvent(item);
517
+ }));
518
+ await projection.start(source);
519
+ }
520
+
521
+ /**
522
+ * Tell the session's watchers what the turn is doing.
523
+ *
524
+ * The controller clears its "thinking" state on a terminal `maker:event` and on
525
+ * nothing else: `maker:status-changed` only retires a *closed* session, and the
526
+ * list-level `local-db:sessions:activity` push belongs to the `sessions` topic,
527
+ * which a controller that is merely viewing one session does not hold. So a
528
+ * turn that finished while the phone watched its session left the phone
529
+ * spinning forever — the answer arrived, the composer still said 思考中.
530
+ * @param item - one lifecycle item from the projection sink.
531
+ */
532
+ function publishTurnEvent(item) {
533
+ // Keep the cached row's turn state in step with the lifecycle itself.
534
+ //
535
+ // The cache is refreshed by a **list read**, and a controller sitting inside
536
+ // one session may not cause one for minutes — so a `running: true` captured
537
+ // when the turn began outlived the turn that ended it. That stale `true` is
538
+ // what silenced both safety nets (`pushTurnIdle` on attach and
539
+ // `reconcileTurnState`), leaving the phone spinning after the answer had
540
+ // arrived. The lifecycle event is the authority; the cache follows it.
541
+ const cached = sessionRowCache.get(item?.sessionId);
542
+ if (cached !== undefined) {
543
+ cached.running = item.phase === 'running' || item.phase === 'waiting';
544
+ }
545
+ const turn = turnEventFor(item);
546
+ if (turn === null) return;
547
+ pushSessionUpdate(turn.payload.sessionId, turn.channel, turn.payload);
548
+ }
549
+
550
+ /**
551
+ * Attach or replace the DSH read source.
552
+ *
553
+ * The runtime starts before the source exists (the settings page must work
554
+ * even when this profile exposes no session API), so this is how the
555
+ * projection joins once the supplying plugin activates.
556
+ * @param next - the source, or undefined to detach.
557
+ */
558
+ async function setSource(next) {
559
+ source = next === undefined ? undefined : next;
560
+ if (stopped) return;
561
+ if (projection !== null) {
562
+ const closing = projection;
563
+ projection = null;
564
+ try {
565
+ await closing.stop();
566
+ } catch {
567
+ // Already unreachable; a stop failure cannot make it worse.
568
+ }
569
+ }
570
+ if (active) await ensureProjection();
571
+ }
572
+
573
+ /**
574
+ * Wait, then bring the relay up again.
575
+ *
576
+ * A lost socket is **not** a terminal state. The reference here is the Cindy
577
+ * device-link client, which keeps a `connecting` status and retries forever with
578
+ * exponential backoff — the reason a Cindy client "从来没有掉线过". This Host used to
579
+ * report `failed` and stop, which on a phone-controlled Host is a silent outage: the
580
+ * card says 断线 and the handset has no DSH in its device list until a person walks
581
+ * over to the desktop. The manual `/reconnect` route stays as an escape hatch, but it
582
+ * must never be the mechanism.
583
+ *
584
+ * @param reason - what was lost, for the status line.
585
+ */
586
+ function scheduleReconnect(reason) {
587
+ // Never reconnect behind the user's back: an explicit switch-off or a full stop
588
+ // ends the story, and a second timer must not stack on an already-pending one.
589
+ if (stopped || !active || reconnectTimer !== null) return;
590
+ const attempt = reconnectAttempt;
591
+ reconnectAttempt += 1;
592
+ reconnectReason = reason;
593
+ const delay = computeReconnectDelayMs({ attempt, random: Math.random() });
594
+ status.setState('connecting', `${reason},${Math.ceil(delay / 1_000)}s 后自动重连(第 ${reconnectAttempt} 次)`);
595
+ reconnectTimer = setTimeoutImpl(() => {
596
+ reconnectTimer = null;
597
+ void connect().catch((error) => recordHandlerError('reconnect', error));
598
+ }, delay);
599
+ if (typeof reconnectTimer?.unref === 'function') reconnectTimer.unref();
600
+ }
601
+
602
+ /** Forget the attempt ladder once a connection has held for the stable window. */
603
+ function markConnectionStable() {
604
+ if (stableTimer !== null) clearTimeoutImpl(stableTimer);
605
+ stableTimer = setTimeoutImpl(() => {
606
+ stableTimer = null;
607
+ reconnectAttempt = 0;
608
+ }, RECONNECT_STABLE_RESET_MS);
609
+ if (typeof stableTimer?.unref === 'function') stableTimer.unref();
610
+ }
611
+
612
+ /**
613
+ * Reconnect **now**, because a person asked.
614
+ *
615
+ * The card's button is an escape hatch, not the mechanism: it drops any queued retry and
616
+ * resets the ladder so a host that had backed off to 30s comes back immediately.
617
+ */
618
+ async function reconnectNow() {
619
+ cancelReconnect();
620
+ reconnectAttempt = 0;
621
+ reconnectReason = null;
622
+ await connect();
623
+ }
624
+
625
+ /** Cancel a pending retry — the switch was turned off, or the runtime stopped. */
626
+ function cancelReconnect() {
627
+ if (reconnectTimer !== null) {
628
+ clearTimeoutImpl(reconnectTimer);
629
+ reconnectTimer = null;
630
+ }
631
+ if (stableTimer !== null) {
632
+ clearTimeoutImpl(stableTimer);
633
+ stableTimer = null;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Drop a socket and come back on our own.
639
+ *
640
+ * `keepSwitch` is what makes this a retry rather than a shutdown: the user asked for
641
+ * the phone link, and a dead socket is not them changing their mind. The reason is
642
+ * carried through to the card, because "心跳已丢失" and "连接已断开" are different
643
+ * diagnoses for whoever reads the settings page.
644
+ *
645
+ * @param reason - what was lost.
646
+ */
647
+ async function dropAndRetry(reason) {
648
+ await disconnect({ keepSwitch: true, preserveExports: true });
649
+ scheduleReconnect(reason);
650
+ }
651
+
652
+ /**
653
+ * Every session a device has held a subscription to, remembered past a disconnect.
654
+ *
655
+ * The relay may declare the device offline while it is still looking at these sessions — a
656
+ * presence snapshot is an opinion, not a fact, and **it never removes the subscription**. What
657
+ * it removes is the device's right to be *counted as reached*. This map is what lets the Host
658
+ * tell the device the truth about each session when it speaks again, instead of leaving a stale
659
+ * spinner until the user re-enters the session by hand.
660
+ */
661
+ const watchedSessionsByDevice = new Map();
662
+ /**
663
+ * `deviceId\0sessionId` → when that pair was last told a turn is over.
664
+ *
665
+ * A `done` is not idempotent for the controller, and a re-link can race the ordinary
666
+ * terminal announcement for the same turn; this is the de-duplication window.
667
+ */
668
+ const lastTerminalAnnounce = new Map();
669
+ const TERMINAL_ANNOUNCE_WINDOW_MS = 30_000;
670
+ /**
671
+ * Devices the relay has told us it cannot route to.
672
+ *
673
+ * Their subscriptions are **untouched** — see {@link markDeviceUnreachable} for why deleting
674
+ * them was the wrong repair. This is what keeps the *unwatched* fallback in
675
+ * {@link announceTurnIdle} from sending into the same void and then recording the frame as
676
+ * delivered. Cleared the moment the device links again, which is itself proof of a route.
677
+ */
678
+ const offlineDevices = new Set();
679
+ /**
680
+ * Devices that subscribed to the `sessions` topic at least once.
681
+ *
682
+ * Their session topics are remembered in {@link watchedSessionsByDevice}; the list topic has
683
+ * no session id to hang off, so it is remembered here.
684
+ *
685
+ * Left over from the reversed policy above: nothing reads this any more (the subscription it
686
+ * was meant to restore is never removed), so it is bookkeeping with no consumer.
687
+ */
688
+ const watchedListDevices = new Set();
689
+
690
+ /** De-duplication key for "this device was told this session's turn is over". */
691
+ function terminalAnnounceKey(deviceId, sessionId) {
692
+ return `${deviceId}\u0000${sessionId}`;
693
+ }
694
+
695
+ /** Note that one device has just been told a session's turn is over. */
696
+ function noteTerminalAnnounce(deviceId, sessionId) {
697
+ lastTerminalAnnounce.set(terminalAnnounceKey(deviceId, sessionId), now().getTime());
698
+ }
699
+
700
+ /** Note that a device is (or was) watching one session. */
701
+ function rememberWatchedSession(deviceId, sessionId) {
702
+ let sessions = watchedSessionsByDevice.get(deviceId);
703
+ if (sessions === undefined) {
704
+ sessions = new Set();
705
+ watchedSessionsByDevice.set(deviceId, sessions);
706
+ }
707
+ sessions.add(sessionId);
708
+ }
709
+
710
+ /**
711
+ * Mark a device unreachable because the relay says so.
712
+ *
713
+ * **It does not touch the subscription set**, and that is the whole point. The first version
714
+ * deleted the device's topics here, on the reasoning that a subscription the relay cannot
715
+ * reach is not a subscription. Measured twice over, that reasoning is backwards:
716
+ *
717
+ * - the relay's presence is an *opinion* it never has to correct: it sends deltas, so a stale
718
+ * `online:false` is never followed by a fresh `online:true` unless the device really
719
+ * reconnects. At 09:48:04 it reported the handset offline while the user was watching it,
720
+ * the topics went, and three minutes of 转圈 followed — the exact symptom the deletion was
721
+ * meant to fix.
722
+ * - the repair was dead code in practice. `markDeviceReachable` below is triggered by an
723
+ * inbound frame, and the client *stops sending them* while it believes the peer is
724
+ * unavailable (it cancels its own peer recovery and filters the device out of its plans).
725
+ * So the host could destroy its only record of who asked to be pushed and then wait for the
726
+ * very device it had just gone silent on.
727
+ *
728
+ * The subscription set is this process's own record of *who asked to be pushed*. Reachability
729
+ * is a different fact, owned here, and it only decides whether a push is **counted as
730
+ * delivered** — never whether the record exists. `push` is a routed fire-and-forget frame
731
+ * with no acknowledgement, so an unreachable-device push costs one frame the relay discards;
732
+ * losing the record costs the session.
733
+ *
734
+ * @param deviceId - the device the relay reports offline.
735
+ */
736
+ function markDeviceUnreachable(deviceId) {
737
+ if (typeof deviceId !== 'string' || deviceId === '') return;
738
+ offlineDevices.add(deviceId);
739
+ }
740
+
741
+ /**
742
+ * Revoke an unreachability verdict, because the device itself just proved otherwise.
743
+ *
744
+ * A frame from a device is stronger evidence than a presence snapshot: it arrived over the
745
+ * very link that was declared dead. The subscriptions are untouched (`markDeviceUnreachable`
746
+ * never removed them), so what is left to do is tell it what it may have missed — the turn
747
+ * state, so a spinner ends, and a view invalidation, so the transcript it is looking at is
748
+ * re-read instead of staying stale.
749
+ *
750
+ * This is an accelerator, not a lifeline: pushes are sent regardless of reachability, so a
751
+ * device that never speaks again still receives everything the relay can route to it.
752
+ *
753
+ * @returns the sessions it is watching.
754
+ */
755
+ function markDeviceReachable(deviceId) {
756
+ if (!offlineDevices.delete(deviceId)) return [];
757
+ const sessions = [...(watchedSessionsByDevice.get(deviceId) ?? [])];
758
+ for (const sessionId of sessions) {
759
+ announceWatchedTurnState(deviceId, sessionId);
760
+ pushHistoryViewChanged(sessionId);
761
+ }
762
+ return sessions;
763
+ }
764
+
765
+ /**
766
+ * Tell one device the turn state of one session it is (or was) watching.
767
+ *
768
+ * Only when the cache positively says the turn is over: a cold row must never be read as
769
+ * idle, or this would clear a spinner on a turn that is still running. And only once per
770
+ * turn — a `done` is not idempotent for the controller (it finalizes streaming rows), so
771
+ * the same `device × session` pair is de-duplicated through the window every other terminal
772
+ * announcement uses, because this is racing them by construction.
773
+ * @param deviceId - the device to tell.
774
+ * @param sessionId - the session whose turn state to report.
775
+ */
776
+ function announceWatchedTurnState(deviceId, sessionId) {
777
+ const cached = sessionRowFor(sessionId);
778
+ if (cached === undefined || cached.running === true) return;
779
+ const key = terminalAnnounceKey(deviceId, sessionId);
780
+ const at = now().getTime();
781
+ if ((lastTerminalAnnounce.get(key) ?? 0) + TERMINAL_ANNOUNCE_WINDOW_MS > at) return;
782
+ lastTerminalAnnounce.set(key, at);
783
+ const payload = { sessionId, event: { type: 'done' } };
784
+ recordPush(sessionId, 'maker:event', 1, payload);
785
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
786
+ }
787
+
788
+ /** Accept a device as a controller of this Host and reflect it in the status. */
789
+ function linkController(deviceId) {
790
+ const known = status.snapshot().devices.find((device) => device.deviceId === deviceId);
791
+ acceptedControllers.add(deviceId);
792
+ status.upsertDevice(deviceId, { isController: true, linkedAt: now().toISOString(), online: true });
793
+ status.refreshConnectionState();
794
+ // A device that linked with no presence broadcast is a bare id until the
795
+ // directory names it. Ask at most once per device per connection, so a
796
+ // device the directory cannot name does not cost a request per link-open.
797
+ if ((known === undefined || known.platform === null) && !directoryAsked.has(deviceId)) {
798
+ directoryAsked.add(deviceId);
799
+ void refreshDeviceDirectory().catch((error) => recordHandlerError('device-directory', error));
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Name the devices we have only seen by id.
805
+ *
806
+ * A device that linked before this Host connected, or one that never sent a
807
+ * presence broadcast, is otherwise shown as a hex prefix. One directory read
808
+ * fixes every such row at once; failures are silent because a missing name
809
+ * only costs a nicer label, never a connection.
810
+ */
811
+ async function refreshDeviceDirectory() {
812
+ if (session === null) return;
813
+ const myGeneration = generation;
814
+ let answer;
815
+ try {
816
+ answer = await listDevices(session, relayUrl);
817
+ } catch {
818
+ return;
819
+ }
820
+ if (stopped || !active || myGeneration !== generation) return;
821
+ const rows = Array.isArray(answer?.devices) ? answer.devices : [];
822
+ for (const row of rows) {
823
+ if (row === null || typeof row !== 'object') continue;
824
+ if (typeof row.deviceId !== 'string' || row.deviceId === '' || row.isSelf === true) continue;
825
+ // Only overwrite a field when the directory actually carries one: a
826
+ // presence snapshot already learned may be newer than the stored profile.
827
+ const patch = {};
828
+ const name = typeof row.selfName === 'string' && row.selfName !== '' ? row.selfName : row.name;
829
+ if (typeof name === 'string' && name !== '') patch.name = name;
830
+ if (typeof row.platform === 'string' && row.platform !== '') patch.platform = row.platform;
831
+ if (typeof row.online === 'boolean') patch.online = row.online;
832
+ if (typeof row.lastSeenAt === 'string' && row.lastSeenAt !== '') patch.lastSeenAt = row.lastSeenAt;
833
+ if (Object.keys(patch).length > 0) status.upsertDevice(row.deviceId, patch);
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Record a controller's topic choices.
839
+ *
840
+ * Two topics exist: `sessions` for list-level changes, and `session:<id>` for
841
+ * one session's live stream. They are tracked separately because a push must
842
+ * reach exactly the controllers that asked for it — sending a session's
843
+ * transcript to every subscriber would leak one session into another's view.
844
+ */
845
+ function subscribeTopics(deviceId, topics) {
846
+ for (const topic of topics) {
847
+ if (topic === 'sessions') {
848
+ subscribers.add(deviceId);
849
+ // Remembered for the same reason a session topic is (the device's turn state has to be
850
+ // announced when it speaks again). Nothing reads it today: it existed to hand the topic
851
+ // back to a device that had been dropped for being unreachable, and that drop is gone.
852
+ watchedListDevices.add(deviceId);
853
+ } else if (topic.startsWith('session:')) {
854
+ const sessionId = topic.slice('session:'.length);
855
+ const devices = sessionSubscribers.get(sessionId) ?? new Set();
856
+ devices.add(deviceId);
857
+ sessionSubscribers.set(sessionId, devices);
858
+ // Remembered beyond the subscription itself: a session a controller has
859
+ // ever attached to belongs to that controller's user, so the questions it
860
+ // asks are theirs to answer even if they have since looked away. See
861
+ // `claimsInteraction`.
862
+ remoteOwnedSessions.add(sessionId);
863
+ // …and beyond a *disconnect*: when this device re-links, the Host has to be able to
864
+ // tell it the turn state of every session it was inside, not just the running ones.
865
+ rememberWatchedSession(deviceId, sessionId);
866
+ }
867
+ }
868
+ }
869
+
870
+ function unsubscribeTopics(deviceId, topics) {
871
+ for (const topic of topics) {
872
+ if (topic === 'sessions') subscribers.delete(deviceId);
873
+ else if (topic.startsWith('session:')) {
874
+ const sessionId = topic.slice('session:'.length);
875
+ const devices = sessionSubscribers.get(sessionId);
876
+ if (devices === undefined) continue;
877
+ devices.delete(deviceId);
878
+ if (devices.size === 0) sessionSubscribers.delete(sessionId);
879
+ }
880
+ }
881
+ }
882
+
883
+ /** Drop every subscription a device holds, on disconnect. */
884
+ function forgetSubscriptions(deviceId) {
885
+ subscribers.delete(deviceId);
886
+ for (const [sessionId, devices] of sessionSubscribers) {
887
+ devices.delete(deviceId);
888
+ if (devices.size === 0) sessionSubscribers.delete(sessionId);
889
+ }
890
+ }
891
+
892
+ /** How many controllers are following one session's live stream. */
893
+ function watchersFor(sessionId) {
894
+ return sessionSubscribers.get(sessionId)?.size ?? 0;
895
+ }
896
+
897
+ /**
898
+ * How often one session may be repaired because a controller spoke about it.
899
+ *
900
+ * A controller that returns from the background does **not** re-subscribe — measured: twenty
901
+ * consecutive invokes with no `device-link:subscribe` among them — but it does keep asking
902
+ * (`messages:view`, `view-intent`, `input:enqueue` every few seconds). So "this device said
903
+ * something about this session" is the one recovery trigger certain to arrive, and this is the
904
+ * rate limit that keeps ordinary polling from becoming a flood.
905
+ */
906
+ const REPAIR_PULSE_MS = 30_000;
907
+ /** `sessionId` -> when it was last repaired this way. */
908
+ const lastRepairPulse = new Map();
909
+
910
+ /**
911
+ * Repair one session for its watchers because a controller just spoke about it.
912
+ *
913
+ * The two frames are exactly what a dropped push would have carried: the authoritative input
914
+ * projection (which retires a bubble left spinning) and a view invalidation (which makes the
915
+ * transcript re-read). Idempotent, bounded to one per {@link REPAIR_PULSE_MS} per session, and
916
+ * it does nothing at all when nobody is watching.
917
+ * @param sessionId - the session a controller just asked about.
918
+ */
919
+ function pulseRepair(sessionId) {
920
+ const devices = sessionSubscribers.get(sessionId);
921
+ if (devices === undefined || devices.size === 0) return;
922
+ const at = now().getTime();
923
+ if ((lastRepairPulse.get(sessionId) ?? 0) + REPAIR_PULSE_MS > at) return;
924
+ lastRepairPulse.set(sessionId, at);
925
+ pushSessionUpdate(sessionId, 'maker:input:projection', inputQueue.projectionFor(sessionId, sessionRowFor(sessionId)));
926
+ pushHistoryViewChanged(sessionId);
927
+ }
928
+
929
+ /**
930
+ * Whether this Host should answer one session's approval or question itself.
931
+ *
932
+ * Claimed when a controller is watching **now**, or when one has watched this
933
+ * session at any point in this process. Anything else is passed down the chain to
934
+ * the desk's own UI, because swallowing a question a person at the desk could
935
+ * answer is worse than not answering it here.
936
+ * @param sessionId - the session the question belongs to.
937
+ * @returns true when this Host owns the card.
938
+ */
939
+ function claimsInteraction(sessionId) {
940
+ return watchersFor(sessionId) > 0 || remoteOwnedSessions.has(sessionId);
941
+ }
942
+
943
+ /**
944
+ * Ask the controllers watching a session to decide a DSH approval.
945
+ *
946
+ * Returns `null` when nobody is watching, which is the answerer's cue to call
947
+ * `next()` and let the local UI — or DSH's fail-closed default — decide. A
948
+ * Host must never swallow a question the user could have answered at the desk.
949
+ */
950
+ async function beginApproval({ sessionId, toolName, reason, callId, signal }) {
951
+ if (typeof sessionId !== 'string' || sessionId === '') return null;
952
+ if (!claimsInteraction(sessionId)) return null;
953
+ const handle = await approvals.ask({ sessionId, toolName, reason, callId, signal });
954
+ pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
955
+ return handle;
956
+ }
957
+
958
+ async function askApproval(options) {
959
+ const handle = await beginApproval(options);
960
+ return handle === null ? null : handle.answered;
961
+ }
962
+
963
+ /** Answer one pending interaction on the controller's behalf. */
964
+ function resolveInteraction(requestId, decision) {
965
+ return approvals.settle(requestId, decision);
966
+ }
967
+
968
+ function settleInteractionOutcome(requestId, outcome) {
969
+ return approvals.settleOutcome(requestId, outcome);
970
+ }
971
+
972
+ /**
973
+ * Ask the controllers watching a session a structured question.
974
+ *
975
+ * DSH's `user-questions/request` waterfall blocks the tool call until this
976
+ * resolves, so an unanswered question does not degrade the conversation — it
977
+ * stops it. The answerer therefore only claims questions for a session a
978
+ * controller is actually watching, and passes every other one down the chain
979
+ * so the local UI can answer it.
980
+ *
981
+ * Returns `null` when nobody is watching, which is the answerer's cue to call
982
+ * `next()`.
983
+ */
984
+ async function beginUserQuestion({ sessionId, questions, signal }) {
985
+ if (typeof sessionId !== 'string' || sessionId === '') return null;
986
+ if (!claimsInteraction(sessionId)) return null;
987
+ const handle = await approvals.askUser({ sessionId, questions, signal });
988
+ pushSessionUpdate(sessionId, 'maker:interaction-request', { sessionId, request: handle.request });
989
+ return handle;
990
+ }
991
+
992
+ async function askUserQuestion(options) {
993
+ const handle = await beginUserQuestion(options);
994
+ return handle === null ? null : handle.answered;
995
+ }
996
+
997
+ /**
998
+ * Push one session-scoped event to the controllers watching that session.
999
+ *
1000
+ * The frame goes to every watcher, reachable or not — the relay discards what it cannot route,
1001
+ * and a presence snapshot is not evidence enough to stop delivering to a device the user may
1002
+ * be staring at. The **count** is what presence decides: a watcher the relay calls
1003
+ * unreachable is not recorded as reached, so the terminal-state de-duplication stays honest
1004
+ * and the repair announcement when it returns is still allowed to fire.
1005
+ *
1006
+ * @param sessionId - the session the event belongs to.
1007
+ * @param channel - the push channel.
1008
+ * @param payload - the channel's payload.
1009
+ */
1010
+ function pushSessionUpdate(sessionId, channel, payload) {
1011
+ const devices = sessionSubscribers.get(sessionId);
1012
+ const watchers = devices === undefined ? 0 : devices.size;
1013
+ const reachable = devices === undefined ? 0 : [...devices].filter((deviceId) => !offlineDevices.has(deviceId)).length;
1014
+ recordPush(sessionId, channel, reachable, payload);
1015
+ if (watchers === 0) return;
1016
+ for (const deviceId of devices) {
1017
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel, payload } });
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * How long view invalidations for one session are coalesced.
1023
+ *
1024
+ * Every renderable row sends one `maker:history-view-changed`, and the controller answers
1025
+ * that channel by re-reading the newest page — up to `HISTORY_PAGE_BYTES` (256 KiB) after
1026
+ * its own 500 ms debounce. A working turn appends several rows a second, so the frames are
1027
+ * cheap but the *re-reads* are not: without coalescing, N rows cost N page reads of up to
1028
+ * 256 KiB. One trailing invalidation per second carries the same fact — the view's content
1029
+ * changed — and the last row of a turn is still announced, because the timer always fires.
1030
+ */
1031
+ const HISTORY_VIEW_PUSH_COALESCE_MS = 1000;
1032
+ /** `sessionId` → the pending view invalidation. */
1033
+ const historyViewPushes = new Map();
1034
+
1035
+ /**
1036
+ * Tell the controllers watching a session that its history view changed.
1037
+ * @param sessionId - the session whose projection changed.
1038
+ */
1039
+ function pushHistoryViewChanged(sessionId) {
1040
+ if (typeof sessionId !== 'string' || sessionId === '') return;
1041
+ if (watchersFor(sessionId) === 0) return;
1042
+ if (historyViewPushes.has(sessionId)) return;
1043
+ const timer = setTimeoutImpl(() => {
1044
+ historyViewPushes.delete(sessionId);
1045
+ pushSessionUpdate(sessionId, 'maker:history-view-changed', { sessionId });
1046
+ }, HISTORY_VIEW_PUSH_COALESCE_MS);
1047
+ if (typeof timer?.unref === 'function') timer.unref();
1048
+ historyViewPushes.set(sessionId, timer);
1049
+ }
1050
+
1051
+ /** Drop every pending view invalidation (teardown: no timers outlive the socket). */
1052
+ function clearHistoryViewPushes() {
1053
+ for (const timer of historyViewPushes.values()) {
1054
+ if (typeof clearTimeoutImpl === 'function') clearTimeoutImpl(timer);
1055
+ }
1056
+ historyViewPushes.clear();
1057
+ }
1058
+
1059
+ /**
1060
+ * Push one live message to the controllers watching that session.
1061
+ *
1062
+ * The payload is exactly what the controller's `local-db:messages:created`
1063
+ * handler reads — `{ sessionId, message }` — so a live append and a transcript
1064
+ * read produce identical rows.
1065
+ *
1066
+ * "Identical rows" includes the images: a row is only renderable once its handles are inlined,
1067
+ * and this is the live half of that rule. It used to be the *only* half missing — the two read
1068
+ * paths hydrated, this one did not, so a photo the user had just sent arrived as a file entry
1069
+ * whose `imageRef` means nothing to the phone, and stayed that way until something re-read a
1070
+ * hydrated page (measured: 「我发给你的照片我在信息流里看不到」, with
1071
+ * `attachmentReads.attempted = 0`).
1072
+ *
1073
+ * Hydration failure must never cost the message: the row goes regardless, it just shows a file
1074
+ * chip instead of the picture.
1075
+ */
1076
+ async function pushSessionMessage(sessionId, message) {
1077
+ try {
1078
+ // `options.resolveCapabilities` is the plugin's provider — the only source of the hydrator.
1079
+ // (Reading it through the router's own `resolveCapabilities` is not possible from here: that
1080
+ // name exists only as a property of the router's options object, and calling it as a bare
1081
+ // function threw a ReferenceError this `catch` then swallowed — hydration silently did
1082
+ // nothing, which is the same class of failure as the bug being fixed.)
1083
+ const provider = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : undefined;
1084
+ const hydrate = provider?.hydrateImages;
1085
+ if (typeof hydrate === 'function') await hydrate([message]);
1086
+ } catch {
1087
+ // Un-hydrated is still worth sending.
1088
+ }
1089
+ pushSessionUpdate(sessionId, 'local-db:messages:created', { sessionId, message });
1090
+ // …and tell the history view to re-read, coalesced. The two are not the same message: the
1091
+ // row above feeds the controller's message store, while this one is the only signal the
1092
+ // work-grouped view acts on (`DeviceLinkContext`: `historyView.invalidate(); return;`).
1093
+ // Without it a session the user was already inside did not update until it was reopened —
1094
+ // 报「重新进入会话之后对话就出来了」.
1095
+ pushHistoryViewChanged(sessionId);
1096
+ }
1097
+
1098
+ /**
1099
+ * Serve one invoke.
1100
+ *
1101
+ * Channels that need the DSH corpus answer `NOT_AVAILABLE` when this profile
1102
+ * exposes no session API, rather than letting a missing source surface as an
1103
+ * opaque failure — the controller can tell "no capability" from "it broke".
1104
+ */
1105
+ /**
1106
+ * The most recent source listing, keyed by id.
1107
+ *
1108
+ * The input projection needs each session's working directory, which the read
1109
+ * model does not carry; caching the listing keeps that free rather than paying
1110
+ * a second corpus read per projection request.
1111
+ */
1112
+ const sessionRowCache = new Map();
1113
+ function sessionRowFor(sessionId) {
1114
+ return sessionRowCache.get(sessionId);
1115
+ }
1116
+
1117
+ const routeInvoke = createChannelRouter({
1118
+ getSession: async (sessionId) => {
1119
+ if (source == null) throw new Error('no DSH session source is attached');
1120
+ const row = typeof source.getSession === 'function'
1121
+ ? await source.getSession(sessionId)
1122
+ : (await source.listSessions()).find((candidate) => String(candidate.id) === sessionId);
1123
+ if (row == null) return undefined;
1124
+ const [flagged] = sessionFlags.projectAll([row]);
1125
+ if (flagged !== undefined) sessionRowCache.set(String(flagged.id), flagged);
1126
+ return flagged;
1127
+ },
1128
+ listSessions: async () => {
1129
+ if (source === undefined || source === null) throw new Error('no DSH session source is attached');
1130
+ const rows = await source.listSessions();
1131
+ // The archive/delete/pin flags ride the source row's `status`/`pinnedAt`, which
1132
+ // is how they reach the wire row (`cindy-session-row.js`) at every read path —
1133
+ // list, get, and the row echoed back to a writer.
1134
+ const flagged = sessionFlags.projectAll(rows);
1135
+ sessionRowCache.clear();
1136
+ for (const row of flagged) sessionRowCache.set(String(row.id), row);
1137
+ return flagged;
1138
+ },
1139
+ /**
1140
+ * The same sessions, without the expensive half.
1141
+ *
1142
+ * `maker:list-active` and `maker:session-in-turn` only ask whether a turn is running,
1143
+ * and the controller polls them hard. Reading them through the full row fold made the
1144
+ * cheapest question on this Host pay the dearest read — measured 8.4s against a
1145
+ * profile with 219 sessions, in front of a handset that gives up at 15s, which is
1146
+ * exactly what "电脑暂时没有回应请求" looked like from the outside. A source without
1147
+ * the cheap read (the fixture used by tests) falls back to the full list, so a source
1148
+ * is never required to provide it.
1149
+ */
1150
+ listSessionStates: async (requestedSessionId) => {
1151
+ if (source === undefined || source === null) throw new Error('no DSH session source is attached');
1152
+ const rows = typeof source.listSessionStates === 'function' ? await source.listSessionStates(requestedSessionId) : await source.listSessions();
1153
+ return sessionFlags.projectAll(rows);
1154
+ },
1155
+ // Resolved per request. The approval pair lives here because the registry is
1156
+ // this runtime's own; everything else comes from the plugin, whose DSH
1157
+ // services may activate after this runtime is built.
1158
+ resolveCapabilities: () => {
1159
+ const provided = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : {};
1160
+ return {
1161
+ listPendingInteractions: (sessionId) => approvals.list(sessionId),
1162
+ resolveInteraction: (requestId, decision) => approvals.settle(requestId, decision),
1163
+ // The archive/delete/pin writes, and the one reader that decides whether a
1164
+ // session still belongs on the controller's live surfaces.
1165
+ applySessionFlags: (sessionId, patch) => sessionFlags.apply(sessionId, patch),
1166
+ sessionHidden: (sessionId) => sessionFlags.isHidden(sessionId),
1167
+ publishSessionMeta,
1168
+ // The controller's only way to *see* a file that lives on this machine: an image
1169
+ // the agent produced, or one picked in the file browser. Host-owned because the
1170
+ // upload needs this runtime's account session.
1171
+ exportFileStart: startFileExport,
1172
+ exportFileStatus: fileExportStatus,
1173
+ fileUrl,
1174
+ filePeer: (peer, payload) => filePeer.handle(peer, payload),
1175
+ // The chat-image path: the controller names a file on this machine (an image the
1176
+ // agent drew, referenced by path) and gets a staged key back.
1177
+ fetchLocalMedia,
1178
+ // The work-grouped history window (`local-db:messages:view` and its two siblings):
1179
+ // what lets the controller keep a projection across re-entries instead of
1180
+ // re-deriving continuity from 20-row pages and dropping what it cannot prove.
1181
+ historyView: provided.historyView ?? null,
1182
+ // The live push path's hydrator. It is not a controller capability — it never reaches a
1183
+ // channel — but it crosses this boundary for the same reason `historyView` does: the
1184
+ // plugin owns the attachment reader, and a row must be renderable in the frame that
1185
+ // carries it (see `pushSessionMessage`).
1186
+ hydrateImages: provided.hydrateImages,
1187
+ inputProjection: (sessionId, pending) => inputQueue.projectionFor(sessionId, sessionRowFor(sessionId), pending ?? null),
1188
+ queuedRow: ({ clientId, text, sessionId }) => queuedRowFromController({ clientId, text, session: sessionRowFor(sessionId) }),
1189
+ goalStatus: (sessionId) => toGoalStatusPayload(sessionId, projections.goalOf(sessionId)),
1190
+ /**
1191
+ * The queue's locally-owned half.
1192
+ *
1193
+ * Every committed queue mutation is folded here as well as in DSH: the
1194
+ * service's own `queue` frame trails the commit, so answering the
1195
+ * controller from the un-mirrored fold would hand back the row it just
1196
+ * deleted. The controller's expanded/lock flags live only here — they are
1197
+ * queue state that the controller reads back out of the projection.
1198
+ */
1199
+ queueMirror: {
1200
+ has: (sessionId, itemId) => inputQueue.hasItem(sessionId, itemId),
1201
+ dshItemId: (sessionId, controllerId) => inputQueue.dshItemId(sessionId, controllerId),
1202
+ mirror: (sessionId, itemId, action) => inputQueue.mirror(sessionId, itemId, action),
1203
+ moveItem: (sessionId, itemId, targetIndex) => inputQueue.moveItem(sessionId, itemId, targetIndex),
1204
+ setExpanded: (sessionId, expanded) => inputQueue.setExpanded(sessionId, expanded),
1205
+ setEditLock: (sessionId, clientId, locked) => inputQueue.setEditLock(sessionId, clientId, locked),
1206
+ setInteractionLock: (sessionId, lockId, locked) => inputQueue.setInteractionLock(sessionId, lockId, locked),
1207
+ clearSession: (sessionId) => inputQueue.clearSession(sessionId),
1208
+ adopt: (sessionId, items) => inputQueue.adopt(sessionId, items),
1209
+ // A prompt just accepted as steering: the controller's own id has to appear in
1210
+ // `steeringQueueClientIds`, or its bubble has nothing to hold on to until the
1211
+ // durable row lands (the model driver retires it in between).
1212
+ markSteering: (sessionId, item) => inputQueue.markSteering(sessionId, item),
1213
+ // And a prompt accepted as *queued*: the authoritative read can race DSH's splice,
1214
+ // so the row the controller is showing has to exist in the fold for a later
1215
+ // promotion, edit or removal to resolve.
1216
+ markQueued: (sessionId, item) => inputQueue.markQueued(sessionId, item),
1217
+ itemIds: (sessionId) => inputQueue.itemIds(sessionId),
1218
+ },
1219
+ /** Project a queue DSH actually reported, rather than the folded one. */
1220
+ projectionFromItems: (sessionId, items, pending = null) => inputQueue.projectionFor(sessionId, sessionRowFor(sessionId), pending, items),
1221
+ /**
1222
+ * Tell every watcher what the queue looks like now.
1223
+ *
1224
+ * A queue command answers the controller that sent it, but a second screen
1225
+ * watching the same session has no reason to ask again — without this it
1226
+ * keeps rendering a row the user just deleted.
1227
+ */
1228
+ pushInputProjection: (sessionId) => pushSessionUpdate(sessionId, 'maker:input:projection', inputQueue.projectionFor(sessionId, sessionRowFor(sessionId))),
1229
+ /**
1230
+ * Ask the controllers watching a session to re-read its history view.
1231
+ *
1232
+ * Exposed for the subscribe path: a controller that has just (re)subscribed is rebuilding
1233
+ * its view, and every push it missed while it was away was dropped by the relay, not queued.
1234
+ */
1235
+ invalidateHistoryView: (sessionId) => pushHistoryViewChanged(sessionId),
1236
+ /**
1237
+ * Whether a turn is live for this session.
1238
+ *
1239
+ * It decides whether an accepted prompt queues or is admitted immediately,
1240
+ * which is the difference between showing a 队列中 row and inventing one.
1241
+ */
1242
+ isSessionRunning: (sessionId) => isSessionRunningNow(sessionId),
1243
+ /**
1244
+ * Tell a session's watchers the turn is over.
1245
+ *
1246
+ * The controller starts its spinner optimistically when it sends, and only
1247
+ * an explicit terminal `maker:event` or an explicit `isTurnRunning: false`
1248
+ * snapshot clears it — a *missing* session in `maker:list-active` is
1249
+ * deliberately not treated as idle. So an action whose turn never started
1250
+ * (a refused steer, a prompt that failed) left the phone spinning with
1251
+ * nothing on the Host to correct it. This is that correction.
1252
+ */
1253
+ pushTurnIdle: (sessionId) => announceTurnIdle(sessionId),
1254
+ /**
1255
+ * Tell a session's watchers the turn is live.
1256
+ *
1257
+ * The counterpart of {@link pushTurnIdle}, and what makes attaching
1258
+ * authoritative in both directions: a controller that reconnects into a
1259
+ * running turn must be told so rather than left to infer it from silence.
1260
+ */
1261
+ pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
1262
+ // The controller's goal panel refreshes from this push, so a write made on
1263
+ // the phone reaches any other screen watching the same session.
1264
+ pushGoalStatus: (sessionId, status) => pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: status }),
1265
+ ...provided,
1266
+ /**
1267
+ * The plugin's send seam, carrying this Host's media access.
1268
+ *
1269
+ * An attachment the controller uploaded is fetched with *this runtime's*
1270
+ * account session (see {@link createMediaRefResolver}), and only this runtime
1271
+ * holds it — so the seam is handed the two media functions rather than
1272
+ * resolving the credential again somewhere else. The wrap is deliberate: the
1273
+ * seam stays the plugin's, and nothing here knows how a prompt is built.
1274
+ */
1275
+ ...(typeof provided.sendMessage === 'function'
1276
+ ? { sendMessage: (input) => provided.sendMessage({ ...input, resolveAttachmentRef, releaseAttachmentRef }) }
1277
+ : {}),
1278
+ };
1279
+ },
1280
+ subscribers,
1281
+ onSubscribe: subscribeTopics,
1282
+ onUnsubscribe: unsubscribeTopics,
1283
+ now,
1284
+ // Every row must name the device it came from, or the phone's home filter
1285
+ // drops it as soon as the user selects this Host.
1286
+ getDevice: () => ({ deviceId: status.ownDeviceId, deviceName: HOST_DEVICE_NAME }),
1287
+ });
1288
+
1289
+ /**
1290
+ * What this Host actually pushed, most recent last.
1291
+ *
1292
+ * `pushSessionUpdate` returns silently when nobody is watching, so "the phone
1293
+ * never updated" has two indistinguishable causes from the outside: no
1294
+ * controller held the topic, or the frame went out and the controller dropped
1295
+ * it. Recording the destination count separates them without guessing.
1296
+ */
1297
+ /** How long to wait before deciding a finished turn will never say so itself. */
1298
+ const TURN_RECONCILE_MS = 1500;
1299
+
1300
+ const pushLog = [];
1301
+ /**
1302
+ * How many pushes the ring keeps.
1303
+ *
1304
+ * Forty was too few to read a single turn back: a turn appends dozens of events
1305
+ * and pushes a message row for several of them, so the frames that explain the
1306
+ * beginning were evicted before anyone could look — the `status isRunning:true`
1307
+ * that opens a turn disappeared while the `done` frames that followed stayed.
1308
+ */
1309
+ const PUSH_LOG_LIMIT = 200;
1310
+ /**
1311
+ * How many pushes of each channel this Host has sent, since start.
1312
+ *
1313
+ * The ring above is bounded and churns during a busy turn, so a count taken
1314
+ * from it can go *down* — which makes it useless as evidence that a push
1315
+ * happened. A monotonic tally answers the only question worth asking: did this
1316
+ * channel ever fire, and is it still firing.
1317
+ */
1318
+ const pushTotals = new Map();
1319
+ /**
1320
+ * How many times each invoke channel has been served, and how many refusals each
1321
+ * has produced, since start.
1322
+ *
1323
+ * Same reasoning as `pushTotals`, for the request direction: the invoke ring holds
1324
+ * forty entries and churns within seconds while a controller polls a transcript,
1325
+ * so "did the phone ever call this channel?" — the question that decides whether a
1326
+ * feature is exercised or merely implemented — cannot be answered from it. A
1327
+ * monotonic tally can, and it makes channel coverage measurable instead of
1328
+ * assumed.
1329
+ */
1330
+ const invokeTotals = new Map();
1331
+ const refusalTotals = new Map();
1332
+ /**
1333
+ * Pending "did this turn really end?" checks, one per session.
1334
+ *
1335
+ * Kept so a turn that announces its own end cancels the fallback instead of
1336
+ * being followed by a duplicate `done`, and so dozens of appended events do not
1337
+ * arm dozens of timers.
1338
+ */
1339
+ const pendingReconciles = new Map();
1340
+ /**
1341
+ * sessionId -> whether a turn is live, from DSH's own urn/start / urn/end.
1342
+ *
1343
+ * Deliberately not the row cache: see {@link setCachedRunning} — the cache is empty until
1344
+ * something reads the session list, and a Host that has just restarted has read nothing.
1345
+ */
1346
+ const liveTurnState = new Map();
1347
+
1348
+ /**
1349
+ * Keep the cached row's turn state in step with an authoritative boundary.
1350
+ *
1351
+ * `isSessionRunning` and the reconcile fallback both read this cache, and the
1352
+ * cache is only refreshed by a **list read** — which a controller sitting inside
1353
+ * one session may not cause for minutes. So the cache is fed by every authority
1354
+ * instead: DSH's `turn/start` and `turn/end` (the real boundaries) and the
1355
+ * lifecycle events. Leaving `turn/start` out was measurable: the cached flag
1356
+ * stayed at the flapping `api-session/status(false)`, the 1.5s fallback then
1357
+ * decided the turn was over, and a `done` went out 116 ms *before* the
1358
+ * assistant's message was appended.
1359
+ * @param sessionId - the session whose turn state changed.
1360
+ * @param running - whether a turn is live.
1361
+ */
1362
+ function setCachedRunning(sessionId, running) {
1363
+ // The authoritative state lives **outside** the row cache.
1364
+ //
1365
+ // The cache is populated by list reads, so right after a restart — or simply before any
1366
+ // controller has listed sessions — there is no row to write to and the old version dropped the
1367
+ // fact on the floor. Measured consequence: a live turn reported `isSessionRunning: false`, so
1368
+ // `local-db:messages:view` marked the running work group `isStreaming: false` and the phone
1369
+ // neither drew the live card nor let this Host pin it to the end of the page
1370
+ // (「你一直在我的对话之上在工作」). The map is fed by every authority the cache is, and is the
1371
+ // one thing that is true whether or not a row has ever been read.
1372
+ liveTurnState.set(sessionId, running === true);
1373
+ const cached = sessionRowCache.get(sessionId);
1374
+ if (cached !== undefined) cached.running = running === true;
1375
+ }
1376
+
1377
+ /** The turn state from DSH's own boundaries, falling back to a read row when none was seen. */
1378
+ function isSessionRunningNow(sessionId) {
1379
+ const known = liveTurnState.get(sessionId);
1380
+ return known === undefined ? sessionRowFor(sessionId)?.running === true : known;
1381
+ }
1382
+
1383
+ /**
1384
+ * Announce the end of a turn, and retire any pending fallback for it.
1385
+ *
1386
+ * A `done` is not idempotent for the controller: it finalizes streaming rows,
1387
+ * clears the reconnect state and drops the input-projection continuation owner.
1388
+ * Sending it twice is not free, and sending it when the turn ended on its own is
1389
+ * pure noise.
1390
+ * @param sessionId - the session whose turn ended.
1391
+ */
1392
+ function announceTurnIdle(sessionId) {
1393
+ const timer = pendingReconciles.get(sessionId);
1394
+ if (timer !== undefined) {
1395
+ pendingReconciles.delete(sessionId);
1396
+ if (typeof clearTimeoutImpl === 'function') clearTimeoutImpl(timer);
1397
+ }
1398
+ setCachedRunning(sessionId, false);
1399
+ // The frame goes to every watcher; only the **reachable** ones are recorded as told. The
1400
+ // same `device × session` pair must not be told twice inside the window, whichever path
1401
+ // tells it first — and the re-link repair depends on that record meaning "it arrived", so a
1402
+ // device the relay calls unreachable must not be written into it.
1403
+ const watchers = [...(sessionSubscribers.get(sessionId) ?? [])];
1404
+ pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: { type: 'done' } });
1405
+ for (const deviceId of watchers) {
1406
+ if (!offlineDevices.has(deviceId)) noteTerminalAnnounce(deviceId, sessionId);
1407
+ }
1408
+ // …and, when nobody is watching, to the controllers that could still be waiting.
1409
+ //
1410
+ // A controller whose subscription died with the process (a Host restart, a reconnect)
1411
+ // shows 思考中 for a turn that is over and has no way to learn otherwise — its
1412
+ // watchdog only ever answers "is a turn running", and a missing session is
1413
+ // deliberately not read as idle. The frame carries nothing but the turn's end, and it
1414
+ // only goes out when the session has no watchers (with watchers the line above already
1415
+ // reached everyone who asked), so this cannot duplicate work or leak content.
1416
+ if (watchersFor(sessionId) === 0 && acceptedControllers.size > 0 && acceptedControllers.size <= MAX_TURN_ANNOUNCE) {
1417
+ const payload = { sessionId, event: { type: 'done' } };
1418
+ // Reachable controllers only. A device the relay has declared offline is not told and
1419
+ // is not counted as told — the de-duplication entry below is a claim that the frame
1420
+ // arrived, and the re-link announcement above depends on it being true.
1421
+ const reachable = [...acceptedControllers].filter((deviceId) => !offlineDevices.has(deviceId));
1422
+ if (reachable.length > 0) {
1423
+ recordPush(sessionId, 'maker:event', reachable.length, payload);
1424
+ for (const deviceId of reachable) {
1425
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
1426
+ noteTerminalAnnounce(deviceId, sessionId);
1427
+ }
1428
+ }
1429
+ }
1430
+ publishRowTurnState(sessionId, false);
1431
+ }
1432
+
1433
+ /**
1434
+ * How many controllers one unwatched turn boundary may be fanned out to.
1435
+ *
1436
+ * The announcement above is a repair for a lost subscription, not a broadcast channel:
1437
+ * a Host with more controllers than this simply keeps the old behaviour for them (their
1438
+ * next read corrects it), rather than turning every turn boundary into a fan-out.
1439
+ */
1440
+ const MAX_TURN_ANNOUNCE = 8;
1441
+
1442
+ /**
1443
+ * Patch one session row's turn flag, for every holder of that row.
1444
+ *
1445
+ * The session-scoped `maker:event` reaches a controller that is *viewing* the
1446
+ * session. A **list** spinner reads the row instead, and the row only changes when
1447
+ * something patches it: our `local-db:sessions:patched` push carried `updatedAt`
1448
+ * alone, so a controller holding the row kept `running: true` from the optimistic
1449
+ * start and spun on it — the reported "agent 已经结束了,但我这边还是显示 思考中",
1450
+ * both after a normal turn and after a goal round finished.
1451
+ *
1452
+ * Published at the same two moments as the `maker:event` frame (DSH's own
1453
+ * `turn/start` / `turn/end` boundaries), never from the flapping `idle` phase: the
1454
+ * row must not assert a turn state the Host cannot stand behind. Both directions of
1455
+ * the wire are covered — the `sessions` topic for list holders, and this session's
1456
+ * watchers for a header that reads its own row.
1457
+ * @param sessionId - the session whose row to patch.
1458
+ * @param running - the turn state DSH just reported.
1459
+ */
1460
+ function publishRowTurnState(sessionId, running) {
1461
+ publishRowPatch(sessionId, { running, updatedAt: now().toISOString() });
1462
+ }
1463
+
1464
+ /**
1465
+ * Send one session row patch to every controller holding that row.
1466
+ *
1467
+ * Delivered once per controller, not once per topic: a controller looking at one
1468
+ * session holds both topics, and the row patch is a state assertion rather than an
1469
+ * event, so the same reader receiving it twice is pure duplicate work — and it is
1470
+ * what made "one patch per turn boundary" unverifiable from the frame log.
1471
+ * @param sessionId - the session whose row changed.
1472
+ * @param patch - the fields the controller's `local-db:sessions:patched` handler applies.
1473
+ */
1474
+ function publishRowPatch(sessionId, patch) {
1475
+ const payload = { sessionId, patch };
1476
+ const devices = new Set(subscribers);
1477
+ for (const deviceId of sessionSubscribers.get(sessionId) ?? []) devices.add(deviceId);
1478
+ recordPush(sessionId, 'local-db:sessions:patched', devices.size, payload);
1479
+ if (devices.size === 0) return;
1480
+ for (const dst of devices) send({ v: PROTOCOL_VERSION, kind: 'push', dst, payload: { channel: 'local-db:sessions:patched', payload } });
1481
+ }
1482
+
1483
+ /**
1484
+ * Tell the controllers holding this session's row about an archive/delete/pin write.
1485
+ *
1486
+ * The writer already applied the change optimistically and gets this echo too —
1487
+ * `sessionPendingWrites.consumeMaskedPush` exists on the controller for exactly
1488
+ * that — while every *other* linked device would otherwise keep the stale status
1489
+ * until its next list read.
1490
+ * @param sessionId - the session the controller edited.
1491
+ * @param patch - the fields the controller wrote, with this Host's effective values.
1492
+ */
1493
+ function publishSessionMeta(sessionId, patch) {
1494
+ publishRowPatch(sessionId, { ...patch, updatedAt: now().toISOString() });
1495
+ }
1496
+
1497
+ /**
1498
+ * Tell one controller which sessions are in a turn right now.
1499
+ *
1500
+ * A controller that has just re-linked knows nothing about this Host's live state, and
1501
+ * its view of a session is otherwise only corrected by a push it can no longer receive
1502
+ * (subscriptions die with the process). Reported as 手机一直显示思考中,5 分钟都没抓到最新
1503
+ * 的信息, right after a Host restart: the session was genuinely running, the phone was
1504
+ * never told, and nothing could ever end the spinner it already had.
1505
+ *
1506
+ * Bounded by construction: only sessions currently in a turn, and only to the one device
1507
+ * that just linked.
1508
+ * @param deviceId - the controller that re-linked.
1509
+ */
1510
+ async function announceRunningSessions(deviceId) {
1511
+ let rows;
1512
+ try {
1513
+ rows = await listSessionRowsForAnnouncement();
1514
+ } catch {
1515
+ return;
1516
+ }
1517
+ const running = rows.filter((row) => row?.running === true && !sessionFlags.isHidden(row.id));
1518
+ if (running.length === 0) return;
1519
+ for (const row of running) {
1520
+ const payload = { sessionId: String(row.id), event: runningStatusEvent() };
1521
+ recordPush(String(row.id), 'maker:event', 1, payload);
1522
+ send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
1523
+ }
1524
+ }
1525
+
1526
+ /** The current listing, for the re-link announcement; the cache when the source is away. */
1527
+ async function listSessionRowsForAnnouncement() {
1528
+ if (source !== undefined && source !== null) {
1529
+ const rows = typeof source.listSessionStates === 'function' ? await source.listSessionStates() : await source.listSessions();
1530
+ return Array.isArray(rows) ? rows : [];
1531
+ }
1532
+ return [...sessionRowCache.values()];
1533
+ }
1534
+
1535
+ /**
1536
+ * Announce that a turn is live.
1537
+ * @param sessionId - the session whose turn started.
1538
+ */
1539
+ function announceTurnRunning(sessionId) {
1540
+ setCachedRunning(sessionId, true);
1541
+ pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent() });
1542
+ publishRowTurnState(sessionId, true);
1543
+ }
1544
+
1545
+ /**
1546
+ * A bounded, decision-shaped digest of one pushed payload.
1547
+ *
1548
+ * The channel and the watcher count answer "did it go out"; they cannot answer
1549
+ * "what did it say, and in what order". A controller that keeps spinning after
1550
+ * the answer arrived has two possible causes — the terminal event never fired,
1551
+ * or it fired and a later frame lit the state back up — and those are
1552
+ * indistinguishable without the payload. Only the fields that decide the
1553
+ * controller's own branches are kept, and each is truncated: this is evidence,
1554
+ * not a data dump.
1555
+ * @param channel - the push channel.
1556
+ * @param payload - the pushed payload.
1557
+ * @returns a short summary string.
1558
+ */
1559
+ function payloadDigest(channel, payload) {
1560
+ const clamp = (value) => {
1561
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
1562
+ return typeof text === 'string' && text.length > 160 ? `${text.slice(0, 160)}…` : text;
1563
+ };
1564
+ if (payload === null || typeof payload !== 'object') return clamp(payload);
1565
+ if (channel === 'maker:event') {
1566
+ const event = payload.event ?? {};
1567
+ // `type` is the branch the controller switches on: a non-terminal event
1568
+ // clears its reconnect state, `done` is what retires the spinner.
1569
+ const data = event.data !== null && typeof event.data === 'object' ? event.data : {};
1570
+ return clamp({ type: event.type, isRunning: data.isRunning, status: data.status, turnContinuationId: event.turnContinuationId });
1571
+ }
1572
+ if (channel === 'local-db:messages:created') {
1573
+ const message = payload.message ?? {};
1574
+ // `role` + `kind` decide which card the row becomes, and `isStreaming`
1575
+ // decides whether that card keeps ticking.
1576
+ return clamp({ id: message.clientId ?? message.id, role: message.role, kind: message.kind, isStreaming: message.isStreaming, text: typeof message.content?.text === 'string' ? message.content.text.slice(0, 40) : undefined });
1577
+ }
1578
+ if (channel === 'maker:goal:status-changed') {
1579
+ const goal = payload.goal ?? {};
1580
+ return clamp({ status: goal.status, objective: goal.objective, maxTurns: goal.maxTurns, turnsUsed: goal.turnsUsed });
1581
+ }
1582
+ if (channel === 'local-db:sessions:activity') return clamp({ phase: payload.phase, attention: payload.attention });
1583
+ if (channel === 'local-db:sessions:patched') return clamp({ patch: payload.patch });
1584
+ return clamp(payload);
1585
+ }
1586
+
1587
+ function recordPush(sessionId, channel, watchers, payload) {
1588
+ const name = typeof channel === 'string' ? channel : null;
1589
+ if (name !== null) pushTotals.set(name, (pushTotals.get(name) ?? 0) + 1);
1590
+ pushLog.push({
1591
+ at: now().toISOString(),
1592
+ sessionId: typeof sessionId === 'string' ? sessionId : null,
1593
+ channel: name,
1594
+ watchers,
1595
+ // The digest is what turns "the spinner stayed on" into a readable
1596
+ // sequence: which frames went out, in which order, saying what.
1597
+ said: payload === undefined ? null : payloadDigest(name, payload),
1598
+ });
1599
+ if (pushLog.length > PUSH_LOG_LIMIT) pushLog.splice(0, pushLog.length - PUSH_LOG_LIMIT);
1600
+ }
1601
+
1602
+ /**
1603
+ * What this Host actually answered, most recent last.
1604
+ *
1605
+ * A controller's view is invisible from here, so "the phone shows nothing" has
1606
+ * three very different causes — the channel was never asked for, we refused it,
1607
+ * or we answered with something unusable. Only the channel name, outcome, and
1608
+ * reply size are kept; no payload ever enters this log.
1609
+ */
1610
+ const invokeLog = [];
1611
+ const INVOKE_LOG_LIMIT = 40;
1612
+ /**
1613
+ * Every refusal, kept separately and for far longer than the successes.
1614
+ *
1615
+ * A polling controller fills the ring in seconds — the phone re-reads
1616
+ * `local-db:messages:list` on every screen tick — so the one refused channel
1617
+ * that explains "host failed to serve this channel" is evicted long before
1618
+ * anyone looks. Refusals are rare and are the only entries that need to
1619
+ * survive.
1620
+ */
1621
+ const refusalLog = [];
1622
+ const REFUSAL_LOG_LIMIT = 60;
1623
+ /**
1624
+ * The phone's own diagnostics, kept apart from everything else and with their timestamps.
1625
+ *
1626
+ * A controller with no log of its own reports by invoking an unknown channel whose **name
1627
+ * carries the reading** — `psdiag.s10.p0.a0.c0.o0` from device-link's peer-silence probe,
1628
+ * `tdiag.stall.s96.r1.frame` / `tdiag.recover.after31s.r1.frame` from the mobile timer
1629
+ * liveness guard. Every one of them is refused here, which is the point: refusal is the
1630
+ * mailbox. Ordinary polling churns the rings above within seconds, so a stall that happened
1631
+ * two minutes ago is unreadable by the time anyone asks. This ring is evicted only by more
1632
+ * diagnostics, and each entry keeps the `at` the phone's report arrived at — the only
1633
+ * timeline that exists for a stalled app.
1634
+ */
1635
+ const phoneDiagnostics = [];
1636
+ const PHONE_DIAGNOSTIC_LIMIT = 40;
1637
+ const PHONE_DIAGNOSTIC_PREFIXES = ['psdiag.', 'tdiag.'];
1638
+ /** Longest reply excerpt kept for a corpus read; enough to compare fields, not a data dump. */
1639
+ const INVOKE_PREVIEW_CHARS = 1200;
1640
+ const PREVIEW_CHANNELS = new Set([
1641
+ 'local-db:sessions:list',
1642
+ 'local-db:sessions:get',
1643
+ 'maker:list-active',
1644
+ // The palette lists. Whether the controller renders a skill row depends on the
1645
+ // *shape* this Host sends (`{ success, skills: [{ kind: 'agent-skill', … }] }`)
1646
+ // and on it asking about a session at all — a bounded preview is the only way
1647
+ // to tell "we sent nothing" from "the phone dropped a correct list".
1648
+ 'maker:list-agent-skills',
1649
+ 'maker:list-agent-commands',
1650
+ ]);
1651
+
1652
+ function recordInvoke(channel, reply, failure, src, ask = null) {
1653
+ const result = reply?.payload?.result;
1654
+ // Two reply families carry their list inside an envelope rather than as the
1655
+ // result itself, so "how many rows did we send" needs its own read.
1656
+ const enveloped = Array.isArray(result?.skills) ? result.skills
1657
+ : Array.isArray(result?.commands) ? result.commands
1658
+ : null;
1659
+ const entry = {
1660
+ at: now().toISOString(),
1661
+ channel: typeof channel === 'string' ? channel : null,
1662
+ // What was asked, for the channels where the question decides the answer.
1663
+ ask: typeof ask === 'string' ? ask : null,
1664
+ // Which device asked. Without this, polling from another linked computer
1665
+ // is indistinguishable from polling by the phone the user is holding —
1666
+ // and "the phone shows nothing" becomes unattributable.
1667
+ src: typeof src === 'string' ? src : null,
1668
+ ok: failure === undefined && reply !== null && reply?.payload?.ok === true,
1669
+ code: failure !== undefined ? 'THREW' : (reply?.payload?.ok === true ? null : (reply?.payload?.error?.code ?? 'NO_REPLY')),
1670
+ // Reply size is the cheapest signal that we answered with a list rather
1671
+ // than an empty envelope.
1672
+ bytes: reply === null ? 0 : JSON.stringify(reply).length,
1673
+ items: enveloped !== null ? enveloped.length : (Array.isArray(result) ? result.length : null),
1674
+ // A controller's view is invisible from the Host, so the row we actually
1675
+ // sent is the only way to tell "we sent the wrong shape" from "the phone
1676
+ // dropped a correct shape". Bounded, and only for corpus reads — the
1677
+ // settings page's own API carries no session data.
1678
+ preview: PREVIEW_CHANNELS.has(channel) && result !== undefined && reply?.payload?.ok === true
1679
+ ? JSON.stringify(result).slice(0, INVOKE_PREVIEW_CHARS)
1680
+ : null,
1681
+ detail: failure === undefined ? null : describeFailure(failure),
1682
+ };
1683
+ invokeLog.push(entry);
1684
+ if (invokeLog.length > INVOKE_LOG_LIMIT) invokeLog.splice(0, invokeLog.length - INVOKE_LOG_LIMIT);
1685
+ // Monotonic, so the ring above can churn without the evidence going with it.
1686
+ const served = typeof channel === 'string' ? channel : '<none>';
1687
+ invokeTotals.set(served, (invokeTotals.get(served) ?? 0) + 1);
1688
+ if (entry.ok === false) {
1689
+ refusalTotals.set(served, (refusalTotals.get(served) ?? 0) + 1);
1690
+ refusalLog.push(entry);
1691
+ if (refusalLog.length > REFUSAL_LOG_LIMIT) refusalLog.splice(0, refusalLog.length - REFUSAL_LOG_LIMIT);
1692
+ // A controller's self-report is the one refusal that carries evidence rather than a
1693
+ // capability gap, and it is worthless without its arrival time.
1694
+ if (PHONE_DIAGNOSTIC_PREFIXES.some((prefix) => served.startsWith(prefix))) {
1695
+ phoneDiagnostics.push({ at: entry.at, channel: served, src: entry.src ?? null });
1696
+ if (phoneDiagnostics.length > PHONE_DIAGNOSTIC_LIMIT) {
1697
+ phoneDiagnostics.splice(0, phoneDiagnostics.length - PHONE_DIAGNOSTIC_LIMIT);
1698
+ }
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ /**
1704
+ * Describe a failure well enough to act on.
1705
+ *
1706
+ * The message alone is not enough: a phone asked for `maker:input:steer` and
1707
+ * the log recorded `THREW` with an empty detail, which says only that
1708
+ * *something* went wrong. The error's name, its own `code`, and the first
1709
+ * stack frame are what locate the throw.
1710
+ * @param failure - whatever was thrown.
1711
+ * @returns a bounded one-line description.
1712
+ */
1713
+ function describeFailure(failure) {
1714
+ if (failure === null || failure === undefined) return 'threw a falsy value';
1715
+ const name = typeof failure?.name === 'string' && failure.name !== '' ? failure.name : typeof failure;
1716
+ const code = typeof failure?.code === 'string' && failure.code !== '' ? ` code=${failure.code}` : '';
1717
+ const message = typeof failure?.message === 'string' && failure.message !== '' ? failure.message : String(failure);
1718
+ const frame = typeof failure?.stack === 'string'
1719
+ ? failure.stack.split('\n').slice(1).find((line) => line.trim() !== '') ?? ''
1720
+ : '';
1721
+ return `${name}${code}: ${message}${frame === '' ? '' : ` @${frame.trim()}`}`.slice(0, 240);
1722
+ }
1723
+
1724
+ /**
1725
+ * What the controller asked for, as far as it is safe and useful to record.
1726
+ *
1727
+ * The reply side alone cannot explain a bad page: "0 rows" is the correct
1728
+ * answer for a cursor past the start of the transcript and a bug for a cursor
1729
+ * the controller computed itself. A paging cursor is the exact value that
1730
+ * decides which, so it is kept — bounded, and only for reads the controller
1731
+ * paginates.
1732
+ */
1733
+ function askOf(channel, args) {
1734
+ // Which harness the controller is asking about decides the answer: this Host
1735
+ // offers exactly one, so a refusal here means "you asked for a kind that does
1736
+ // not exist" — and without the kind that refusal is indistinguishable from a
1737
+ // broken catalog.
1738
+ if (channel === 'maker:get-capabilities') {
1739
+ return `agentKind=${typeof args?.[0] === 'string' ? args[0] : 'none'}`;
1740
+ }
1741
+ // The composer palettes decide their answer from the *viewing session*: skills
1742
+ // and commands live in layered registries that answer per agent, so a request
1743
+ // without a session names the global layer alone and comes back empty on a
1744
+ // profile whose skills are all registered by a preset. Recording which session
1745
+ // was asked about — and the working directory, which selects project roots —
1746
+ // is what makes "the menu is empty" a fact instead of a guess.
1747
+ if (channel === 'maker:list-agent-skills' || channel === 'maker:list-agent-commands') {
1748
+ const options = args?.[1] !== null && typeof args?.[1] === 'object' ? args[1] : null;
1749
+ const sessionId = typeof options?.sessionId === 'string' && options.sessionId !== '' ? options.sessionId : 'none';
1750
+ const cwd = typeof options?.workingDir === 'string' && options.workingDir !== '' ? 'yes' : 'no';
1751
+ return `agentKind=${typeof args?.[0] === 'string' ? args[0] : 'none'} sessionId=${sessionId} cwd=${cwd}`;
1752
+ }
1753
+ // Attachments arrive on the send paths and split into two forms: a path on
1754
+ // this machine (readable, becomes a durable attachment) and a transit reference
1755
+ // to the account's OSS staging area, whose bytes this Host fetches with its own
1756
+ // credential. Recording which forms arrived is what makes "the agent could not
1757
+ // see my image" answerable from diagnostics.
1758
+ //
1759
+ // Both reference schemes count as the reference form. The phones send the
1760
+ // **legacy** `xdt-oss-attach://` one, so a classifier that knew only the current
1761
+ // scheme reported a photographed page as `form=host-paths` — which is exactly
1762
+ // how a live attachment failure was first misread as "the phone never sent".
1763
+ if (channel === 'maker:send' || channel === 'maker:input:enqueue' || channel === 'maker:input:steer') {
1764
+ const files = Array.isArray(args?.[1]?.files) ? args[1].files : [];
1765
+ // Which session received the prompt, and which text: a send is the one request whose
1766
+ // effect is invisible in this log otherwise, and 「我发了两条消息,你收到了吗」 took a
1767
+ // log excavation to answer — the session id and a bounded length make it a lookup.
1768
+ const sessionId = typeof args?.[0] === 'string' && args[0] !== '' ? args[0] : (typeof args?.[0]?.sessionId === 'string' ? args[0].sessionId : '?');
1769
+ const text = typeof args?.[1] === 'string' ? args[1] : (typeof args?.[1]?.text === 'string' ? args[1].text : null);
1770
+ const clientId = typeof args?.[1]?.clientId === 'string' && args[1].clientId !== '' ? args[1].clientId : null;
1771
+ const parts = [
1772
+ sessionId,
1773
+ `text=${text === null ? 'none' : `${text.length}c`}`,
1774
+ `clientId=${clientId ?? 'none'}`,
1775
+ ];
1776
+ if (files.length > 0) {
1777
+ const refs = files.filter((file) => isAttachmentOssRef(file?.path)).length;
1778
+ const form = refs === files.length ? 'oss-refs' : refs === 0 ? 'host-paths' : 'mixed';
1779
+ parts.push(`attachments=${files.length} form=${form}`);
1780
+ }
1781
+ return parts.join(' ');
1782
+ }
1783
+ if (channel !== 'local-db:messages:list') return null;
1784
+ // Which session is being read matters as much as the cursor: two sessions
1785
+ // with very different transcripts produce very different pages, and without
1786
+ // the id a short page is unattributable.
1787
+ const sessionId = typeof args?.[0] === 'string' ? args[0] : '?';
1788
+ const options = Array.isArray(args) && args[1] !== null && typeof args[1] === 'object' ? args[1] : null;
1789
+ const limit = Number.isFinite(options?.limit) ? options.limit : null;
1790
+ const before = typeof options?.before === 'string' ? options.before : null;
1791
+ return `${sessionId} limit=${limit === null ? 'default' : limit} before=${before === null ? 'none' : before}`;
1792
+ }
1793
+
1794
+ async function handleInvoke(frame) {
1795
+ const channel = frame?.payload?.channel;
1796
+ const ask = askOf(channel, frame?.payload?.args);
1797
+ const needsCorpus = channel === 'local-db:sessions:list' || channel === 'local-db:sessions:get' || channel === 'maker:list-active' || channel === 'local-db:messages:list';
1798
+ if (needsCorpus && (source === undefined || source === null)) {
1799
+ const reply = invokeError(frame, 'NOT_AVAILABLE', 'This DSH profile exposes no session API');
1800
+ recordInvoke(channel, reply, undefined, frame?.src, ask);
1801
+ return reply;
1802
+ }
1803
+ try {
1804
+ const reply = await routeInvoke(frame);
1805
+ recordInvoke(channel, reply, undefined, frame?.src, ask);
1806
+ return reply;
1807
+ } catch (failure) {
1808
+ recordInvoke(channel, null, failure, frame?.src, ask);
1809
+ throw failure;
1810
+ }
1811
+ }
1812
+
1813
+ /**
1814
+ * Make one reply fit the relay's frame ceiling before it is sent.
1815
+ *
1816
+ * The device-link contract is explicit about who owes what here: the controlled end
1817
+ * degrades a message page (content truncation → placeholder → row trimming, each
1818
+ * marked so the controller keeps its "load earlier" affordance) and answers
1819
+ * everything else with a compact `PAYLOAD_TOO_LARGE` refusal, because an oversized
1820
+ * frame 「控制端收不到任何 invoke-result,只能干等 30s 超时」
1821
+ * (`apps/desktop/src/main/device-link/dispatch.ts`). Without this, a page carrying a
1822
+ * large tool output or an inlined photo simply never arrives — reported from the
1823
+ * handset as 历史消息全部消失、重新加载更早消息也没有响应.
1824
+ *
1825
+ * @param reply - the frame `routeInvoke` produced.
1826
+ * @param requestFrame - the inbound invoke, for its channel.
1827
+ * @returns the frame to send.
1828
+ */
1829
+ function fitReplyToFrame(reply, requestFrame) {
1830
+ const channel = typeof requestFrame?.payload?.channel === 'string' ? requestFrame.payload.channel : null;
1831
+ const fitted = fitInvokeResultToFrame({
1832
+ reply,
1833
+ request: { id: reply?.id, src: reply?.dst },
1834
+ channel,
1835
+ });
1836
+ if (fitted.level === 'fits') return reply;
1837
+ frameDegradations.push({
1838
+ at: now().toISOString(),
1839
+ channel,
1840
+ level: fitted.level,
1841
+ rows: fitted.rows,
1842
+ bytes: Math.round(fitted.bytes),
1843
+ });
1844
+ if (frameDegradations.length > FRAME_REFUSAL_LOG_LIMIT) {
1845
+ frameDegradations.splice(0, frameDegradations.length - FRAME_REFUSAL_LOG_LIMIT);
1846
+ }
1847
+ if (fitted.level === 'refused') frameRefusalTotal += 1;
1848
+ return fitted.reply;
1849
+ }
1850
+
1851
+ function handleFrame(frame) {
1852
+ if (frame === null || typeof frame !== 'object') return;
1853
+ // **Any** inbound frame proves the link is alive, not just a `pong`.
1854
+ //
1855
+ // The Cindy client states the rule for its own watchdog ("入站业务流量会把这组计数
1856
+ // 清零"), and the difference is not academic: a relay that keeps delivering presence
1857
+ // and invoke frames while answering pings late — or not at all for a few periods —
1858
+ // would otherwise be declared dead and torn down mid-conversation.
1859
+ pongMisses = 0;
1860
+ switch (frame.kind) {
1861
+ case 'hello-ack': {
1862
+ if (boundSocketGeneration !== generation) {
1863
+ boundSocketGeneration = generation;
1864
+ exportOwner.bind({ realm: mediaBase + '|' + normalizeAuthBaseUrl(socketSession?.authBaseUrl),
1865
+ userId: frame.payload?.userId, session: socketSession });
1866
+ }
1867
+ const payload = frame.payload ?? {};
1868
+ status.setHost({
1869
+ deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
1870
+ userId: typeof payload.userId === 'string' ? payload.userId : null,
1871
+ protocolVersion: typeof payload.serverProtocolVersion === 'number' ? payload.serverProtocolVersion : null,
1872
+ });
1873
+ status.setState('waiting');
1874
+ // The relay itself accepted us, which is the first moment "online" is true —
1875
+ // so this is where the reconnect ladder starts forgetting failed attempts.
1876
+ markConnectionStable();
1877
+ // Devices already online for this account never re-announce themselves.
1878
+ void refreshDeviceDirectory().catch((error) => recordHandlerError('device-directory', error));
1879
+ return;
1880
+ }
1881
+ case 'pong':
1882
+ pongMisses = 0;
1883
+ return;
1884
+ case 'ping':
1885
+ send({ v: PROTOCOL_VERSION, kind: 'pong' });
1886
+ return;
1887
+ case 'presence-changed': {
1888
+ const snapshot = frame.payload ?? {};
1889
+ if (typeof snapshot.deviceId !== 'string' || snapshot.deviceId === '') return;
1890
+ if (snapshot.deviceId === status.ownDeviceId) return;
1891
+ status.upsertDevice(snapshot.deviceId, {
1892
+ name: typeof snapshot.selfName === 'string' && snapshot.selfName !== '' ? snapshot.selfName : snapshot.deviceName,
1893
+ platform: typeof snapshot.platform === 'string' ? snapshot.platform : null,
1894
+ online: snapshot.online === true,
1895
+ lastSeenAt: typeof snapshot.lastSeenAt === 'number' ? new Date(snapshot.lastSeenAt).toISOString() : null,
1896
+ });
1897
+ // The relay's own answer about reachability, and the only one that counts.
1898
+ //
1899
+ // Measured failure this prevents: the relay reported the handset `online:false` at
1900
+ // 09:17:38 while the Host kept pushing rows and turn events at it for minutes — each
1901
+ // frame dropped with no route, the phone frozen on 思考中. What presence decides is that
1902
+ // those frames are **not counted as delivered** (so the re-link announcement is still
1903
+ // allowed to fire); it does **not** delete the subscription. See
1904
+ // `markDeviceUnreachable` — the first version did delete it, and a stale `online:false`
1905
+ // at 09:48:04 then silenced a handset the user was holding.
1906
+ if (snapshot.online !== true) {
1907
+ // Marked unreachable *before* anything else, because the terminal announcement for a
1908
+ // turn that ends while the device is away must not be counted as delivered: it is a
1909
+ // frame into a void, and recording it would suppress the re-link announcement that
1910
+ // is supposed to repair exactly that.
1911
+ filePeer.closePeer(snapshot.deviceId);
1912
+ markDeviceUnreachable(snapshot.deviceId);
1913
+ } else if (typeof snapshot.deviceId === 'string') {
1914
+ // Reachable again by the relay's own account: same restoration as an inbound frame,
1915
+ // so a device that comes back does not have to re-subscribe by hand to be pushed to.
1916
+ markDeviceReachable(snapshot.deviceId);
1917
+ }
1918
+ return;
1919
+ }
1920
+ case 'link-open': {
1921
+ if (typeof frame.id !== 'string' || typeof frame.src !== 'string') return;
1922
+ if (!policy.canAccept(frame.src)) return;
1923
+ // A link-open is itself proof the relay has a route to this device.
1924
+ markDeviceReachable(frame.src);
1925
+ linkController(frame.src);
1926
+ send(acceptLink(frame, acceptedControllers));
1927
+ // A controller that has just re-linked knows nothing about this Host's live state.
1928
+ //
1929
+ // Subscriptions live in this process's memory, so a Host restart (or any reconnect)
1930
+ // empties them while the controller still believes it is inside a session. The
1931
+ // result was reported as 手机一直显示思考中,5 分钟都没抓到最新的信息: no live rows,
1932
+ // and — worse — no terminal `maker:event`, so the spinner had nothing to end it.
1933
+ // Tell the controller directly which sessions are running right now; the set is
1934
+ // bounded by the sessions actually in a turn, and the frame is session-scoped, so
1935
+ // it lands on the row the controller already holds.
1936
+ void announceRunningSessions(frame.src).catch((error) => recordHandlerError('announce-running', error));
1937
+ // …and the *terminal* truth for the sessions it was watching before the relay
1938
+ // dropped it. `announceRunningSessions` covers a turn still in flight; a turn that
1939
+ // ended while the device was away would otherwise be announced by nothing, and the
1940
+ // handset would keep spinning until the user re-entered the session by hand — the
1941
+ // reported 「我必须要返回上一页退出会话重新进才能看到你的回答」.
1942
+ for (const sessionId of watchedSessionsByDevice.get(frame.src) ?? []) {
1943
+ announceWatchedTurnState(frame.src, sessionId);
1944
+ }
1945
+ return;
1946
+ }
1947
+ case 'link-close': {
1948
+ if (typeof frame.src !== 'string') return;
1949
+ filePeer.closePeer(frame.src);
1950
+ acceptedControllers.delete(frame.src);
1951
+ status.upsertDevice(frame.src, { isController: false });
1952
+ status.refreshConnectionState();
1953
+ return;
1954
+ }
1955
+ case 'invoke': {
1956
+ if (typeof frame.id !== 'string' || typeof frame.src !== 'string') return;
1957
+ // Before anything else: a frame from a device the relay declared unreachable revokes
1958
+ // that verdict, and everything it was dropped from comes back. See
1959
+ // `markDeviceReachable` — presence can be stale in the direction that leaves a
1960
+ // live handset with no pushes at all.
1961
+ markDeviceReachable(frame.src);
1962
+ // …and, when this frame names a session somebody is watching, repair it for them. This is
1963
+ // the trigger that survives a background/foreground transition: the client does not
1964
+ // re-subscribe on resume, but it always talks to us about the session it is showing.
1965
+ {
1966
+ const candidate = typeof frame?.payload?.args?.[0] === 'string' ? frame.payload.args[0] : null;
1967
+ if (candidate !== null && sessionSubscribers.has(candidate)) pulseRepair(candidate);
1968
+ }
1969
+ if (!policy.canAccept(frame.src)) {
1970
+ send({
1971
+ v: PROTOCOL_VERSION,
1972
+ kind: 'invoke-result',
1973
+ id: frame.id,
1974
+ dst: frame.src,
1975
+ payload: { ok: false, error: { code: 'CHANNEL_NOT_ALLOWED', message: 'This device is not authorized on the DSH Host' } },
1976
+ });
1977
+ return;
1978
+ }
1979
+ // Listing-only controllers never send `link-open`; an invoke is itself
1980
+ // proof that the phone reached this Host, so it links the device too.
1981
+ if (!acceptedControllers.has(frame.src)) linkController(frame.src);
1982
+ void handleInvoke(frame).then(
1983
+ (reply) => {
1984
+ // `null` means the request was unaddressable; the relay guard above
1985
+ // already rejects those, so this only defends the invariant.
1986
+ if (reply === null) return;
1987
+ // Fitting and sending are Host code on a reply that ultimately came from DSH, so
1988
+ // they are exactly where an unexpected shape would surface — and a throw from
1989
+ // inside a `.then` callback is an unhandled rejection, which this process treats
1990
+ // as fatal. The controller still needs an answer, so it gets one.
1991
+ try {
1992
+ send(fitReplyToFrame(reply, frame));
1993
+ } catch (error) {
1994
+ recordHandlerError('invoke-reply', error);
1995
+ send({
1996
+ v: PROTOCOL_VERSION,
1997
+ kind: 'invoke-result',
1998
+ id: frame.id,
1999
+ dst: frame.src,
2000
+ payload: { ok: false, error: { code: 'INTERNAL', message: 'DSH Host failed to serve this channel' } },
2001
+ });
2002
+ }
2003
+ },
2004
+ (error) => {
2005
+ // Named with the channel: seven identical `TimeoutError`s in the diagnostics after one
2006
+ // restart were unattributable, and "which read is missing its budget" is the whole
2007
+ // question. `frame.payload.channel` is the only thing that answers it.
2008
+ recordHandlerError(`invoke:${typeof frame?.payload?.channel === 'string' ? frame.payload.channel : '?'}`, error);
2009
+ send({ v: PROTOCOL_VERSION, kind: 'invoke-result', id: frame.id, dst: frame.src, payload: { ok: false, error: { code: 'INTERNAL', message: 'DSH Host failed to serve this channel' } } });
2010
+ },
2011
+ );
2012
+ return;
2013
+ }
2014
+ case 'relay-error': {
2015
+ const payload = frame.payload ?? {};
2016
+ const code = typeof payload.code === 'string' && payload.code !== '' ? payload.code : 'UNKNOWN';
2017
+ const message = typeof payload.message === 'string' && payload.message !== '' ? payload.message : `Cindy relay 返回错误:${code}`;
2018
+ // Only a protocol mismatch means this Host cannot be talked to. The rest
2019
+ // are per-request outcomes — `DEVICE_OFFLINE` says one push had no live
2020
+ // recipient, which is ordinary when a phone locks its screen. Treating
2021
+ // those as `failed` would tell the phone this Host is gone while its
2022
+ // relay link is perfectly healthy.
2023
+ if (code === 'VERSION_MISMATCH') status.setState('failed', message);
2024
+ else status.setState(status.snapshot().state, message);
2025
+ return;
2026
+ }
2027
+ default:
2028
+ // Unknown kinds are ignored, which is what lets the relay protocol grow.
2029
+ return;
2030
+ }
2031
+ }
2032
+
2033
+ function wireSocket(socket, myGeneration) {
2034
+ socket.on('open', () => {
2035
+ if (myGeneration !== generation) return;
2036
+ send({
2037
+ v: PROTOCOL_VERSION,
2038
+ kind: 'hello',
2039
+ payload: {
2040
+ deviceName: HOST_DEVICE_NAME,
2041
+ platform: HOST_PLATFORM,
2042
+ appVersion: HOST_APP_VERSION,
2043
+ // The relay refuses to route link-open/invoke to a target that did not
2044
+ // advertise this, so it carries exactly the user's own opt-in.
2045
+ remoteControlEnabled: policy.isEnabled(),
2046
+ busy: false,
2047
+ },
2048
+ });
2049
+ startHeartbeat();
2050
+ });
2051
+ socket.on('message', (data) => {
2052
+ if (myGeneration !== generation) return;
2053
+ let frame;
2054
+ try {
2055
+ frame = JSON.parse(String(data));
2056
+ } catch {
2057
+ return;
2058
+ }
2059
+ // A relay frame is outside input, and a synchronous throw here would leave this
2060
+ // listener and become an uncaught exception — fatal to the whole DSH process, not just
2061
+ // to this Host. Contain it and record what it was.
2062
+ try {
2063
+ handleFrame(frame);
2064
+ } catch (error) {
2065
+ recordHandlerError('frame', error);
2066
+ }
2067
+ });
2068
+ socket.on('error', () => {
2069
+ if (myGeneration !== generation) return;
2070
+ // A socket error is a retry, not a verdict: the switch is still on, so the Host
2071
+ // comes back on its own rather than asking the user to press reconnect.
2072
+ void dropAndRetry('Cindy relay 连接出错').then(() => undefined, () => undefined);
2073
+ });
2074
+ socket.on('close', () => {
2075
+ if (myGeneration !== generation || !active) return;
2076
+ void dropAndRetry('与 Cindy relay 的连接已断开').then(() => undefined, () => undefined);
2077
+ });
2078
+ }
2079
+
2080
+ /** Bring the relay up. Safe to call repeatedly; only the first call connects. */
2081
+ async function connect() {
2082
+ if (stopped || connecting === generation || ws !== null || !active) return;
2083
+ const myGeneration = ++generation;
2084
+ connecting = myGeneration;
2085
+ try {
2086
+ status.setLogin({ authenticated: false, required: false });
2087
+ status.setState('authenticating', '正在读取本机 Cindy 登录态');
2088
+
2089
+ let resolved;
2090
+ try {
2091
+ resolved = await serializeCredentialOperation(() => resolveSession());
2092
+ } catch {
2093
+ resolved = { ok: false, message: '读取本机 Cindy 登录态失败' };
2094
+ }
2095
+ if (stopped || !active || myGeneration !== generation) return;
2096
+
2097
+ if (!resolved?.ok) {
2098
+ if (resolved?.reason === 'CREDENTIAL_STORE_UNAVAILABLE') {
2099
+ // Repair the local store first; another login cannot be persisted either.
2100
+ status.setLogin({ authenticated: false, required: false });
2101
+ status.setState('failed', resolved.message);
2102
+ return;
2103
+ }
2104
+ // No usable session: the relay stays down and the card shows the login
2105
+ // form. This is not a failure state — nothing has failed yet.
2106
+ status.setLogin({ authenticated: false, required: true });
2107
+ status.setState('disconnected', resolved?.message ?? '需要登录 Cindy');
2108
+ return;
2109
+ }
2110
+
2111
+ session = resolved.session;
2112
+ status.setLogin({
2113
+ authenticated: true,
2114
+ required: false,
2115
+ kind: typeof session?.kind === 'string' ? session.kind : null,
2116
+ identifier: typeof session?.identifier === 'string' ? session.identifier : null,
2117
+ });
2118
+ status.setState('authenticating', '正在连接 Cindy DeviceLink');
2119
+
2120
+ if (stopped || !active || myGeneration !== generation) return;
2121
+
2122
+ await ensureProjection();
2123
+ if (stopped || !active || myGeneration !== generation) return;
2124
+
2125
+ let socket;
2126
+ try {
2127
+ socketSession = Object.freeze({ ...session });
2128
+ socket = openSocket(socketSession, relayUrl);
2129
+ } catch {
2130
+ // A socket that cannot even be created is the ordinary case when the network is
2131
+ // down at startup, so it retries like every other loss. Reporting `failed` here
2132
+ // is how a Host that booted before its network stayed dead until a human noticed.
2133
+ scheduleReconnect('无法建立 Cindy relay 连接');
2134
+ return;
2135
+ }
2136
+ ws = socket;
2137
+ wireSocket(socket, myGeneration);
2138
+ } finally {
2139
+ if (connecting === myGeneration) connecting = null;
2140
+ }
2141
+ }
2142
+
2143
+ /**
2144
+ * Tear the relay down and forget everything it taught us.
2145
+ * @param options - `keepSwitch` leaves the switch on (a heartbeat recovery
2146
+ * attempt) instead of returning the status to `disconnected`.
2147
+ */
2148
+ async function disconnect({ keepSwitch = false, preserveExports = false } = {}) {
2149
+ if (!preserveExports) exportOwner.invalidate();
2150
+ generation += 1;
2151
+ filePeer.closeAll();
2152
+ stopHeartbeat();
2153
+ // A pending view invalidation has no socket to reach once this returns; firing it later
2154
+ // would push to a link that is gone.
2155
+ clearHistoryViewPushes();
2156
+ acceptedControllers.clear();
2157
+ subscribers.clear();
2158
+ sessionSubscribers.clear();
2159
+ liveTurnState.clear();
2160
+ lastRepairPulse.clear();
2161
+ directoryAsked.clear();
2162
+ // A question asked over a link that is going away can never be answered;
2163
+ // settling them cancelled is what releases DSH's awaiting turn.
2164
+ approvals.clear();
2165
+ inputQueue.clear();
2166
+ projections.clear();
2167
+
2168
+ const socket = ws;
2169
+ ws = null;
2170
+ // The socket is deliberately gone: a queued retry for it would only race the next
2171
+ // connect. A retry that should survive this call is scheduled by the caller.
2172
+ cancelReconnect();
2173
+ if (socket !== null) {
2174
+ try {
2175
+ socket.close();
2176
+ } catch {
2177
+ // Closing an already-dead socket is not an error worth surfacing.
2178
+ }
2179
+ }
2180
+
2181
+ if (projection !== null) {
2182
+ const closing = projection;
2183
+ projection = null;
2184
+ try {
2185
+ await closing.stop();
2186
+ } catch {
2187
+ // The projection is already unreachable; a stop failure cannot make it worse.
2188
+ }
2189
+ }
2190
+
2191
+ session = null;
2192
+ socketSession = null;
2193
+ if (!preserveExports) transferDiagnostics.clear();
2194
+ status.clearDevices();
2195
+ if (!keepSwitch) {
2196
+ status.setLogin({ authenticated: false, required: false });
2197
+ status.reset();
2198
+ }
2199
+ }
2200
+
2201
+ /**
2202
+ * Apply new settings. This is the only entry point that starts or stops the
2203
+ * relay, so the switch and the socket can never disagree.
2204
+ * @param next - the newly resolved settings section.
2205
+ */
2206
+ async function updateSettings(next) {
2207
+ if (stopped) return;
2208
+ const previous = current;
2209
+ current = next;
2210
+ // The settings section is the durable home of the archive/delete/pin flags, so
2211
+ // every settings change is also a chance for another instance (or a hand edit)
2212
+ // to have moved them. `replace` never writes back — see `session-flags.js`.
2213
+ sessionFlags.replace(next?.sessionFlags);
2214
+ policy.update(next);
2215
+ filePeer.pruneUnauthorized();
2216
+ fileExports.pruneUnauthorized();
2217
+ const wanted = next?.transportEnabled === true || next?.remoteControlEnabled === true;
2218
+
2219
+ if (!wanted) {
2220
+ if (active) {
2221
+ active = false;
2222
+ await disconnect();
2223
+ }
2224
+ return;
2225
+ }
2226
+
2227
+ if (active) {
2228
+ // Already up: refresh what the relay was told about controllability.
2229
+ if (ws !== null && previous?.remoteControlEnabled !== next?.remoteControlEnabled) {
2230
+ send({ v: PROTOCOL_VERSION, kind: 'presence-set', payload: { remoteControlEnabled: policy.isEnabled() } });
2231
+ }
2232
+ return;
2233
+ }
2234
+
2235
+ active = true;
2236
+ await connect();
2237
+ }
2238
+
2239
+ /** Full stop: used by plugin teardown, not by the switch. */
2240
+ async function stop() {
2241
+ stopped = true;
2242
+ active = false;
2243
+ await disconnect();
2244
+ await fileExports.stop();
2245
+ status.setState('disconnected');
2246
+ }
2247
+
2248
+ /** Start if the resolved settings already ask for transport. */
2249
+ await updateSettings(settings);
2250
+
2251
+ return {
2252
+ model,
2253
+ subscribers,
2254
+ acceptedControllers,
2255
+ policy,
2256
+ status,
2257
+ getStatus: () => status.snapshot(),
2258
+ subscribeStatus: (listener) => status.subscribe(listener),
2259
+ /** The most recent invokes this Host served, for the status route's diagnostics. */
2260
+ getInvokeLog: () => invokeLog.slice(),
2261
+ /** Every refusal still in the window, so a rare one is never evicted by polling. */
2262
+ getRefusalLog: () => refusalLog.slice(),
2263
+ /** The most recent pushes this Host attempted, with their watcher counts. */
2264
+ getPushLog: () => pushLog.slice(),
2265
+ /** Per-channel push totals since start; monotonic, unlike the bounded ring. */
2266
+ getPushTotals: () => Object.fromEntries(pushTotals),
2267
+ /**
2268
+ * Per-channel invoke totals since start, and the refusals among them.
2269
+ *
2270
+ * Answers "has this channel ever been asked for", which the bounded ring cannot
2271
+ * once a controller starts polling, and turns channel coverage into something a
2272
+ * test run can report rather than something a person has to remember.
2273
+ */
2274
+ getInvokeTotals: () => Object.fromEntries(invokeTotals),
2275
+ getRefusalTotals: () => Object.fromEntries(refusalTotals),
2276
+ /** The phone's own reports, oldest first, each with the time it arrived. */
2277
+ getPhoneDiagnostics: () => phoneDiagnostics.slice(),
2278
+ /**
2279
+ * How the automatic reconnection is doing.
2280
+ *
2281
+ * "The Host disappeared from the phone" and "the Host was briefly offline and came
2282
+ * back on its own" are indistinguishable from the outside without this, and the
2283
+ * difference is exactly what the user cares about: `attempts` counts retries since
2284
+ * the last stable connection, `pending` says one is queued right now.
2285
+ */
2286
+ getReconnectState: () => ({ attempts: reconnectAttempt, pending: reconnectTimer !== null, lastReason: reconnectReason }),
2287
+ /**
2288
+ * How the frame budget is doing.
2289
+ *
2290
+ * "The phone shows only its own last message and nothing loads" has two very
2291
+ * different causes — a reply that was degraded to fit, and a frame that was
2292
+ * refused outright — and neither is visible from the controller. `degradations`
2293
+ * names the stage that saved each reply; `refusals` should stay empty, because
2294
+ * message pages are degraded before the ceiling is ever reached.
2295
+ */
2296
+ getFrameBudget: () => ({
2297
+ limitBytes: MAX_FRAME_BYTES,
2298
+ refusals: frameRefusalTotal,
2299
+ recentRefusals: frameRefusals.slice(),
2300
+ degradations: frameDegradations.slice(),
2301
+ }),
2302
+ /**
2303
+ * Errors caught at this Host's own boundaries.
2304
+ *
2305
+ * Empty is the only healthy value. A populated entry means some frame, reply or
2306
+ * background chain would otherwise have escaped as an unhandled rejection — which DSH
2307
+ * answers with `fatal load failure` and `exit(1)` for the entire desktop process, so
2308
+ * this list is the record of an outage that was prevented rather than survived.
2309
+ */
2310
+ getHandlerErrors: () => handlerErrors.slice(),
2311
+ /** How many rows were deliberately not shown; see {@link suppressedNotices}. */
2312
+ getSuppressedNotices: () => suppressedNotices,
2313
+ /** Count one hidden row. */
2314
+ noteSuppressedNotice: () => { suppressedNotices += 1; },
2315
+ /**
2316
+ * How long this Host has been up.
2317
+ *
2318
+ * Read by the diagnostics liveness verdict: a clean boundary record is only meaningful next
2319
+ * to the time it has had to fire, because a boundary that never fires and one that is not
2320
+ * installed look identical from the outside.
2321
+ */
2322
+ getUptimeMs: () => now().getTime() - startedAtMs,
2323
+ /**
2324
+ * Record an error caught in the plugin layer.
2325
+ *
2326
+ * Cordis dispatches listeners with no `try`/`catch` of its own, so a throw from a
2327
+ * `session/event` handler leaves DSH's event dispatch as an unhandled rejection — which
2328
+ * this process treats as fatal. The plugin's own boundaries have nowhere else to put
2329
+ * what they caught, so they hand it here.
2330
+ */
2331
+ noteHandlerError: (where, error) => recordHandlerError(where, error),
2332
+ /** The topics controllers currently hold, for the status route's diagnostics. */
2333
+ getSubscriptions: () => ({
2334
+ devices: [...subscribers],
2335
+ sessions: [...sessionSubscribers].map(([sessionId, devices]) => ({ sessionId, devices: [...devices] })),
2336
+ }),
2337
+ /** Push one live session message to the controllers watching that session. */
2338
+ pushSessionMessage,
2339
+ /**
2340
+ * Announce a real turn boundary to a session's watchers.
2341
+ *
2342
+ * The plugin drives these from DSH's `turn/start` and `turn/end` events, which
2343
+ * are the boundaries the controller's spinner is defined against. The
2344
+ * projection's running flag flaps inside a single turn, so it is not allowed
2345
+ * to end one (see `turnEventFor`).
2346
+ */
2347
+ pushTurnRunning: (sessionId) => announceTurnRunning(sessionId),
2348
+ pushTurnIdle: (sessionId) => announceTurnIdle(sessionId),
2349
+ /**
2350
+ * Make sure a controller is not left spinning after a turn it was watching.
2351
+ *
2352
+ * The terminal `maker:event` comes from DSH's own turn boundary, and a
2353
+ * controller clears "thinking" on nothing else. If that event never arrives
2354
+ * the spinner would simply stay. A durable message is the second witness that
2355
+ * something finished, so this re-checks shortly afterwards and says so only if
2356
+ * the session really is idle by then.
2357
+ *
2358
+ * The delay is what makes it safe: a live turn is still running a moment
2359
+ * later, so this cannot clear a spinner for work in progress. **One pending
2360
+ * check per session**, because this is called once per appended event — a
2361
+ * single turn appends dozens, and an un-deduplicated timer turned one turn
2362
+ * into eighteen `done` pushes, each of which makes the controller finalize
2363
+ * its streaming rows again.
2364
+ */
2365
+ reconcileTurnState: (sessionId) => {
2366
+ if (typeof sessionId !== 'string' || sessionId === '') return;
2367
+ if (pendingReconciles.has(sessionId)) return;
2368
+ const timer = setTimeoutImpl(() => {
2369
+ pendingReconciles.delete(sessionId);
2370
+ if (isSessionRunningNow(sessionId)) return;
2371
+ announceTurnIdle(sessionId);
2372
+ }, TURN_RECONCILE_MS);
2373
+ pendingReconciles.set(sessionId, timer);
2374
+ if (typeof timer?.unref === 'function') timer.unref();
2375
+ },
2376
+ /**
2377
+ * Retire the controller's queue row for a prompt that has been admitted.
2378
+ *
2379
+ * DSH's inbox drops an entry when its message becomes part of the session,
2380
+ * but the queue frame that would say so is not guaranteed to reach us — so
2381
+ * the controller's optimistic "队列中" row could outlive the message it
2382
+ * describes, leaving a queue that never drains and a spinner that never
2383
+ * stops. The durable message is the proof that the row is done.
2384
+ */
2385
+ retireQueuedItem: (sessionId, controllerId) => {
2386
+ if (typeof sessionId !== 'string' || sessionId === '') return;
2387
+ if (typeof controllerId !== 'string' || controllerId === '') return;
2388
+ inputQueue.mirror(sessionId, controllerId, { kind: 'remove' });
2389
+ pushSessionUpdate(sessionId, 'maker:input:projection', inputQueue.projectionFor(sessionId, sessionRowFor(sessionId)));
2390
+ },
2391
+ /** Begin a controller approval and expose its cancellable pending handle. */
2392
+ beginApproval,
2393
+ /** Ask the watching controllers to decide a DSH approval; null when none is watching. */
2394
+ askApproval,
2395
+ /** Begin a controller question and expose its cancellable pending handle. */
2396
+ beginUserQuestion,
2397
+ /** Ask the watching controllers a structured question; null when none is watching. */
2398
+ askUserQuestion,
2399
+ /** Fold one `sessionController.control()` frame into the input queue. */
2400
+ applyControlFrame: (frame) => {
2401
+ inputQueue.apply(frame);
2402
+ // A goal the Host advances on its own — the round driver counting a round,
2403
+ // the phase turning `complete` — changes the projection exactly like a user
2404
+ // write does, and the controller's goal card refreshes from
2405
+ // `maker:goal:status-changed` alone. Pushing only on our own writes left the
2406
+ // card frozen at whatever it was when the session was opened.
2407
+ const before = typeof frame?.sessionId === 'string' && frame.type !== 'baseline'
2408
+ ? toGoalStatusPayload(frame.sessionId, projections.goalOf(frame.sessionId))
2409
+ : undefined;
2410
+ projections.apply(frame);
2411
+ // A queue or job change is the controller's `maker:input:projection`
2412
+ // topic. Pushing it is what keeps the composer live after an enqueue that
2413
+ // the controller only saw the answer to.
2414
+ const sessionId = frame?.type === 'baseline' ? null : frame?.sessionId;
2415
+ if (typeof sessionId === 'string' && sessionId !== '') {
2416
+ const after = toGoalStatusPayload(sessionId, projections.goalOf(sessionId));
2417
+ if (after !== undefined && JSON.stringify(after) !== JSON.stringify(before)) {
2418
+ pushSessionUpdate(sessionId, 'maker:goal:status-changed', { sessionId, goal: after });
2419
+ }
2420
+ pushSessionUpdate(sessionId, 'maker:input:projection', inputQueue.projectionFor(sessionId, sessionRowFor(sessionId)));
2421
+ }
2422
+ },
2423
+ /** The controller's `InputProjection` for one session. */
2424
+ inputProjectionFor: (sessionId) => inputQueue.projectionFor(sessionId, sessionRowFor(sessionId)),
2425
+ /** The goal status the controller shows, read from the projection stream. */
2426
+ goalStatusFor: (sessionId) => toGoalStatusPayload(sessionId, projections.goalOf(sessionId)),
2427
+ /**
2428
+ * Run one channel exactly as a controller would, from inside this process.
2429
+ *
2430
+ * The Host's own view of what it sends is otherwise invisible: two real bugs
2431
+ * here were found only after a handset round trip (`local-db:sessions:list`
2432
+ * returning the wrong shape, and `prompt` throwing on a missing required
2433
+ * signal), and both looked identical from the outside — the controller just
2434
+ * showed nothing. This makes the same path reachable locally, against the
2435
+ * real services, so a fix can be proved without a phone.
2436
+ *
2437
+ * It performs whatever the channel performs: `maker:send` here really sends.
2438
+ * The route above it is loopback-only, the same trust boundary as the
2439
+ * settings API beside it.
2440
+ * @param channel - the channel to invoke.
2441
+ * @param args - its arguments.
2442
+ * @returns the reply frame, or a description of what it threw.
2443
+ */
2444
+ async invokeForTest(channel, args = []) {
2445
+ const frame = { v: PROTOCOL_VERSION, kind: 'invoke', id: `selftest-${Date.now()}`, src: 'selftest', payload: { channel, args } };
2446
+ try {
2447
+ return { ok: true, reply: await handleInvoke(frame) };
2448
+ } catch (error) {
2449
+ return { ok: false, error: String(error?.message ?? error) };
2450
+ }
2451
+ },
2452
+ /** How many controllers are watching one session's live stream. */
2453
+ watchersFor,
2454
+ /**
2455
+ * Whether a turn is live for one session, from DSH's own boundaries.
2456
+ *
2457
+ * Public because it is the fact the history view's `isStreaming` marker is built on: "the
2458
+ * phone drew no live card" and "the running card was not pinned last" both reduce to this
2459
+ * answering false during a live turn.
2460
+ */
2461
+ isSessionRunning: (sessionId) => isSessionRunningNow(sessionId),
2462
+ /** The questions still open, in the controller's shape. */
2463
+ listPendingInteractions: (sessionId) => approvals.list(sessionId),
2464
+ /**
2465
+ * Let the Web/API bridge observe the same ask_user registry as the phone.
2466
+ * Registering replays open questions; disposing only ends observation.
2467
+ */
2468
+ observeUserQuestions: (observer) => approvals.observeUserQuestions(observer),
2469
+ /** Both Web and Android answers go through this one registry settlement. */
2470
+ resolveInteraction,
2471
+ /** A DSH-local UI answer is already in DSH outcome form. */
2472
+ settleInteractionOutcome,
2473
+ updateSettings,
2474
+ connect,
2475
+ /** The card's reconnect button: retry immediately and forget the backoff ladder. */
2476
+ reconnectNow,
2477
+ disconnect,
2478
+ // Explicit successful login/select only; never called by Export or refresh.
2479
+ beginIdentityChange: () => disconnect({ keepSwitch: true }),
2480
+ getTransferDiagnostics: () => ({ ...transferDiagnostics.snapshot(), jobs: fileExports.getStats() }),
2481
+ setSource,
2482
+ stop,
2483
+ get sessionDeviceId() {
2484
+ return session?.deviceId ?? null;
2485
+ },
2486
+ /** Whether the DSH session projection is currently feeding the phone. */
2487
+ get projectionRunning() {
2488
+ return projection !== null;
2489
+ },
2490
+ };
2491
+ }
2492
+
2493
+ if (import.meta.url === `file://${process.argv[1]}`) {
2494
+ // Interactive CLI entry. `DEFAULT_HOST_SETTINGS` leaves transport off, so this
2495
+ // starts disconnected and prints nothing — the settings card owns the switch.
2496
+ await startHost();
2497
+ }