@letta-ai/letta-agent-sdk 0.3.3 → 0.5.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.
Files changed (55) hide show
  1. package/AGENTS.md +0 -3
  2. package/README.md +12 -0
  3. package/dist/app-server-management.d.ts +3 -48
  4. package/dist/app-server-management.d.ts.map +1 -1
  5. package/dist/app-server-session.d.ts +0 -6
  6. package/dist/app-server-session.d.ts.map +1 -1
  7. package/dist/client-base.d.ts +7 -15
  8. package/dist/client-base.d.ts.map +1 -1
  9. package/dist/client-entry.js +369 -468
  10. package/dist/client-entry.js.map +13 -12
  11. package/dist/client.d.ts +3 -3
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/cloud-session.d.ts.map +1 -1
  14. package/dist/cloud-status-transport.d.ts +53 -0
  15. package/dist/cloud-status-transport.d.ts.map +1 -0
  16. package/dist/index.d.ts +7 -16
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +896 -2019
  19. package/dist/index.js.map +18 -18
  20. package/dist/local-app-server-session.d.ts +1 -1
  21. package/dist/local-app-server-session.d.ts.map +1 -1
  22. package/dist/local-app-server.d.ts +11 -0
  23. package/dist/local-app-server.d.ts.map +1 -1
  24. package/dist/management.d.ts +0 -7
  25. package/dist/management.d.ts.map +1 -1
  26. package/dist/remote-session-protocol.d.ts +2 -0
  27. package/dist/remote-session-protocol.d.ts.map +1 -1
  28. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  29. package/dist/types.d.ts +57 -66
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/validation.d.ts.map +1 -1
  32. package/package.json +2 -2
  33. package/src/app-server-management.ts +20 -256
  34. package/src/app-server-session.ts +0 -32
  35. package/src/client-base.ts +47 -73
  36. package/src/client.ts +31 -70
  37. package/src/cloud-session.ts +9 -267
  38. package/src/cloud-status-transport.ts +305 -0
  39. package/src/index.ts +11 -29
  40. package/src/local-app-server-session.ts +13 -3
  41. package/src/local-app-server.ts +97 -3
  42. package/src/management.ts +0 -7
  43. package/src/remote-session-protocol.ts +14 -0
  44. package/src/remote-turn-coordinator.ts +20 -2
  45. package/src/types.ts +64 -111
  46. package/src/validation.ts +21 -6
  47. package/dist/protocol.d.ts +0 -205
  48. package/dist/protocol.d.ts.map +0 -1
  49. package/dist/session.d.ts +0 -155
  50. package/dist/session.d.ts.map +0 -1
  51. package/dist/transport.d.ts +0 -53
  52. package/dist/transport.d.ts.map +0 -1
  53. package/src/protocol.ts +0 -249
  54. package/src/session.ts +0 -1638
  55. package/src/transport.ts +0 -484
@@ -1,9 +1,6 @@
1
1
  import {
2
2
  createAppServerClient,
3
3
  type AppServerClient,
4
- type AppServerSocketConstructor,
5
- type AppServerSocketLike,
6
- type AppServerSocketOptions,
7
4
  } from "@letta-ai/letta-code/app-server-client";
8
5
  import {
9
6
  AppServerRuntimeController,
@@ -13,6 +10,7 @@ import {
13
10
  externalToolGroups,
14
11
  registerAppServerControlRequestHandler,
15
12
  } from "./app-server-session.js";
13
+ import { createCloudStatusTransportConstructor } from "./cloud-status-transport.js";
16
14
  import {
17
15
  RemoteEnvironmentClient,
18
16
  type RemoteEnvironmentTarget,
@@ -33,7 +31,6 @@ import type {
33
31
  LettaCodeCloudClientOptions,
34
32
  LettaCodeEnvironment,
35
33
  LettaCodeSocketConstructor,
36
- LettaCodeSocketLike,
37
34
  RepositoryResource,
38
35
  } from "./types.js";
39
36
  import {
@@ -53,24 +50,6 @@ const SDK_AGENT_ORIGIN = "@letta-ai/letta-agent-sdk";
53
50
 
54
51
  type FetchLike = typeof fetch;
55
52
 
56
- type CloudStatusMessage = ProtocolMessage & {
57
- type: string;
58
- seq?: unknown;
59
- event_seq?: unknown;
60
- idempotency_key?: unknown;
61
- runId?: unknown;
62
- run_id?: unknown;
63
- stopReason?: unknown;
64
- stop_reason?: unknown;
65
- conversation_id?: unknown;
66
- conversationId?: unknown;
67
- agent_id?: unknown;
68
- agentId?: unknown;
69
- request?: unknown;
70
- loop_status?: unknown;
71
- delta?: unknown;
72
- };
73
-
74
53
  type CloudRuntimeStartResponse = ProtocolMessage & {
75
54
  type: "runtime_start_response";
76
55
  success: boolean;
@@ -328,231 +307,6 @@ function buildCloudStatusWebSocketUrl(params: {
328
307
  return base.toString();
329
308
  }
330
309
 
331
- function addSocketListener(
332
- socket: LettaCodeSocketLike,
333
- type: string,
334
- listener: (event: unknown) => void,
335
- ): () => void {
336
- if (socket.addEventListener) {
337
- socket.addEventListener(type, listener);
338
- return () => socket.removeEventListener?.(type, listener);
339
- }
340
- if (socket.on) {
341
- socket.on(type, listener);
342
- return () => socket.off?.(type, listener);
343
- }
344
- throw new Error("WebSocket implementation does not support event listeners.");
345
- }
346
-
347
- function messageEventData(event: unknown): string | null {
348
- if (typeof event === "string") return event;
349
- if (event && typeof event === "object") {
350
- const data = (event as { data?: unknown }).data;
351
- if (typeof data === "string") return data;
352
- if (data instanceof ArrayBuffer) {
353
- return new TextDecoder().decode(data);
354
- }
355
- if (data instanceof Uint8Array) {
356
- return new TextDecoder().decode(data);
357
- }
358
- }
359
- return null;
360
- }
361
-
362
- type CloudStatusSocketControl = AppServerSocketLike & {
363
- sendCloudCommand(command: Record<string, unknown>): void;
364
- };
365
-
366
- type CloudStatusSocketState = {
367
- runtime: RuntimeScope;
368
- seenIdempotencyKeys: Set<string>;
369
- seenIdempotencyOrder: string[];
370
- lastEventSeq: number | null;
371
- controlSocket: CloudStatusSocketControl | null;
372
- };
373
-
374
- function createCloudStatusWebSocketConstructor(params: {
375
- cloudOptions: LettaCodeCloudClientOptions;
376
- runtime: RuntimeScope;
377
- }): AppServerSocketConstructor {
378
- const WebSocketCtor = getWebSocketConstructor(params.cloudOptions.WebSocket);
379
- const authMode = params.cloudOptions.webSocketAuth ?? "header";
380
- const cloudHeaders = authMode === "header" ? cloudWebSocketHeaders(params.cloudOptions) : undefined;
381
- const pingIntervalMs = params.cloudOptions.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
382
- const state: CloudStatusSocketState = {
383
- runtime: params.runtime,
384
- seenIdempotencyKeys: new Set<string>(),
385
- seenIdempotencyOrder: [],
386
- lastEventSeq: null,
387
- controlSocket: null,
388
- };
389
-
390
- class CloudStatusSocketAdapter implements AppServerSocketLike {
391
- private readonly socket: LettaCodeSocketLike;
392
- private readonly channel: string | null;
393
- private readonly listenerRemovers = new Map<string, Map<(event: unknown) => void, () => void>>();
394
- private pingTimer: ReturnType<typeof setInterval> | null = null;
395
- private removeCloseListener: (() => void) | null = null;
396
-
397
- constructor(url: string, options?: AppServerSocketOptions) {
398
- this.channel = new URL(url).searchParams.get("channel");
399
- const mergedHeaders = authMode === "header"
400
- ? {
401
- ...(options?.headers ?? {}),
402
- ...(cloudHeaders ?? {}),
403
- }
404
- : undefined;
405
- const socketOptions = mergedHeaders && Object.keys(mergedHeaders).length > 0
406
- ? { headers: mergedHeaders }
407
- : undefined;
408
- this.socket = new WebSocketCtor(url, socketOptions);
409
- if (this.channel === "control") {
410
- state.controlSocket = this;
411
- }
412
- this.removeCloseListener = addSocketListener(this.socket, "close", () => {
413
- this.stopPing();
414
- if (state.controlSocket === this) state.controlSocket = null;
415
- });
416
- this.startPing(pingIntervalMs);
417
- }
418
-
419
- get readyState(): number {
420
- return this.socket.readyState;
421
- }
422
-
423
- send(data: string): void {
424
- this.socket.send(data);
425
- }
426
-
427
- close(): void {
428
- this.stopPing();
429
- this.removeCloseListener?.();
430
- this.removeCloseListener = null;
431
- if (state.controlSocket === this) state.controlSocket = null;
432
- this.socket.close();
433
- }
434
-
435
- addEventListener(type: string, listener: (event: unknown) => void): void {
436
- const wrapped = type === "message"
437
- ? (event: unknown) => {
438
- if (this.handleIncomingMessage(event)) {
439
- listener(event);
440
- }
441
- }
442
- : listener;
443
- const remove = addSocketListener(this.socket, type, wrapped);
444
- let typeRemovers = this.listenerRemovers.get(type);
445
- if (!typeRemovers) {
446
- typeRemovers = new Map();
447
- this.listenerRemovers.set(type, typeRemovers);
448
- }
449
- typeRemovers.set(listener, remove);
450
- }
451
-
452
- removeEventListener(type: string, listener: (event: unknown) => void): void {
453
- const typeRemovers = this.listenerRemovers.get(type);
454
- const remove = typeRemovers?.get(listener);
455
- if (!remove) return;
456
- remove();
457
- typeRemovers?.delete(listener);
458
- if (typeRemovers?.size === 0) {
459
- this.listenerRemovers.delete(type);
460
- }
461
- }
462
-
463
- private handleIncomingMessage(event: unknown): boolean {
464
- const data = messageEventData(event);
465
- if (!data) return true;
466
-
467
- let message: CloudStatusMessage;
468
- try {
469
- message = JSON.parse(data) as CloudStatusMessage;
470
- } catch {
471
- return true;
472
- }
473
-
474
- this.ackIfSequenced(message);
475
-
476
- // The status gateway can mirror device stream frames to the control
477
- // subscriber. Drop them at the Cloud transport boundary instead of making
478
- // shared app-server session code understand Cloud fanout quirks. Do this
479
- // before idempotency/event tracking so the canonical stream-channel frame
480
- // is still delivered and sequenced.
481
- if (this.channel === "control" && message.type === "stream_delta") {
482
- return false;
483
- }
484
-
485
- if (this.isDuplicate(message)) return false;
486
- this.trackEventSeq(message);
487
- return true;
488
- }
489
-
490
- private ackIfSequenced(message: CloudStatusMessage): void {
491
- if (typeof message.seq !== "number") return;
492
- this.sendCloudCommand({ type: "ack", seq: message.seq });
493
- }
494
-
495
- private isDuplicate(message: CloudStatusMessage): boolean {
496
- const idempotencyKey =
497
- typeof message.idempotency_key === "string" ? message.idempotency_key : undefined;
498
- if (!idempotencyKey) return false;
499
- if (state.seenIdempotencyKeys.has(idempotencyKey)) return true;
500
- state.seenIdempotencyKeys.add(idempotencyKey);
501
- state.seenIdempotencyOrder.push(idempotencyKey);
502
- while (state.seenIdempotencyOrder.length > 1_000) {
503
- const oldest = state.seenIdempotencyOrder.shift();
504
- if (oldest) state.seenIdempotencyKeys.delete(oldest);
505
- }
506
- return false;
507
- }
508
-
509
- private trackEventSeq(message: CloudStatusMessage): void {
510
- if (typeof message.event_seq !== "number") return;
511
- if (state.lastEventSeq !== null && message.event_seq > state.lastEventSeq + 1) {
512
- this.sendSync(true);
513
- }
514
- if (state.lastEventSeq === null || message.event_seq > state.lastEventSeq) {
515
- state.lastEventSeq = message.event_seq;
516
- }
517
- }
518
-
519
- private sendSync(recoverApprovals: boolean): void {
520
- const target = state.controlSocket?.readyState === 1 ? state.controlSocket : this;
521
- target.sendCloudCommand({
522
- type: "sync",
523
- runtime: state.runtime,
524
- recover_approvals: recoverApprovals,
525
- force_device_status: true,
526
- });
527
- }
528
-
529
- sendCloudCommand(command: Record<string, unknown>): void {
530
- if (this.readyState !== 1) return;
531
- try {
532
- this.socket.send(JSON.stringify(command));
533
- } catch {
534
- // Best-effort Cloud status reliability command.
535
- }
536
- }
537
-
538
- private startPing(intervalMs: number): void {
539
- if (this.pingTimer) return;
540
- this.pingTimer = setInterval(() => {
541
- this.sendCloudCommand({ type: "ping" });
542
- }, intervalMs);
543
- (this.pingTimer as { unref?: () => void }).unref?.();
544
- }
545
-
546
- private stopPing(): void {
547
- if (!this.pingTimer) return;
548
- clearInterval(this.pingTimer);
549
- this.pingTimer = null;
550
- }
551
- }
552
-
553
- return CloudStatusSocketAdapter as AppServerSocketConstructor;
554
- }
555
-
556
310
  function isCloudConversation(value: unknown): value is CloudConversation & { id: string } {
557
311
  return Boolean(value && typeof value === "object" && typeof (value as CloudConversation).id === "string");
558
312
  }
@@ -638,24 +392,6 @@ export function assertCloudSessionOptionsSupported(
638
392
  if (options.environment !== undefined && options.sandbox !== undefined) {
639
393
  throw new Error(`Letta Cloud ${action}() cannot specify both environment and sandbox options.`);
640
394
  }
641
- if (options.systemPrompt !== undefined) {
642
- throw new Error(`Letta Cloud ${action}() cannot rewrite an existing agent's systemPrompt from the SDK adapter yet.`);
643
- }
644
- if (options.disallowedTools !== undefined) {
645
- throw new Error(`Letta Cloud ${action}() has not wired disallowedTools to the remote device protocol yet.`);
646
- }
647
- if (options.systemInfoReminder !== undefined) {
648
- throw new Error(`Letta Cloud ${action}() has not wired systemInfoReminder to the remote device protocol yet.`);
649
- }
650
- if (options.dreaming?.behavior !== undefined) {
651
- throw new Error(`Letta Cloud ${action}() does not yet support dreaming.behavior overrides.`);
652
- }
653
- if ((options as { memfsStartup?: unknown }).memfsStartup !== undefined) {
654
- throw new Error(`Letta Cloud ${action}() does not support memfsStartup.`);
655
- }
656
- if (options.includePartialMessages !== undefined) {
657
- throw new Error(`Letta Cloud ${action}() streams Remote Client deltas directly; includePartialMessages is not a separate toggle.`);
658
- }
659
395
  }
660
396
 
661
397
  export class CloudEnvironmentSession extends RemoteClientSessionCore {
@@ -699,8 +435,14 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
699
435
  });
700
436
  const client = applyUniqueRequestIds(createAppServerClient({
701
437
  url,
702
- WebSocket: createCloudStatusWebSocketConstructor({
703
- cloudOptions: this.cloudOptions,
438
+ WebSocket: createCloudStatusTransportConstructor({
439
+ url,
440
+ WebSocket: getWebSocketConstructor(this.cloudOptions.WebSocket),
441
+ ...((this.cloudOptions.webSocketAuth ?? "header") === "header"
442
+ ? { headers: cloudWebSocketHeaders(this.cloudOptions) }
443
+ : {}),
444
+ pingIntervalMs:
445
+ this.cloudOptions.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS,
704
446
  runtime: resolved.runtime,
705
447
  }),
706
448
  requestTimeoutMs: this.cloudOptions.requestTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
@@ -0,0 +1,305 @@
1
+ import type {
2
+ AppServerSocketConstructor,
3
+ AppServerSocketLike,
4
+ AppServerSocketOptions,
5
+ } from "@letta-ai/letta-code/app-server-client";
6
+ import type { ProtocolMessage, RuntimeScope } from "./remote-session-protocol.js";
7
+ import type {
8
+ LettaCodeSocketConstructor,
9
+ LettaCodeSocketLike,
10
+ } from "./types.js";
11
+
12
+ type CloudStatusMessage = ProtocolMessage & {
13
+ type: string;
14
+ seq?: unknown;
15
+ event_seq?: unknown;
16
+ idempotency_key?: unknown;
17
+ };
18
+
19
+ type CloudStatusTransportOptions = {
20
+ url: string;
21
+ WebSocket: LettaCodeSocketConstructor;
22
+ headers?: Record<string, string>;
23
+ pingIntervalMs: number;
24
+ runtime: RuntimeScope;
25
+ };
26
+
27
+ type SocketChannel = "control" | "stream";
28
+ type Listener = (event: unknown) => void;
29
+
30
+ const CONNECTING = 0;
31
+ const OPEN = 1;
32
+ const CLOSING = 2;
33
+ const CLOSED = 3;
34
+ const MAX_IDEMPOTENCY_KEYS = 1_000;
35
+
36
+ function addSocketListener(
37
+ socket: LettaCodeSocketLike,
38
+ type: string,
39
+ listener: Listener,
40
+ ): () => void {
41
+ if (socket.addEventListener) {
42
+ socket.addEventListener(type, listener);
43
+ return () => socket.removeEventListener?.(type, listener);
44
+ }
45
+ if (socket.on) {
46
+ socket.on(type, listener);
47
+ return () => socket.off?.(type, listener);
48
+ }
49
+ throw new Error("WebSocket implementation does not support event listeners.");
50
+ }
51
+
52
+ function messageEventData(event: unknown): string | null {
53
+ if (typeof event === "string") return event;
54
+ if (!event || typeof event !== "object") return null;
55
+ const data = (event as { data?: unknown }).data;
56
+ if (typeof data === "string") return data;
57
+ if (data instanceof ArrayBuffer) {
58
+ return new TextDecoder().decode(data);
59
+ }
60
+ if (data instanceof Uint8Array) {
61
+ return new TextDecoder().decode(data);
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function channelUrl(url: string, channel: SocketChannel): string {
67
+ const parsed = new URL(url);
68
+ parsed.searchParams.set("channel", channel);
69
+ return parsed.toString();
70
+ }
71
+
72
+ /**
73
+ * Cloud's status relay still exposes separate control and stream sockets.
74
+ *
75
+ * This adapter presents those two relay sockets as one AppServerSocketLike,
76
+ * allowing CloudSession to reuse AppServerClient's request correlation and
77
+ * the SDK's shared runtime controller without weakening the local app-server's
78
+ * strict one-socket contract. Remove it when LET-10236 migrates the relay.
79
+ */
80
+ export class CloudStatusTransport implements AppServerSocketLike {
81
+ readonly controlSocket: LettaCodeSocketLike;
82
+ readonly streamSocket: LettaCodeSocketLike;
83
+
84
+ private state = CONNECTING;
85
+ private openedChannels = new Set<SocketChannel>();
86
+ private listeners = new Map<string, Set<Listener>>();
87
+ private removers: Array<() => void> = [];
88
+ private seenIdempotencyKeys = new Set<string>();
89
+ private seenIdempotencyOrder: string[] = [];
90
+ private lastEventSeq: number | null = null;
91
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
92
+ private closeEmitted = false;
93
+
94
+ constructor(private readonly options: CloudStatusTransportOptions) {
95
+ const socketOptions = options.headers
96
+ ? { headers: options.headers }
97
+ : undefined;
98
+ this.controlSocket = new options.WebSocket(
99
+ channelUrl(options.url, "control"),
100
+ socketOptions,
101
+ );
102
+ this.streamSocket = new options.WebSocket(
103
+ channelUrl(options.url, "stream"),
104
+ socketOptions,
105
+ );
106
+ this.bindSocket("control", this.controlSocket);
107
+ this.bindSocket("stream", this.streamSocket);
108
+ this.startPing();
109
+ }
110
+
111
+ get readyState(): number {
112
+ return this.state;
113
+ }
114
+
115
+ send(data: string): void {
116
+ if (this.state !== OPEN || this.controlSocket.readyState !== OPEN) {
117
+ throw new Error("Cloud status control socket is not open");
118
+ }
119
+ this.controlSocket.send(data);
120
+ }
121
+
122
+ close(): void {
123
+ if (this.state === CLOSED || this.state === CLOSING) return;
124
+ this.state = CLOSING;
125
+ this.stopPing();
126
+ this.closeUnderlyingSockets();
127
+ this.finishClose({});
128
+ }
129
+
130
+ addEventListener(type: string, listener: Listener): void {
131
+ let listeners = this.listeners.get(type);
132
+ if (!listeners) {
133
+ listeners = new Set();
134
+ this.listeners.set(type, listeners);
135
+ }
136
+ listeners.add(listener);
137
+ }
138
+
139
+ removeEventListener(type: string, listener: Listener): void {
140
+ this.listeners.get(type)?.delete(listener);
141
+ }
142
+
143
+ private bindSocket(
144
+ channel: SocketChannel,
145
+ socket: LettaCodeSocketLike,
146
+ ): void {
147
+ this.removers.push(
148
+ addSocketListener(socket, "open", () => {
149
+ this.openedChannels.add(channel);
150
+ if (this.openedChannels.size === 2 && this.state === CONNECTING) {
151
+ this.state = OPEN;
152
+ this.emit("open", {});
153
+ }
154
+ }),
155
+ addSocketListener(socket, "message", (event) => {
156
+ if (this.shouldForwardMessage(channel, socket, event)) {
157
+ this.emit("message", event);
158
+ }
159
+ }),
160
+ addSocketListener(socket, "error", (event) => {
161
+ this.emit("error", event);
162
+ }),
163
+ addSocketListener(socket, "close", (event) => {
164
+ if (this.state !== CLOSED) {
165
+ this.state = CLOSING;
166
+ this.stopPing();
167
+ this.closeUnderlyingSockets(socket);
168
+ this.finishClose(event);
169
+ }
170
+ }),
171
+ );
172
+ }
173
+
174
+ private shouldForwardMessage(
175
+ channel: SocketChannel,
176
+ socket: LettaCodeSocketLike,
177
+ event: unknown,
178
+ ): boolean {
179
+ const data = messageEventData(event);
180
+ if (!data) return true;
181
+ let message: CloudStatusMessage;
182
+ try {
183
+ message = JSON.parse(data) as CloudStatusMessage;
184
+ } catch {
185
+ return true;
186
+ }
187
+
188
+ this.ackIfSequenced(socket, message);
189
+ if (channel === "control" && message.type === "stream_delta") {
190
+ return false;
191
+ }
192
+ if (this.isDuplicate(message)) return false;
193
+ this.trackEventSequence(message);
194
+ return true;
195
+ }
196
+
197
+ private ackIfSequenced(
198
+ socket: LettaCodeSocketLike,
199
+ message: CloudStatusMessage,
200
+ ): void {
201
+ if (typeof message.seq !== "number") return;
202
+ this.sendCommand(socket, { type: "ack", seq: message.seq });
203
+ }
204
+
205
+ private isDuplicate(message: CloudStatusMessage): boolean {
206
+ const key =
207
+ typeof message.idempotency_key === "string"
208
+ ? message.idempotency_key
209
+ : null;
210
+ if (!key) return false;
211
+ if (this.seenIdempotencyKeys.has(key)) return true;
212
+ this.seenIdempotencyKeys.add(key);
213
+ this.seenIdempotencyOrder.push(key);
214
+ while (this.seenIdempotencyOrder.length > MAX_IDEMPOTENCY_KEYS) {
215
+ const oldest = this.seenIdempotencyOrder.shift();
216
+ if (oldest) this.seenIdempotencyKeys.delete(oldest);
217
+ }
218
+ return false;
219
+ }
220
+
221
+ private trackEventSequence(message: CloudStatusMessage): void {
222
+ if (typeof message.event_seq !== "number") return;
223
+ if (
224
+ this.lastEventSeq !== null &&
225
+ message.event_seq > this.lastEventSeq + 1
226
+ ) {
227
+ this.sendCommand(this.controlSocket, {
228
+ type: "sync",
229
+ runtime: this.options.runtime,
230
+ recover_approvals: true,
231
+ force_device_status: true,
232
+ });
233
+ }
234
+ if (
235
+ this.lastEventSeq === null ||
236
+ message.event_seq > this.lastEventSeq
237
+ ) {
238
+ this.lastEventSeq = message.event_seq;
239
+ }
240
+ }
241
+
242
+ private sendCommand(
243
+ socket: LettaCodeSocketLike,
244
+ command: Record<string, unknown>,
245
+ ): void {
246
+ if (socket.readyState !== OPEN) return;
247
+ try {
248
+ socket.send(JSON.stringify(command));
249
+ } catch {
250
+ // ACK, sync recovery, and ping are best-effort relay reliability frames.
251
+ }
252
+ }
253
+
254
+ private startPing(): void {
255
+ this.pingTimer = setInterval(() => {
256
+ this.sendCommand(this.controlSocket, { type: "ping" });
257
+ this.sendCommand(this.streamSocket, { type: "ping" });
258
+ }, this.options.pingIntervalMs);
259
+ (this.pingTimer as { unref?: () => void }).unref?.();
260
+ }
261
+
262
+ private stopPing(): void {
263
+ if (!this.pingTimer) return;
264
+ clearInterval(this.pingTimer);
265
+ this.pingTimer = null;
266
+ }
267
+
268
+ private closeUnderlyingSockets(
269
+ except?: LettaCodeSocketLike,
270
+ ): void {
271
+ for (const socket of [this.controlSocket, this.streamSocket]) {
272
+ if (
273
+ socket !== except &&
274
+ (socket.readyState === CONNECTING || socket.readyState === OPEN)
275
+ ) {
276
+ socket.close();
277
+ }
278
+ }
279
+ }
280
+
281
+ private finishClose(event: unknown): void {
282
+ if (this.closeEmitted) return;
283
+ this.closeEmitted = true;
284
+ this.state = CLOSED;
285
+ this.stopPing();
286
+ for (const remove of this.removers.splice(0)) remove();
287
+ this.emit("close", event);
288
+ }
289
+
290
+ private emit(type: string, event: unknown): void {
291
+ for (const listener of this.listeners.get(type) ?? []) {
292
+ listener(event);
293
+ }
294
+ }
295
+ }
296
+
297
+ export function createCloudStatusTransportConstructor(
298
+ options: CloudStatusTransportOptions,
299
+ ): AppServerSocketConstructor {
300
+ return class CloudStatusTransportSocket extends CloudStatusTransport {
301
+ constructor(_url: string, _socketOptions?: AppServerSocketOptions) {
302
+ super(options);
303
+ }
304
+ };
305
+ }