@letta-ai/letta-agent-sdk 0.3.2 → 0.3.3

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 (49) hide show
  1. package/AGENTS.md +47 -0
  2. package/README.md +17 -0
  3. package/dist/client-entry.js +800 -732
  4. package/dist/client-entry.js.map +8 -5
  5. package/dist/cloud-sandbox.d.ts +33 -0
  6. package/dist/cloud-sandbox.d.ts.map +1 -0
  7. package/dist/cloud-session.d.ts.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +799 -732
  11. package/dist/index.js.map +9 -6
  12. package/dist/remote-client-session-core.d.ts +6 -93
  13. package/dist/remote-client-session-core.d.ts.map +1 -1
  14. package/dist/remote-session-protocol.d.ts +130 -0
  15. package/dist/remote-session-protocol.d.ts.map +1 -0
  16. package/dist/remote-turn-coordinator.d.ts +49 -0
  17. package/dist/remote-turn-coordinator.d.ts.map +1 -0
  18. package/dist/types.d.ts +2 -20
  19. package/dist/types.d.ts.map +1 -1
  20. package/package.json +5 -2
  21. package/src/app-server-management.ts +641 -0
  22. package/src/app-server-session.ts +948 -0
  23. package/src/cli-resolver.ts +46 -0
  24. package/src/client-base.ts +482 -0
  25. package/src/client-entry.ts +31 -0
  26. package/src/client.ts +138 -0
  27. package/src/cloud-management.ts +360 -0
  28. package/src/cloud-sandbox.ts +117 -0
  29. package/src/cloud-session.ts +1313 -0
  30. package/src/index.ts +440 -0
  31. package/src/interactiveToolPolicy.ts +62 -0
  32. package/src/local-app-server-session.ts +39 -0
  33. package/src/local-app-server.ts +137 -0
  34. package/src/management-types.ts +133 -0
  35. package/src/management.ts +206 -0
  36. package/src/protocol.ts +249 -0
  37. package/src/remote-client-session-core.ts +786 -0
  38. package/src/remote-session-protocol.ts +660 -0
  39. package/src/remote-turn-coordinator.ts +505 -0
  40. package/src/remote.ts +177 -0
  41. package/src/repositories.ts +340 -0
  42. package/src/request-ids.ts +33 -0
  43. package/src/session.ts +1638 -0
  44. package/src/stream-events.ts +88 -0
  45. package/src/tool-helpers.ts +147 -0
  46. package/src/transport.ts +484 -0
  47. package/src/types.ts +1328 -0
  48. package/src/validation.ts +223 -0
  49. package/src/websocket.ts +22 -0
@@ -0,0 +1,641 @@
1
+ import {
2
+ createAppServerClient,
3
+ type AppServerClient,
4
+ type AppServerRequestCommandWithId,
5
+ type AppServerSocketConstructor,
6
+ type AppServerSocketLike,
7
+ } from "@letta-ai/letta-code/app-server-client";
8
+ import type {
9
+ ManagementQuery,
10
+ ManagementTransport,
11
+ } from "./management.js";
12
+ import { applyUniqueRequestIds } from "./request-ids.js";
13
+ import type {
14
+ ConversationMessagesResult,
15
+ LettaAgent,
16
+ LettaConversation,
17
+ } from "./management-types.js";
18
+ import type {
19
+ LettaCodeModelEntry,
20
+ LettaCodeRemoteClientOptions,
21
+ ListModelsResult,
22
+ } from "./types.js";
23
+
24
+ type OwnedConnection = { url: string; close(): void };
25
+
26
+ type ManagementResponse = {
27
+ type: string;
28
+ success: boolean;
29
+ error?: string;
30
+ };
31
+
32
+ type AgentListResponse = ManagementResponse & {
33
+ agents: LettaAgent[];
34
+ };
35
+
36
+ type AgentResponse = ManagementResponse & {
37
+ agent: LettaAgent | null;
38
+ };
39
+
40
+ type ConversationListResponse = ManagementResponse & {
41
+ conversations: LettaConversation[];
42
+ };
43
+
44
+ type ConversationResponse = ManagementResponse & {
45
+ conversation: LettaConversation | null;
46
+ };
47
+
48
+ type ConversationMessagesResponse = ManagementResponse & {
49
+ messages: Record<string, unknown>[];
50
+ };
51
+
52
+ type ListModelsResponse = ManagementResponse & {
53
+ entries?: unknown;
54
+ available_handles?: unknown;
55
+ byok_provider_aliases?: unknown;
56
+ };
57
+
58
+ export type AppServerManagementOptions =
59
+ Partial<LettaCodeRemoteClientOptions> & {
60
+ url?: string;
61
+ connect?: () => Promise<OwnedConnection>;
62
+ /**
63
+ * How long (in milliseconds) an idle control connection lingers before it
64
+ * is released. Defaults to {@link DEFAULT_IDLE_LINGER_MS}.
65
+ */
66
+ idleLingerMs?: number;
67
+ };
68
+
69
+ /**
70
+ * How long an idle control connection lingers before it is released.
71
+ *
72
+ * Long enough to batch a burst of management calls (for example a screen
73
+ * fetching a list plus a retrieve together) over one connection, short enough
74
+ * that the app-server's single control-client slot frees up quickly for
75
+ * sessions.
76
+ */
77
+ const DEFAULT_IDLE_LINGER_MS = 250;
78
+
79
+ type ActiveConnection = {
80
+ client: AppServerClient;
81
+ ownedConnection: OwnedConnection | null;
82
+ detachDisconnect: () => void;
83
+ unregister: () => void;
84
+ };
85
+
86
+ type RegisteredManagementTransport = {
87
+ releaseIdleConnection(): Promise<void>;
88
+ };
89
+
90
+ type ManagementTransportRegistration = {
91
+ transport: RegisteredManagementTransport;
92
+ };
93
+
94
+ const registeredTransportsByUrl =
95
+ new Map<string, Set<ManagementTransportRegistration>>();
96
+
97
+ function appServerUrlKey(value: string): string {
98
+ try {
99
+ const url = new URL(value);
100
+ if (url.protocol === "http:") url.protocol = "ws:";
101
+ if (url.protocol === "https:") url.protocol = "wss:";
102
+ url.hash = "";
103
+ url.searchParams.delete("channel");
104
+ return url.toString();
105
+ } catch {
106
+ return value;
107
+ }
108
+ }
109
+
110
+ function registerManagementTransport(
111
+ url: string,
112
+ transport: RegisteredManagementTransport,
113
+ ): () => void {
114
+ const key = appServerUrlKey(url);
115
+ const registration = { transport };
116
+ const transports =
117
+ registeredTransportsByUrl.get(key) ??
118
+ new Set<ManagementTransportRegistration>();
119
+ transports.add(registration);
120
+ registeredTransportsByUrl.set(key, transports);
121
+ return () => {
122
+ transports.delete(registration);
123
+ if (transports.size === 0) {
124
+ registeredTransportsByUrl.delete(key);
125
+ }
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Wait for every management transport targeting this app-server to release
131
+ * its control connection. The registry is process-wide so a session created
132
+ * by a different `LettaAgentClient` instance can still take the server's
133
+ * single control slot safely.
134
+ */
135
+ export async function releaseAppServerManagementConnections(
136
+ url: string,
137
+ ): Promise<void> {
138
+ const key = appServerUrlKey(url);
139
+ while (true) {
140
+ const transports = [...(registeredTransportsByUrl.get(key) ?? [])];
141
+ if (transports.length === 0) return;
142
+ await Promise.all(
143
+ transports.map(({ transport }) => transport.releaseIdleConnection()),
144
+ );
145
+ }
146
+ }
147
+
148
+ function ensureResponse<T>(
149
+ response: { success: boolean; error?: string },
150
+ value: T | null | undefined,
151
+ fallback: string,
152
+ ): T {
153
+ if (!response.success || value == null) {
154
+ throw new Error(response.error ?? fallback);
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function modelEntries(value: unknown): LettaCodeModelEntry[] {
160
+ if (!Array.isArray(value)) return [];
161
+ return value.flatMap((entry) => {
162
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
163
+ const record = entry as Record<string, unknown>;
164
+ if (
165
+ typeof record.id !== "string" ||
166
+ typeof record.handle !== "string" ||
167
+ typeof record.label !== "string" ||
168
+ typeof record.description !== "string"
169
+ ) {
170
+ return [];
171
+ }
172
+ return [{
173
+ ...record,
174
+ id: record.id,
175
+ handle: record.handle,
176
+ label: record.label,
177
+ description: record.description,
178
+ }];
179
+ });
180
+ }
181
+
182
+ function stringRecord(value: unknown): Record<string, string> | undefined {
183
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
184
+ return undefined;
185
+ }
186
+ const entries = Object.entries(value as Record<string, unknown>).filter(
187
+ (entry): entry is [string, string] => typeof entry[1] === "string",
188
+ );
189
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
190
+ }
191
+
192
+ /**
193
+ * Management transport that speaks the app-server control protocol.
194
+ *
195
+ * Connection lifecycle: the Letta Code app-server currently accepts a single
196
+ * control client at a time — while one control socket is attached, additional
197
+ * control sockets are rejected with close code 1008
198
+ * `"control channel already connected"` (see letta-ai/letta-code
199
+ * `src/websocket/app-server.ts`). Sessions (`AppServerSession`) need that same
200
+ * control slot, so a management transport that holds an idle connection
201
+ * starves any later `resumeSession()`/`createSession()` from the same process
202
+ * (live-reproduced in the letta-mobile reference app, SDK-FEEDBACK.md #00).
203
+ *
204
+ * To stay out of the way, this transport pools a single lazily-connected
205
+ * client while requests are in flight (bursts share one connection and one
206
+ * request-id counter, so responses correlate correctly), then releases the
207
+ * connection shortly after it goes idle ({@link DEFAULT_IDLE_LINGER_MS}) and
208
+ * reconnects lazily on the next request. Before a session connects, all
209
+ * management transports registered for the same app-server URL relinquish
210
+ * their connections; the handoff waits for in-flight work and for the control
211
+ * socket's close event. The inverse contention — a management request issued
212
+ * while a session holds the control slot — cannot be solved client-side and
213
+ * still fails until the app-server allows multiple control clients.
214
+ */
215
+ export class AppServerManagementTransport
216
+ implements ManagementTransport
217
+ {
218
+ private connectionPromise: Promise<ActiveConnection> | null = null;
219
+ private releasePromise: Promise<void> | null = null;
220
+ private closingConnections = new Set<Promise<void>>();
221
+ private inFlightRequests = 0;
222
+ private idleWaiters = new Set<() => void>();
223
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
224
+ private readonly idleLingerMs: number;
225
+
226
+ constructor(private readonly options: AppServerManagementOptions) {
227
+ this.idleLingerMs = options.idleLingerMs ?? DEFAULT_IDLE_LINGER_MS;
228
+ }
229
+
230
+ async listAgents(query: ManagementQuery): Promise<LettaAgent[]> {
231
+ const response = await this.request<AgentListResponse>(
232
+ "agent_list",
233
+ { query },
234
+ "agent_list_response",
235
+ );
236
+ if (!response.success) {
237
+ throw new Error(response.error ?? "Failed to list agents.");
238
+ }
239
+ return response.agents;
240
+ }
241
+
242
+ async retrieveAgent(agentId: string): Promise<LettaAgent> {
243
+ const response = await this.request<AgentResponse>(
244
+ "agent_retrieve",
245
+ { agent_id: agentId },
246
+ "agent_retrieve_response",
247
+ );
248
+ return ensureResponse(
249
+ response,
250
+ response.agent,
251
+ `Failed to retrieve agent ${agentId}.`,
252
+ );
253
+ }
254
+
255
+ async updateAgent(
256
+ agentId: string,
257
+ body: Record<string, unknown>,
258
+ ): Promise<LettaAgent> {
259
+ const response = await this.request<AgentResponse>(
260
+ "agent_update",
261
+ { agent_id: agentId, body },
262
+ "agent_update_response",
263
+ );
264
+ return ensureResponse(
265
+ response,
266
+ response.agent,
267
+ `Failed to update agent ${agentId}.`,
268
+ );
269
+ }
270
+
271
+ async deleteAgent(agentId: string): Promise<void> {
272
+ const response = await this.request<ManagementResponse>(
273
+ "agent_delete",
274
+ { agent_id: agentId },
275
+ "agent_delete_response",
276
+ );
277
+ if (!response.success) {
278
+ throw new Error(response.error ?? `Failed to delete agent ${agentId}.`);
279
+ }
280
+ }
281
+
282
+ async listModels(): Promise<ListModelsResult> {
283
+ // A bare list_models command (no runtime scope) is answered on the
284
+ // control channel, so no session or conversation is required.
285
+ const response = await this.request<ListModelsResponse>(
286
+ "list_models",
287
+ {},
288
+ "list_models_response",
289
+ );
290
+ if (!response.success) {
291
+ throw new Error(response.error ?? "Failed to list models.");
292
+ }
293
+ const result: ListModelsResult = {
294
+ entries: modelEntries(response.entries),
295
+ };
296
+ if (response.available_handles === null) {
297
+ result.availableHandles = null;
298
+ } else if (Array.isArray(response.available_handles)) {
299
+ result.availableHandles = response.available_handles.filter(
300
+ (handle): handle is string => typeof handle === "string",
301
+ );
302
+ }
303
+ const aliases = stringRecord(response.byok_provider_aliases);
304
+ if (aliases) result.byokProviderAliases = aliases;
305
+ return result;
306
+ }
307
+
308
+ async listConversations(
309
+ query: ManagementQuery,
310
+ ): Promise<LettaConversation[]> {
311
+ const response = await this.request<ConversationListResponse>(
312
+ "conversation_list",
313
+ { query },
314
+ "conversation_list_response",
315
+ );
316
+ if (!response.success) {
317
+ throw new Error(response.error ?? "Failed to list conversations.");
318
+ }
319
+ return response.conversations;
320
+ }
321
+
322
+ async retrieveConversation(
323
+ conversationId: string,
324
+ ): Promise<LettaConversation> {
325
+ const response = await this.request<ConversationResponse>(
326
+ "conversation_retrieve",
327
+ { conversation_id: conversationId },
328
+ "conversation_retrieve_response",
329
+ );
330
+ return ensureResponse(
331
+ response,
332
+ response.conversation,
333
+ `Failed to retrieve conversation ${conversationId}.`,
334
+ );
335
+ }
336
+
337
+ async createConversation(
338
+ body: Record<string, unknown>,
339
+ ): Promise<LettaConversation> {
340
+ const response = await this.request<ConversationResponse>(
341
+ "conversation_create",
342
+ { body },
343
+ "conversation_create_response",
344
+ );
345
+ return ensureResponse(
346
+ response,
347
+ response.conversation,
348
+ "Failed to create conversation.",
349
+ );
350
+ }
351
+
352
+ async updateConversation(
353
+ conversationId: string,
354
+ body: Record<string, unknown>,
355
+ ): Promise<LettaConversation> {
356
+ const response = await this.request<ConversationResponse>(
357
+ "conversation_update",
358
+ { conversation_id: conversationId, body },
359
+ "conversation_update_response",
360
+ );
361
+ return ensureResponse(
362
+ response,
363
+ response.conversation,
364
+ `Failed to update conversation ${conversationId}.`,
365
+ );
366
+ }
367
+
368
+ async listConversationMessages(
369
+ conversationId: string,
370
+ query: ManagementQuery,
371
+ ): Promise<ConversationMessagesResult> {
372
+ const response =
373
+ await this.request<ConversationMessagesResponse>(
374
+ "conversation_messages_list",
375
+ { conversation_id: conversationId, query },
376
+ "conversation_messages_list_response",
377
+ );
378
+ if (!response.success) {
379
+ throw new Error(
380
+ response.error ??
381
+ `Failed to list messages for conversation ${conversationId}.`,
382
+ );
383
+ }
384
+ return { messages: response.messages };
385
+ }
386
+
387
+ /**
388
+ * Release the pooled control connection instead of waiting out the idle
389
+ * linger. If requests are in flight, wait for them to settle first.
390
+ *
391
+ * The app-server accepts a single control client (see the class docs), so
392
+ * This is called before opening a session to hand the control slot over
393
+ * without a linger-sized or socket-close race window.
394
+ */
395
+ releaseIdleConnection(): Promise<void> {
396
+ this.clearIdleTimer();
397
+ if (this.releasePromise) return this.releasePromise;
398
+
399
+ const release = this.releaseConnectionWhenIdle();
400
+ this.releasePromise = release;
401
+ void release.then(
402
+ () => {
403
+ if (this.releasePromise === release) {
404
+ this.releasePromise = null;
405
+ }
406
+ },
407
+ () => {
408
+ if (this.releasePromise === release) {
409
+ this.releasePromise = null;
410
+ }
411
+ },
412
+ );
413
+ return release;
414
+ }
415
+
416
+ private async request<TResponse extends { type: string }>(
417
+ type: string,
418
+ body: Record<string, unknown>,
419
+ responseType: string,
420
+ ): Promise<TResponse> {
421
+ if (this.releasePromise) {
422
+ await this.releasePromise;
423
+ }
424
+ if (this.closingConnections.size > 0) {
425
+ await Promise.all([...this.closingConnections]);
426
+ }
427
+ this.clearIdleTimer();
428
+ this.inFlightRequests += 1;
429
+ try {
430
+ // Concurrent requests share the pooled connection (queueing behind the
431
+ // same connect promise) and its request-id counter, so correlation is
432
+ // stable across a burst and across reconnects: a fresh connection gets a
433
+ // fresh client whose pending map starts empty.
434
+ const { client } = await this.acquireConnection();
435
+ const command = {
436
+ type,
437
+ request_id: client.nextRequestId(type),
438
+ ...body,
439
+ } as AppServerRequestCommandWithId;
440
+ const response = await client.request(command, {
441
+ predicate: (message): message is typeof message =>
442
+ message.type === responseType,
443
+ });
444
+ return response as unknown as TResponse;
445
+ } finally {
446
+ this.inFlightRequests -= 1;
447
+ if (this.inFlightRequests === 0) {
448
+ const waiters = [...this.idleWaiters];
449
+ this.idleWaiters.clear();
450
+ for (const resolve of waiters) resolve();
451
+ if (!this.releasePromise) {
452
+ this.scheduleIdleRelease();
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ private async releaseConnectionWhenIdle(): Promise<void> {
459
+ if (this.inFlightRequests > 0) {
460
+ await new Promise<void>((resolve) => {
461
+ this.idleWaiters.add(resolve);
462
+ });
463
+ }
464
+
465
+ this.clearIdleTimer();
466
+ const promise = this.connectionPromise;
467
+ try {
468
+ if (promise) {
469
+ this.connectionPromise = null;
470
+ await this.trackClosingConnection(await promise);
471
+ }
472
+ if (this.closingConnections.size > 0) {
473
+ await Promise.all([...this.closingConnections]);
474
+ }
475
+ } catch {
476
+ // A failed connect already closes its partially-created resources.
477
+ }
478
+ }
479
+
480
+ private acquireConnection(): Promise<ActiveConnection> {
481
+ if (this.connectionPromise) return this.connectionPromise;
482
+ const promise: Promise<ActiveConnection> = this.openConnection().then(
483
+ (connection) => {
484
+ // Unexpected disconnects (explicit closes do not notify) drop the
485
+ // pooled connection so the next request reconnects lazily.
486
+ connection.detachDisconnect = connection.client.onDisconnect(() => {
487
+ this.discardConnection(promise, connection);
488
+ });
489
+ return connection;
490
+ },
491
+ (error) => {
492
+ if (this.connectionPromise === promise) {
493
+ this.connectionPromise = null;
494
+ }
495
+ throw error;
496
+ },
497
+ );
498
+ this.connectionPromise = promise;
499
+ return promise;
500
+ }
501
+
502
+ private async openConnection(): Promise<ActiveConnection> {
503
+ const ownedConnection = this.options.url
504
+ ? null
505
+ : ((await this.options.connect?.()) ?? null);
506
+ const url = this.options.url ?? ownedConnection?.url;
507
+ if (!url) {
508
+ throw new Error("App-server management requires a url or connect hook.");
509
+ }
510
+ const unregister = registerManagementTransport(url, this);
511
+
512
+ let client: AppServerClient | null = null;
513
+ try {
514
+ client = applyUniqueRequestIds(createAppServerClient({
515
+ url,
516
+ ...(this.options.authToken !== undefined
517
+ ? { authToken: this.options.authToken }
518
+ : {}),
519
+ ...(this.options.WebSocket
520
+ ? {
521
+ WebSocket:
522
+ this.options.WebSocket as AppServerSocketConstructor,
523
+ }
524
+ : {}),
525
+ ...(this.options.requestTimeoutMs !== undefined
526
+ ? { requestTimeoutMs: this.options.requestTimeoutMs }
527
+ : {}),
528
+ }));
529
+ await client.connect();
530
+ } catch (error) {
531
+ try {
532
+ if (client) {
533
+ const controlClosed = waitForSocketClose(client.control);
534
+ client.close();
535
+ ownedConnection?.close();
536
+ await controlClosed;
537
+ } else {
538
+ ownedConnection?.close();
539
+ }
540
+ } catch {
541
+ // Preserve the original connect error after best-effort cleanup.
542
+ } finally {
543
+ unregister();
544
+ }
545
+ throw error;
546
+ }
547
+ return {
548
+ client,
549
+ ownedConnection,
550
+ detachDisconnect: () => {},
551
+ unregister,
552
+ };
553
+ }
554
+
555
+ private discardConnection(
556
+ promise: Promise<ActiveConnection>,
557
+ connection: ActiveConnection,
558
+ ): void {
559
+ if (this.connectionPromise === promise) {
560
+ this.connectionPromise = null;
561
+ this.clearIdleTimer();
562
+ }
563
+ void this.trackClosingConnection(connection);
564
+ }
565
+
566
+ private trackClosingConnection(
567
+ connection: ActiveConnection,
568
+ ): Promise<void> {
569
+ const closing = closeConnection(connection);
570
+ this.closingConnections.add(closing);
571
+ void closing.then(
572
+ () => this.closingConnections.delete(closing),
573
+ () => this.closingConnections.delete(closing),
574
+ );
575
+ return closing;
576
+ }
577
+
578
+ private scheduleIdleRelease(): void {
579
+ if (!this.connectionPromise) return;
580
+ this.clearIdleTimer();
581
+ const timer = setTimeout(() => {
582
+ this.idleTimer = null;
583
+ this.releaseIdleConnection();
584
+ }, this.idleLingerMs);
585
+ this.idleTimer = timer;
586
+ // Do not keep a Node event loop alive just for the linger.
587
+ (timer as unknown as { unref?: () => void }).unref?.();
588
+ }
589
+
590
+ private clearIdleTimer(): void {
591
+ if (this.idleTimer === null) return;
592
+ clearTimeout(this.idleTimer);
593
+ this.idleTimer = null;
594
+ }
595
+ }
596
+
597
+ async function closeConnection(connection: ActiveConnection): Promise<void> {
598
+ connection.detachDisconnect();
599
+ const controlClosed = waitForSocketClose(connection.client.control);
600
+ try {
601
+ connection.client.close();
602
+ connection.ownedConnection?.close();
603
+ await controlClosed;
604
+ } finally {
605
+ connection.unregister();
606
+ }
607
+ }
608
+
609
+ function waitForSocketClose(socket: AppServerSocketLike): Promise<void> {
610
+ if (socket.readyState === 3) return Promise.resolve();
611
+
612
+ return new Promise((resolve) => {
613
+ let settled = false;
614
+ let detach = () => {};
615
+ const finish = () => {
616
+ if (settled) return;
617
+ settled = true;
618
+ clearTimeout(timeout);
619
+ detach();
620
+ resolve();
621
+ };
622
+
623
+ if (socket.addEventListener) {
624
+ socket.addEventListener("close", finish);
625
+ detach = () => socket.removeEventListener?.("close", finish);
626
+ } else if (socket.once) {
627
+ socket.once("close", finish);
628
+ detach = () => socket.off?.("close", finish);
629
+ } else if (socket.on) {
630
+ socket.on("close", finish);
631
+ detach = () => socket.off?.("close", finish);
632
+ } else {
633
+ resolve();
634
+ return;
635
+ }
636
+
637
+ const timeout = setTimeout(finish, 1_000);
638
+ (timeout as unknown as { unref?: () => void }).unref?.();
639
+ if (socket.readyState === 3) finish();
640
+ });
641
+ }