pinomad 0.7.0 → 0.8.0

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.
@@ -0,0 +1,2984 @@
1
+ import {
2
+ HOST_STREAM_NAMES,
3
+ MAX_ATTACHMENTS_PER_MESSAGE,
4
+ PROTOCOL,
5
+ RTC_DATA_CHANNEL_LABEL,
6
+ UNAUTHORIZED_CLOSE_CODE,
7
+ checkoutOf,
8
+ conversationStream,
9
+ docStream,
10
+ homeOf,
11
+ hostStreams,
12
+ hostSupportsCall,
13
+ hostSupportsFeature,
14
+ isRtcHello,
15
+ organize,
16
+ parseRtcMaxMessageSize,
17
+ rtcSelectedPair
18
+ } from "./chunk-jgd38rna.js";
19
+ import {
20
+ SECURE_PROLOGUE,
21
+ SecureSession,
22
+ decodeHostHello,
23
+ encodeClientHello,
24
+ initiateIK,
25
+ isLoopbackUrl,
26
+ parseEndpointSet
27
+ } from "./chunk-sygeb9qb.js";
28
+
29
+ // packages/protocol/src/remote-durable.ts
30
+ import { applyImmutable } from "@earendil-works/chord/delta";
31
+
32
+ // packages/protocol/src/remote-candidates.ts
33
+ var MIXED_CONTENT_REASON = "mixed content: this HTTPS page blocks the insecure ws:// connection";
34
+ function filterCandidatesForMode(candidates, mode) {
35
+ if (mode === "auto")
36
+ return [...candidates];
37
+ if (mode === "relayOnly") {
38
+ return candidates.filter((candidate) => candidate.role === "loopback" || candidate.role === "relay" || candidate.role === "legacy");
39
+ }
40
+ return candidates.filter((candidate) => candidate.role === "loopback" || candidate.role === "direct");
41
+ }
42
+ function signalCandidates(candidates) {
43
+ return candidates.filter((candidate) => candidate.role === "relay" || candidate.role === "legacy");
44
+ }
45
+ var DIRECT_UNAVAILABLE = "Direct connection unavailable: this host cannot be reached without the relay and WebRTC did not connect.";
46
+ var isInsecureRemote = (url) => {
47
+ try {
48
+ return new URL(url).protocol === "ws:" && !isLoopbackUrl(url);
49
+ } catch {
50
+ return false;
51
+ }
52
+ };
53
+ var reasonFor = (url, options) => options.pageProtocol === "https:" && isInsecureRemote(url) ? MIXED_CONTENT_REASON : undefined;
54
+ function candidatePlan(legacyUrl, endpointSet, options = {}) {
55
+ const candidates = [];
56
+ const seen = new Set;
57
+ const add = (url, role) => {
58
+ if (seen.has(url))
59
+ return;
60
+ seen.add(url);
61
+ const unsupported = reasonFor(url, options);
62
+ candidates.push({ url, role, ...unsupported === undefined ? {} : { unsupported } });
63
+ };
64
+ if (isLoopbackUrl(legacyUrl))
65
+ add(legacyUrl, "loopback");
66
+ if (endpointSet !== undefined) {
67
+ for (const endpoint of endpointSet.endpoints)
68
+ if (endpoint.role === "relay")
69
+ add(endpoint.url, "relay");
70
+ for (const endpoint of endpointSet.endpoints)
71
+ if (endpoint.role === "direct")
72
+ add(endpoint.url, "direct");
73
+ } else if (!isLoopbackUrl(legacyUrl)) {
74
+ add(legacyUrl, "legacy");
75
+ }
76
+ return candidates;
77
+ }
78
+
79
+ // packages/protocol/src/rtc-carrier.ts
80
+ var RTC_HIGH_WATER_BYTES = 256 * 1024;
81
+ var RTC_LOW_WATER_BYTES = 64 * 1024;
82
+ var RTC_STALL_MS = 1e4;
83
+ var SYSTEM_RTC_CLOCK = {
84
+ now: () => typeof performance === "undefined" ? Date.now() : performance.now(),
85
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
86
+ clearTimeout: (handle) => clearTimeout(handle)
87
+ };
88
+ function rtcTransport(options) {
89
+ return {
90
+ label: `webrtc:${RTC_DATA_CHANNEL_LABEL}`,
91
+ open(handlers) {
92
+ const clock = options.clock ?? SYSTEM_RTC_CLOCK;
93
+ const stallMs = options.stallMs ?? RTC_STALL_MS;
94
+ const prologue = options.prologue ?? SECURE_PROLOGUE;
95
+ const ik = initiateIK({ prologue, static: options.device, remoteStatic: options.hostKey });
96
+ const frames = [];
97
+ const records = [];
98
+ let peer;
99
+ let channel;
100
+ let session;
101
+ let opened = false;
102
+ let closed = false;
103
+ let lastProgress = clock.now();
104
+ let stallTimer;
105
+ let retryTimer;
106
+ let handshakeStarted = false;
107
+ const clearStall = () => {
108
+ if (stallTimer !== undefined)
109
+ clock.clearTimeout(stallTimer);
110
+ stallTimer = undefined;
111
+ };
112
+ const clearRetry = () => {
113
+ if (retryTimer !== undefined)
114
+ clock.clearTimeout(retryTimer);
115
+ retryTimer = undefined;
116
+ };
117
+ const fail = (reason) => {
118
+ if (closed)
119
+ return;
120
+ closed = true;
121
+ clearStall();
122
+ clearRetry();
123
+ try {
124
+ peer?.close();
125
+ } catch {}
126
+ handlers.closed(1006, reason);
127
+ };
128
+ const armStall = () => {
129
+ if (closed || records.length === 0 || stallTimer !== undefined)
130
+ return;
131
+ stallTimer = clock.setTimeout(() => {
132
+ stallTimer = undefined;
133
+ if (closed)
134
+ return;
135
+ if (records.length === 0)
136
+ return;
137
+ if (clock.now() - lastProgress < stallMs) {
138
+ armStall();
139
+ return;
140
+ }
141
+ fail(`the DataChannel made no progress for ${stallMs}ms`);
142
+ }, stallMs);
143
+ };
144
+ const pump = () => {
145
+ if (closed || channel === undefined)
146
+ return;
147
+ if (records.length === 0) {
148
+ clearStall();
149
+ clearRetry();
150
+ return;
151
+ }
152
+ if (channel.bufferedAmount >= RTC_HIGH_WATER_BYTES)
153
+ return;
154
+ let outcome;
155
+ try {
156
+ outcome = channel.send(records[0]);
157
+ } catch (error) {
158
+ fail(error instanceof Error ? error.message : "the DataChannel send failed");
159
+ return;
160
+ }
161
+ if (outcome === "rejected") {
162
+ if (retryTimer === undefined) {
163
+ retryTimer = clock.setTimeout(() => {
164
+ retryTimer = undefined;
165
+ pump();
166
+ }, 50);
167
+ }
168
+ return;
169
+ }
170
+ clearRetry();
171
+ records.shift();
172
+ lastProgress = clock.now();
173
+ armStall();
174
+ queueMicrotask(pump);
175
+ };
176
+ const enqueue = (next) => {
177
+ if (closed || next.length === 0)
178
+ return;
179
+ records.push(...next);
180
+ lastProgress = clock.now();
181
+ armStall();
182
+ pump();
183
+ };
184
+ const sealFrames = () => {
185
+ if (session === undefined)
186
+ return;
187
+ for (const text of frames.splice(0))
188
+ enqueue(session.seal(text));
189
+ };
190
+ const receive = (bytes) => {
191
+ if (closed)
192
+ return;
193
+ try {
194
+ if (session === undefined) {
195
+ const { payload, result } = ik.readMessage2(bytes);
196
+ decodeHostHello(payload);
197
+ session = new SecureSession(result);
198
+ handlers.authenticated?.();
199
+ sealFrames();
200
+ return;
201
+ }
202
+ const text = session.open(bytes);
203
+ if (text !== undefined)
204
+ handlers.message(text);
205
+ } catch (error) {
206
+ fail(error instanceof Error ? error.message : "secure DataChannel failure");
207
+ }
208
+ };
209
+ options.platform.createPeer(options.stun).then((created) => {
210
+ if (closed) {
211
+ created.close();
212
+ return;
213
+ }
214
+ peer = created;
215
+ channel = created.channel;
216
+ channel.onOpen(() => {
217
+ if (closed || opened)
218
+ return;
219
+ opened = true;
220
+ options.onSelectedPair?.(created.selectedPair?.());
221
+ handlers.opened?.();
222
+ if (!handshakeStarted) {
223
+ handshakeStarted = true;
224
+ enqueue([ik.writeMessage1(encodeClientHello({ v: 1 }))]);
225
+ }
226
+ });
227
+ channel.onMessage(receive);
228
+ channel.onClosed((reason) => {
229
+ if (!closed)
230
+ fail(reason === undefined ? "the DataChannel closed" : `the DataChannel closed: ${reason}`);
231
+ });
232
+ channel.onBufferedAmountLow(pump);
233
+ let answer;
234
+ return options.offer(created.offerSdp).then((sdp) => {
235
+ if (closed)
236
+ return;
237
+ const limit = parseRtcMaxMessageSize(sdp);
238
+ if (limit.kind === "invalid")
239
+ throw new Error(limit.reason);
240
+ answer = sdp;
241
+ created.acceptAnswer(answer);
242
+ handlers.signaled?.();
243
+ });
244
+ }, (error) => fail(error instanceof Error ? error.message : String(error))).catch((error) => fail(error instanceof Error ? error.message : String(error)));
245
+ return {
246
+ send(text) {
247
+ if (closed)
248
+ return;
249
+ if (session === undefined) {
250
+ frames.push(text);
251
+ return;
252
+ }
253
+ enqueue(session.seal(text));
254
+ },
255
+ close() {
256
+ if (closed)
257
+ return;
258
+ closed = true;
259
+ clearStall();
260
+ clearRetry();
261
+ try {
262
+ peer?.close();
263
+ } catch {}
264
+ }
265
+ };
266
+ }
267
+ };
268
+ }
269
+
270
+ // packages/protocol/src/transport.ts
271
+ function webSocketTransport(url, token) {
272
+ return {
273
+ label: url,
274
+ open(handlers) {
275
+ const address = new URL(url);
276
+ address.searchParams.set("token", token);
277
+ const socket = new WebSocket(address);
278
+ socket.onopen = () => {
279
+ handlers.opened?.();
280
+ handlers.authenticated?.();
281
+ };
282
+ socket.onmessage = (event) => handlers.message(String(event.data));
283
+ socket.onclose = (event) => handlers.closed(event.code);
284
+ return { send: (data) => socket.send(data), close: () => socket.close() };
285
+ }
286
+ };
287
+ }
288
+
289
+ // packages/protocol/src/transcript.ts
290
+ function isBusy(view) {
291
+ const live = view.docs?.["pi.live"];
292
+ return live?.run !== undefined;
293
+ }
294
+
295
+ // packages/protocol/src/remote-durable.ts
296
+ var DEFAULT_CANDIDATE_TIMEOUTS = {
297
+ socketOpenMs: 5000,
298
+ noiseMs: 5000,
299
+ helloMs: 5000,
300
+ syncMs: 1e4
301
+ };
302
+
303
+ class ConnectionFailedError extends Error {
304
+ failures;
305
+ constructor(failures) {
306
+ super(`Could not connect to the host: ${failures.map(describeFailure).join("; ")}`);
307
+ this.name = "ConnectionFailedError";
308
+ this.failures = failures;
309
+ }
310
+ }
311
+
312
+ class DirectUnavailableError extends Error {
313
+ reason;
314
+ constructor(reason) {
315
+ super(`${DIRECT_UNAVAILABLE} (${reason})`);
316
+ this.name = "DirectUnavailableError";
317
+ this.reason = reason;
318
+ }
319
+ }
320
+ var REMOTE_ACCESS_UNAVAILABLE = "Remote access is unavailable: the host published no remote path and this device has no local entry.";
321
+ var LIVENESS_INTERVAL_MS = 5000;
322
+ var LIVENESS_BUDGET_MS = 15000;
323
+ var LIVENESS_VERIFY_MS = 3000;
324
+ var UPGRADE_DELAY_MS = 1000;
325
+ var UPGRADE_IDLE_MS = 300;
326
+ var UPGRADE_IDLE_LIMIT_MS = 30000;
327
+ var UPGRADE_DEADLINE_MS = 25000;
328
+ var PREPARE_RETRY_MS = 1000;
329
+ var COOLDOWN_START_MS = 30000;
330
+ var COOLDOWN_MAX_MS = 10 * 60000;
331
+ var P2P_STABLE_MS = 10 * 60000;
332
+ var FALLBACK_QUIET_MS = 60000;
333
+ var COOLDOWN_CLEAR_INTERVAL_MS = 60000;
334
+ var SUBMISSION_TIMEOUT_MS = 30000;
335
+ var asError = (error) => error instanceof Error ? error : new Error(String(error));
336
+ var randomHex = (bytes) => Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
337
+ var describeFailure = (failure) => {
338
+ const role = failure.candidate.role === "loopback" ? "local" : failure.candidate.role;
339
+ const where = `${role} ${failure.candidate.url}`;
340
+ if (failure.stage === "unsupported")
341
+ return `${where} skipped (${failure.reason ?? "unsupported"})`;
342
+ const code = failure.code === undefined ? "" : ` (close ${failure.code})`;
343
+ return `${where} failed at ${failure.stage}${code}${failure.reason === undefined ? "" : `: ${failure.reason}`}`;
344
+ };
345
+ function connectRemoteDurable(options) {
346
+ return new RemoteClient(options).ready;
347
+ }
348
+
349
+ class UnauthorizedError extends Error {
350
+ constructor() {
351
+ super("Unauthorized: this client is not allowed (wrong token, or the device is not paired or was revoked)");
352
+ this.name = "UnauthorizedError";
353
+ }
354
+ }
355
+
356
+ class DisconnectedError extends Error {
357
+ constructor() {
358
+ super("Disconnected from host");
359
+ this.name = "DisconnectedError";
360
+ }
361
+ }
362
+
363
+ class ProtocolMismatchError extends Error {
364
+ hostMajor;
365
+ clientMajor;
366
+ hostVersion;
367
+ direction;
368
+ constructor(hostMajor, clientMajor, hostVersion) {
369
+ const direction = hostMajor > clientMajor ? "client-older" : "host-older";
370
+ super(direction === "client-older" ? `Client out of date: the host speaks protocol major ${hostMajor}, this client speaks ${clientMajor}` : `Host out of date: this client speaks protocol major ${clientMajor}, the host speaks ${hostMajor}`);
371
+ this.name = "ProtocolMismatchError";
372
+ this.hostMajor = hostMajor;
373
+ this.clientMajor = clientMajor;
374
+ if (hostVersion !== undefined)
375
+ this.hostVersion = hostVersion;
376
+ this.direction = direction;
377
+ }
378
+ }
379
+ function needsImageRefs(images) {
380
+ return images.some((image) => {
381
+ const data = "data" in image ? image.data : "";
382
+ const ref = "ref" in image ? image.ref : "";
383
+ return data === "" && ref !== "";
384
+ });
385
+ }
386
+
387
+ class RemoteClient {
388
+ ready;
389
+ #options;
390
+ #listeners = new Set;
391
+ #values = new Map;
392
+ #wanted = new Set;
393
+ #fresh = new Set;
394
+ #docs = [];
395
+ #toolPresentations = {};
396
+ #snapshotWaiters = new Map;
397
+ #pending = new Map;
398
+ #fullCandidates;
399
+ #candidates;
400
+ #mode;
401
+ #rtc;
402
+ #clock;
403
+ #hostRtc;
404
+ #signalOnlyHint;
405
+ #standby;
406
+ #signalAttempt;
407
+ #handoff;
408
+ #syncGate;
409
+ #upgradeTimer;
410
+ #upgradeSeq = 0;
411
+ #upgradeFailures = 0;
412
+ #cooldownUntil = 0;
413
+ #noUpgradeUntil = 0;
414
+ #lastCooldownClear = Number.NEGATIVE_INFINITY;
415
+ #stableTimer;
416
+ #activePath = "loopback";
417
+ #p2pFailure;
418
+ #selectedPair;
419
+ #livenessTimer;
420
+ #livenessDeadline = 0;
421
+ #livenessNext = 0;
422
+ #background = false;
423
+ #probes = new Map;
424
+ #roundCandidates = [];
425
+ #openTransport;
426
+ #baseUrl;
427
+ #pageProtocol;
428
+ #timeouts;
429
+ #roundTimeoutMs;
430
+ #followsHostEndpoints;
431
+ #pairing;
432
+ #dial;
433
+ #generation = 0;
434
+ #round = 0;
435
+ #candidateIndex = 0;
436
+ #failures = [];
437
+ #roundTimer;
438
+ #backoffTimer;
439
+ #backoffGeneration = 0;
440
+ #networkUnsubscribe;
441
+ #lifecycleUnsubscribe;
442
+ #signalCleanup;
443
+ #roundExpired = false;
444
+ #roundActive = false;
445
+ #everReady = false;
446
+ #outageNoticed = false;
447
+ #state;
448
+ #current;
449
+ #side;
450
+ #nextCall = 1;
451
+ #nextNotice = 1;
452
+ #submissions = new Map;
453
+ #submissionOrder = [];
454
+ #submissionLocks = new Set;
455
+ #streamVersions = new Map;
456
+ #hostMinor = 0;
457
+ #helloReceived = false;
458
+ #helloModels = [];
459
+ #helloDefaults = {};
460
+ #hostVersion;
461
+ #attempt = 0;
462
+ #rtcAttemptNonce = randomHex(8);
463
+ #closed = false;
464
+ #notifying = false;
465
+ #commands = Promise.resolve();
466
+ #resolveReady;
467
+ #rejectReady;
468
+ constructor(options) {
469
+ this.#options = options;
470
+ this.#mode = options.connectionMode ?? "auto";
471
+ this.#rtc = options.rtc;
472
+ this.#clock = options.clock ?? SYSTEM_RTC_CLOCK;
473
+ this.#signalOnlyHint = options.rtc?.signalOnlyHint === true;
474
+ this.#followsHostEndpoints = "candidates" in options;
475
+ if ("candidates" in options) {
476
+ this.#fullCandidates = [...options.candidates];
477
+ this.#openTransport = options.openTransport;
478
+ this.#pairing = options.pairing;
479
+ this.#baseUrl = options.legacyUrl ?? options.candidates[0]?.url ?? "";
480
+ this.#pageProtocol = options.pageProtocol;
481
+ } else if ("transport" in options) {
482
+ this.#fullCandidates = [{ url: options.transport.label, role: "legacy" }];
483
+ this.#openTransport = () => options.transport;
484
+ this.#baseUrl = options.transport.label;
485
+ this.#pageProtocol = undefined;
486
+ } else {
487
+ this.#fullCandidates = candidatePlan(options.url, undefined);
488
+ this.#openTransport = () => webSocketTransport(options.url, options.token);
489
+ this.#baseUrl = options.url;
490
+ this.#pageProtocol = undefined;
491
+ }
492
+ this.#candidates = filterCandidatesForMode(this.#fullCandidates, this.#mode);
493
+ this.#timeouts = { ...DEFAULT_CANDIDATE_TIMEOUTS, ...options.candidateTimeouts };
494
+ this.#roundTimeoutMs = options.roundTimeoutMs;
495
+ this.ready = new Promise((resolve, reject) => {
496
+ this.#resolveReady = resolve;
497
+ this.#rejectReady = reject;
498
+ });
499
+ const signal = options.signal;
500
+ const initiallyAborted = signal?.aborted === true;
501
+ if (signal !== undefined) {
502
+ const abort = () => this.#abort();
503
+ if (initiallyAborted)
504
+ queueMicrotask(abort);
505
+ else {
506
+ signal.addEventListener("abort", abort, { once: true });
507
+ this.#signalCleanup = () => signal.removeEventListener("abort", abort);
508
+ }
509
+ }
510
+ this.#networkUnsubscribe = options.network?.subscribe(() => this.#networkChanged());
511
+ this.#lifecycleUnsubscribe = options.lifecycle?.subscribe((state) => {
512
+ if (state === "background")
513
+ this.#enterBackground();
514
+ else
515
+ this.#enterForeground();
516
+ });
517
+ if (!initiallyAborted)
518
+ this.#beginRound();
519
+ }
520
+ #isActive(dial) {
521
+ return !this.#closed && this.#dial === dial && !dial.done && dial.generation === this.#generation;
522
+ }
523
+ #clearTimer(dial) {
524
+ if (dial.timer === undefined)
525
+ return;
526
+ clearTimeout(dial.timer);
527
+ dial.timer = undefined;
528
+ }
529
+ #arm(dial, phase, ms) {
530
+ this.#clearTimer(dial);
531
+ dial.timer = setTimeout(() => {
532
+ dial.timer = undefined;
533
+ if (!this.#isActive(dial))
534
+ return;
535
+ this.#failDial(dial, phase, undefined, `no progress within ${ms}ms`);
536
+ }, Math.max(0, ms));
537
+ }
538
+ #beginRound() {
539
+ if (this.#closed || this.#dial !== undefined)
540
+ return;
541
+ this.#clearRoundTimers();
542
+ this.#clearBackoff();
543
+ this.#round += 1;
544
+ const round = this.#round;
545
+ this.#roundCandidates = [...this.#candidates];
546
+ this.#candidateIndex = 0;
547
+ this.#failures = [];
548
+ this.#roundExpired = false;
549
+ this.#roundActive = true;
550
+ const perCandidate = this.#timeouts.socketOpenMs + this.#timeouts.noiseMs + this.#timeouts.helloMs + this.#timeouts.syncMs;
551
+ const budget = this.#roundTimeoutMs ?? this.#roundCandidates.length * perCandidate + 1000;
552
+ this.#roundTimer = setTimeout(() => {
553
+ this.#roundTimer = undefined;
554
+ if (this.#closed || !this.#roundActive || this.#round !== round)
555
+ return;
556
+ const dial = this.#dial;
557
+ this.#roundExpired = true;
558
+ if (dial !== undefined) {
559
+ this.#recordFailure(dial.candidate, "round", false, undefined, `the connection round exceeded ${budget}ms`);
560
+ this.#abandonDial(dial);
561
+ }
562
+ this.#roundFailed();
563
+ }, budget);
564
+ this.#openCandidate();
565
+ }
566
+ #openCandidate() {
567
+ if (this.#closed || !this.#roundActive || this.#dial !== undefined)
568
+ return;
569
+ if (this.#roundExpired) {
570
+ this.#roundFailed();
571
+ return;
572
+ }
573
+ const candidate = this.#roundCandidates[this.#candidateIndex++];
574
+ if (candidate === undefined) {
575
+ this.#roundFailed();
576
+ return;
577
+ }
578
+ if (candidate.unsupported !== undefined) {
579
+ this.#recordFailure(candidate, "unsupported", false, undefined, candidate.unsupported);
580
+ queueMicrotask(() => this.#openCandidate());
581
+ return;
582
+ }
583
+ const dial = {
584
+ candidate,
585
+ generation: ++this.#generation,
586
+ phase: "open",
587
+ done: false
588
+ };
589
+ this.#dial = dial;
590
+ this.#helloReceived = false;
591
+ this.#arm(dial, "open", this.#timeouts.socketOpenMs);
592
+ const handlers = {
593
+ opened: () => {
594
+ if (this.#isActive(dial))
595
+ this.#phase(dial, "noise", this.#timeouts.noiseMs);
596
+ },
597
+ authenticated: () => {
598
+ if (!this.#isActive(dial))
599
+ return;
600
+ this.#phase(dial, "hello", this.#timeouts.helloMs);
601
+ this.#paired();
602
+ },
603
+ message: (data) => {
604
+ if (this.#isActive(dial))
605
+ this.#message(dial, data);
606
+ },
607
+ closed: (code, reason) => {
608
+ if (this.#isActive(dial))
609
+ this.#failDial(dial, dial.phase === "ready" ? "closed" : dial.phase, code, reason);
610
+ }
611
+ };
612
+ let transport;
613
+ try {
614
+ transport = this.#openTransport(candidate, this.#pairing);
615
+ } catch (error) {
616
+ this.#failDial(dial, "open", undefined, asError(error).message);
617
+ return;
618
+ }
619
+ let connection;
620
+ try {
621
+ connection = transport.open(handlers);
622
+ } catch (error) {
623
+ this.#failDial(dial, "open", undefined, asError(error).message);
624
+ return;
625
+ }
626
+ if (!this.#isActive(dial)) {
627
+ connection.close();
628
+ return;
629
+ }
630
+ dial.connection = connection;
631
+ }
632
+ #phase(dial, phase, ms) {
633
+ if (!this.#isActive(dial))
634
+ return;
635
+ dial.phase = phase;
636
+ this.#arm(dial, phase, ms);
637
+ }
638
+ #paired() {
639
+ if (this.#pairing === undefined)
640
+ return;
641
+ this.#pairing = undefined;
642
+ if (this.#options.onPaired === undefined)
643
+ return;
644
+ const report = (error) => {
645
+ try {
646
+ this.#options.onPairedError?.(asError(error));
647
+ } catch {}
648
+ };
649
+ try {
650
+ Promise.resolve(this.#options.onPaired()).catch(report);
651
+ } catch (error) {
652
+ report(error);
653
+ }
654
+ }
655
+ #message(dial, data) {
656
+ let frame;
657
+ try {
658
+ frame = JSON.parse(data);
659
+ } catch {
660
+ this.#failDial(dial, dial.phase === "ready" ? "closed" : dial.phase, undefined, "the host sent a frame that is not JSON");
661
+ return;
662
+ }
663
+ if (frame === null || typeof frame !== "object")
664
+ return;
665
+ if (frame.type === "hello") {
666
+ this.#onHello(dial, frame);
667
+ return;
668
+ }
669
+ this.#receive(frame);
670
+ }
671
+ #onHello(dial, frame) {
672
+ const announced = frame.protocol;
673
+ const hostMajor = typeof announced === "number" ? announced : announced !== null && typeof announced === "object" ? announced.major : undefined;
674
+ if (hostMajor !== PROTOCOL.major) {
675
+ const mismatch = new ProtocolMismatchError(typeof hostMajor === "number" ? hostMajor : Number.NaN, PROTOCOL.major, frame.hostVersion);
676
+ this.#closed = true;
677
+ this.#clearTimers();
678
+ this.#stopCoordination();
679
+ this.#stopListeners();
680
+ this.#abandonDial();
681
+ if (this.#state === undefined) {
682
+ this.#rejectReady(mismatch);
683
+ } else {
684
+ this.#update({
685
+ connection: "outdated",
686
+ protocolMismatch: {
687
+ hostMajor: mismatch.hostMajor,
688
+ clientMajor: mismatch.clientMajor,
689
+ ...mismatch.hostVersion === undefined ? {} : { hostVersion: mismatch.hostVersion },
690
+ direction: mismatch.direction
691
+ }
692
+ });
693
+ this.#notice("error", mismatch.message);
694
+ }
695
+ return;
696
+ }
697
+ this.#clearTimer(dial);
698
+ dial.phase = "sync";
699
+ this.#arm(dial, "sync", this.#timeouts.syncMs);
700
+ const hostMinor = announced !== null && typeof announced === "object" && typeof announced.minor === "number" ? announced.minor : 0;
701
+ this.#hostMinor = hostMinor;
702
+ this.#helloReceived = true;
703
+ this.#hostRtc = frame.rtc !== undefined && isRtcHello(frame.rtc) ? frame.rtc : undefined;
704
+ if (this.#hostRtc?.signalOnly === true && !this.#signalOnlyHint) {
705
+ this.#signalOnlyHint = true;
706
+ this.#reportSignalOnly();
707
+ }
708
+ this.#docs = frame.docs;
709
+ this.#toolPresentations = frame.toolPresentations;
710
+ this.#helloModels = frame.models;
711
+ this.#helloDefaults = frame.defaults ?? {};
712
+ this.#hostVersion = frame.hostVersion;
713
+ this.#fresh.clear();
714
+ const covered = new Set(hostStreams(hostMinor));
715
+ for (const name of HOST_STREAM_NAMES) {
716
+ if (covered.has(name)) {
717
+ this.#wanted.add(name);
718
+ } else {
719
+ this.#wanted.delete(name);
720
+ this.#values.delete(name);
721
+ this.#fresh.delete(name);
722
+ }
723
+ }
724
+ if (this.#current !== undefined)
725
+ for (const stream of this.#conversationStreams(this.#current))
726
+ this.#wanted.add(stream);
727
+ if (this.#side !== undefined)
728
+ for (const stream of this.#conversationStreams(this.#side))
729
+ this.#wanted.add(stream);
730
+ for (const stream of this.#wanted)
731
+ this.#subscribe(stream);
732
+ if (frame.endpointSet !== undefined) {
733
+ const parsed = parseEndpointSet(frame.endpointSet);
734
+ if (parsed.kind === "valid") {
735
+ if (this.#followsHostEndpoints) {
736
+ this.#fullCandidates = candidatePlan(this.#baseUrl, parsed.endpointSet, { pageProtocol: this.#pageProtocol });
737
+ this.#candidates = filterCandidatesForMode(this.#fullCandidates, this.#mode);
738
+ }
739
+ if (this.#options.onEndpointSet !== undefined) {
740
+ const report = (error) => {
741
+ try {
742
+ this.#options.onEndpointSetError?.(asError(error));
743
+ } catch {}
744
+ };
745
+ try {
746
+ Promise.resolve(this.#options.onEndpointSet(parsed.endpointSet)).catch(report);
747
+ } catch (error) {
748
+ report(error);
749
+ }
750
+ }
751
+ }
752
+ }
753
+ if (this.#state !== undefined) {
754
+ this.#update({
755
+ session: frame.session,
756
+ toolPresentations: frame.toolPresentations,
757
+ connection: "reconnecting",
758
+ host: this.#hostInfo()
759
+ });
760
+ this.#refresh();
761
+ }
762
+ this.#afterHello(dial, frame, hostMinor);
763
+ }
764
+ async#afterHello(dial, hello, hostMinor) {
765
+ try {
766
+ const all = this.#syncStreams(hostMinor);
767
+ const hostOnly = new Set(hostStreams(hostMinor));
768
+ const missingHost = all.filter((stream) => hostOnly.has(stream) && !this.#fresh.has(stream));
769
+ await Promise.all(missingHost.map((stream) => this.#snapshot(stream, true)));
770
+ if (this.#state !== undefined && dial.p2p !== true) {
771
+ this.#update({ connection: "connected" });
772
+ this.#refresh();
773
+ this.#clearRoundTimer();
774
+ this.#roundActive = false;
775
+ dial.phase = "ready";
776
+ }
777
+ const missing = all.filter((stream) => !this.#fresh.has(stream));
778
+ if (this.#state !== undefined && dial.p2p !== true && missing.length > 0) {
779
+ this.#syncGate = { entries: [] };
780
+ }
781
+ await Promise.all(missing.map((stream) => this.#snapshot(stream, true)));
782
+ } catch {
783
+ return;
784
+ }
785
+ if (!this.#isActive(dial))
786
+ return;
787
+ this.#clearTimer(dial);
788
+ this.#clearRoundTimer();
789
+ dial.phase = "ready";
790
+ this.#roundActive = false;
791
+ this.#attempt = 0;
792
+ this.#everReady = true;
793
+ this.#outageNoticed = false;
794
+ this.#activePath = this.#pathOf(dial.candidate, dial.p2p === true);
795
+ if (this.#state === undefined) {
796
+ this.#state = {
797
+ session: hello.session,
798
+ organized: { chats: [], projects: [], archived: [] },
799
+ models: this.#host("models") ?? hello.models,
800
+ defaults: this.#effectiveDefaults(hello.defaults ?? {}),
801
+ notices: [],
802
+ connection: "connected",
803
+ docs: [],
804
+ submissions: [],
805
+ toolPresentations: hello.toolPresentations,
806
+ devices: [],
807
+ mcp: null,
808
+ auth: null,
809
+ providers: null,
810
+ settings: null,
811
+ agents: null,
812
+ host: this.#hostInfo()
813
+ };
814
+ this.#refresh();
815
+ this.#resolveReady({
816
+ view: this.#viewSource(),
817
+ controller: this.#controller(),
818
+ setConnectionMode: (mode) => this.setConnectionMode(mode),
819
+ rtcDiagnostics: () => this.rtcDiagnostics(),
820
+ close: () => this.#close()
821
+ });
822
+ this.#finishSyncGate();
823
+ this.#afterCarrierReady(dial);
824
+ return;
825
+ }
826
+ this.#update({ session: hello.session, connection: "connected" });
827
+ this.#refresh();
828
+ await this.#recoverSubmissions();
829
+ if (!this.#isActive(dial))
830
+ return;
831
+ this.#finishSyncGate();
832
+ this.#afterCarrierReady(dial);
833
+ }
834
+ #afterCarrierReady(dial) {
835
+ if (dial.p2p === true) {
836
+ this.#finishHandoff();
837
+ this.#startLiveness();
838
+ this.#startStableTimer();
839
+ return;
840
+ }
841
+ this.#stopLiveness();
842
+ this.#scheduleUpgrade(this.#upgradeDelayMs());
843
+ }
844
+ #recordFailure(candidate, stage, unauthorized, code, reason) {
845
+ const failure = {
846
+ candidate,
847
+ stage,
848
+ unauthorized,
849
+ ...code === undefined ? {} : { code },
850
+ ...reason === undefined ? {} : { reason }
851
+ };
852
+ this.#failures.push(failure);
853
+ try {
854
+ this.#options.onCandidateFailure?.(failure);
855
+ } catch {}
856
+ }
857
+ #failDial(dial, stage, code, reason) {
858
+ if (!this.#isActive(dial))
859
+ return;
860
+ const wasReady = dial.phase === "ready";
861
+ if (dial.p2p === true) {
862
+ this.#recordP2PFailure(wasReady ? "liveness" : "commit", reason, this.#selectedPair);
863
+ if (wasReady) {
864
+ this.#noUpgradeUntil = this.#now() + FALLBACK_QUIET_MS;
865
+ this.#stopLiveness();
866
+ this.#clearStableTimer();
867
+ }
868
+ }
869
+ this.#recordFailure(dial.candidate, stage, code === UNAUTHORIZED_CLOSE_CODE, code, reason);
870
+ this.#abandonDial(dial);
871
+ this.#loseSubmissions();
872
+ this.#rejectPending();
873
+ if (this.#handoff !== undefined && (this.#handoff.old === dial || this.#handoff.standby.installed && this.#handoff.standby === this.#standby)) {
874
+ this.#abortHandoff(new DisconnectedError);
875
+ }
876
+ if (this.#closed)
877
+ return;
878
+ if (dial.p2p === true && !wasReady) {
879
+ this.#update({ connection: "reconnecting" });
880
+ this.#beginRound();
881
+ return;
882
+ }
883
+ if (wasReady) {
884
+ this.#update({ connection: "reconnecting" });
885
+ this.#beginRound();
886
+ return;
887
+ }
888
+ if (this.#roundExpired) {
889
+ this.#roundFailed();
890
+ return;
891
+ }
892
+ queueMicrotask(() => this.#openCandidate());
893
+ }
894
+ #abandonDial(expected) {
895
+ const dial = this.#dial;
896
+ if (dial === undefined || expected !== undefined && dial !== expected)
897
+ return;
898
+ if (dial.p2p === true)
899
+ this.#selectedPair = undefined;
900
+ this.#dial = undefined;
901
+ dial.done = true;
902
+ this.#clearTimer(dial);
903
+ if (this.#syncGate !== undefined) {
904
+ for (const entry of this.#syncGate.entries.splice(0))
905
+ entry.reject(new DisconnectedError);
906
+ this.#syncGate = undefined;
907
+ }
908
+ this.#rejectSnapshotWaiters(new DisconnectedError);
909
+ dial.connection?.close();
910
+ }
911
+ #rejectPending() {
912
+ for (const pending of this.#pending.values())
913
+ pending.reject(new DisconnectedError);
914
+ this.#pending.clear();
915
+ }
916
+ #rejectSnapshotWaiters(error) {
917
+ for (const waiters of this.#snapshotWaiters.values()) {
918
+ for (const waiter of waiters.splice(0))
919
+ waiter.reject(error);
920
+ }
921
+ }
922
+ #dialedFailures(failures) {
923
+ return failures.filter((failure) => failure.stage !== "unsupported");
924
+ }
925
+ #unauthorizedRound(failures) {
926
+ const dialed = this.#dialedFailures(failures);
927
+ return dialed.length > 0 && dialed.every((failure) => failure.unauthorized);
928
+ }
929
+ #roundFailed() {
930
+ if (!this.#roundActive)
931
+ return;
932
+ this.#roundActive = false;
933
+ this.#clearRoundTimers();
934
+ this.#clearBackoff();
935
+ if (this.#syncGate !== undefined) {
936
+ for (const entry of this.#syncGate.entries.splice(0))
937
+ entry.reject(new DisconnectedError);
938
+ this.#syncGate = undefined;
939
+ }
940
+ this.#dial = undefined;
941
+ if (this.#closed)
942
+ return;
943
+ const failures = [...this.#failures];
944
+ const dialed = this.#dialedFailures(failures);
945
+ const unauthorized = this.#unauthorizedRound(failures);
946
+ const emptyPlan = this.#roundCandidates.length === 0;
947
+ if (!unauthorized && this.#mode === "directOnly" && this.#rtc !== undefined && (this.#signalOnlyHint || this.#hostRtc !== undefined) && this.#signalAttempt === undefined && this.#standby === undefined && this.#dial === undefined) {
948
+ this.#openSignalAttempt();
949
+ return;
950
+ }
951
+ if (!this.#everReady) {
952
+ this.#closed = true;
953
+ this.#clearTimers();
954
+ this.#stopListeners();
955
+ this.#rejectReady(unauthorized ? new UnauthorizedError : emptyPlan ? new Error(REMOTE_ACCESS_UNAVAILABLE) : this.#aggregateError(failures));
956
+ return;
957
+ }
958
+ if (unauthorized) {
959
+ this.#closed = true;
960
+ this.#clearTimers();
961
+ this.#stopListeners();
962
+ this.#update({ connection: "closed", unauthorized: true });
963
+ this.#notice("error", new UnauthorizedError().message);
964
+ return;
965
+ }
966
+ if (dialed.length === 0) {
967
+ this.#closed = true;
968
+ this.#clearTimers();
969
+ this.#stopListeners();
970
+ this.#update({ connection: "closed" });
971
+ this.#notice("error", emptyPlan ? REMOTE_ACCESS_UNAVAILABLE : this.#aggregateError(failures).message);
972
+ return;
973
+ }
974
+ this.#update({ connection: "reconnecting" });
975
+ if (!this.#outageNoticed) {
976
+ this.#outageNoticed = true;
977
+ this.#notice("error", this.#aggregateError(failures).message);
978
+ }
979
+ this.#scheduleBackoff();
980
+ }
981
+ #aggregateError(failures) {
982
+ if (this.#unauthorizedRound(failures))
983
+ return new UnauthorizedError;
984
+ if (failures.length === 0)
985
+ return new Error(REMOTE_ACCESS_UNAVAILABLE);
986
+ return new ConnectionFailedError(failures);
987
+ }
988
+ #scheduleBackoff() {
989
+ this.#clearBackoff();
990
+ const { min, max } = this.#options.reconnectDelayMs ?? { min: 200, max: 2000 };
991
+ const delay = Math.min(max, min * 2 ** this.#attempt++);
992
+ const generation = this.#backoffGeneration;
993
+ this.#backoffTimer = setTimeout(() => {
994
+ if (this.#backoffGeneration !== generation)
995
+ return;
996
+ this.#backoffTimer = undefined;
997
+ if (!this.#closed)
998
+ this.#beginRound();
999
+ }, delay);
1000
+ }
1001
+ #networkChanged() {
1002
+ if (this.#closed)
1003
+ return;
1004
+ this.#clearCooldownRateLimited();
1005
+ if (this.#dial?.p2p === true && this.#dial.phase === "ready") {
1006
+ this.#verifyLiveness();
1007
+ return;
1008
+ }
1009
+ if (this.#dial?.phase === "ready")
1010
+ return;
1011
+ this.#clearBackoff();
1012
+ this.#clearRoundTimers();
1013
+ this.#abandonDial();
1014
+ this.#attempt = 0;
1015
+ this.#beginRound();
1016
+ }
1017
+ #now() {
1018
+ return this.#clock.now();
1019
+ }
1020
+ #upgradeDelayMs() {
1021
+ return UPGRADE_DELAY_MS;
1022
+ }
1023
+ #pathOf(candidate, p2p = false) {
1024
+ if (p2p)
1025
+ return "p2p";
1026
+ return candidate.role === "legacy" ? "relay" : candidate.role;
1027
+ }
1028
+ #reportSignalOnly(signalOnly = true) {
1029
+ const report = (error) => {
1030
+ try {
1031
+ this.#rtc?.onSignalOnlyError?.(asError(error));
1032
+ } catch {}
1033
+ };
1034
+ try {
1035
+ Promise.resolve(this.#rtc?.onSignalOnly?.(signalOnly)).catch(report);
1036
+ } catch (error) {
1037
+ report(error);
1038
+ }
1039
+ }
1040
+ #hasSnapshotWaiters() {
1041
+ for (const waiters of this.#snapshotWaiters.values())
1042
+ if (waiters.length > 0)
1043
+ return true;
1044
+ return false;
1045
+ }
1046
+ #scheduleUpgrade(delayMs) {
1047
+ if (this.#closed || this.#rtc === undefined || this.#mode === "relayOnly")
1048
+ return;
1049
+ this.#clearUpgradeTimer();
1050
+ const seq = ++this.#upgradeSeq;
1051
+ this.#upgradeTimer = this.#clock.setTimeout(() => {
1052
+ this.#upgradeTimer = undefined;
1053
+ if (seq !== this.#upgradeSeq)
1054
+ return;
1055
+ this.#maybeStartUpgrade();
1056
+ }, Math.max(0, delayMs));
1057
+ }
1058
+ #clearUpgradeTimer() {
1059
+ if (this.#upgradeTimer === undefined)
1060
+ return;
1061
+ this.#clock.clearTimeout(this.#upgradeTimer);
1062
+ this.#upgradeTimer = undefined;
1063
+ }
1064
+ #maybeStartUpgrade() {
1065
+ if (this.#closed || this.#rtc === undefined || this.#mode === "relayOnly")
1066
+ return;
1067
+ if (this.#standby !== undefined || this.#handoff !== undefined || this.#signalAttempt !== undefined)
1068
+ return;
1069
+ const now = this.#now();
1070
+ const blockedUntil = Math.max(this.#cooldownUntil, this.#noUpgradeUntil);
1071
+ if (now < blockedUntil) {
1072
+ this.#scheduleUpgrade(blockedUntil - now);
1073
+ return;
1074
+ }
1075
+ const active = this.#dial;
1076
+ if (active?.phase === "ready" && active.connection !== undefined && this.#state?.connection === "connected") {
1077
+ if (this.#hostRtc === undefined)
1078
+ return;
1079
+ this.#beginRtcStandby(active.connection, false);
1080
+ return;
1081
+ }
1082
+ if (this.#mode !== "directOnly")
1083
+ return;
1084
+ if (this.#hostRtc === undefined && !this.#signalOnlyHint)
1085
+ return;
1086
+ this.#openSignalAttempt();
1087
+ }
1088
+ #beginRtcStandby(signal, signalOnly) {
1089
+ const rtc = this.#rtc;
1090
+ if (rtc === undefined || this.#standby !== undefined || this.#closed)
1091
+ return;
1092
+ const generation = ++this.#upgradeSeq;
1093
+ const standby = {
1094
+ generation,
1095
+ phase: "signal",
1096
+ signal,
1097
+ signalOnly,
1098
+ installed: false
1099
+ };
1100
+ this.#standby = standby;
1101
+ this.#p2pFailure = undefined;
1102
+ standby.timer = this.#clock.setTimeout(() => {
1103
+ if (this.#standby !== standby || this.#handoff !== undefined)
1104
+ return;
1105
+ this.#failStandby(standby, "warm", `WebRTC preparation exceeded ${UPGRADE_DEADLINE_MS}ms`);
1106
+ }, UPGRADE_DEADLINE_MS);
1107
+ const attempt = `rtc-${this.#rtcAttemptNonce}-${generation}`;
1108
+ const transport = rtcTransport({
1109
+ platform: rtc.platform,
1110
+ stun: this.#hostRtc?.stun ?? [],
1111
+ hostKey: rtc.hostKey,
1112
+ device: rtc.device,
1113
+ clock: this.#clock,
1114
+ offer: (sdp) => this.#rtcOfferOn(signal, attempt, sdp),
1115
+ onSelectedPair: (pair) => {
1116
+ if (pair !== undefined)
1117
+ standby.selectedPair = rtcSelectedPair(pair.local, pair.remote);
1118
+ }
1119
+ });
1120
+ const handlers = {
1121
+ signaled: () => {
1122
+ if (this.#standby === standby)
1123
+ standby.phase = "ice";
1124
+ },
1125
+ opened: () => {
1126
+ if (this.#standby === standby)
1127
+ standby.phase = "noise";
1128
+ },
1129
+ authenticated: () => {
1130
+ if (this.#standby !== standby)
1131
+ return;
1132
+ standby.phase = "noise";
1133
+ if (standby.signalOnly) {
1134
+ try {
1135
+ standby.signal?.close();
1136
+ } catch {}
1137
+ standby.signal = undefined;
1138
+ }
1139
+ },
1140
+ message: (data) => {
1141
+ if (standby.installed) {
1142
+ const dial = standby.installed && this.#dial?.connection === standby.connection ? this.#dial : undefined;
1143
+ if (dial !== undefined && this.#isActive(dial))
1144
+ this.#message(dial, data);
1145
+ return;
1146
+ }
1147
+ this.#standbyMessage(standby, data);
1148
+ },
1149
+ closed: (code, reason) => {
1150
+ if (standby.installed) {
1151
+ const dial = this.#dial?.connection === standby.connection ? this.#dial : undefined;
1152
+ if (dial !== undefined && this.#isActive(dial)) {
1153
+ this.#failDial(dial, dial.phase === "ready" ? "closed" : dial.phase, code, reason);
1154
+ }
1155
+ return;
1156
+ }
1157
+ if (this.#standby === standby)
1158
+ this.#failStandby(standby, standby.phase, reason ?? `DataChannel closed (${code})`);
1159
+ }
1160
+ };
1161
+ try {
1162
+ standby.connection = transport.open(handlers);
1163
+ } catch (error) {
1164
+ this.#failStandby(standby, "signal", asError(error).message);
1165
+ }
1166
+ }
1167
+ #rtcOfferOn(connection, attempt, sdp) {
1168
+ const id = this.#nextCall++;
1169
+ return new Promise((resolve, reject) => {
1170
+ this.#pending.set(id, { resolve, reject });
1171
+ try {
1172
+ connection.send(JSON.stringify({ type: "call", id, method: "rtcOffer", args: { attempt, sdp } }));
1173
+ } catch (error) {
1174
+ this.#pending.delete(id);
1175
+ reject(asError(error));
1176
+ }
1177
+ }).then((value) => {
1178
+ const answer = value;
1179
+ if (answer === null || typeof answer !== "object" || typeof answer.sdp !== "string") {
1180
+ throw new Error("the host returned a malformed rtcOffer answer");
1181
+ }
1182
+ return answer.sdp;
1183
+ });
1184
+ }
1185
+ #standbyMessage(standby, data) {
1186
+ if (this.#standby !== standby)
1187
+ return;
1188
+ let frame;
1189
+ try {
1190
+ frame = JSON.parse(data);
1191
+ } catch {
1192
+ this.#failStandby(standby, "noise", "the DataChannel sent a frame that is not JSON");
1193
+ return;
1194
+ }
1195
+ if (frame === null || typeof frame !== "object")
1196
+ return;
1197
+ if (frame.type === "hello") {
1198
+ standby.hello = frame;
1199
+ if (standby.pingId === undefined)
1200
+ this.#warmStandby(standby);
1201
+ return;
1202
+ }
1203
+ if (frame.type === "result" && standby.verify?.id === frame.id) {
1204
+ const verify = standby.verify;
1205
+ standby.verify = undefined;
1206
+ if (verify.timer !== undefined)
1207
+ this.#clock.clearTimeout(verify.timer);
1208
+ if (frame.ok)
1209
+ verify.resolve();
1210
+ else
1211
+ verify.reject(new Error(frame.error));
1212
+ return;
1213
+ }
1214
+ }
1215
+ #warmStandby(standby) {
1216
+ if (this.#standby !== standby || standby.hello === undefined)
1217
+ return;
1218
+ const id = this.#nextCall++;
1219
+ standby.pingId = id;
1220
+ this.#probeStandby(standby, id, 5000).then(() => {
1221
+ if (this.#standby !== standby || standby.installed)
1222
+ return;
1223
+ standby.phase = "warm";
1224
+ standby.warmAt = this.#now();
1225
+ if (standby.timer !== undefined)
1226
+ this.#clock.clearTimeout(standby.timer);
1227
+ standby.timer = this.#clock.setTimeout(() => {
1228
+ if (this.#standby === standby && this.#handoff === undefined) {
1229
+ this.#deferStandby(standby, `no idle window within ${UPGRADE_IDLE_LIMIT_MS}ms`);
1230
+ }
1231
+ }, UPGRADE_IDLE_LIMIT_MS);
1232
+ this.#pollStandby(standby);
1233
+ }, (error) => {
1234
+ if (this.#standby === standby)
1235
+ this.#failStandby(standby, "warm", asError(error).message);
1236
+ });
1237
+ }
1238
+ #probeStandby(standby, id, timeoutMs) {
1239
+ if (this.#standby !== standby || standby.connection === undefined) {
1240
+ return Promise.reject(new Error("the RTC carrier is gone"));
1241
+ }
1242
+ return new Promise((resolve, reject) => {
1243
+ const timer = this.#clock.setTimeout(() => {
1244
+ if (standby.verify?.id !== id)
1245
+ return;
1246
+ standby.verify = undefined;
1247
+ reject(new Error(`the RTC carrier did not answer a ping within ${timeoutMs}ms`));
1248
+ }, timeoutMs);
1249
+ standby.verify = { id, resolve, reject, timer };
1250
+ try {
1251
+ standby.connection.send(JSON.stringify({ type: "call", id, method: "ping", args: { id: `rtc-${id}` } }));
1252
+ } catch (error) {
1253
+ this.#clock.clearTimeout(timer);
1254
+ standby.verify = undefined;
1255
+ reject(asError(error));
1256
+ }
1257
+ });
1258
+ }
1259
+ #pollStandby(standby) {
1260
+ if (this.#standby !== standby || standby.phase !== "warm" || standby.installed)
1261
+ return;
1262
+ if (this.#handoff !== undefined)
1263
+ return;
1264
+ if (this.#dial?.phase !== "ready") {
1265
+ this.#beginHandoff(standby);
1266
+ return;
1267
+ }
1268
+ const busy = this.#pending.size > 0 || this.#hasSnapshotWaiters();
1269
+ if (busy) {
1270
+ standby.idleSince = undefined;
1271
+ } else {
1272
+ const now = this.#now();
1273
+ standby.idleSince ??= now;
1274
+ if (now - standby.idleSince >= UPGRADE_IDLE_MS) {
1275
+ this.#beginHandoff(standby);
1276
+ return;
1277
+ }
1278
+ }
1279
+ if (standby.idleTimer !== undefined)
1280
+ this.#clock.clearTimeout(standby.idleTimer);
1281
+ standby.idleTimer = this.#clock.setTimeout(() => this.#pollStandby(standby), 100);
1282
+ }
1283
+ #beginHandoff(standby) {
1284
+ if (this.#standby !== standby || this.#handoff !== undefined || standby.hello === undefined)
1285
+ return;
1286
+ if (standby.idleTimer !== undefined)
1287
+ this.#clock.clearTimeout(standby.idleTimer);
1288
+ standby.idleTimer = undefined;
1289
+ const handoff = {
1290
+ standby,
1291
+ old: this.#dial,
1292
+ entries: [],
1293
+ warmAt: standby.warmAt ?? this.#now(),
1294
+ prepared: false,
1295
+ released: false
1296
+ };
1297
+ this.#handoff = handoff;
1298
+ if (handoff.old?.phase === "ready" && handoff.old.connection !== undefined) {
1299
+ const id = this.#nextCall++;
1300
+ standby.verify = undefined;
1301
+ this.#probeStandby(standby, id, 3000).then(() => {
1302
+ if (this.#handoff !== handoff || handoff.released)
1303
+ return;
1304
+ handoff.prepared = true;
1305
+ this.#installStandby(handoff);
1306
+ }, (error) => {
1307
+ if (this.#handoff !== handoff || handoff.released)
1308
+ return;
1309
+ this.#releaseToOld(handoff, asError(error).message);
1310
+ });
1311
+ } else {
1312
+ handoff.prepared = true;
1313
+ this.#installStandby(handoff);
1314
+ }
1315
+ }
1316
+ #installStandby(handoff) {
1317
+ if (this.#handoff !== handoff || handoff.released)
1318
+ return;
1319
+ const standby = handoff.standby;
1320
+ const hello = standby.hello;
1321
+ if (hello === undefined || standby.connection === undefined) {
1322
+ this.#abortHandoff(new Error("the RTC carrier lost its hello before handoff"));
1323
+ return;
1324
+ }
1325
+ const old = handoff.old;
1326
+ if (old !== undefined) {
1327
+ old.done = true;
1328
+ try {
1329
+ old.connection?.close();
1330
+ } catch {}
1331
+ }
1332
+ const dial = {
1333
+ candidate: { url: "webrtc", role: "direct" },
1334
+ generation: ++this.#generation,
1335
+ phase: "sync",
1336
+ connection: standby.connection,
1337
+ done: false,
1338
+ p2p: true
1339
+ };
1340
+ this.#dial = dial;
1341
+ standby.installed = true;
1342
+ this.#activePath = "p2p";
1343
+ this.#selectedPair = standby.selectedPair;
1344
+ if (standby.signalOnly)
1345
+ standby.signal = undefined;
1346
+ this.#onHello(dial, hello);
1347
+ }
1348
+ #releaseToOld(handoff, reason) {
1349
+ this.#handoff = undefined;
1350
+ this.#standby = undefined;
1351
+ this.#clearStandbyTimers(handoff.standby);
1352
+ try {
1353
+ handoff.standby.connection?.close();
1354
+ } catch {}
1355
+ if (handoff.standby.signalOnly && handoff.standby.signal !== undefined) {
1356
+ try {
1357
+ handoff.standby.signal.close();
1358
+ } catch {}
1359
+ }
1360
+ this.#recordP2PFailure("commit", reason, handoff.standby.selectedPair);
1361
+ this.#preparationFailed();
1362
+ const old = handoff.old;
1363
+ for (const entry of handoff.entries.splice(0)) {
1364
+ if (old === undefined)
1365
+ entry.reject(new Error("Not connected to the host"));
1366
+ else
1367
+ this.#deliverDeferred(old, entry);
1368
+ }
1369
+ this.#scheduleUpgrade(this.#cooldownRemainingMs());
1370
+ }
1371
+ #abortHandoff(error) {
1372
+ const handoff = this.#handoff;
1373
+ if (handoff === undefined)
1374
+ return;
1375
+ this.#handoff = undefined;
1376
+ this.#standby = undefined;
1377
+ this.#clearStandbyTimers(handoff.standby);
1378
+ try {
1379
+ handoff.standby.connection?.close();
1380
+ } catch {}
1381
+ if (handoff.standby.signalOnly && handoff.standby.signal !== undefined) {
1382
+ try {
1383
+ handoff.standby.signal.close();
1384
+ } catch {}
1385
+ }
1386
+ for (const entry of handoff.entries.splice(0))
1387
+ entry.reject(error);
1388
+ }
1389
+ #finishSyncGate() {
1390
+ const gate = this.#syncGate;
1391
+ if (gate === undefined)
1392
+ return;
1393
+ this.#syncGate = undefined;
1394
+ const dial = this.#dial;
1395
+ for (const entry of gate.entries.splice(0)) {
1396
+ if (dial === undefined)
1397
+ entry.reject(new DisconnectedError);
1398
+ else
1399
+ this.#deliverDeferred(dial, entry);
1400
+ }
1401
+ }
1402
+ #finishHandoff() {
1403
+ const handoff = this.#handoff;
1404
+ if (handoff === undefined || !handoff.standby.installed)
1405
+ return;
1406
+ this.#handoff = undefined;
1407
+ this.#standby = undefined;
1408
+ const dial = this.#dial;
1409
+ for (const entry of handoff.entries.splice(0)) {
1410
+ if (dial === undefined)
1411
+ entry.reject(new Error("Not connected to the host"));
1412
+ else
1413
+ this.#deliverDeferred(dial, entry);
1414
+ }
1415
+ this.#startStableTimer();
1416
+ }
1417
+ #deliverDeferred(dial, entry) {
1418
+ if (!this.#isActive(dial) || dial.phase !== "ready" || dial.connection === undefined) {
1419
+ entry.reject(new Error("Not connected to the host"));
1420
+ return;
1421
+ }
1422
+ this.#pending.set(entry.id, { resolve: entry.resolve, reject: entry.reject });
1423
+ try {
1424
+ dial.connection.send(JSON.stringify(entry.frame));
1425
+ } catch (error) {
1426
+ this.#pending.delete(entry.id);
1427
+ entry.reject(asError(error));
1428
+ }
1429
+ }
1430
+ #clearStandbyTimers(standby) {
1431
+ for (const timer of [standby.timer, standby.pingTimer, standby.idleTimer, standby.verify?.timer]) {
1432
+ if (timer !== undefined)
1433
+ this.#clock.clearTimeout(timer);
1434
+ }
1435
+ standby.timer = undefined;
1436
+ standby.pingTimer = undefined;
1437
+ standby.idleTimer = undefined;
1438
+ standby.verify = undefined;
1439
+ }
1440
+ #failStandby(standby, stage, reason) {
1441
+ if (this.#standby !== standby)
1442
+ return;
1443
+ const handoff = this.#handoff;
1444
+ if (handoff !== undefined && handoff.standby === standby) {
1445
+ this.#releaseToOld(handoff, reason);
1446
+ return;
1447
+ }
1448
+ this.#standby = undefined;
1449
+ this.#clearStandbyTimers(standby);
1450
+ try {
1451
+ standby.connection?.close();
1452
+ } catch {}
1453
+ if (standby.signalOnly && standby.signal !== undefined) {
1454
+ try {
1455
+ standby.signal.close();
1456
+ } catch {}
1457
+ }
1458
+ this.#recordP2PFailure(stage, reason, standby.selectedPair);
1459
+ if (standby.signalOnly && stage === "signal") {
1460
+ this.#signalOnlyHint = false;
1461
+ this.#reportSignalOnly(false);
1462
+ }
1463
+ this.#preparationFailed();
1464
+ if (this.#state === undefined && this.#mode === "directOnly" && this.#dial === undefined) {
1465
+ this.#directUnavailable(reason);
1466
+ return;
1467
+ }
1468
+ this.#scheduleUpgrade(this.#cooldownRemainingMs());
1469
+ }
1470
+ #deferStandby(standby, reason) {
1471
+ if (this.#standby !== standby)
1472
+ return;
1473
+ this.#standby = undefined;
1474
+ this.#clearStandbyTimers(standby);
1475
+ try {
1476
+ standby.connection?.close();
1477
+ } catch {}
1478
+ if (standby.signalOnly && standby.signal !== undefined) {
1479
+ try {
1480
+ standby.signal.close();
1481
+ } catch {}
1482
+ }
1483
+ this.#recordP2PFailure("commit", reason, standby.selectedPair);
1484
+ this.#scheduleUpgrade(PREPARE_RETRY_MS);
1485
+ }
1486
+ #recordP2PFailure(stage, reason, pair) {
1487
+ this.#p2pFailure = {
1488
+ stage,
1489
+ ...reason === undefined ? {} : { reason },
1490
+ atMs: this.#now(),
1491
+ ...pair === undefined ? {} : { pair }
1492
+ };
1493
+ }
1494
+ #preparationFailed() {
1495
+ this.#upgradeFailures += 1;
1496
+ const delay = Math.min(COOLDOWN_MAX_MS, COOLDOWN_START_MS * 2 ** (this.#upgradeFailures - 1));
1497
+ this.#cooldownUntil = this.#now() + delay;
1498
+ }
1499
+ #cooldownRemainingMs() {
1500
+ return Math.max(0, Math.max(this.#cooldownUntil, this.#noUpgradeUntil) - this.#now());
1501
+ }
1502
+ #startStableTimer() {
1503
+ if (this.#stableTimer !== undefined)
1504
+ this.#clock.clearTimeout(this.#stableTimer);
1505
+ this.#stableTimer = this.#clock.setTimeout(() => {
1506
+ this.#stableTimer = undefined;
1507
+ if (this.#activePath === "p2p" && this.#dial?.phase === "ready") {
1508
+ this.#upgradeFailures = 0;
1509
+ this.#cooldownUntil = 0;
1510
+ this.#noUpgradeUntil = 0;
1511
+ }
1512
+ }, P2P_STABLE_MS);
1513
+ }
1514
+ #clearCooldown() {
1515
+ this.#upgradeFailures = 0;
1516
+ this.#cooldownUntil = 0;
1517
+ this.#noUpgradeUntil = 0;
1518
+ }
1519
+ #openSignalAttempt() {
1520
+ const rtc = this.#rtc;
1521
+ if (rtc === undefined || this.#signalAttempt !== undefined || this.#closed)
1522
+ return;
1523
+ if (rtc.openSignalTransport === undefined) {
1524
+ this.#directUnavailable("the platform has no signal-only transport");
1525
+ return;
1526
+ }
1527
+ const candidates = signalCandidates(this.#fullCandidates);
1528
+ if (candidates.length === 0) {
1529
+ this.#directUnavailable("no relay candidate is available for signaling");
1530
+ return;
1531
+ }
1532
+ const attempt = { generation: ++this.#upgradeSeq, index: 0, token: 0, done: false };
1533
+ this.#signalAttempt = attempt;
1534
+ this.#openSignalCandidate(attempt, candidates);
1535
+ }
1536
+ #openSignalCandidate(attempt, candidates) {
1537
+ if (this.#signalAttempt !== attempt || attempt.done || this.#closed)
1538
+ return;
1539
+ if (attempt.timer !== undefined) {
1540
+ this.#clock.clearTimeout(attempt.timer);
1541
+ attempt.timer = undefined;
1542
+ }
1543
+ while (attempt.index < candidates.length) {
1544
+ const candidate = candidates[attempt.index++];
1545
+ if (candidate.unsupported !== undefined)
1546
+ continue;
1547
+ const openSignal = this.#rtc?.openSignalTransport;
1548
+ if (openSignal === undefined) {
1549
+ this.#failSignalAttempt(attempt, "the platform has no signal-only transport");
1550
+ return;
1551
+ }
1552
+ let transport;
1553
+ try {
1554
+ transport = openSignal(candidate);
1555
+ } catch (error) {
1556
+ this.#failSignalAttempt(attempt, asError(error).message);
1557
+ return;
1558
+ }
1559
+ const token = ++attempt.token;
1560
+ const timeoutMs = this.#timeouts.socketOpenMs + this.#timeouts.noiseMs + this.#timeouts.helloMs;
1561
+ attempt.timer = this.#clock.setTimeout(() => {
1562
+ if (this.#signalAttempt !== attempt || attempt.done || attempt.token !== token)
1563
+ return;
1564
+ attempt.done = true;
1565
+ attempt.token += 1;
1566
+ try {
1567
+ attempt.connection?.close();
1568
+ } catch {}
1569
+ attempt.connection = undefined;
1570
+ attempt.done = false;
1571
+ this.#openSignalCandidate(attempt, candidates);
1572
+ }, timeoutMs);
1573
+ const handlers = {
1574
+ message: (data) => this.#receiveSignalFrame(attempt, data),
1575
+ closed: (_code, _reason) => {
1576
+ if (attempt.token !== token)
1577
+ return;
1578
+ if (this.#signalAttempt !== attempt || attempt.done) {
1579
+ const standby = this.#standby;
1580
+ if (standby?.signalOnly === true && standby.signal === attempt.connection && !standby.installed && standby.phase !== "noise" && standby.phase !== "warm") {
1581
+ this.#failStandby(standby, standby.phase, "the signal-only connection closed");
1582
+ }
1583
+ return;
1584
+ }
1585
+ attempt.done = true;
1586
+ attempt.connection = undefined;
1587
+ attempt.done = false;
1588
+ this.#openSignalCandidate(attempt, candidates);
1589
+ }
1590
+ };
1591
+ try {
1592
+ attempt.connection = transport.open(handlers);
1593
+ } catch {
1594
+ continue;
1595
+ }
1596
+ return;
1597
+ }
1598
+ this.#failSignalAttempt(attempt, "every relay signal path failed");
1599
+ }
1600
+ #receiveSignalFrame(attempt, data) {
1601
+ let frame;
1602
+ try {
1603
+ frame = JSON.parse(data);
1604
+ } catch {
1605
+ return;
1606
+ }
1607
+ if (frame === null || typeof frame !== "object")
1608
+ return;
1609
+ if (frame.type === "result") {
1610
+ const pending = this.#pending.get(frame.id);
1611
+ this.#pending.delete(frame.id);
1612
+ if (frame.ok)
1613
+ pending?.resolve(frame.value);
1614
+ else
1615
+ pending?.reject(new Error(frame.error));
1616
+ return;
1617
+ }
1618
+ if (frame.type !== "signalHello")
1619
+ return;
1620
+ if (this.#signalAttempt !== attempt || attempt.done)
1621
+ return;
1622
+ attempt.done = true;
1623
+ this.#signalAttempt = undefined;
1624
+ if (attempt.timer !== undefined)
1625
+ this.#clock.clearTimeout(attempt.timer);
1626
+ attempt.timer = undefined;
1627
+ if (frame.rtc === undefined) {
1628
+ this.#directUnavailable("the host did not advertise WebRTC on its signal connection");
1629
+ return;
1630
+ }
1631
+ this.#hostRtc = frame.rtc;
1632
+ if (frame.rtc.signalOnly)
1633
+ this.#reportSignalOnly();
1634
+ const connection = attempt.connection;
1635
+ if (connection === undefined) {
1636
+ this.#directUnavailable("the signal-only connection vanished before its offer");
1637
+ return;
1638
+ }
1639
+ this.#beginRtcStandby(connection, true);
1640
+ }
1641
+ #failSignalAttempt(attempt, reason) {
1642
+ if (this.#signalAttempt !== attempt)
1643
+ return;
1644
+ this.#signalAttempt = undefined;
1645
+ if (attempt.timer !== undefined)
1646
+ this.#clock.clearTimeout(attempt.timer);
1647
+ attempt.timer = undefined;
1648
+ this.#recordP2PFailure("signal", reason);
1649
+ this.#signalOnlyHint = false;
1650
+ this.#directUnavailable(reason);
1651
+ }
1652
+ #directUnavailable(reason) {
1653
+ if (this.#closed)
1654
+ return;
1655
+ if (this.#state === undefined) {
1656
+ this.#closed = true;
1657
+ this.#clearTimers();
1658
+ this.#stopListeners();
1659
+ this.#rejectPending();
1660
+ this.#rejectSnapshotWaiters(new DisconnectedError);
1661
+ this.#rejectReady(new DirectUnavailableError(reason));
1662
+ return;
1663
+ }
1664
+ this.#notice("error", `${DIRECT_UNAVAILABLE} (${reason})`);
1665
+ this.#update({ connection: "reconnecting" });
1666
+ this.#scheduleBackoff();
1667
+ }
1668
+ #enterBackground() {
1669
+ if (this.#closed)
1670
+ return;
1671
+ this.#background = true;
1672
+ this.#stopLiveness();
1673
+ }
1674
+ #enterForeground() {
1675
+ if (this.#closed)
1676
+ return;
1677
+ this.#background = false;
1678
+ if (this.#dial?.p2p === true && this.#dial.phase === "ready") {
1679
+ this.#verifyLiveness();
1680
+ return;
1681
+ }
1682
+ if (this.#dial?.phase === "ready")
1683
+ return;
1684
+ this.#networkChanged();
1685
+ }
1686
+ #startLiveness() {
1687
+ this.#stopLiveness();
1688
+ if (this.#closed || this.#background || this.#dial?.p2p !== true || this.#dial.phase !== "ready")
1689
+ return;
1690
+ const now = this.#now();
1691
+ this.#livenessDeadline = now + LIVENESS_BUDGET_MS;
1692
+ this.#livenessNext = now + LIVENESS_INTERVAL_MS;
1693
+ this.#scheduleLiveness();
1694
+ }
1695
+ #scheduleLiveness() {
1696
+ if (this.#closed || this.#background || this.#dial?.p2p !== true || this.#dial.phase !== "ready")
1697
+ return;
1698
+ const delay = Math.max(0, this.#livenessNext - this.#now());
1699
+ this.#livenessTimer = this.#clock.setTimeout(() => {
1700
+ this.#livenessTimer = undefined;
1701
+ this.#livenessTick();
1702
+ }, delay);
1703
+ }
1704
+ #stopLiveness() {
1705
+ if (this.#livenessTimer !== undefined)
1706
+ this.#clock.clearTimeout(this.#livenessTimer);
1707
+ this.#livenessTimer = undefined;
1708
+ for (const probe of this.#probes.values()) {
1709
+ if (probe.timer !== undefined)
1710
+ this.#clock.clearTimeout(probe.timer);
1711
+ }
1712
+ this.#probes.clear();
1713
+ }
1714
+ #livenessTick() {
1715
+ const dial = this.#dial;
1716
+ if (this.#closed || this.#background || dial?.p2p !== true || dial.phase !== "ready")
1717
+ return;
1718
+ const now = this.#now();
1719
+ if (now >= this.#livenessDeadline) {
1720
+ this.#fallbackFromP2P("no pong within 15000ms");
1721
+ return;
1722
+ }
1723
+ this.#sendProbe(false);
1724
+ this.#livenessNext += LIVENESS_INTERVAL_MS;
1725
+ if (this.#livenessNext <= now)
1726
+ this.#livenessNext = now + LIVENESS_INTERVAL_MS;
1727
+ this.#scheduleLiveness();
1728
+ }
1729
+ #verifyLiveness() {
1730
+ this.#stopLiveness();
1731
+ if (this.#dial?.p2p !== true || this.#dial.phase !== "ready")
1732
+ return;
1733
+ const probe = this.#sendProbe(true);
1734
+ if (probe === undefined)
1735
+ return;
1736
+ this.#livenessNext = this.#now() + LIVENESS_INTERVAL_MS;
1737
+ this.#scheduleLiveness();
1738
+ }
1739
+ #sendProbe(verify) {
1740
+ const dial = this.#dial;
1741
+ if (dial?.connection === undefined || dial.p2p !== true)
1742
+ return;
1743
+ const id = this.#nextCall++;
1744
+ const entry = { generation: dial.generation };
1745
+ this.#probes.set(id, entry);
1746
+ if (verify) {
1747
+ entry.timer = this.#clock.setTimeout(() => {
1748
+ if (!this.#probes.has(id))
1749
+ return;
1750
+ this.#probes.delete(id);
1751
+ this.#fallbackFromP2P("the foreground verification ping did not answer within 3000ms");
1752
+ }, LIVENESS_VERIFY_MS);
1753
+ }
1754
+ try {
1755
+ dial.connection.send(JSON.stringify({ type: "call", id, method: "ping", args: { id: `probe-${id}` } }));
1756
+ } catch (error) {
1757
+ this.#probes.delete(id);
1758
+ if (entry.timer !== undefined)
1759
+ this.#clock.clearTimeout(entry.timer);
1760
+ this.#fallbackFromP2P(asError(error).message);
1761
+ return;
1762
+ }
1763
+ return id;
1764
+ }
1765
+ #fallbackFromP2P(reason) {
1766
+ const dial = this.#dial;
1767
+ if (dial?.p2p !== true)
1768
+ return;
1769
+ this.#recordP2PFailure("liveness", reason, this.#selectedPair);
1770
+ this.#selectedPair = undefined;
1771
+ this.#noUpgradeUntil = this.#now() + FALLBACK_QUIET_MS;
1772
+ this.#stopLiveness();
1773
+ this.#clearStableTimer();
1774
+ dial.done = true;
1775
+ try {
1776
+ dial.connection?.close();
1777
+ } catch {}
1778
+ if (this.#dial === dial)
1779
+ this.#dial = undefined;
1780
+ this.#rejectPending();
1781
+ this.#rejectSnapshotWaiters(new DisconnectedError);
1782
+ this.#clearRoundTimers();
1783
+ this.#update({ connection: "reconnecting" });
1784
+ this.#beginRound();
1785
+ }
1786
+ #clearStableTimer() {
1787
+ if (this.#stableTimer === undefined)
1788
+ return;
1789
+ this.#clock.clearTimeout(this.#stableTimer);
1790
+ this.#stableTimer = undefined;
1791
+ }
1792
+ #livenessBudgetMs() {
1793
+ return LIVENESS_BUDGET_MS;
1794
+ }
1795
+ #clearCooldownRateLimited() {
1796
+ const now = this.#now();
1797
+ if (now - this.#lastCooldownClear < COOLDOWN_CLEAR_INTERVAL_MS)
1798
+ return;
1799
+ this.#lastCooldownClear = now;
1800
+ this.#clearCooldown();
1801
+ }
1802
+ setConnectionMode(mode) {
1803
+ if (mode === this.#mode)
1804
+ return;
1805
+ this.#mode = mode;
1806
+ this.#candidates = filterCandidatesForMode(this.#fullCandidates, mode);
1807
+ this.#clearUpgradeTimer();
1808
+ this.#stopLiveness();
1809
+ this.#clearStableTimer();
1810
+ if (this.#handoff !== undefined)
1811
+ this.#abortHandoff(new DisconnectedError);
1812
+ if (this.#standby !== undefined) {
1813
+ const standby = this.#standby;
1814
+ this.#standby = undefined;
1815
+ this.#clearStandbyTimers(standby);
1816
+ try {
1817
+ standby.connection?.close();
1818
+ } catch {}
1819
+ }
1820
+ if (this.#signalAttempt !== undefined) {
1821
+ const attempt = this.#signalAttempt;
1822
+ this.#signalAttempt = undefined;
1823
+ if (attempt.timer !== undefined)
1824
+ this.#clock.clearTimeout(attempt.timer);
1825
+ try {
1826
+ attempt.connection?.close();
1827
+ } catch {}
1828
+ }
1829
+ if (this.#closed)
1830
+ return;
1831
+ this.#clearRoundTimers();
1832
+ this.#clearBackoff();
1833
+ this.#abandonDial();
1834
+ this.#rejectPending();
1835
+ this.#rejectSnapshotWaiters(new DisconnectedError);
1836
+ if (this.#state !== undefined)
1837
+ this.#update({ connection: "reconnecting" });
1838
+ this.#beginRound();
1839
+ }
1840
+ rtcDiagnostics() {
1841
+ const standby = this.#standby;
1842
+ const cooldownRemainingMs = this.#cooldownRemainingMs();
1843
+ let phase;
1844
+ if (standby !== undefined)
1845
+ phase = standby.phase;
1846
+ else if (this.#dial?.p2p === true && this.#dial.phase === "ready")
1847
+ phase = "active";
1848
+ else if (this.#handoff !== undefined)
1849
+ phase = "warm";
1850
+ else if (cooldownRemainingMs > 0)
1851
+ phase = "cooldown";
1852
+ else if (this.#upgradeTimer !== undefined)
1853
+ phase = "scheduled";
1854
+ else
1855
+ phase = "idle";
1856
+ const failure = this.#p2pFailure;
1857
+ return {
1858
+ path: this.#activePath,
1859
+ phase,
1860
+ ...failure === undefined ? {} : {
1861
+ lastFailure: {
1862
+ stage: failure.stage,
1863
+ ...failure.reason === undefined ? {} : { reason: failure.reason },
1864
+ agoMs: Math.max(0, this.#now() - failure.atMs),
1865
+ ...failure.pair === undefined ? {} : { pair: failure.pair }
1866
+ }
1867
+ },
1868
+ ...this.#selectedPair === undefined ? {} : { selectedPair: this.#selectedPair },
1869
+ cooldownRemainingMs
1870
+ };
1871
+ }
1872
+ #clearTimerDial() {
1873
+ if (this.#dial !== undefined)
1874
+ this.#clearTimer(this.#dial);
1875
+ }
1876
+ #clearRoundTimer() {
1877
+ if (this.#roundTimer === undefined)
1878
+ return;
1879
+ clearTimeout(this.#roundTimer);
1880
+ this.#roundTimer = undefined;
1881
+ }
1882
+ #clearRoundTimers() {
1883
+ this.#clearRoundTimer();
1884
+ this.#clearTimerDial();
1885
+ }
1886
+ #clearBackoff() {
1887
+ this.#backoffGeneration += 1;
1888
+ if (this.#backoffTimer === undefined)
1889
+ return;
1890
+ clearTimeout(this.#backoffTimer);
1891
+ this.#backoffTimer = undefined;
1892
+ }
1893
+ #clearTimers() {
1894
+ for (const record of this.#submissionOrder)
1895
+ this.#clearSubmissionTimer(record);
1896
+ this.#clearRoundTimer();
1897
+ this.#clearBackoff();
1898
+ this.#clearUpgradeTimer();
1899
+ this.#stopLiveness();
1900
+ this.#clearStableTimer();
1901
+ if (this.#dial !== undefined)
1902
+ this.#clearTimer(this.#dial);
1903
+ }
1904
+ #stopCoordination() {
1905
+ this.#clearUpgradeTimer();
1906
+ this.#stopLiveness();
1907
+ this.#clearStableTimer();
1908
+ if (this.#handoff !== undefined)
1909
+ this.#abortHandoff(new DisconnectedError);
1910
+ if (this.#standby !== undefined) {
1911
+ const standby = this.#standby;
1912
+ this.#standby = undefined;
1913
+ this.#clearStandbyTimers(standby);
1914
+ try {
1915
+ standby.connection?.close();
1916
+ } catch {}
1917
+ if (standby.signalOnly && standby.signal !== undefined) {
1918
+ try {
1919
+ standby.signal.close();
1920
+ } catch {}
1921
+ }
1922
+ }
1923
+ if (this.#signalAttempt !== undefined) {
1924
+ const attempt = this.#signalAttempt;
1925
+ this.#signalAttempt = undefined;
1926
+ if (attempt.timer !== undefined)
1927
+ this.#clock.clearTimeout(attempt.timer);
1928
+ try {
1929
+ attempt.connection?.close();
1930
+ } catch {}
1931
+ }
1932
+ }
1933
+ #stopListeners() {
1934
+ this.#networkUnsubscribe?.();
1935
+ this.#networkUnsubscribe = undefined;
1936
+ this.#lifecycleUnsubscribe?.();
1937
+ this.#lifecycleUnsubscribe = undefined;
1938
+ this.#signalCleanup?.();
1939
+ this.#signalCleanup = undefined;
1940
+ }
1941
+ #abort() {
1942
+ if (this.#closed)
1943
+ return;
1944
+ this.#closed = true;
1945
+ this.#clearTimers();
1946
+ this.#stopCoordination();
1947
+ this.#stopListeners();
1948
+ this.#abandonDial();
1949
+ this.#rejectPending();
1950
+ this.#rejectSnapshotWaiters(new DisconnectedError);
1951
+ this.#update({ connection: "closed" });
1952
+ this.#rejectReady(new Error("Connection attempt aborted"));
1953
+ }
1954
+ #send(frame) {
1955
+ this.#dial?.connection?.send(JSON.stringify(frame));
1956
+ }
1957
+ #receive(frame) {
1958
+ if (frame === null || typeof frame !== "object")
1959
+ return;
1960
+ switch (frame.type) {
1961
+ case "snapshot":
1962
+ if (!this.#wanted.has(frame.stream))
1963
+ return;
1964
+ this.#values.set(frame.stream, frame.value);
1965
+ this.#fresh.add(frame.stream);
1966
+ this.#streamVersions.set(frame.stream, (this.#streamVersions.get(frame.stream) ?? 0) + 1);
1967
+ this.#refresh();
1968
+ for (const waiter of this.#snapshotWaiters.get(frame.stream)?.splice(0) ?? [])
1969
+ waiter.resolve();
1970
+ return;
1971
+ case "ops":
1972
+ if (!this.#wanted.has(frame.stream) || !this.#values.has(frame.stream))
1973
+ return;
1974
+ this.#values.set(frame.stream, applyImmutable(this.#values.get(frame.stream), frame.ops));
1975
+ this.#refresh();
1976
+ return;
1977
+ case "ended": {
1978
+ this.#wanted.delete(frame.stream);
1979
+ this.#values.delete(frame.stream);
1980
+ this.#fresh.delete(frame.stream);
1981
+ const ended = new Error(`Stream ${frame.stream} ended: ${frame.reason}`);
1982
+ for (const waiter of this.#snapshotWaiters.get(frame.stream)?.splice(0) ?? []) {
1983
+ if (waiter.endSatisfies)
1984
+ waiter.resolve();
1985
+ else
1986
+ waiter.reject(ended);
1987
+ }
1988
+ if (this.#current !== undefined && this.#conversationStreams(this.#current).includes(frame.stream)) {
1989
+ this.#unshow();
1990
+ } else if (this.#side !== undefined && this.#conversationStreams(this.#side).includes(frame.stream)) {
1991
+ this.#side = undefined;
1992
+ }
1993
+ this.#refresh();
1994
+ return;
1995
+ }
1996
+ case "result": {
1997
+ const probe = this.#probes.get(frame.id);
1998
+ if (probe !== undefined) {
1999
+ this.#probes.delete(frame.id);
2000
+ if (probe.timer !== undefined)
2001
+ this.#clock.clearTimeout(probe.timer);
2002
+ if (frame.ok && this.#dial?.generation === probe.generation) {
2003
+ this.#livenessDeadline = Math.max(this.#livenessDeadline, this.#clock.now() + this.#livenessBudgetMs());
2004
+ }
2005
+ if (this.#standby?.phase === "warm")
2006
+ this.#pollStandby(this.#standby);
2007
+ return;
2008
+ }
2009
+ const pending = this.#pending.get(frame.id);
2010
+ this.#pending.delete(frame.id);
2011
+ if (frame.ok)
2012
+ pending?.resolve(frame.value);
2013
+ else
2014
+ pending?.reject(new Error(frame.error));
2015
+ if (this.#standby?.phase === "warm")
2016
+ this.#pollStandby(this.#standby);
2017
+ return;
2018
+ }
2019
+ case "notice":
2020
+ this.#notice(frame.level, frame.message);
2021
+ return;
2022
+ }
2023
+ }
2024
+ #snapshot(stream, endSatisfies = false) {
2025
+ return new Promise((resolve, reject) => {
2026
+ const waiters = this.#snapshotWaiters.get(stream) ?? [];
2027
+ waiters.push({ resolve, reject, endSatisfies });
2028
+ this.#snapshotWaiters.set(stream, waiters);
2029
+ });
2030
+ }
2031
+ #syncStreams(hostMinor) {
2032
+ const streams = new Set(hostStreams(hostMinor));
2033
+ for (const id of [this.#current, this.#side]) {
2034
+ if (id === undefined)
2035
+ continue;
2036
+ for (const stream of this.#conversationStreams(id))
2037
+ streams.add(stream);
2038
+ }
2039
+ return [...streams];
2040
+ }
2041
+ #conversationStreams(id) {
2042
+ return [conversationStream(id), ...this.#docs.map((doc) => docStream(doc.kind, id))];
2043
+ }
2044
+ async#acquire(id) {
2045
+ const missing = this.#conversationStreams(id).filter((stream) => !this.#wanted.has(stream));
2046
+ const stale = this.#conversationStreams(id).filter((stream) => this.#wanted.has(stream) && !this.#fresh.has(stream));
2047
+ const shown = Promise.all([...missing, ...stale].map((stream) => this.#snapshot(stream)));
2048
+ for (const stream of missing) {
2049
+ this.#wanted.add(stream);
2050
+ if (this.#helloReceived)
2051
+ this.#subscribe(stream);
2052
+ }
2053
+ await shown;
2054
+ }
2055
+ #release(id, other) {
2056
+ const keep = new Set(other === undefined ? [] : this.#conversationStreams(other));
2057
+ for (const stream of this.#conversationStreams(id)) {
2058
+ if (keep.has(stream))
2059
+ continue;
2060
+ this.#wanted.delete(stream);
2061
+ this.#values.delete(stream);
2062
+ this.#fresh.delete(stream);
2063
+ this.#send({ type: "unsubscribe", stream });
2064
+ }
2065
+ }
2066
+ #subscribe(stream) {
2067
+ const refs = stream.startsWith("conversation:") && hostSupportsFeature(this.#hostMinor, "imageRefs");
2068
+ this.#send({ type: "subscribe", stream, ...refs ? { images: "ref" } : {} });
2069
+ }
2070
+ #host(name) {
2071
+ return this.#values.get(name);
2072
+ }
2073
+ #hostInfo() {
2074
+ return {
2075
+ protocol: { major: PROTOCOL.major, minor: this.#hostMinor },
2076
+ ...this.#hostVersion === undefined ? {} : { version: this.#hostVersion }
2077
+ };
2078
+ }
2079
+ #effectiveDefaults(fallback) {
2080
+ const settings = this.#host("settings");
2081
+ if (settings === undefined)
2082
+ return fallback;
2083
+ return {
2084
+ ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
2085
+ ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
2086
+ };
2087
+ }
2088
+ #descendsFrom(id, ancestor) {
2089
+ const summaries = this.#host("conversations") ?? [];
2090
+ const parentOf = new Map(summaries.map((summary) => [summary.id, summary.parent]));
2091
+ let cursor = id;
2092
+ for (let depth = 0;cursor !== undefined && depth < 8; depth++) {
2093
+ if (cursor === ancestor)
2094
+ return true;
2095
+ cursor = parentOf.get(cursor);
2096
+ }
2097
+ return false;
2098
+ }
2099
+ #loseSubmissions() {
2100
+ for (const record of this.#submissionOrder) {
2101
+ if (record.phase !== "inFlight" || record.admitted)
2102
+ continue;
2103
+ record.phase = "unknown";
2104
+ this.#clearSubmissionTimer(record);
2105
+ }
2106
+ }
2107
+ #activeSubmissions() {
2108
+ return this.#submissionOrder.filter((record) => record.phase !== "settled").map((record) => ({
2109
+ requestId: record.requestId,
2110
+ ...record.conversationId === undefined ? {} : { conversationId: record.conversationId },
2111
+ text: record.text,
2112
+ whenBusy: record.whenBusy,
2113
+ placement: record.placement,
2114
+ phase: record.phase,
2115
+ images: record.images,
2116
+ ...record.imageViews === undefined ? {} : { imageViews: record.imageViews },
2117
+ skills: record.skills ?? [],
2118
+ attachments: record.attachments,
2119
+ ...record.attachmentDetails === undefined ? {} : { attachmentDetails: record.attachmentDetails },
2120
+ ...record.submissionId === undefined ? {} : { submissionId: record.submissionId },
2121
+ ...record.entry === undefined ? {} : { entry: record.entry },
2122
+ ...record.error === undefined ? {} : { error: record.error }
2123
+ }));
2124
+ }
2125
+ #touchSubmissions() {
2126
+ this.#refresh();
2127
+ }
2128
+ #startSubmissionTimer(record) {
2129
+ this.#clearSubmissionTimer(record);
2130
+ record.timer = setTimeout(() => {
2131
+ record.timer = undefined;
2132
+ if (this.#closed || record.phase === "settled" || record.admitted)
2133
+ return;
2134
+ if (record.phase === "inFlight")
2135
+ record.phase = "unknown";
2136
+ const resolve = record.timeoutResolve;
2137
+ record.timeoutResolve = undefined;
2138
+ this.#touchSubmissions();
2139
+ resolve?.();
2140
+ }, SUBMISSION_TIMEOUT_MS);
2141
+ }
2142
+ #armSubmissionDeadline(record) {
2143
+ this.#startSubmissionTimer(record);
2144
+ let resolve;
2145
+ const deadline = new Promise((done) => {
2146
+ resolve = done;
2147
+ });
2148
+ record.timeoutResolve = resolve;
2149
+ return deadline;
2150
+ }
2151
+ #clearSubmissionTimer(record) {
2152
+ const resolve = record.timeoutResolve;
2153
+ record.timeoutResolve = undefined;
2154
+ if (record.timer !== undefined) {
2155
+ clearTimeout(record.timer);
2156
+ record.timer = undefined;
2157
+ }
2158
+ resolve?.();
2159
+ }
2160
+ #submissionSettled(record) {
2161
+ return record.phase === "settled";
2162
+ }
2163
+ #settleSubmission(record) {
2164
+ if (this.#submissionSettled(record))
2165
+ return;
2166
+ record.phase = "settled";
2167
+ record.error = undefined;
2168
+ this.#clearSubmissionTimer(record);
2169
+ }
2170
+ #entriesOf(id) {
2171
+ if (id === undefined)
2172
+ return [];
2173
+ const view = this.#values.get(conversationStream(id));
2174
+ return view?.entries ?? [];
2175
+ }
2176
+ #entryPresent(record) {
2177
+ if (record.entry === undefined)
2178
+ return false;
2179
+ return this.#entriesOf(record.conversationId).some((entry) => String(entry.id) === String(record.entry));
2180
+ }
2181
+ #inboxHas(record) {
2182
+ if (record.conversationId === undefined || record.submissionId === undefined)
2183
+ return false;
2184
+ const inbox = this.#values.get(docStream("pi.inbox", record.conversationId));
2185
+ return (inbox?.items ?? []).some((item) => String(item.id) === String(record.submissionId));
2186
+ }
2187
+ #applySubmissionRecord(record, found) {
2188
+ record.admitted = true;
2189
+ record.submissionId = found.submissionId;
2190
+ record.error = undefined;
2191
+ this.#clearSubmissionTimer(record);
2192
+ if (found.entry !== undefined)
2193
+ record.entry = found.entry;
2194
+ if (record.entry !== undefined) {
2195
+ if (this.#entryPresent(record) || found.status === "done")
2196
+ this.#settleSubmission(record);
2197
+ else
2198
+ record.phase = "inFlight";
2199
+ } else if (this.#inboxHas(record)) {
2200
+ this.#settleSubmission(record);
2201
+ } else if (found.status === "placed" || found.status === "done" || found.status === "unanswered") {
2202
+ this.#settleSubmission(record);
2203
+ } else {
2204
+ record.phase = "inFlight";
2205
+ }
2206
+ this.#touchSubmissions();
2207
+ }
2208
+ #reconcileSubmissions() {
2209
+ for (const record of this.#submissionOrder) {
2210
+ if (record.phase === "settled" || record.submissionId === undefined)
2211
+ continue;
2212
+ if (this.#entryPresent(record) || this.#inboxHas(record)) {
2213
+ this.#settleSubmission(record);
2214
+ continue;
2215
+ }
2216
+ if (record.conversationId !== undefined && record.submissionId !== undefined && record.admitted && (record.entry !== undefined || !this.#inboxHas(record))) {
2217
+ const stream = conversationStream(record.conversationId);
2218
+ const version = (this.#streamVersions.get(stream) ?? 0) + (this.#streamVersions.get(docStream("pi.inbox", record.conversationId)) ?? 0);
2219
+ if (this.#fresh.has(stream) && record.checkedVersion !== version) {
2220
+ record.checkedVersion = version;
2221
+ this.#confirmSubmission(record);
2222
+ }
2223
+ }
2224
+ }
2225
+ }
2226
+ #querySubmission(record, direct = false) {
2227
+ if (!hostSupportsCall(this.#hostMinor, "submission")) {
2228
+ return Promise.reject(new Error("The host is too old for submission receipts (needs protocol 5.25); run `pinomad upgrade` on the host"));
2229
+ }
2230
+ if (record.conversationId === undefined) {
2231
+ return Promise.reject(new Error("No conversation selected"));
2232
+ }
2233
+ const args = { conversationId: record.conversationId, requestId: record.requestId };
2234
+ const call = direct ? this.#callNow("submission", args) : this.#call("submission", args);
2235
+ return call;
2236
+ }
2237
+ async#confirmSubmission(record) {
2238
+ if (record.confirming || record.phase === "settled" || !hostSupportsCall(this.#hostMinor, "submission"))
2239
+ return;
2240
+ record.confirming = true;
2241
+ try {
2242
+ const found = await this.#querySubmission(record);
2243
+ if (this.#closed || this.#submissionSettled(record))
2244
+ return;
2245
+ if (found !== null) {
2246
+ this.#applySubmissionRecord(record, found);
2247
+ } else if (record.admitted && record.entry === undefined) {
2248
+ this.#settleSubmission(record);
2249
+ this.#touchSubmissions();
2250
+ }
2251
+ } catch {} finally {
2252
+ record.confirming = false;
2253
+ }
2254
+ }
2255
+ #submissionArgs(record) {
2256
+ const conversationId = record.conversationId;
2257
+ if (conversationId === undefined)
2258
+ throw new Error("No conversation selected");
2259
+ return {
2260
+ conversationId,
2261
+ text: record.text,
2262
+ whenBusy: record.whenBusy,
2263
+ requestId: record.requestId,
2264
+ ...record.images.length === 0 ? {} : { images: record.images },
2265
+ ...this.#boundSkills(record.skills),
2266
+ ...record.attachments.length === 0 ? {} : { attachments: record.attachments }
2267
+ };
2268
+ }
2269
+ #validateSubmission(record) {
2270
+ if (record.images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
2271
+ throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
2272
+ }
2273
+ if (needsImageRefs(record.images) && !hostSupportsFeature(this.#hostMinor, "imageRefs")) {
2274
+ throw new Error("The host is too old for image refs (needs protocol 5.15); run `pinomad upgrade` on the host");
2275
+ }
2276
+ if (record.attachments.length > MAX_ATTACHMENTS_PER_MESSAGE) {
2277
+ throw new Error("At most " + MAX_ATTACHMENTS_PER_MESSAGE + " attachments per message");
2278
+ }
2279
+ if (record.attachments.length > 0 && !hostSupportsFeature(this.#hostMinor, "attachments")) {
2280
+ throw new Error("The host is too old for attachments (needs protocol 5.19); run `pinomad upgrade` on the host");
2281
+ }
2282
+ }
2283
+ #applySubmitResult(record, result) {
2284
+ if (this.#closed)
2285
+ return;
2286
+ this.#clearSubmissionTimer(record);
2287
+ record.admitted = true;
2288
+ record.submissionId = result.submissionId;
2289
+ if (result.entry !== undefined)
2290
+ record.entry = result.entry;
2291
+ if (!hostSupportsFeature(this.#hostMinor, "submitEntry")) {
2292
+ this.#settleSubmission(record);
2293
+ } else if (record.entry !== undefined) {
2294
+ record.phase = "inFlight";
2295
+ if (this.#entryPresent(record))
2296
+ this.#settleSubmission(record);
2297
+ } else {
2298
+ record.phase = "inFlight";
2299
+ if (record.conversationId !== undefined) {
2300
+ record.checkedVersion = (this.#streamVersions.get(conversationStream(record.conversationId)) ?? 0) + (this.#streamVersions.get(docStream("pi.inbox", record.conversationId)) ?? 0);
2301
+ }
2302
+ this.#confirmSubmission(record);
2303
+ }
2304
+ this.#touchSubmissions();
2305
+ }
2306
+ #applySubmitError(record, error) {
2307
+ if (this.#closed)
2308
+ return;
2309
+ this.#clearSubmissionTimer(record);
2310
+ if (error instanceof DisconnectedError || error instanceof Error && error.message === "Not connected to the host") {
2311
+ record.phase = "unknown";
2312
+ } else {
2313
+ record.phase = "failed";
2314
+ record.error = error instanceof Error ? error.message : String(error);
2315
+ }
2316
+ this.#touchSubmissions();
2317
+ }
2318
+ async#sendSubmission(record, direct, deadline) {
2319
+ if (record.phase === "settled" || this.#closed)
2320
+ return;
2321
+ if (deadline !== undefined && record.timer === undefined) {
2322
+ if (record.phase !== "failed") {
2323
+ record.phase = "unknown";
2324
+ this.#touchSubmissions();
2325
+ }
2326
+ return;
2327
+ }
2328
+ record.phase = "inFlight";
2329
+ record.error = undefined;
2330
+ const call = direct ? this.#callNow("submit", this.#submissionArgs(record)) : this.#call("submit", this.#submissionArgs(record));
2331
+ const timedOut = deadline ?? this.#armSubmissionDeadline(record);
2332
+ this.#touchSubmissions();
2333
+ const handled = (async () => {
2334
+ try {
2335
+ const result = await call;
2336
+ this.#applySubmitResult(record, result);
2337
+ } catch (error) {
2338
+ this.#applySubmitError(record, error);
2339
+ }
2340
+ })();
2341
+ await Promise.race([handled, timedOut]);
2342
+ }
2343
+ async#runSubmission(record) {
2344
+ if (record.phase === "settled" || this.#closed)
2345
+ return;
2346
+ try {
2347
+ this.#validateSubmission(record);
2348
+ } catch (error) {
2349
+ if (this.#closed)
2350
+ return;
2351
+ record.phase = "failed";
2352
+ record.error = error instanceof Error ? error.message : String(error);
2353
+ this.#touchSubmissions();
2354
+ return;
2355
+ }
2356
+ if (!record.fixed) {
2357
+ const target = this.#current;
2358
+ if (target === undefined) {
2359
+ record.phase = "failed";
2360
+ record.error = "No conversation selected";
2361
+ this.#touchSubmissions();
2362
+ return;
2363
+ }
2364
+ record.conversationId = target;
2365
+ record.fixed = true;
2366
+ }
2367
+ await this.#sendSubmission(record, false);
2368
+ }
2369
+ async#recoverSubmissions() {
2370
+ const unknown = this.#submissionOrder.filter((record) => record.phase === "unknown");
2371
+ if (this.#syncGate === undefined && unknown.some((record) => record.fixed) && hostSupportsCall(this.#hostMinor, "submission")) {
2372
+ this.#syncGate = { entries: [] };
2373
+ }
2374
+ for (const record of unknown) {
2375
+ if (this.#closed || record.phase !== "unknown" || !record.fixed)
2376
+ continue;
2377
+ try {
2378
+ if (!hostSupportsCall(this.#hostMinor, "submission")) {
2379
+ continue;
2380
+ }
2381
+ const deadline = this.#armSubmissionDeadline(record);
2382
+ const found = await this.#querySubmissionWithinDeadline(record, true, deadline);
2383
+ if (found === "deadline")
2384
+ continue;
2385
+ if (found !== null) {
2386
+ this.#applySubmissionRecord(record, found);
2387
+ continue;
2388
+ }
2389
+ if (record.phase !== "unknown" || record.timer === undefined)
2390
+ continue;
2391
+ await this.#sendSubmission(record, true, deadline);
2392
+ } catch (error) {
2393
+ if (this.#closed)
2394
+ return;
2395
+ this.#clearSubmissionTimer(record);
2396
+ record.phase = "unknown";
2397
+ this.#touchSubmissions();
2398
+ if (error instanceof DisconnectedError || error instanceof Error && error.message === "Not connected to the host") {
2399
+ return;
2400
+ }
2401
+ }
2402
+ }
2403
+ }
2404
+ #submissionDraft(record, images = record.images) {
2405
+ return {
2406
+ text: record.text,
2407
+ whenBusy: record.whenBusy,
2408
+ images,
2409
+ skills: record.skills ?? [],
2410
+ attachments: record.attachments,
2411
+ ...record.attachmentDetails === undefined ? {} : { attachmentDetails: record.attachmentDetails }
2412
+ };
2413
+ }
2414
+ async#resolveSubmissionImages(record) {
2415
+ const views = record.imageViews ?? record.images;
2416
+ const resolved = [];
2417
+ for (const image of views) {
2418
+ if ("data" in image) {
2419
+ resolved.push(image);
2420
+ } else {
2421
+ const bytes = await this.#call("image", { ref: image.ref });
2422
+ resolved.push({ data: bytes.data, mimeType: bytes.mimeType });
2423
+ }
2424
+ }
2425
+ return resolved;
2426
+ }
2427
+ async#querySubmissionWithinDeadline(record, direct, deadline) {
2428
+ const query = this.#querySubmission(record, direct);
2429
+ const outcome = await Promise.race([
2430
+ query.then((found) => ({ kind: "found", found }), (error) => ({ kind: "error", error })),
2431
+ deadline.then(() => ({ kind: "deadline" }))
2432
+ ]);
2433
+ if (outcome.kind === "deadline") {
2434
+ query.then((found) => {
2435
+ if (this.#closed || found === null || record.phase === "settled")
2436
+ return;
2437
+ this.#applySubmissionRecord(record, found);
2438
+ }, () => {});
2439
+ return "deadline";
2440
+ }
2441
+ if (outcome.kind === "error")
2442
+ throw outcome.error;
2443
+ return outcome.found;
2444
+ }
2445
+ async#retrySubmission(record) {
2446
+ if (record.phase !== "unknown" && record.phase !== "failed")
2447
+ return;
2448
+ const deadline = this.#armSubmissionDeadline(record);
2449
+ try {
2450
+ if (record.phase === "unknown" && hostSupportsCall(this.#hostMinor, "submission")) {
2451
+ const found = await this.#querySubmissionWithinDeadline(record, false, deadline);
2452
+ if (found === "deadline")
2453
+ return;
2454
+ if (found !== null) {
2455
+ this.#applySubmissionRecord(record, found);
2456
+ return;
2457
+ }
2458
+ if (record.phase !== "unknown" || record.timer === undefined)
2459
+ return;
2460
+ }
2461
+ await this.#sendSubmission(record, false, deadline);
2462
+ } catch (error) {
2463
+ if (this.#closed)
2464
+ return;
2465
+ this.#clearSubmissionTimer(record);
2466
+ if (error instanceof DisconnectedError || error instanceof Error && error.message === "Not connected to the host") {
2467
+ record.phase = "unknown";
2468
+ record.error = undefined;
2469
+ this.#touchSubmissions();
2470
+ return;
2471
+ }
2472
+ record.phase = "failed";
2473
+ record.error = error instanceof Error ? error.message : String(error);
2474
+ this.#touchSubmissions();
2475
+ throw error;
2476
+ }
2477
+ }
2478
+ async#takeBackSubmission(record) {
2479
+ let resolved;
2480
+ const images = async () => {
2481
+ if (resolved !== undefined)
2482
+ return resolved;
2483
+ try {
2484
+ resolved = await this.#resolveSubmissionImages(record);
2485
+ return resolved;
2486
+ } catch {
2487
+ throw new Error("Couldn't load the original images — try again");
2488
+ }
2489
+ };
2490
+ if (record.phase === "queued" || record.phase === "failed") {
2491
+ const draft2 = await images();
2492
+ this.#settleSubmission(record);
2493
+ this.#touchSubmissions();
2494
+ return this.#submissionDraft(record, draft2);
2495
+ }
2496
+ if (record.phase !== "unknown" && !(record.phase === "inFlight" && record.submissionId !== undefined)) {
2497
+ return;
2498
+ }
2499
+ if (!hostSupportsCall(this.#hostMinor, "submission"))
2500
+ return;
2501
+ const found = await this.#querySubmission(record);
2502
+ if (found === null) {
2503
+ const draft2 = await images();
2504
+ this.#settleSubmission(record);
2505
+ this.#touchSubmissions();
2506
+ return this.#submissionDraft(record, draft2);
2507
+ }
2508
+ this.#applySubmissionRecord(record, found);
2509
+ if (this.#submissionSettled(record))
2510
+ return;
2511
+ if (found.status === "placed" || found.status === "done" || found.entry !== undefined)
2512
+ return;
2513
+ if (found.status === "unanswered") {
2514
+ const draft2 = await images();
2515
+ this.#settleSubmission(record);
2516
+ this.#touchSubmissions();
2517
+ return this.#submissionDraft(record, draft2);
2518
+ }
2519
+ if (record.conversationId === undefined)
2520
+ return;
2521
+ const draft = await images();
2522
+ const { outcome } = await this.#call("withdrawQueued", {
2523
+ conversationId: record.conversationId,
2524
+ submissionId: found.submissionId
2525
+ });
2526
+ if (outcome === "sent") {
2527
+ this.#settleSubmission(record);
2528
+ this.#touchSubmissions();
2529
+ return;
2530
+ }
2531
+ this.#settleSubmission(record);
2532
+ this.#touchSubmissions();
2533
+ return this.#submissionDraft(record, draft);
2534
+ }
2535
+ #docsOf(id) {
2536
+ return this.#docs.flatMap((doc) => {
2537
+ const stream = docStream(doc.kind, id);
2538
+ return this.#values.has(stream) ? [{ kind: doc.kind, presentation: doc.presentation, value: this.#values.get(stream) }] : [];
2539
+ });
2540
+ }
2541
+ #refresh() {
2542
+ if (this.#state === undefined)
2543
+ return;
2544
+ this.#reconcileSubmissions();
2545
+ const current = this.#current;
2546
+ const side = this.#side;
2547
+ const conversation = current === undefined ? undefined : this.#values.get(conversationStream(current));
2548
+ const summaries = this.#host("conversations") ?? [];
2549
+ const index = this.#host("index") ?? { projects: [], conversations: [] };
2550
+ const devices = this.#host("devices")?.devices ?? [];
2551
+ const tasks = this.#wanted.has("tasks") ? this.#values.get("tasks") : undefined;
2552
+ this.#update({
2553
+ conversation,
2554
+ models: this.#host("models") ?? this.#helloModels,
2555
+ organized: organize(index, summaries),
2556
+ home: current === undefined ? undefined : homeOf(index, summaries, current),
2557
+ checkout: current === undefined ? undefined : checkoutOf(index, summaries, current),
2558
+ tasks,
2559
+ docs: current === undefined ? [] : this.#docsOf(current),
2560
+ submissions: this.#activeSubmissions(),
2561
+ side: side === undefined ? undefined : {
2562
+ id: side,
2563
+ conversation: this.#values.get(conversationStream(side)),
2564
+ docs: this.#docsOf(side)
2565
+ },
2566
+ devices,
2567
+ mcp: this.#host("mcp") ?? null,
2568
+ auth: this.#host("auth") ?? null,
2569
+ providers: this.#host("providers") ?? null,
2570
+ settings: this.#host("settings") ?? null,
2571
+ agents: this.#host("agents") ?? null,
2572
+ defaults: this.#effectiveDefaults(this.#helloDefaults)
2573
+ });
2574
+ }
2575
+ #update(patch) {
2576
+ if (this.#state === undefined)
2577
+ return;
2578
+ this.#state = { ...this.#state, ...patch };
2579
+ if (this.#notifying)
2580
+ return;
2581
+ this.#notifying = true;
2582
+ queueMicrotask(() => {
2583
+ this.#notifying = false;
2584
+ for (const listener of this.#listeners)
2585
+ listener();
2586
+ });
2587
+ }
2588
+ #boundSkills(skills) {
2589
+ return skills === undefined || !hostSupportsFeature(this.#hostMinor, "boundSkills") ? {} : { skills };
2590
+ }
2591
+ #notice(level, message) {
2592
+ if (this.#state === undefined)
2593
+ return;
2594
+ this.#update({ notices: [...this.#state.notices, { id: this.#nextNotice++, level, message }].slice(-20) });
2595
+ }
2596
+ #requireProviders() {
2597
+ if (!hostSupportsCall(this.#hostMinor, "beginLogin")) {
2598
+ throw new Error("The host is too old for this (needs protocol 5.21); run `pinomad upgrade` on the host");
2599
+ }
2600
+ }
2601
+ #call(method, args) {
2602
+ const handoff = this.#handoff;
2603
+ if (handoff !== undefined || this.#syncGate !== undefined) {
2604
+ const id = this.#nextCall++;
2605
+ const frame = { type: "call", id, method, args };
2606
+ const entries = handoff?.entries ?? this.#syncGate.entries;
2607
+ return new Promise((resolve, reject) => {
2608
+ entries.push({ id, frame, resolve, reject });
2609
+ });
2610
+ }
2611
+ if (this.#dial?.phase !== "ready" || this.#state?.connection !== "connected") {
2612
+ return Promise.reject(new Error("Not connected to the host"));
2613
+ }
2614
+ return this.#deliverCall(this.#dial, this.#nextCall++, method, args);
2615
+ }
2616
+ #callNow(method, args) {
2617
+ if (this.#dial?.phase !== "ready" || this.#state?.connection !== "connected") {
2618
+ return Promise.reject(new Error("Not connected to the host"));
2619
+ }
2620
+ return this.#deliverCall(this.#dial, this.#nextCall++, method, args);
2621
+ }
2622
+ #deliverCall(dial, id, method, args) {
2623
+ if (!this.#isActive(dial) || dial.phase !== "ready" || dial.connection === undefined) {
2624
+ return Promise.reject(new Error("Not connected to the host"));
2625
+ }
2626
+ return new Promise((resolve, reject) => {
2627
+ this.#pending.set(id, { resolve, reject });
2628
+ try {
2629
+ dial.connection.send(JSON.stringify({ type: "call", id, method, args }));
2630
+ } catch (error) {
2631
+ this.#pending.delete(id);
2632
+ reject(asError(error));
2633
+ }
2634
+ });
2635
+ }
2636
+ #command(operation) {
2637
+ this.#commands = this.#commands.then(operation).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
2638
+ return this.#commands;
2639
+ }
2640
+ #request(operation) {
2641
+ const request = this.#commands.then(operation);
2642
+ this.#commands = request.then(() => {}, () => {});
2643
+ return request;
2644
+ }
2645
+ #viewSource() {
2646
+ return {
2647
+ current: () => this.#state,
2648
+ subscribe: (listener) => {
2649
+ this.#listeners.add(listener);
2650
+ return () => this.#listeners.delete(listener);
2651
+ }
2652
+ };
2653
+ }
2654
+ #controller() {
2655
+ const newRequestId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
2656
+ const conversationId = () => {
2657
+ if (this.#current === undefined)
2658
+ throw new Error("No conversation selected");
2659
+ return this.#current;
2660
+ };
2661
+ return {
2662
+ addProject: (path) => this.#request(() => this.#call("addProject", { path })),
2663
+ listFolders: (path) => this.#request(() => {
2664
+ if (!hostSupportsCall(this.#hostMinor, "listFolders")) {
2665
+ throw new Error("The host is too old for this (needs protocol 5.3); run `pinomad upgrade` on the host");
2666
+ }
2667
+ return this.#call("listFolders", path === undefined ? {} : { path });
2668
+ }),
2669
+ removeProject: (path) => this.#command(() => this.#call("removeProject", { path })),
2670
+ renameConversation: (id, title) => this.#request(async () => {
2671
+ if (!hostSupportsCall(this.#hostMinor, "renameConversation")) {
2672
+ throw new Error("The host is too old for this (needs protocol 5.4); run `pinomad upgrade` on the host");
2673
+ }
2674
+ await this.#call("renameConversation", { conversationId: id, title });
2675
+ }),
2676
+ createConversation: (home, text, options) => this.#command(async () => {
2677
+ const images = options?.images ?? [];
2678
+ if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
2679
+ throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
2680
+ }
2681
+ if (needsImageRefs(images) && !hostSupportsFeature(this.#hostMinor, "imageRefs")) {
2682
+ throw new Error("The host is too old for image refs (needs protocol 5.15); run `pinomad upgrade` on the host");
2683
+ }
2684
+ const attachments = options?.attachments ?? [];
2685
+ if (attachments.length > MAX_ATTACHMENTS_PER_MESSAGE) {
2686
+ throw new Error(`At most ${MAX_ATTACHMENTS_PER_MESSAGE} attachments per message`);
2687
+ }
2688
+ if (attachments.length > 0 && !hostSupportsFeature(this.#hostMinor, "attachments")) {
2689
+ throw new Error("The host is too old for attachments (needs protocol 5.19); run `pinomad upgrade` on the host");
2690
+ }
2691
+ if (options?.agent !== undefined && !hostSupportsFeature(this.#hostMinor, "agentConversations")) {
2692
+ throw new Error("The host is too old for named agents (needs protocol 5.10); run `pinomad upgrade` on the host");
2693
+ }
2694
+ const { conversationId: conversationId2 } = await this.#call("createConversation", {
2695
+ home,
2696
+ text,
2697
+ requestId: newRequestId(),
2698
+ ...options?.checkout === undefined ? {} : { checkout: options.checkout },
2699
+ ...options?.model === undefined ? {} : { model: options.model },
2700
+ ...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel },
2701
+ ...options?.agent === undefined ? {} : { agent: options.agent },
2702
+ ...images.length === 0 ? {} : { images },
2703
+ ...this.#boundSkills(options?.skills),
2704
+ ...attachments.length === 0 ? {} : { attachments }
2705
+ });
2706
+ await this.#switch(conversationId2);
2707
+ }),
2708
+ archive: (id, archived) => this.#command(async () => {
2709
+ await this.#call("archive", { conversationId: id, archived });
2710
+ if (archived && id === this.#current)
2711
+ this.#unshow();
2712
+ if (archived && this.#side !== undefined && this.#descendsFrom(this.#side, id))
2713
+ await this.#showSide(undefined);
2714
+ }),
2715
+ submit: (text, whenBusy, options) => {
2716
+ const requestId = options?.requestId ?? newRequestId();
2717
+ if (this.#submissions.has(requestId))
2718
+ return Promise.resolve();
2719
+ const target = options?.target ?? this.#current;
2720
+ const shown = target === undefined ? undefined : this.#values.get(conversationStream(target));
2721
+ const record = {
2722
+ requestId,
2723
+ text,
2724
+ whenBusy,
2725
+ placement: shown !== undefined && isBusy(shown) ? "queue" : "conversation",
2726
+ images: options?.images ?? [],
2727
+ ...options?.imageViews === undefined ? {} : { imageViews: options.imageViews },
2728
+ ...options?.skills === undefined ? {} : { skills: options.skills },
2729
+ attachments: options?.attachments ?? [],
2730
+ ...options?.attachmentDetails === undefined ? {} : { attachmentDetails: options.attachmentDetails },
2731
+ phase: "queued",
2732
+ ...target === undefined ? {} : { conversationId: target },
2733
+ fixed: options?.target !== undefined,
2734
+ admitted: false,
2735
+ confirming: false
2736
+ };
2737
+ this.#submissions.set(requestId, record);
2738
+ this.#submissionOrder.push(record);
2739
+ this.#touchSubmissions();
2740
+ return this.#request(() => this.#runSubmission(record)).then(() => {}, () => {});
2741
+ },
2742
+ retrySubmission: (requestId) => {
2743
+ const record = this.#submissions.get(requestId);
2744
+ if (record === undefined || this.#submissionLocks.has(requestId))
2745
+ return Promise.resolve();
2746
+ this.#submissionLocks.add(requestId);
2747
+ return this.#request(() => this.#retrySubmission(record)).finally(() => this.#submissionLocks.delete(requestId));
2748
+ },
2749
+ takeBackSubmission: (requestId) => {
2750
+ const record = this.#submissions.get(requestId);
2751
+ if (record === undefined || this.#submissionLocks.has(requestId))
2752
+ return Promise.resolve(undefined);
2753
+ this.#submissionLocks.add(requestId);
2754
+ return this.#request(() => this.#takeBackSubmission(record)).finally(() => this.#submissionLocks.delete(requestId));
2755
+ },
2756
+ compact: (instructions) => this.#command(() => this.#call("compact", { conversationId: conversationId(), ...instructions === undefined ? {} : { instructions } })),
2757
+ runCommand: (name, args, target) => this.#command(() => {
2758
+ if (!hostSupportsCall(this.#hostMinor, "runCommand")) {
2759
+ throw new Error("The host is too old for this (needs protocol 5.17); run `pinomad upgrade` on the host");
2760
+ }
2761
+ return this.#call("runCommand", {
2762
+ conversationId: target ?? conversationId(),
2763
+ name,
2764
+ ...args === undefined ? {} : { args }
2765
+ });
2766
+ }),
2767
+ abort: (target) => {
2768
+ const targetConversation = target ?? this.#current;
2769
+ if (targetConversation === undefined) {
2770
+ this.#notice("error", "No conversation selected");
2771
+ return Promise.resolve();
2772
+ }
2773
+ return this.#call("abort", { conversationId: targetConversation }).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
2774
+ },
2775
+ withdrawQueued: (submissionId, target) => this.#request(async () => {
2776
+ if (!hostSupportsCall(this.#hostMinor, "withdrawQueued")) {
2777
+ throw new Error("The host is too old for this (needs protocol 5.14); run `pinomad upgrade` on the host");
2778
+ }
2779
+ const { outcome } = await this.#call("withdrawQueued", {
2780
+ conversationId: target ?? conversationId(),
2781
+ submissionId
2782
+ });
2783
+ return outcome;
2784
+ }),
2785
+ steerQueued: (submissionId, target) => this.#request(async () => {
2786
+ if (!hostSupportsCall(this.#hostMinor, "steerQueued")) {
2787
+ throw new Error("The host is too old for this (needs protocol 5.14); run `pinomad upgrade` on the host");
2788
+ }
2789
+ const { outcome } = await this.#call("steerQueued", {
2790
+ conversationId: target ?? conversationId(),
2791
+ submissionId
2792
+ });
2793
+ return outcome;
2794
+ }),
2795
+ setThinkingLevel: (level) => this.#command(() => this.#call("setThinkingLevel", { conversationId: conversationId(), level })),
2796
+ setModel: (model) => this.#command(() => this.#call("setModel", { conversationId: conversationId(), model })),
2797
+ setAgent: (agent) => this.#command(() => {
2798
+ if (!hostSupportsCall(this.#hostMinor, "setAgent")) {
2799
+ throw new Error("The host is too old for this (needs protocol 5.18); run `pinomad upgrade` on the host");
2800
+ }
2801
+ return this.#call("setAgent", { conversationId: conversationId(), agent });
2802
+ }),
2803
+ toggleTasks: () => this.#command(async () => {
2804
+ if (this.#wanted.delete("tasks")) {
2805
+ this.#send({ type: "unsubscribe", stream: "tasks" });
2806
+ this.#values.delete("tasks");
2807
+ this.#refresh();
2808
+ return;
2809
+ }
2810
+ this.#wanted.add("tasks");
2811
+ const shown = this.#snapshot("tasks");
2812
+ this.#send({ type: "subscribe", stream: "tasks" });
2813
+ await shown;
2814
+ }),
2815
+ switchConversation: (id) => this.#command(() => this.#switch(id)),
2816
+ showSide: (id) => this.#command(() => this.#showSide(id)),
2817
+ fork: (entryId, prompt, removeTools, options) => this.#command(async () => {
2818
+ const { conversationId: forked } = await this.#call("fork", {
2819
+ conversationId: conversationId(),
2820
+ entryId,
2821
+ ...removeTools === undefined ? {} : { removeTools }
2822
+ });
2823
+ if (options?.show === "side")
2824
+ await this.#showSide(forked);
2825
+ else
2826
+ await this.#switch(forked);
2827
+ await this.#call("submit", { conversationId: forked, text: prompt, whenBusy: "followUp", requestId: newRequestId() });
2828
+ }),
2829
+ answer: (kind, requestId, answers, target) => this.#command(() => this.#call("answer", { conversationId: target ?? conversationId(), kind, requestId, answers })),
2830
+ changes: () => this.#request(() => this.#call("changes", { conversationId: conversationId() })),
2831
+ listFiles: (scope, query) => {
2832
+ if (!hostSupportsCall(this.#hostMinor, "listFiles")) {
2833
+ return Promise.reject(new Error("The host is too old for this (needs protocol 5.10); run `pinomad upgrade` on the host"));
2834
+ }
2835
+ return this.#call("listFiles", { scope, query });
2836
+ },
2837
+ beginAttachment: (name, size) => {
2838
+ if (!hostSupportsCall(this.#hostMinor, "beginAttachment")) {
2839
+ return Promise.reject(new Error("The host is too old for attachments (needs protocol 5.19); run `pinomad upgrade` on the host"));
2840
+ }
2841
+ return this.#call("beginAttachment", { name, size }).then(({ attachmentId }) => attachmentId);
2842
+ },
2843
+ appendAttachment: (attachmentId, offset, data) => {
2844
+ if (!hostSupportsCall(this.#hostMinor, "appendAttachment")) {
2845
+ return Promise.reject(new Error("The host is too old for attachments (needs protocol 5.19); run `pinomad upgrade` on the host"));
2846
+ }
2847
+ return this.#call("appendAttachment", { attachmentId, offset, data });
2848
+ },
2849
+ image: (ref, variant = "original") => {
2850
+ if (!hostSupportsCall(this.#hostMinor, "image")) {
2851
+ return Promise.reject(new Error("The host is too old for this (needs protocol 5.15); run `pinomad upgrade` on the host"));
2852
+ }
2853
+ const derived = variant !== "original" && hostSupportsFeature(this.#hostMinor, "imageVariants");
2854
+ return this.#call("image", { ref, ...derived ? { variant } : {} });
2855
+ },
2856
+ listSkills: (scope) => {
2857
+ if (!hostSupportsCall(this.#hostMinor, "listSkills")) {
2858
+ return Promise.reject(new Error("The host is too old for this (needs protocol 5.13); run `pinomad upgrade` on the host"));
2859
+ }
2860
+ return this.#call("listSkills", { scope });
2861
+ },
2862
+ listCommands: (id) => {
2863
+ if (!hostSupportsCall(this.#hostMinor, "listCommands")) {
2864
+ return Promise.reject(new Error("The host is too old for this (needs protocol 5.17); run `pinomad upgrade` on the host"));
2865
+ }
2866
+ return this.#call("listCommands", { conversationId: id });
2867
+ },
2868
+ createPairing: () => this.#request(() => this.#call("createPairing", {})),
2869
+ revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey })),
2870
+ logout: (providerId) => this.#command(() => {
2871
+ if (!hostSupportsCall(this.#hostMinor, "logout")) {
2872
+ throw new Error("The host is too old for this (needs protocol 5.1); run `pinomad upgrade` on the host");
2873
+ }
2874
+ return this.#call("logout", { providerId });
2875
+ }),
2876
+ beginLogin: (providerId, method) => this.#request(async () => {
2877
+ this.#requireProviders();
2878
+ return (await this.#call("beginLogin", { providerId, method })).loginId;
2879
+ }),
2880
+ answerLogin: (loginId, promptId, value) => this.#request(async () => {
2881
+ this.#requireProviders();
2882
+ await this.#call("answerLogin", { loginId, promptId, value });
2883
+ }),
2884
+ cancelLogin: (loginId) => this.#request(async () => {
2885
+ this.#requireProviders();
2886
+ await this.#call("cancelLogin", { loginId });
2887
+ }),
2888
+ saveCustomProvider: (provider, apiKey) => this.#request(async () => {
2889
+ this.#requireProviders();
2890
+ await this.#call("saveCustomProvider", apiKey === undefined ? { provider } : { provider, apiKey });
2891
+ }),
2892
+ removeCustomProvider: (providerId) => this.#request(async () => {
2893
+ this.#requireProviders();
2894
+ await this.#call("removeCustomProvider", { providerId });
2895
+ }),
2896
+ listEndpointModels: async (input) => {
2897
+ this.#requireProviders();
2898
+ return (await this.#call("listEndpointModels", input)).models;
2899
+ },
2900
+ updateSettings: (patch) => this.#command(() => {
2901
+ if (!hostSupportsCall(this.#hostMinor, "updateSettings")) {
2902
+ throw new Error("The host is too old for this (needs protocol 5.2); run `pinomad upgrade` on the host");
2903
+ }
2904
+ if (patch.acceptAccountDevices !== undefined && !hostSupportsFeature(this.#hostMinor, "acceptAccountDevices")) {
2905
+ throw new Error("The host is too old for this (needs protocol 5.8); run `pinomad upgrade` on the host");
2906
+ }
2907
+ if (patch.hostName !== undefined && !hostSupportsFeature(this.#hostMinor, "hostName")) {
2908
+ throw new Error("The host is too old for this (needs protocol 5.12); run `pinomad upgrade` on the host");
2909
+ }
2910
+ return this.#call("updateSettings", { patch });
2911
+ }),
2912
+ createAgent: (role) => this.#request(async () => {
2913
+ if (!hostSupportsCall(this.#hostMinor, "createAgent")) {
2914
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
2915
+ }
2916
+ await this.#call("createAgent", { role });
2917
+ }),
2918
+ updateAgent: (role) => this.#request(async () => {
2919
+ if (!hostSupportsCall(this.#hostMinor, "updateAgent")) {
2920
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
2921
+ }
2922
+ await this.#call("updateAgent", { role });
2923
+ }),
2924
+ deleteAgent: (name) => this.#request(async () => {
2925
+ if (!hostSupportsCall(this.#hostMinor, "deleteAgent")) {
2926
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
2927
+ }
2928
+ await this.#call("deleteAgent", { name });
2929
+ }),
2930
+ setPushToken: (token) => this.#request(async () => {
2931
+ if (!hostSupportsCall(this.#hostMinor, "setPushToken")) {
2932
+ throw new Error("The host is too old for this (needs protocol 5.7); run `pinomad upgrade` on the host");
2933
+ }
2934
+ if (token !== null && typeof token !== "string" && !hostSupportsFeature(this.#hostMinor, "apnsPush")) {
2935
+ throw new Error("The host is too old for this (needs protocol 5.9); run `pinomad upgrade` on the host");
2936
+ }
2937
+ await this.#call("setPushToken", { token });
2938
+ })
2939
+ };
2940
+ }
2941
+ async#switch(id) {
2942
+ const previous = this.#current;
2943
+ if (id === previous)
2944
+ return;
2945
+ await this.#acquire(id);
2946
+ this.#current = id;
2947
+ if (previous !== undefined)
2948
+ this.#release(previous, this.#side);
2949
+ this.#refresh();
2950
+ }
2951
+ #unshow() {
2952
+ const previous = this.#current;
2953
+ if (previous === undefined)
2954
+ return;
2955
+ this.#current = undefined;
2956
+ this.#release(previous, this.#side);
2957
+ this.#refresh();
2958
+ }
2959
+ async#showSide(id) {
2960
+ const previous = this.#side;
2961
+ if (id === previous)
2962
+ return;
2963
+ if (id !== undefined)
2964
+ await this.#acquire(id);
2965
+ this.#side = id;
2966
+ if (previous !== undefined)
2967
+ this.#release(previous, this.#current);
2968
+ this.#refresh();
2969
+ }
2970
+ #close() {
2971
+ if (this.#closed)
2972
+ return;
2973
+ this.#closed = true;
2974
+ this.#clearTimers();
2975
+ this.#stopCoordination();
2976
+ this.#stopListeners();
2977
+ this.#abandonDial();
2978
+ this.#rejectPending();
2979
+ this.#rejectSnapshotWaiters(new DisconnectedError);
2980
+ this.#update({ connection: "closed" });
2981
+ }
2982
+ }
2983
+
2984
+ export { connectRemoteDurable, ProtocolMismatchError };