dsh-cindy-host 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * The DSH data source for the Cindy Host.
3
+ *
4
+ * The original bundle read DSH through `ctx.apiProxy` (`InProcessApiClient`).
5
+ * Current DSH Web does not mount `apiProxy`, so that source was permanently
6
+ * `undefined` and the projection never started — the phone saw an empty session
7
+ * list. This module reads the same facts from the services the Web profile
8
+ * actually mounts:
9
+ *
10
+ * - `ctx.sessionController.list()` → the session list (`SessionSummary`), the
11
+ * host-side counterpart of the old `client.sessions.list()`;
12
+ * - cordis `api-session/added|removed|status|activity|error` events → the host
13
+ * lifecycle stream, the counterpart of the old `events.host()` frames.
14
+ *
15
+ * Both are ordinary host services and events, so nothing here needs Typert
16
+ * codegen or a client carrier.
17
+ *
18
+ * The projection layer above is unchanged: it needs `listSessions()` and
19
+ * `onEvent(listener)`, and that is exactly what this source offers.
20
+ */
21
+
22
+ /** Activity kinds the projection's allowlist accepts (see `contracts.js`). */
23
+ function activity(kind, sessionId, phase, extra = {}) {
24
+ return { kind, sessionId: String(sessionId), sequence: 0, phase, ...extra };
25
+ }
26
+
27
+ /**
28
+ * Whether a session belongs in a controller's session list.
29
+ *
30
+ * **A subagent run and an unprompted draft are not tasks.** DSH marks delegated
31
+ * sessions in the summary (`origin: 'subagent'`, and a `parentSessionId` when the
32
+ * delegation has a parent). It also marks a newly opened, never-prompted conversation
33
+ * as `blank: true`. Neither appears in DSH's user-facing sidebar, so neither may be
34
+ * projected as a Cindy task — which is why a deployment's
35
+ * `~/.dsh/sessions` can hold two hundred subagent logs the user has never seen. This Host
36
+ * listed every one of them as "Untitled DSH task": measured on a real profile, 219 rows of
37
+ * which the visible handful were the actual tasks, and the whole list had to be folded
38
+ * (titles included) on every poll. Filtering here is therefore both the honest row set and
39
+ * most of the latency.
40
+ * @param item - one `SessionSummary` from the session service.
41
+ * @returns true when a controller should see it.
42
+ */
43
+ function isControllerSession(item) {
44
+ if (item === null || typeof item !== 'object' || item.origin === 'subagent') return false;
45
+ return item.parentSessionId === undefined || item.parentSessionId === null;
46
+ }
47
+ function isControllerVisible(item) {
48
+ return isControllerSession(item) && item.blank !== true;
49
+ }
50
+
51
+ /**
52
+ * @param options - the host session services and the event subscriber to use.
53
+ * @returns a projection source backed by the live Web profile.
54
+ */
55
+ export function createSessionControllerSource({
56
+ sessionController,
57
+ subscribe,
58
+ readTitles,
59
+ readSessionMeta,
60
+ readActivityEvidence,
61
+ readSessionSummary,
62
+ readActiveSessions,
63
+ listTimeoutMs = 15_000,
64
+ // How old a listing may be when it is served in place of one that failed. Long enough to
65
+ // cover a cold start, short enough that a session created or renamed in the meantime is not
66
+ // hidden for long — and the controller polls this channel every few seconds anyway.
67
+ listStaleMaxMs = 120_000,
68
+ // How many uncached titles one read may fold. The phone's responsiveness probe *is*
69
+ // `local-db:sessions:list`, and a title is a log-backed fold (~50ms each) — folding a
70
+ // few hundred of them per read made the probe answer in ~12s on a real profile, which
71
+ // the handset reads as "the computer is not responding". Bounded, the same read costs
72
+ // one batch and the rest of the titles arrive over the next few polls.
73
+ titleRefreshBudget = 16,
74
+ // Transcript folds are heavier than title reads; keep the visible-path batch small.
75
+ activityRefreshBudget = 4,
76
+ // A cached title is re-read at most this often, so a title changed by DSH's own
77
+ // regeneration heals without this module needing to hear about it.
78
+ titleTtlMs = 60_000,
79
+ now = () => Date.now(),
80
+ }) {
81
+ if (sessionController === undefined) throw new Error('createSessionControllerSource requires sessionController');
82
+
83
+ // `SessionSummary` carries no `createdAt`, but the phone's session row requires
84
+ // one. The header read that supplies it is paid once per session id: creation
85
+ // time and working directory are immutable, so a cache can never go stale.
86
+ const createdAtCache = new Map();
87
+ /** sessionId → `{ title: string|null, readAt: number }`; null means "no title yet". */
88
+ const titleCache = new Map();
89
+ /** The in-flight background title warm-up, or null. At most one runs at a time. */
90
+ let warming = null;
91
+ /** Exact `{ messageCount, userSendAt }` observations, invalidated by live events. */
92
+ const activityEvidenceCache = new Map();
93
+ /** The one background transcript-evidence warm-up. */
94
+ let activityWarming = null;
95
+ /** The one listing read currently in flight, shared by every concurrent caller. */
96
+ let inFlightList = null;
97
+ /** The last listing that was really read, and when — the stale fallback's whole state. */
98
+ let lastListed = null;
99
+ /** How many listings were answered from that fallback since start. */
100
+ let staleServes = 0;
101
+ let lastStaleReason = null;
102
+ let activeStateReads = 0;
103
+ let activeStateFallbacks = 0;
104
+ let lastActiveStateError = null;
105
+
106
+ /**
107
+ * Titles for the ids asked about, folding only what is missing or stale.
108
+ *
109
+ * `ids` arrives in the order the session service reports (newest first in practice),
110
+ * so the rows the controller is about to show are the ones that get their title first;
111
+ * a row left out shows as untitled until a later read reaches it.
112
+ */
113
+ async function titlesFor(sessionIds) {
114
+ const out = new Map();
115
+ if (typeof readTitles !== 'function') return out;
116
+ const at = now();
117
+ const stale = [];
118
+ for (const id of sessionIds) {
119
+ const cached = titleCache.get(id);
120
+ // Expiry schedules a refresh; it does not make a known title disappear.
121
+ // In particular, rows outside this read's budget still need their label.
122
+ if (cached?.title != null) out.set(id, cached.title);
123
+ if (cached !== undefined && at - cached.readAt < titleTtlMs) {
124
+ continue;
125
+ }
126
+ stale.push(id);
127
+ }
128
+ const batch = stale.slice(0, titleRefreshBudget);
129
+ if (batch.length === 0) return out;
130
+ await foldTitles(batch, out);
131
+ // The rest of the titles are warmed **after** the answer is on its way: the
132
+ // controller gets its list in one batch's time, and by its next poll the rest are
133
+ // cached, so a few hundred sessions never sit in front of a handset's timeout.
134
+ // `warmTitles` owns its own rejection path (`…catch(…).finally(…)`) and returns
135
+ // nothing, so this call is already incapable of becoming an unhandled rejection —
136
+ // which matters, because DSH's boot treats one of those as fatal to the whole process.
137
+ if (stale.length > batch.length) void warmTitles(stale.slice(titleRefreshBudget));
138
+ return out;
139
+ }
140
+
141
+ /** Fold one batch of titles into the cache (and into `out`, when the caller wants them). */
142
+ async function foldTitles(batch, out) {
143
+ let fetched;
144
+ try {
145
+ fetched = await readTitles(batch);
146
+ } catch {
147
+ // A transient failure is not evidence that a known title was cleared.
148
+ // Fall through so failed attempts still receive the normal retry backoff.
149
+ }
150
+ for (const id of batch) {
151
+ const value = typeof fetched?.get === 'function' ? fetched.get(id) : null;
152
+ // The batch adapter omits rejected and empty snapshots alike. Neither is
153
+ // an authoritative clear; only a usable title replaces the last good one.
154
+ const title = (typeof value === 'string' ? value.trim() : '') || titleCache.get(id)?.title || null;
155
+ titleCache.set(id, { title, readAt: now() });
156
+ if (title !== null && out !== undefined) out.set(id, title);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Continue filling the title cache in the background.
162
+ *
163
+ * One warm-up at a time: the controller polls the list every few seconds, and a
164
+ * pile-up of warm-ups would put the same log folds back in competition with the reads
165
+ * they are meant to keep cheap.
166
+ */
167
+ function warmTitles(ids) {
168
+ if (warming !== null || ids.length === 0) return;
169
+ warming = (async () => {
170
+ for (let index = 0; index < ids.length; index += titleRefreshBudget) {
171
+ const batch = ids.slice(index, index + titleRefreshBudget);
172
+ const fresh = batch.filter((id) => {
173
+ const cached = titleCache.get(id);
174
+ return cached === undefined || now() - cached.readAt >= titleTtlMs;
175
+ });
176
+ if (fresh.length > 0) await foldTitles(fresh);
177
+ }
178
+ })().catch(() => {
179
+ // A warm-up that fails is a missing label, never a broken list.
180
+ }).finally(() => {
181
+ warming = null;
182
+ });
183
+ }
184
+
185
+
186
+
187
+ /** Read exact transcript evidence without making the whole list wait for every log. */
188
+ async function activitiesFor(sessionIds) {
189
+ const out = new Map();
190
+ if (typeof readActivityEvidence !== 'function') return out;
191
+ const missing = [];
192
+ for (const id of sessionIds) {
193
+ const cached = activityEvidenceCache.get(id);
194
+ if (cached !== undefined) out.set(id, cached);
195
+ else missing.push(id);
196
+ }
197
+ const batch = missing.slice(0, activityRefreshBudget);
198
+ if (batch.length > 0) await foldActivities(batch, out);
199
+ if (missing.length > batch.length) void warmActivities(missing.slice(activityRefreshBudget));
200
+ return out;
201
+ }
202
+
203
+ async function foldActivities(ids, out) {
204
+ const settled = await Promise.allSettled(ids.map((id) => readActivityEvidence(id)));
205
+ settled.forEach((result, index) => {
206
+ if (result.status !== 'fulfilled') return;
207
+ const value = result.value;
208
+ if (!Number.isFinite(value?.messageCount) || value.messageCount < 0) return;
209
+ if (value.userSendAt !== null && typeof value.userSendAt !== 'string') return;
210
+ const evidence = { messageCount: Math.floor(value.messageCount), userSendAt: value.userSendAt };
211
+ activityEvidenceCache.set(ids[index], evidence);
212
+ out?.set(ids[index], evidence);
213
+ });
214
+ }
215
+
216
+ function warmActivities(ids) {
217
+ if (activityWarming !== null || ids.length === 0) return;
218
+ activityWarming = (async () => {
219
+ for (let index = 0; index < ids.length; index += activityRefreshBudget) {
220
+ const batch = ids.slice(index, index + activityRefreshBudget)
221
+ .filter((id) => !activityEvidenceCache.has(id));
222
+ if (batch.length > 0) await foldActivities(batch);
223
+ }
224
+ })().catch(() => {
225
+ // Unknown evidence leaves a row unclassified; it never invents activity.
226
+ }).finally(() => {
227
+ activityWarming = null;
228
+ });
229
+ }
230
+
231
+ /** Fill `createdAtCache` for ids the header reader knows. */
232
+ async function metaFor(sessionIds) {
233
+ const missing = sessionIds.filter((id) => !createdAtCache.has(id));
234
+ if (typeof readSessionMeta !== 'function' || missing.length === 0) return;
235
+ try {
236
+ const meta = await readSessionMeta(missing);
237
+ for (const [id, value] of meta ?? []) createdAtCache.set(id, value);
238
+ } catch {
239
+ // Fall through: an unknown creation time degrades the row, not the list.
240
+ }
241
+ }
242
+
243
+ /**
244
+ * One session listing, as the service reports it, minus the sessions a controller
245
+ * must not see (subagent runs).
246
+ *
247
+ * Filtered **here**, at the single point both readers go through, so a future reader
248
+ * cannot accidentally serve them again.
249
+ *
250
+ * Two properties beyond the read itself, both learned from a live failure: seven
251
+ * `local-db:sessions:list` invokes rejected with `TimeoutError` in the thirteen seconds after
252
+ * a restart (the phone reconnecting, plus the two desktop controllers polling, while 45 cold
253
+ * sessions were being read), and a controller renders a failed list as *nothing* — the
254
+ * spinner the user reported.
255
+ *
256
+ * - **Single flight.** Concurrent callers share one read. Three controllers polling during a
257
+ * cold start do not multiply the work that is already missing its deadline.
258
+ * - **A stale answer beats a failed one.** When the read does miss its deadline, the list read
259
+ * moments ago is served instead of an error, inside a bound. The data is real and durable —
260
+ * only its age is in question, and the controller's next poll corrects it — while the
261
+ * failure mode it replaces is an empty list and a spinner.
262
+ * - **With no previous listing, the read gets one retry.** That is the cold start: a Host that
263
+ * has just restarted has served nothing yet, so the stale fallback above has nothing to give,
264
+ * and the first read is the one that pays for caches the second read then finds warm. Measured:
265
+ * `invoke:local-db:sessions:get` and `invoke:local-db:sessions:list` both recorded
266
+ * `TimeoutError`s in the boot window, and a failed list read is what the controller renders as
267
+ * an empty session list. The retry is inside the shared flight, so concurrent callers cost one
268
+ * retry between them, and it only happens when there is no fallback to serve instead.
269
+ */
270
+ let listGeneration = 0;
271
+ function invalidateListing() {
272
+ listGeneration += 1;
273
+ inFlightList = null;
274
+ lastListed = null;
275
+ }
276
+
277
+ async function listItems() {
278
+ if (inFlightList !== null) return inFlightList;
279
+ const generation = listGeneration;
280
+ const attempt = (async () => {
281
+ let lastError = null;
282
+ const tries = lastListed === null ? 2 : 1;
283
+ for (let index = 0; index < tries; index += 1) {
284
+ try {
285
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(listTimeoutMs) : undefined;
286
+ const value = await sessionController.list({}, signal);
287
+ const items = (Array.isArray(value?.items) ? value.items : []).filter(isControllerVisible);
288
+ if (generation === listGeneration) lastListed = { items, at: now() };
289
+ return items;
290
+ } catch (error) {
291
+ lastError = error;
292
+ }
293
+ }
294
+ throw lastError;
295
+ })();
296
+ inFlightList = attempt;
297
+ try {
298
+ return await attempt;
299
+ } catch (error) {
300
+ if (lastListed !== null && now() - lastListed.at <= listStaleMaxMs) {
301
+ staleServes += 1;
302
+ lastStaleReason = error instanceof Error ? error.message : String(error);
303
+ return lastListed.items;
304
+ }
305
+ throw error;
306
+ } finally {
307
+ if (inFlightList === attempt) inFlightList = null;
308
+ }
309
+ }
310
+
311
+ async function projectItems(items) {
312
+ const ids = items.map((item) => String(item.sessionId));
313
+ // Exact observations already carry authoritative immutable header fields.
314
+ // Do not perform a corpus-wide metadata read to rediscover those fields.
315
+ for (const item of items) {
316
+ if (Number.isFinite(item.createdAt) && typeof item.cwd === 'string' && item.cwd !== '') {
317
+ createdAtCache.set(String(item.sessionId), { createdAt: new Date(item.createdAt).toISOString(), cwd: item.cwd });
318
+ }
319
+ }
320
+ const [titles, , activities] = await Promise.all([titlesFor(ids), metaFor(ids), activitiesFor(ids)]);
321
+ return items.map((item) => {
322
+ const id = String(item.sessionId);
323
+ const updatedAt = Number.isFinite(item.updatedAt) ? new Date(item.updatedAt).toISOString() : new Date().toISOString();
324
+ const meta = createdAtCache.get(id);
325
+ const activity = activities.get(id);
326
+ // The session's own model, when the list already carries the
327
+ // `modelSelection` projection. It is free here — the value rides the
328
+ // list response — and it is what lets the controller's picker show the
329
+ // model the session is actually on rather than the Host default.
330
+ const selection = item.projections?.values?.modelSelection;
331
+ const selectedModel = selection?.next?.model ?? selection?.lastUsed?.model;
332
+ // Effort rides the same selection, so it is free here too. The composer
333
+ // shows it in its model control, and a Host that always answers "default"
334
+ // makes a real `maker:set-effort` look like it did nothing.
335
+ const selectedEffort = selection?.next?.reasoningEffort ?? selection?.lastUsed?.reasoningEffort;
336
+ // The provider that serves that selection, from the same projection. The
337
+ // controller stores the source beside the model (`RemoteSession.providerId`)
338
+ // and its new-chat draft follows the most recent session's runtime, so a row
339
+ // that omits it loses the source the user picked while the model still looks
340
+ // right — and the next conversation is created without one.
341
+ const selectedProvider = selection?.next?.provider ?? selection?.lastUsed?.provider;
342
+ // The current permission preset, when the list already carries the
343
+ // `permissions` projection. Its absence means no permission service is
344
+ // composed, which DSH documents as "clients hide the control".
345
+ const permission = item.projections?.values?.permissions;
346
+ const currentPermission = typeof permission?.currentValue === 'string' && permission.currentValue !== ''
347
+ ? permission.currentValue
348
+ : undefined;
349
+ return {
350
+ id,
351
+ title: titles.get(id),
352
+ running: item.running === true,
353
+ updatedAt,
354
+ // Falls back to the last activity time only when the header read is
355
+ // unavailable; the phone requires a string here.
356
+ createdAt: Number.isFinite(item.createdAt) ? new Date(item.createdAt).toISOString() : (meta?.createdAt ?? updatedAt),
357
+ cwd: typeof item.cwd === 'string' && item.cwd !== '' ? item.cwd : (meta?.cwd ?? null),
358
+ ...(typeof selectedModel === 'string' && selectedModel !== '' ? { model: selectedModel } : {}),
359
+ ...(typeof selectedProvider === 'string' && selectedProvider !== '' ? { providerId: selectedProvider } : {}),
360
+ ...(typeof selectedEffort === 'string' && selectedEffort !== '' ? { effort: selectedEffort } : {}),
361
+ ...(currentPermission === undefined ? {} : { permissionMode: currentPermission }),
362
+ ...(activity === undefined ? {} : { messageCount: activity.messageCount, userSendAt: activity.userSendAt }),
363
+ blank: item.blank === true,
364
+ };
365
+ });
366
+ }
367
+
368
+ return {
369
+ invalidateListing,
370
+ invalidateActivityEvidence(sessionId) {
371
+ return activityEvidenceCache.delete(sessionId);
372
+ },
373
+ /** A list hides drafts; an exact ID read must still resolve a real new draft. */
374
+ async getSession(sessionId) {
375
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
376
+ let item;
377
+ if (typeof readSessionSummary === 'function') {
378
+ item = await readSessionSummary(sessionId);
379
+ } else {
380
+ // Older compositions have no exact observation port. Use a fresh,
381
+ // unfiltered read, never the stale/shared list used for discovery.
382
+ const value = await sessionController.list({}, AbortSignal.timeout(listTimeoutMs));
383
+ item = value?.items?.find((candidate) => String(candidate.sessionId) === sessionId);
384
+ }
385
+ if (!isControllerSession(item) || String(item.sessionId) !== sessionId) return undefined;
386
+ const [row] = await projectItems([item]);
387
+ // A draft's missing title is expected, not a minute-long negative result:
388
+ // the first prompt may name it immediately after this exact read.
389
+ if (item.blank === true && row.title == null) titleCache.delete(sessionId);
390
+ return row;
391
+ },
392
+ async listSessions() {
393
+ const rows = await projectItems(await listItems());
394
+ // A current sessionQuery title reader is the authority for sidebar discovery.
395
+ // Hide rows that have never produced a real title; exact getSession remains
396
+ // available for a freshly created draft before its first prompt names it.
397
+ if (typeof readTitles !== 'function') return rows;
398
+ return rows.filter((row) => typeof row.title === 'string' && row.title.trim() !== '');
399
+ },
400
+
401
+ /**
402
+ * Turn states only: no titles, no header reads.
403
+ *
404
+ * `maker:list-active` and `maker:session-in-turn` ask "is a turn running", and the
405
+ * controller polls them. Answering from the full row fold made the most frequently
406
+ * called channel on this Host pay the most expensive read on this Host — measured at
407
+ * 8.4s on a profile with 219 sessions, against a handset that times out in 15s.
408
+ * @returns `{ id, running, updatedAt }` per session.
409
+ */
410
+ async listSessionStates(requestedSessionId) {
411
+ if (typeof readActiveSessions === 'function') {
412
+ try {
413
+ const active = await readActiveSessions();
414
+ if (Array.isArray(active)) {
415
+ activeStateReads += 1;
416
+ lastActiveStateError = null;
417
+ const rows = active.filter(isControllerVisible).map((item) => ({
418
+ id: String(item.sessionId), running: item.running === true,
419
+ updatedAt: Number.isFinite(item.updatedAt) ? new Date(item.updatedAt).toISOString() : null,
420
+ }));
421
+ // A successful live-registry read proves absent IDs are not running.
422
+ // Explicit false prevents the watchdog resurrecting a stale push cache
423
+ // after an Agent was disposed. This is state, not fabricated metadata.
424
+ if (typeof requestedSessionId === 'string' && requestedSessionId !== ''
425
+ && !rows.some((row) => row.id === requestedSessionId)) {
426
+ rows.push({ id: requestedSessionId, running: false, updatedAt: null });
427
+ }
428
+ return rows;
429
+ }
430
+ } catch (error) {
431
+ lastActiveStateError = error instanceof Error ? error.message : String(error);
432
+ }
433
+ activeStateFallbacks += 1;
434
+ }
435
+ const items = await listItems();
436
+ return items.map((item) => ({
437
+ id: String(item.sessionId),
438
+ running: item.running === true,
439
+ updatedAt: Number.isFinite(item.updatedAt) ? new Date(item.updatedAt).toISOString() : null,
440
+ }));
441
+ },
442
+
443
+ /**
444
+ * Forget one session's cached title.
445
+ *
446
+ * A rename is the one title change this Host causes itself, and it must not wait out
447
+ * the TTL: the controller reads the row it just wrote back.
448
+ */
449
+ invalidateTitle(sessionId) {
450
+ titleCache.delete(String(sessionId));
451
+ },
452
+
453
+ /**
454
+ * Wait for the background title warm-up to finish.
455
+ *
456
+ * The list itself never waits for this — that is the point of the bound — so this
457
+ * exists for callers that want the fully populated cache (tests, and a diagnostic
458
+ * that wants to know how many titles are cached at all).
459
+ */
460
+ async titlesSettled() {
461
+ while (warming !== null) await warming;
462
+ },
463
+
464
+ /** How many titles are cached, for diagnostics. */
465
+ cachedTitleCount() {
466
+ return titleCache.size;
467
+ },
468
+
469
+ /**
470
+ * How the session listing is doing, for diagnostics.
471
+ *
472
+ * `staleServes` should stay at 0 in ordinary use. It counts listings answered from the
473
+ * previous read because the live one missed its deadline — every one of those is a
474
+ * controller that would otherwise have been shown an empty list (the reported spinner),
475
+ * so a non-zero value is the record of a degradation that was absorbed, not a healthy
476
+ * number to watch grow.
477
+ */
478
+ listDiagnostics() {
479
+ return { staleServes, lastStaleReason, lastListedAt: lastListed === null ? null : lastListed.at, lastListedCount: lastListed === null ? null : lastListed.items.length, activeStateReads, activeStateFallbacks, lastActiveStateError };
480
+ },
481
+
482
+ /**
483
+ * Follow the host's session lifecycle.
484
+ * @param listener - receives activity items; a non-abort throw becomes a stream failure.
485
+ * @returns the disposer removing every subscription.
486
+ */
487
+ onEvent(listener) {
488
+ if (typeof subscribe !== 'function') return () => {};
489
+ /** A listener failure must surface as a rebaseline, not as a broken subscription. */
490
+ const emit = (item) => {
491
+ try {
492
+ listener(item);
493
+ } catch {
494
+ listener({ kind: 'stream-failed' });
495
+ }
496
+ };
497
+ const disposers = [
498
+ subscribe('api-session/added', (summary) => {
499
+ // A subagent appears as a session to the session service, but never in a
500
+ // controller's list — so telling the controller "a session was created" would
501
+ // add a row it can never open, and its next list read would take it away again.
502
+ if (!isControllerVisible(summary)) return;
503
+ emit(activity('session-added', summary?.sessionId, summary?.running === true ? 'running' : 'idle'));
504
+ }),
505
+ subscribe('api-session/removed', (sessionId) => {
506
+ emit(activity('session-removed', sessionId, 'idle'));
507
+ }),
508
+ subscribe('api-session/status', (sessionId, running) => {
509
+ emit(activity('session-status', sessionId, running === true ? 'running' : 'idle'));
510
+ }),
511
+ subscribe('api-session/activity', (sessionId) => {
512
+ emit(activity('session-status', sessionId, 'active'));
513
+ }),
514
+ subscribe('api-session/error', (sessionId) => {
515
+ emit(activity('session-status', sessionId, 'error'));
516
+ }),
517
+ ];
518
+ return () => {
519
+ for (const dispose of disposers) {
520
+ try {
521
+ dispose();
522
+ } catch {
523
+ // A disposer that throws must not strand the remaining ones.
524
+ }
525
+ }
526
+ };
527
+ },
528
+ };
529
+ }
@@ -0,0 +1,7 @@
1
+ /** Cindy packages/device-link/src/filePeer.ts and filePeerRuntime.ts wire constants. */
2
+ export const FILE_PEER_CHANNEL = 'device-link:file-peer';
3
+ export const FILE_PEER_VERSION = 1;
4
+ export const FILE_PEER_MAX_BYTES = 2 * 1024 * 1024 * 1024;
5
+ export const FILE_PEER_CHUNK_BYTES = 16 * 1024;
6
+ export const FILE_PEER_CREDIT = 16;
7
+ export const FILE_INLINE_MAX_BYTES = 64 * 1024;
@@ -0,0 +1,41 @@
1
+ /** The RTC owner/lifecycle remains in the file-peer manager, not this adapter. */
2
+ export async function createFilePeerConnection({ iceServers = [] } = {}) {
3
+ // Import only when a file peer is requested; ordinary Host startup stays light.
4
+ const { RTCPeerConnection } = await import('werift');
5
+ return new RTCPeerConnection({ iceServers });
6
+ }
7
+
8
+ /**
9
+ * Send one files-v1 binary block, including the zero-byte EOF sentinel.
10
+ *
11
+ * Pinned to werift 0.24.4: its native empty Buffer send creates no SCTP DATA
12
+ * chunk but still consumes an ordered stream sequence number, blocking EOF
13
+ * AND subsequent transfers. RFC 8831 uses PPID 57 plus one ignored octet for
14
+ * empty binary messages. Queue it through the same transport as normal data;
15
+ * direct sctp.send() would bypass the queue and its bufferedAmount accounting.
16
+ *
17
+ * These are private werift APIs. Any dependency upgrade MUST rerun the real
18
+ * Chromium smoke (including EOF followed by another transfer), not just mocks.
19
+ * Await this function: an asynchronous flush rejection must reach the owner.
20
+ */
21
+ export async function sendFilePeerBinary(channel, bytes) {
22
+ if (!Buffer.isBuffer(bytes)) throw new TypeError('FILE_PEER_BINARY_REQUIRED');
23
+ if (!channel || channel.readyState !== 'open') throw new Error('FILE_PEER_CLOSED');
24
+ if (bytes.length) {
25
+ if (typeof channel.send !== 'function') throw new Error('FILE_PEER_RTC_UNSUPPORTED');
26
+ await channel.send(bytes);
27
+ return;
28
+ }
29
+ const transport = channel.sctp;
30
+ if (!transport || !Array.isArray(transport.dataChannelQueue) ||
31
+ typeof transport.dataChannelFlush !== 'function' ||
32
+ typeof channel.addBufferedAmount !== 'function' ||
33
+ !Number.isSafeInteger(channel.messagesSent)) {
34
+ throw new Error('FILE_PEER_RTC_UNSUPPORTED');
35
+ }
36
+ channel.addBufferedAmount(1);
37
+ transport.dataChannelQueue.push([channel, 57, Buffer.from([0])]);
38
+ channel.messagesSent++;
39
+ // bytesSent stays unchanged: the ignored octet is framing, not file payload.
40
+ await transport.dataChannelFlush();
41
+ }
@@ -0,0 +1,13 @@
1
+ export class FixtureDshSource {
2
+ constructor() {
3
+ this.listeners = new Set();
4
+ this.sessions = [
5
+ { id: "dsh-host-demo", title: "DSH Cindy Host demo", running: true, updatedAt: "2026-09-16T05:45:00.000Z" },
6
+ { id: "remote-research", title: "Remote control research", pendingInteraction: true, updatedAt: "2026-09-16T05:44:00.000Z" },
7
+ ];
8
+ }
9
+
10
+ async listSessions() { return this.sessions; }
11
+ onEvent(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
12
+ async emit(event) { for (const listener of this.listeners) await listener(event); }
13
+ }