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.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- 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
|
+
}
|