@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. package/subgraph-store.ts +109 -16
@@ -0,0 +1,540 @@
1
+ /**
2
+ * pair-remote-client — gateway-side WebSocket client for the relay-brokered
3
+ * pair flow (plugin rc.11).
4
+ *
5
+ * TypeScript mirror of ``python/src/totalreclaw/pair/remote_client.py``. Wire
6
+ * formats (WebSocket frame shapes, URL layout, base64url encoding) match the
7
+ * Python implementation byte-for-byte so either side can open a session that
8
+ * the relay (`totalreclaw-relay`) + browser page (`pair-html.ts`) already
9
+ * understand. Crypto primitives come from the shared ``pair-crypto.ts``
10
+ * module — the same ECDH + HKDF + AES-256-GCM stack the loopback HTTP
11
+ * server uses.
12
+ *
13
+ * Flow (this file implements the gateway half):
14
+ *
15
+ * 1. Generate an ephemeral x25519 keypair (`generateGatewayKeypair`).
16
+ * 2. Open a short-lived WebSocket to `wss://<relay>/pair/session/open`.
17
+ * 3. Send `{type: "open", gateway_pubkey, pin, client_id, mode?}`.
18
+ * 4. Receive `{type: "opened", token, short_url, expires_at}` — use these
19
+ * to build the user-facing pair URL (token + `#pk=<gateway_pubkey>`).
20
+ * 5. Block on the WebSocket until the relay pushes
21
+ * `{type: "forward", client_pubkey, nonce, ciphertext}`.
22
+ * 6. Decrypt locally via `decryptPairingPayload` using the gateway private
23
+ * key. If decrypt succeeds and phrase is valid, call the caller's
24
+ * `completePairing` handler (writes credentials.json).
25
+ * 7. Send `{type: "ack"}` back; close the WebSocket.
26
+ *
27
+ * Phrase-safety invariants preserved:
28
+ * - Relay sees only ciphertext; it cannot derive the symmetric key without
29
+ * the gateway's private key.
30
+ * - The gateway pubkey transits the relay as a label in the open frame so
31
+ * the relay can display the session, but is ALSO bound into the URL
32
+ * fragment the user opens — the fragment never hits the relay.
33
+ * - Phrase NEVER enters any logs. PIN is never logged.
34
+ * - No relay credentials are required — auth is the single-use PIN +
35
+ * 5-minute TTL + gateway ECDH private key.
36
+ *
37
+ * Scope / scanner surface:
38
+ * - NO `fs.*` primitives (delegates credentials writes to the caller via
39
+ * `completePairing`). Safe for the check-scanner cross-rule guard.
40
+ * - NO env-var reads. Caller passes `relayBaseUrl` explicitly; the plugin
41
+ * sources it from the `TOTALRECLAW_PAIR_RELAY_URL` env (via `config.ts`)
42
+ * or falls back to the staging default.
43
+ */
44
+
45
+ import { randomBytes, randomInt } from 'node:crypto';
46
+
47
+ import WebSocket from 'ws';
48
+
49
+ import {
50
+ decryptPairingPayload,
51
+ generateGatewayKeypair,
52
+ type GatewayKeypair,
53
+ } from './pair-crypto.js';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** Default relay endpoint. Caller passes `TOTALRECLAW_PAIR_RELAY_URL` via config. */
60
+ export const DEFAULT_RELAY_URL = 'wss://api-staging.totalreclaw.xyz';
61
+
62
+ /** WebSocket connect + handshake timeout (ms). */
63
+ const OPEN_TIMEOUT_MS = 10_000;
64
+
65
+ /** Default blocking-await-for-forward timeout (5 minutes — matches relay TTL). */
66
+ const DEFAULT_AWAIT_TIMEOUT_MS = 5 * 60_000;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Types
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Pair mode forwarded in the open frame. Relay uses it to pick the HTML panel. */
73
+ export type PairRelayMode = 'generate' | 'import' | 'either';
74
+
75
+ /**
76
+ * Handle returned by `openRemotePairSession`. Carries the user-facing URL
77
+ * + PIN + keypair + a live WebSocket. The caller normally hands the URL /
78
+ * PIN to the user via chat, then calls `awaitPhraseUpload(session, ...)` to
79
+ * block until the browser completes.
80
+ */
81
+ export interface RemotePairSession {
82
+ /** User-facing pair URL (https://… plus `#pk=` fragment). */
83
+ url: string;
84
+ /** 6-digit PIN the user types into the browser. */
85
+ pin: string;
86
+ /** Opaque session token issued by the relay. */
87
+ token: string;
88
+ /** ISO-8601 timestamp when the relay will drop the session. */
89
+ expiresAt: string;
90
+ /** Ephemeral gateway keypair for this session. `skB64` stays in-process. */
91
+ keypair: GatewayKeypair;
92
+ /** Relay mode forwarded in the open frame. */
93
+ mode: PairRelayMode;
94
+ /** Live WebSocket. Internal — the caller does not interact with it. */
95
+ _ws: WebSocket;
96
+ }
97
+
98
+ /** Outcome of the caller-supplied completion handler. */
99
+ export interface RelayCompletionResult {
100
+ state: 'active' | 'error';
101
+ accountId?: string;
102
+ error?: string;
103
+ }
104
+
105
+ /**
106
+ * Completion handler signature. Receives the decrypted recovery phrase as a
107
+ * plain string + the live session. Expected to write credentials.json + flip
108
+ * onboarding state. MUST NOT log or return the phrase. The returned
109
+ * `RelayCompletionResult` decides whether the relay sees `ack` or `nack`.
110
+ */
111
+ export type RelayCompletePairingHandler = (inputs: {
112
+ mnemonic: string;
113
+ session: RemotePairSession;
114
+ }) => Promise<RelayCompletionResult>;
115
+
116
+ /** Optional phrase validator — caller can pass `validateMnemonic` from `@scure/bip39`. */
117
+ export type PhraseValidator = (phrase: string) => boolean;
118
+
119
+ /** Default validator — 12 or 24 lowercase ASCII words. Matches pair-http default. */
120
+ function defaultBip39CountValidator(phrase: string): boolean {
121
+ const words = phrase.split(' ');
122
+ if (words.length !== 12 && words.length !== 24) return false;
123
+ return words.every((w) => /^[a-z]+$/.test(w));
124
+ }
125
+
126
+ /** 6-digit uniform PIN. Uses `node:crypto.randomInt` (cryptographically random). */
127
+ function defaultPin(): string {
128
+ const n = randomInt(0, 1_000_000);
129
+ return n.toString(10).padStart(6, '0');
130
+ }
131
+
132
+ /** Random hex client id. Opaque to the relay. */
133
+ function defaultClientId(): string {
134
+ return 'gw-' + randomBytes(8).toString('hex');
135
+ }
136
+
137
+ /**
138
+ * Assemble the user-facing pair URL. Converts `wss://` → `https://` and
139
+ * `ws://` → `http://` for the URL the user opens in a browser. The gateway
140
+ * pubkey lives in the URL fragment so it never hits relay logs.
141
+ */
142
+ function buildUserUrl(relayBase: string, token: string, pkB64: string): string {
143
+ let httpBase = relayBase;
144
+ if (httpBase.startsWith('wss://')) {
145
+ httpBase = 'https://' + httpBase.slice('wss://'.length);
146
+ } else if (httpBase.startsWith('ws://')) {
147
+ httpBase = 'http://' + httpBase.slice('ws://'.length);
148
+ }
149
+ return `${httpBase}/pair/p/${token}#pk=${pkB64}`;
150
+ }
151
+
152
+ /**
153
+ * Normalise the relay base URL for the WebSocket connect. We always hit
154
+ * `wss://` for the open-frame WS even if the caller passed an `https://`
155
+ * browser-facing URL in the config (most self-hosters will pass one URL for
156
+ * both). Strips trailing slashes.
157
+ */
158
+ function wsConnectBase(relayBase: string): string {
159
+ let base = relayBase.replace(/\/+$/, '');
160
+ if (base.startsWith('https://')) {
161
+ base = 'wss://' + base.slice('https://'.length);
162
+ } else if (base.startsWith('http://')) {
163
+ base = 'ws://' + base.slice('http://'.length);
164
+ }
165
+ return base;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Options
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface OpenRemotePairOptions {
173
+ /** Relay base URL. Defaults to `DEFAULT_RELAY_URL`. */
174
+ relayBaseUrl?: string;
175
+ /** Override the auto-generated PIN (tests). */
176
+ pin?: string;
177
+ /** Override the auto-generated client id (tests). */
178
+ clientId?: string;
179
+ /** Pair mode advertised in the open frame. Defaults to 'either'. */
180
+ mode?: PairRelayMode;
181
+ /** Override the random keypair generator (tests). */
182
+ keypair?: GatewayKeypair;
183
+ /** Override the WebSocket constructor (tests inject a stub). */
184
+ webSocketImpl?: typeof WebSocket;
185
+ /** Override `Date.now` for deterministic expiry strings (tests). */
186
+ now?: () => number;
187
+ }
188
+
189
+ export interface AwaitPhraseUploadOptions {
190
+ /** Completion handler — writes credentials and returns state. */
191
+ completePairing: RelayCompletePairingHandler;
192
+ /** Optional phrase validator. Defaults to 12/24-word lowercase-ASCII. */
193
+ phraseValidator?: PhraseValidator;
194
+ /** Timeout for the forward frame arrival (ms). Default 5 min. */
195
+ timeoutMs?: number;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Open
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * Open a pair session on the relay. Returns a handle with the user-facing
204
+ * URL, 6-digit PIN, expiry, keypair, and a live WebSocket the caller holds
205
+ * until `awaitPhraseUpload` resolves.
206
+ *
207
+ * Throws if the relay responds with `{type: "error"}` or an unexpected frame.
208
+ */
209
+ export async function openRemotePairSession(
210
+ opts: OpenRemotePairOptions = {},
211
+ ): Promise<RemotePairSession> {
212
+ const relayBase = (opts.relayBaseUrl ?? DEFAULT_RELAY_URL).replace(/\/+$/, '');
213
+ const wsBase = wsConnectBase(relayBase);
214
+ const wsUrl = `${wsBase}/pair/session/open`;
215
+ const WebSocketImpl = opts.webSocketImpl ?? WebSocket;
216
+ const keypair = opts.keypair ?? generateGatewayKeypair();
217
+ const pin = opts.pin ?? defaultPin();
218
+ const clientId = opts.clientId ?? defaultClientId();
219
+ const mode: PairRelayMode = opts.mode ?? 'either';
220
+
221
+ const ws: WebSocket = new WebSocketImpl(wsUrl, {
222
+ handshakeTimeout: OPEN_TIMEOUT_MS,
223
+ });
224
+
225
+ // Wait for the WS to open (so `send` doesn't race the handshake).
226
+ try {
227
+ await waitOpen(ws, OPEN_TIMEOUT_MS);
228
+ } catch (err) {
229
+ safeClose(ws);
230
+ throw err;
231
+ }
232
+
233
+ // Send the open frame.
234
+ try {
235
+ ws.send(
236
+ JSON.stringify({
237
+ type: 'open',
238
+ gateway_pubkey: keypair.pkB64,
239
+ pin,
240
+ client_id: clientId,
241
+ mode,
242
+ }),
243
+ );
244
+ } catch (err) {
245
+ safeClose(ws);
246
+ throw err instanceof Error ? err : new Error(String(err));
247
+ }
248
+
249
+ // Wait for the opened frame.
250
+ let raw: Buffer | ArrayBuffer | string;
251
+ try {
252
+ raw = await waitNextMessage(ws, OPEN_TIMEOUT_MS);
253
+ } catch (err) {
254
+ safeClose(ws);
255
+ throw err;
256
+ }
257
+
258
+ let msg: { type?: string; [k: string]: unknown };
259
+ try {
260
+ const text = typeof raw === 'string' ? raw : Buffer.from(raw as ArrayBuffer).toString('utf-8');
261
+ msg = JSON.parse(text);
262
+ } catch {
263
+ safeClose(ws);
264
+ throw new Error('pair-remote-client: opened frame not valid JSON');
265
+ }
266
+
267
+ if (msg.type === 'error') {
268
+ const errStr = typeof msg.error === 'string' ? msg.error : 'relay_error';
269
+ safeClose(ws);
270
+ throw new Error(`pair-remote-client: session/open failed: ${errStr}`);
271
+ }
272
+
273
+ if (msg.type !== 'opened') {
274
+ safeClose(ws);
275
+ throw new Error(`pair-remote-client: unexpected response type '${String(msg.type)}'`);
276
+ }
277
+
278
+ const token = typeof msg.token === 'string' ? msg.token : '';
279
+ const expiresAt = typeof msg.expires_at === 'string' ? msg.expires_at : '';
280
+ if (!token || !expiresAt) {
281
+ safeClose(ws);
282
+ throw new Error('pair-remote-client: opened frame missing token or expires_at');
283
+ }
284
+
285
+ const url = buildUserUrl(relayBase, token, keypair.pkB64);
286
+
287
+ return {
288
+ url,
289
+ pin,
290
+ token,
291
+ expiresAt,
292
+ keypair,
293
+ mode,
294
+ _ws: ws,
295
+ };
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Await + decrypt + ack
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Block on the WebSocket until the relay pushes the encrypted phrase, then
304
+ * decrypt and invoke `completePairing`. Sends `{type: "ack"}` on success or
305
+ * `{type: "nack", error: "..."}` on failure, then closes the WebSocket.
306
+ *
307
+ * Returns the `RelayCompletionResult` produced by the caller's handler.
308
+ *
309
+ * Caller semantics: most plugin callers schedule this as a background task so
310
+ * the `totalreclaw_pair` tool handler can return the URL + PIN to the agent
311
+ * immediately, and the phrase-upload wait happens asynchronously while the
312
+ * agent chats with the user.
313
+ */
314
+ export async function awaitPhraseUpload(
315
+ session: RemotePairSession,
316
+ opts: AwaitPhraseUploadOptions,
317
+ ): Promise<RelayCompletionResult> {
318
+ const validate = opts.phraseValidator ?? defaultBip39CountValidator;
319
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS;
320
+ const ws = session._ws;
321
+
322
+ let raw: Buffer | ArrayBuffer | string;
323
+ try {
324
+ raw = await waitNextMessage(ws, timeoutMs);
325
+ } catch (err) {
326
+ safeClose(ws);
327
+ throw err;
328
+ }
329
+
330
+ let msg: { type?: string; [k: string]: unknown };
331
+ try {
332
+ const text = typeof raw === 'string' ? raw : Buffer.from(raw as ArrayBuffer).toString('utf-8');
333
+ msg = JSON.parse(text);
334
+ } catch {
335
+ safeSend(ws, { type: 'nack', error: 'bad_json' });
336
+ safeClose(ws);
337
+ throw new Error('pair-remote-client: forward frame not valid JSON');
338
+ }
339
+
340
+ if (msg.type !== 'forward') {
341
+ safeSend(ws, { type: 'nack', error: 'expected_forward' });
342
+ safeClose(ws);
343
+ throw new Error(`pair-remote-client: unexpected frame '${String(msg.type)}'`);
344
+ }
345
+
346
+ const clientPubkey = typeof msg.client_pubkey === 'string' ? msg.client_pubkey : '';
347
+ const nonce = typeof msg.nonce === 'string' ? msg.nonce : '';
348
+ const ciphertext = typeof msg.ciphertext === 'string' ? msg.ciphertext : '';
349
+ if (!clientPubkey || !nonce || !ciphertext) {
350
+ safeSend(ws, { type: 'nack', error: 'bad_forward_body' });
351
+ safeClose(ws);
352
+ throw new Error('pair-remote-client: forward frame missing required fields');
353
+ }
354
+
355
+ // Decrypt locally (ciphertext + shared-secret derivation never leave this host).
356
+ let plaintext: Buffer;
357
+ try {
358
+ plaintext = decryptPairingPayload({
359
+ skGatewayB64: session.keypair.skB64,
360
+ pkDeviceB64: clientPubkey,
361
+ sid: session.token,
362
+ nonceB64: nonce,
363
+ ciphertextB64: ciphertext,
364
+ });
365
+ } catch (err) {
366
+ safeSend(ws, { type: 'nack', error: 'decrypt_failed' });
367
+ safeClose(ws);
368
+ throw err instanceof Error ? err : new Error(String(err));
369
+ }
370
+
371
+ // Decode + normalize. Match pair-http's BIP-39 norm: NFKC → lowercase → trim → single-space.
372
+ let mnemonic: string;
373
+ try {
374
+ mnemonic = plaintext
375
+ .toString('utf-8')
376
+ .normalize('NFKC')
377
+ .toLowerCase()
378
+ .trim()
379
+ .split(/\s+/)
380
+ .join(' ');
381
+ } catch (err) {
382
+ safeSend(ws, { type: 'nack', error: 'bad_utf8' });
383
+ safeClose(ws);
384
+ throw err instanceof Error ? err : new Error(String(err));
385
+ } finally {
386
+ // Best-effort: scrub the raw plaintext buffer.
387
+ plaintext.fill(0);
388
+ }
389
+
390
+ if (!validate(mnemonic)) {
391
+ safeSend(ws, { type: 'nack', error: 'invalid_mnemonic' });
392
+ safeClose(ws);
393
+ throw new Error('pair-remote-client: phrase failed BIP-39 validation');
394
+ }
395
+
396
+ // Hand off to the caller-supplied completion handler. Wrapped in try/finally
397
+ // so we always drop our own reference to the mnemonic.
398
+ let result: RelayCompletionResult;
399
+ try {
400
+ result = await opts.completePairing({ mnemonic, session });
401
+ } catch (err) {
402
+ safeSend(ws, { type: 'nack', error: 'completion_failed' });
403
+ safeClose(ws);
404
+ throw err instanceof Error ? err : new Error(String(err));
405
+ } finally {
406
+ // Drop our reference. JS strings are immutable so we can't zero them;
407
+ // rebinding at least drops the reference from this closure.
408
+ mnemonic = '';
409
+ }
410
+
411
+ if (result.state !== 'active') {
412
+ safeSend(ws, { type: 'nack', error: result.error ?? 'completion_failed' });
413
+ safeClose(ws);
414
+ return result;
415
+ }
416
+
417
+ safeSend(ws, { type: 'ack' });
418
+ safeClose(ws);
419
+ return result;
420
+ }
421
+
422
+ /**
423
+ * One-shot convenience: open session + await phrase upload + run completion.
424
+ * Tool handlers normally split this into two calls so the agent can tell the
425
+ * user the URL + PIN before blocking. This helper exists for tests and for
426
+ * simpler callers.
427
+ */
428
+ export async function pairViaRelay(opts: {
429
+ completePairing: RelayCompletePairingHandler;
430
+ relayBaseUrl?: string;
431
+ pin?: string;
432
+ mode?: PairRelayMode;
433
+ phraseValidator?: PhraseValidator;
434
+ timeoutMs?: number;
435
+ }): Promise<RelayCompletionResult> {
436
+ const session = await openRemotePairSession({
437
+ relayBaseUrl: opts.relayBaseUrl,
438
+ pin: opts.pin,
439
+ mode: opts.mode,
440
+ });
441
+ return awaitPhraseUpload(session, {
442
+ completePairing: opts.completePairing,
443
+ phraseValidator: opts.phraseValidator,
444
+ timeoutMs: opts.timeoutMs,
445
+ });
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // WS helpers
450
+ // ---------------------------------------------------------------------------
451
+
452
+ function waitOpen(ws: WebSocket, timeoutMs: number): Promise<void> {
453
+ return new Promise((resolve, reject) => {
454
+ if (ws.readyState === WebSocket.OPEN) {
455
+ resolve();
456
+ return;
457
+ }
458
+ const onOpen = (): void => {
459
+ cleanup();
460
+ resolve();
461
+ };
462
+ const onError = (err: Error): void => {
463
+ cleanup();
464
+ reject(err instanceof Error ? err : new Error(String(err)));
465
+ };
466
+ const onClose = (code: number): void => {
467
+ cleanup();
468
+ reject(new Error(`pair-remote-client: ws closed before open (${code})`));
469
+ };
470
+ const timer = setTimeout(() => {
471
+ cleanup();
472
+ reject(new Error('pair-remote-client: ws open timeout'));
473
+ }, timeoutMs);
474
+ const cleanup = (): void => {
475
+ clearTimeout(timer);
476
+ ws.off('open', onOpen);
477
+ ws.off('error', onError);
478
+ ws.off('close', onClose);
479
+ };
480
+ ws.on('open', onOpen);
481
+ ws.on('error', onError);
482
+ ws.on('close', onClose);
483
+ });
484
+ }
485
+
486
+ function waitNextMessage(
487
+ ws: WebSocket,
488
+ timeoutMs: number,
489
+ ): Promise<Buffer | ArrayBuffer | string> {
490
+ return new Promise((resolve, reject) => {
491
+ const onMessage = (data: Buffer | ArrayBuffer | string): void => {
492
+ cleanup();
493
+ resolve(data);
494
+ };
495
+ const onError = (err: Error): void => {
496
+ cleanup();
497
+ reject(err instanceof Error ? err : new Error(String(err)));
498
+ };
499
+ const onClose = (code: number): void => {
500
+ cleanup();
501
+ reject(new Error(`pair-remote-client: ws closed before message (${code})`));
502
+ };
503
+ const timer = setTimeout(() => {
504
+ cleanup();
505
+ reject(new Error('pair-remote-client: ws message timeout'));
506
+ }, timeoutMs);
507
+ const cleanup = (): void => {
508
+ clearTimeout(timer);
509
+ ws.off('message', onMessage);
510
+ ws.off('error', onError);
511
+ ws.off('close', onClose);
512
+ };
513
+ ws.on('message', onMessage);
514
+ ws.on('error', onError);
515
+ ws.on('close', onClose);
516
+ });
517
+ }
518
+
519
+ function safeSend(ws: WebSocket, msg: unknown): void {
520
+ try {
521
+ if (ws.readyState === WebSocket.OPEN) {
522
+ ws.send(JSON.stringify(msg));
523
+ }
524
+ } catch {
525
+ /* swallow */
526
+ }
527
+ }
528
+
529
+ function safeClose(ws: WebSocket): void {
530
+ try {
531
+ if (
532
+ ws.readyState === WebSocket.OPEN ||
533
+ ws.readyState === WebSocket.CONNECTING
534
+ ) {
535
+ ws.close();
536
+ }
537
+ } catch {
538
+ /* swallow */
539
+ }
540
+ }