@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
package/src/session.ts ADDED
@@ -0,0 +1,1638 @@
1
+ /**
2
+ * Session
3
+ *
4
+ * Represents a conversation session with a Letta agent.
5
+ * Implements the V2 API pattern: send() / receive()
6
+ */
7
+
8
+ import { SubprocessTransport } from "./transport.js";
9
+ import type {
10
+ InternalSessionOptions,
11
+ SDKMessage,
12
+ SDKInitMessage,
13
+ SDKResultMessage,
14
+ SDKErrorCode,
15
+ MessageWire,
16
+ WireMessage,
17
+ ControlRequest,
18
+ CanUseToolControlRequest,
19
+ CanUseToolResponse,
20
+ CanUseToolResponseAllow,
21
+ CanUseToolResponseDeny,
22
+ SendMessage,
23
+ AnyAgentTool,
24
+ ExecuteExternalToolRequest,
25
+ ListMessagesOptions,
26
+ ListMessagesResult,
27
+ ListModelsResult,
28
+ BootstrapStateOptions,
29
+ BootstrapStateResult,
30
+ ChangeDeviceStateOptions,
31
+ GetDeviceStatusOptions,
32
+ SessionDeviceStatus,
33
+ SDKStreamEventPayload,
34
+ RunTurnOptions,
35
+ RecoverPendingApprovalsOptions,
36
+ RecoverPendingApprovalsResult,
37
+ RemoveQueuedMessageResult,
38
+ SDKProtocolCommand,
39
+ SDKProtocolMessage,
40
+ SendCommandOptions,
41
+ UpdateModelOptions,
42
+ UpdateModelResult,
43
+ } from "./types.js";
44
+ import {
45
+ buildCanUseToolContext,
46
+ isHeadlessAutoAllowTool,
47
+ requiresRuntimeUserInput,
48
+ } from "./interactiveToolPolicy.js";
49
+ import { isUnrestrictedPermissionMode } from "./remote-client-session-core.js";
50
+
51
+
52
+ // All logging gated behind DEBUG_SDK env var
53
+ function sessionLog(tag: string, ...args: unknown[]) {
54
+ if (process.env.DEBUG_SDK) console.error(`[SDK-Session] [${tag}]`, ...args);
55
+ }
56
+
57
+ const MAX_BUFFERED_STREAM_MESSAGES = 100;
58
+ const DEFAULT_MAX_APPROVAL_RECOVERY_ATTEMPTS = 1;
59
+ const DEFAULT_APPROVAL_RECOVERY_TIMEOUT_MS = 5_000;
60
+
61
+ const KNOWN_SDK_ERROR_CODES = new Set<SDKErrorCode>([
62
+ "approval_conflict",
63
+ "approval_conflict_terminal",
64
+ "protocol_error",
65
+ "error",
66
+ "llm_api_error",
67
+ "max_steps",
68
+ "interrupted",
69
+ "stream_closed",
70
+ ]);
71
+
72
+ function isKnownSdkErrorCode(value: string): value is SDKErrorCode {
73
+ return KNOWN_SDK_ERROR_CODES.has(value as SDKErrorCode);
74
+ }
75
+
76
+ function toSdkErrorCode(value: string | undefined): SDKErrorCode | undefined {
77
+ if (!value || value.length === 0) return undefined;
78
+ return isKnownSdkErrorCode(value) ? value : undefined;
79
+ }
80
+
81
+ function isUnknownControlSubtypeError(
82
+ errorMessage: string | undefined,
83
+ subtype: string,
84
+ ): boolean {
85
+ if (!errorMessage) return false;
86
+ return errorMessage
87
+ .toLowerCase()
88
+ .includes(`unknown control request subtype: ${subtype}`.toLowerCase());
89
+ }
90
+
91
+ function extractApiErrorDetail(apiError: Record<string, unknown> | undefined): string | undefined {
92
+ const detail = apiError?.detail;
93
+ if (typeof detail !== "string") return undefined;
94
+ const trimmed = detail.trim();
95
+ return trimmed.length > 0 ? trimmed : undefined;
96
+ }
97
+
98
+ function isApprovalConflictSignal(params: {
99
+ detail?: string;
100
+ message?: string;
101
+ stopReason?: string;
102
+ }): boolean {
103
+ if (params.stopReason === "requires_approval") return true;
104
+ // Stopgap heuristic until the server emits a first-class structured
105
+ // approval-conflict code in wire payloads.
106
+ const haystack = [params.detail, params.message]
107
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
108
+ .join("\n")
109
+ .toLowerCase();
110
+ if (!haystack) return false;
111
+ return (
112
+ haystack.includes("waiting for approval on a tool call") ||
113
+ haystack.includes("cannot send a new message") ||
114
+ haystack.includes("requires_approval")
115
+ );
116
+ }
117
+
118
+ type BufferedStreamMessage = {
119
+ message: SDKMessage;
120
+ generation: number;
121
+ runId?: string;
122
+ };
123
+
124
+ export class Session implements AsyncDisposable {
125
+ private transport: SubprocessTransport;
126
+ private _agentId: string | null = null;
127
+ private _sessionId: string | null = null;
128
+ private _conversationId: string | null = null;
129
+ private initialized = false;
130
+ private closed = false;
131
+ private initializePromise: Promise<SDKInitMessage> | null = null;
132
+ private externalTools: Map<string, AnyAgentTool> = new Map();
133
+ private streamQueue: BufferedStreamMessage[] = [];
134
+ private streamResolvers: Array<(msg: BufferedStreamMessage | null) => void> = [];
135
+ private pumpPromise: Promise<void> | null = null;
136
+ private pumpClosed = false;
137
+ private droppedStreamMessages = 0;
138
+ // Monotonic counter incremented after each send(). Messages enqueued by the
139
+ // pump are tagged with the current generation; stream() filters out messages
140
+ // from earlier generations to prevent N-1 desync (stale events from a
141
+ // previous run leaking into the current run's stream).
142
+ private sendGeneration = 0;
143
+ // Run IDs that completed in the previous streamed turn. Used to drop
144
+ // late-arriving stale events from the old run if they arrive after send().
145
+ private lastCompletedRunIds = new Set<string>();
146
+ // Waiters for SDK-initiated control requests (e.g., listMessages).
147
+ // Keyed by request_id; pump resolves the matching waiter when it sees
148
+ // a control_response with that request_id instead of queuing it as a stream msg.
149
+ private controlResponseWaiters = new Map<
150
+ string,
151
+ (response: { subtype: string; response?: unknown; error?: string }) => void
152
+ >();
153
+
154
+ constructor(
155
+ private options: InternalSessionOptions = {}
156
+ ) {
157
+ // Note: Validation happens in public API functions (createSession, createAgent, etc.)
158
+ if (options.reasoningEffort !== undefined) {
159
+ throw new Error(
160
+ "reasoningEffort is not supported by this session. Create a Cloud, Remote, or local agent session before setting reasoning effort.",
161
+ );
162
+ }
163
+ this.transport = new SubprocessTransport(options);
164
+
165
+ // Store external tools in a map for quick lookup
166
+ if (options.tools) {
167
+ for (const tool of options.tools) {
168
+ this.externalTools.set(tool.name, tool);
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Initialize the session (called automatically on first send).
175
+ *
176
+ * Single-flight: concurrent callers (including lazily-initializing entry
177
+ * points such as send()) share one in-flight initialization instead of
178
+ * each connecting the transport. A failed stdio attempt closes the session
179
+ * because its subprocess transport is no longer safe to reuse.
180
+ */
181
+ async initialize(): Promise<SDKInitMessage> {
182
+ if (this.closed) {
183
+ throw new Error("Session is closed");
184
+ }
185
+ if (this.initializePromise) {
186
+ return this.initializePromise;
187
+ }
188
+ if (this.initialized) {
189
+ throw new Error("Session already initialized");
190
+ }
191
+
192
+ const attempt = this.performInitialize();
193
+ const memo = attempt
194
+ .catch((error: unknown) => {
195
+ // A failed stdio initialization can leave its one subprocess
196
+ // transport unusable. Closing this session cannot affect another
197
+ // initialize attempt because all callers share this promise.
198
+ this.cleanupFailedInitialize();
199
+ throw error;
200
+ })
201
+ .finally(() => {
202
+ if (this.initializePromise === memo) {
203
+ this.initializePromise = null;
204
+ }
205
+ });
206
+ this.initializePromise = memo;
207
+ return memo;
208
+ }
209
+
210
+ private async performInitialize(): Promise<SDKInitMessage> {
211
+ sessionLog("init", "connecting transport...");
212
+ await this.transport.connect();
213
+ sessionLog("init", "transport connected, sending initialize request");
214
+
215
+ // Send initialize control request
216
+ await this.transport.write({
217
+ type: "control_request",
218
+ request_id: "init_1",
219
+ request: { subtype: "initialize" },
220
+ });
221
+
222
+ // Wait for init message
223
+ sessionLog("init", "waiting for init message from CLI...");
224
+ for await (const msg of this.transport.messages()) {
225
+ sessionLog("init", `received wire message: type=${msg.type}`);
226
+
227
+ if (msg.type === "control_request") {
228
+ const handled = await this.handleControlRequest(msg as ControlRequest);
229
+ if (!handled) {
230
+ const wireMsgAny = msg as unknown as Record<string, unknown>;
231
+ sessionLog("init", `DROPPED unsupported control_request: subtype=${(wireMsgAny.request as Record<string, unknown>)?.subtype || "N/A"}`);
232
+ }
233
+ continue;
234
+ }
235
+
236
+ if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
237
+ const initMsg = msg as WireMessage & {
238
+ agent_id: string;
239
+ session_id: string;
240
+ conversation_id: string;
241
+ model: string;
242
+ tools: string[];
243
+ memfs_enabled?: boolean;
244
+ skill_sources?: Array<"bundled" | "global" | "agent" | "project">;
245
+ system_info_reminder_enabled?: boolean;
246
+ reflection_trigger?: "off" | "step-count" | "compaction-event";
247
+ reflection_behavior?: "reminder" | "auto-launch";
248
+ reflection_step_count?: number;
249
+ };
250
+ this._agentId = initMsg.agent_id;
251
+ this._sessionId = initMsg.session_id;
252
+ this._conversationId = initMsg.conversation_id;
253
+
254
+ // Register external tools with CLI
255
+ if (this.externalTools.size > 0) {
256
+ await this.registerExternalTools();
257
+ }
258
+ if (this.closed) {
259
+ throw new Error("Session is closed");
260
+ }
261
+
262
+ this.initialized = true;
263
+ this.startBackgroundPump();
264
+
265
+ // Include external tool names in the tools list
266
+ const allTools = [
267
+ ...initMsg.tools,
268
+ ...Array.from(this.externalTools.keys()),
269
+ ];
270
+
271
+ sessionLog("init", `initialized: agent=${initMsg.agent_id} conversation=${initMsg.conversation_id} model=${initMsg.model} tools=${allTools.length} (${this.externalTools.size} external)`);
272
+
273
+ return {
274
+ type: "init",
275
+ agentId: initMsg.agent_id,
276
+ sessionId: initMsg.session_id,
277
+ conversationId: initMsg.conversation_id,
278
+ model: initMsg.model,
279
+ tools: allTools,
280
+ memfsEnabled: initMsg.memfs_enabled,
281
+ skillSources: initMsg.skill_sources,
282
+ systemInfoReminderEnabled: initMsg.system_info_reminder_enabled,
283
+ dreaming:
284
+ initMsg.reflection_trigger &&
285
+ initMsg.reflection_behavior &&
286
+ typeof initMsg.reflection_step_count === "number"
287
+ ? {
288
+ trigger: initMsg.reflection_trigger,
289
+ behavior: initMsg.reflection_behavior,
290
+ stepCount: initMsg.reflection_step_count,
291
+ }
292
+ : undefined,
293
+ };
294
+ }
295
+ }
296
+
297
+ const stderr = this.transport.getStderr();
298
+ const detail = stderr ? `\nCLI stderr:\n${stderr}` : '';
299
+ sessionLog("init", `ERROR: transport closed before init message received${detail}`);
300
+ throw new Error(`Failed to initialize session - no init message received${detail}`);
301
+ }
302
+
303
+ private cleanupFailedInitialize(): void {
304
+ this.transport.close();
305
+ this.closed = true;
306
+ this.initialized = false;
307
+ this._agentId = null;
308
+ this._sessionId = null;
309
+ this._conversationId = null;
310
+ this.pumpClosed = true;
311
+ this.resolveAllStreamWaiters(null);
312
+ }
313
+
314
+ /**
315
+ * Send a message to the agent
316
+ *
317
+ * @param message - Text string or multimodal content array
318
+ *
319
+ * @example
320
+ * // Simple text
321
+ * await session.send("Hello!");
322
+ *
323
+ * @example
324
+ * // With image
325
+ * await session.send([
326
+ * { type: "text", text: "What's in this image?" },
327
+ * { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }
328
+ * ]);
329
+ */
330
+ async send(message: SendMessage): Promise<void> {
331
+ if (!this.initialized) {
332
+ sessionLog("send", "auto-initializing (not yet initialized)");
333
+ await this.initialize();
334
+ }
335
+
336
+ const preview = typeof message === "string"
337
+ ? message.slice(0, 100)
338
+ : Array.isArray(message) ? `[multimodal: ${message.length} parts]` : String(message).slice(0, 100);
339
+ sessionLog("send", `sending message: ${preview}${typeof message === "string" && message.length > 100 ? "..." : ""}`);
340
+
341
+ // Clear stale messages from previous turn to prevent desync
342
+ if (this.streamQueue.length > 0) {
343
+ sessionLog("send", `clearing ${this.streamQueue.length} stale messages from previous turn`);
344
+ this.streamQueue.length = 0;
345
+ }
346
+
347
+ await this.transport.write({
348
+ type: "user",
349
+ message: { role: "user", content: message },
350
+ });
351
+
352
+ // Advance generation AFTER the write so any messages the pump enqueues
353
+ // during the await (from the previous run's lingering events) are tagged
354
+ // with the old generation and will be filtered by stream().
355
+ this.sendGeneration++;
356
+ sessionLog("send", `message written to transport (generation=${this.sendGeneration})`);
357
+ }
358
+
359
+ /**
360
+ * Run a full turn (send + stream terminal result), with optional bounded
361
+ * SDK-owned approval-conflict recovery.
362
+ */
363
+ async runTurn(
364
+ message: SendMessage,
365
+ options: RunTurnOptions = {},
366
+ ): Promise<SDKResultMessage> {
367
+ const maxApprovalRecoveryAttempts =
368
+ this.resolveMaxApprovalRecoveryAttempts(options);
369
+ const recoveryTimeoutMs = this.resolveApprovalRecoveryTimeoutMs(options);
370
+
371
+ let recoveryAttempts = 0;
372
+ let result = await this.runSingleTurn(message);
373
+
374
+ while (
375
+ !result.success &&
376
+ result.approvalConflict === true &&
377
+ recoveryAttempts < maxApprovalRecoveryAttempts
378
+ ) {
379
+ recoveryAttempts += 1;
380
+ const recovery = await this.recoverPendingApprovals({
381
+ timeoutMs: recoveryTimeoutMs,
382
+ });
383
+
384
+ if (!recovery.recovered) {
385
+ return this.toTerminalApprovalConflictResult(result, {
386
+ recoveryAttempts,
387
+ detail: recovery.detail,
388
+ });
389
+ }
390
+
391
+ result = await this.runSingleTurn(message);
392
+ }
393
+
394
+ if (!result.success && result.approvalConflict === true) {
395
+ return this.toTerminalApprovalConflictResult(result, { recoveryAttempts });
396
+ }
397
+
398
+ if (recoveryAttempts > 0) {
399
+ result.recoveryAttempts = recoveryAttempts;
400
+ }
401
+
402
+ return result;
403
+ }
404
+
405
+ /**
406
+ * Ask the CLI to recover pending approvals for the current
407
+ * agent/conversation context.
408
+ */
409
+ async recoverPendingApprovals(
410
+ options: RecoverPendingApprovalsOptions = {},
411
+ ): Promise<RecoverPendingApprovalsResult> {
412
+ if (!this.initialized) {
413
+ await this.initialize();
414
+ }
415
+
416
+ const timeoutMs =
417
+ options.timeoutMs ??
418
+ this.options.approvalRecoveryTimeoutMs ??
419
+ DEFAULT_APPROVAL_RECOVERY_TIMEOUT_MS;
420
+
421
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
422
+ throw new Error(
423
+ "Invalid approval recovery timeout. Expected a positive integer.",
424
+ );
425
+ }
426
+
427
+ const requestId = `recover-approvals-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
428
+
429
+ let resp: { subtype: string; response?: unknown; error?: string };
430
+ try {
431
+ resp = await this.requestControlResponse(
432
+ requestId,
433
+ {
434
+ subtype: "recover_pending_approvals",
435
+ ...(this._agentId ? { agent_id: this._agentId } : {}),
436
+ ...(this._conversationId ? { conversation_id: this._conversationId } : {}),
437
+ },
438
+ { timeoutMs },
439
+ );
440
+ } catch (error) {
441
+ const detail = error instanceof Error ? error.message : String(error);
442
+ return {
443
+ recovered: false,
444
+ unsupported: false,
445
+ detail,
446
+ };
447
+ }
448
+
449
+ if (resp.subtype === "error") {
450
+ const detail = resp.error ?? "recover_pending_approvals failed";
451
+ return {
452
+ recovered: false,
453
+ pendingApproval: true,
454
+ unsupported: isUnknownControlSubtypeError(
455
+ detail,
456
+ "recover_pending_approvals",
457
+ ),
458
+ detail,
459
+ };
460
+ }
461
+
462
+ return {
463
+ recovered: true,
464
+ pendingApproval: false,
465
+ unsupported: false,
466
+ };
467
+ }
468
+
469
+ private resolveMaxApprovalRecoveryAttempts(options: RunTurnOptions): number {
470
+ const value =
471
+ options.maxApprovalRecoveryAttempts ??
472
+ this.options.maxApprovalRecoveryAttempts ??
473
+ DEFAULT_MAX_APPROVAL_RECOVERY_ATTEMPTS;
474
+
475
+ if (!Number.isInteger(value) || value < 0) {
476
+ throw new Error(
477
+ "Invalid maxApprovalRecoveryAttempts. Expected a non-negative integer.",
478
+ );
479
+ }
480
+
481
+ return value;
482
+ }
483
+
484
+ private resolveApprovalRecoveryTimeoutMs(options: RunTurnOptions): number {
485
+ const value =
486
+ options.recoveryTimeoutMs ??
487
+ this.options.approvalRecoveryTimeoutMs ??
488
+ DEFAULT_APPROVAL_RECOVERY_TIMEOUT_MS;
489
+
490
+ if (!Number.isInteger(value) || value <= 0) {
491
+ throw new Error(
492
+ "Invalid approval recovery timeout. Expected a positive integer.",
493
+ );
494
+ }
495
+
496
+ return value;
497
+ }
498
+
499
+ private toTerminalApprovalConflictResult(
500
+ result: SDKResultMessage,
501
+ options: { recoveryAttempts: number; detail?: string },
502
+ ): SDKResultMessage {
503
+ return {
504
+ ...result,
505
+ success: false,
506
+ error: "approval_conflict_terminal",
507
+ errorCode: "approval_conflict_terminal",
508
+ approvalConflict: true,
509
+ recoverable: false,
510
+ recoveryAttempts: options.recoveryAttempts,
511
+ errorDetail:
512
+ options.detail ||
513
+ result.errorDetail ||
514
+ "Approval conflict remained unresolved after bounded SDK recovery.",
515
+ };
516
+ }
517
+
518
+ private async runSingleTurn(message: SendMessage): Promise<SDKResultMessage> {
519
+ await this.send(message);
520
+
521
+ let latestApprovalConflictDetail: string | undefined;
522
+ let latestNonConflictErrorDetail: string | undefined;
523
+
524
+ for await (const msg of this.stream()) {
525
+ if (msg.type === "error") {
526
+ const detail = msg.errorDetail || msg.message;
527
+ if (msg.approvalConflict) {
528
+ latestApprovalConflictDetail = detail;
529
+ } else {
530
+ latestNonConflictErrorDetail = detail;
531
+ }
532
+ continue;
533
+ }
534
+
535
+ if (msg.type === "result") {
536
+ if (!msg.success && !msg.errorDetail) {
537
+ if (msg.approvalConflict && latestApprovalConflictDetail) {
538
+ return {
539
+ ...msg,
540
+ errorDetail: latestApprovalConflictDetail,
541
+ };
542
+ }
543
+
544
+ if (!msg.approvalConflict && latestNonConflictErrorDetail) {
545
+ return {
546
+ ...msg,
547
+ errorDetail: latestNonConflictErrorDetail,
548
+ };
549
+ }
550
+ }
551
+
552
+ return msg;
553
+ }
554
+ }
555
+
556
+ return {
557
+ type: "result",
558
+ success: false,
559
+ error: "stream_closed",
560
+ errorCode: "stream_closed",
561
+ recoverable: false,
562
+ errorDetail: "Stream ended before terminal result",
563
+ durationMs: 0,
564
+ conversationId: this._conversationId,
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Stream messages from the agent
570
+ */
571
+ async *stream(): AsyncGenerator<SDKMessage> {
572
+ const streamStart = Date.now();
573
+ const minGeneration = this.sendGeneration;
574
+ let yieldCount = 0;
575
+ let staleCount = 0;
576
+ let staleRunIdCount = 0;
577
+ let gotResult = false;
578
+ const currentStreamRunIds = new Set<string>();
579
+ const staleRunIds = new Set(this.lastCompletedRunIds);
580
+ const approvalConflictDetailsByRunId = new Map<string, string>();
581
+ let latestApprovalConflictDetail: string | undefined;
582
+
583
+ this.startBackgroundPump();
584
+ sessionLog("stream", `starting stream (agent=${this._agentId}, conversation=${this._conversationId}, generation=${minGeneration})`);
585
+
586
+ while (true) {
587
+ const bufferedMsg = await this.nextBufferedMessage();
588
+ if (!bufferedMsg) {
589
+ break;
590
+ }
591
+
592
+ // Filter stale messages from previous runs. Messages enqueued before
593
+ // the current send() carry an older generation tag.
594
+ if (bufferedMsg.generation < minGeneration) {
595
+ staleCount++;
596
+ sessionLog("stream", `discarding stale message: type=${bufferedMsg.message.type} generation=${bufferedMsg.generation} (current=${minGeneration})`);
597
+ continue;
598
+ }
599
+
600
+ // Filter late old-run messages that arrive after send() has already
601
+ // advanced generation and stream queue was cleared.
602
+ if (bufferedMsg.runId && staleRunIds.has(bufferedMsg.runId)) {
603
+ staleRunIdCount++;
604
+ sessionLog("stream", `discarding stale message: type=${bufferedMsg.message.type} runId=${bufferedMsg.runId}`);
605
+ continue;
606
+ }
607
+
608
+ if (bufferedMsg.runId) {
609
+ currentStreamRunIds.add(bufferedMsg.runId);
610
+ }
611
+
612
+ const sdkMsg = bufferedMsg.message;
613
+
614
+ if (sdkMsg.type === "error" && sdkMsg.approvalConflict) {
615
+ const detail = sdkMsg.errorDetail || sdkMsg.message;
616
+ if (detail) {
617
+ latestApprovalConflictDetail = detail;
618
+ if (sdkMsg.runId) {
619
+ approvalConflictDetailsByRunId.set(sdkMsg.runId, detail);
620
+ }
621
+ }
622
+ }
623
+
624
+ let normalizedMsg: SDKMessage = sdkMsg;
625
+
626
+ if (sdkMsg.type === "result" && !sdkMsg.success) {
627
+ let detail = sdkMsg.errorDetail;
628
+ if (!detail && Array.isArray(sdkMsg.runIds)) {
629
+ for (const runId of sdkMsg.runIds) {
630
+ const fromRun = approvalConflictDetailsByRunId.get(runId);
631
+ if (fromRun) {
632
+ detail = fromRun;
633
+ break;
634
+ }
635
+ }
636
+ }
637
+ if (!detail) {
638
+ detail = latestApprovalConflictDetail;
639
+ }
640
+
641
+ const approvalConflict =
642
+ sdkMsg.approvalConflict === true
643
+ || isApprovalConflictSignal({
644
+ detail,
645
+ message: sdkMsg.errorCode || sdkMsg.error,
646
+ stopReason: sdkMsg.stopReason,
647
+ });
648
+
649
+ if (approvalConflict) {
650
+ const normalizedErrorCode =
651
+ !sdkMsg.errorCode || sdkMsg.errorCode === "error"
652
+ ? "approval_conflict"
653
+ : sdkMsg.errorCode;
654
+ const normalizedError =
655
+ !sdkMsg.error || sdkMsg.error === "error"
656
+ ? normalizedErrorCode
657
+ : sdkMsg.error;
658
+
659
+ normalizedMsg = {
660
+ ...sdkMsg,
661
+ approvalConflict: true,
662
+ recoverable: sdkMsg.recoverable ?? true,
663
+ ...(detail ? { errorDetail: detail } : {}),
664
+ errorCode: normalizedErrorCode,
665
+ error: normalizedError,
666
+ };
667
+ } else if (!sdkMsg.errorCode && sdkMsg.error) {
668
+ const errorCode = toSdkErrorCode(sdkMsg.error);
669
+ if (errorCode) {
670
+ normalizedMsg = {
671
+ ...sdkMsg,
672
+ errorCode,
673
+ };
674
+ }
675
+ }
676
+ }
677
+
678
+ yieldCount++;
679
+ sessionLog("stream", `yield #${yieldCount}: type=${normalizedMsg.type}${normalizedMsg.type === "result" ? ` success=${(normalizedMsg as SDKResultMessage).success} error=${(normalizedMsg as SDKResultMessage).error || "none"}` : ""}`);
680
+ yield normalizedMsg;
681
+
682
+ // Stop on result message
683
+ if (normalizedMsg.type === "result") {
684
+ gotResult = true;
685
+ this.updateCompletedRunIds((normalizedMsg as SDKResultMessage).runIds, currentStreamRunIds);
686
+ break;
687
+ }
688
+ }
689
+
690
+ const elapsed = Date.now() - streamStart;
691
+ sessionLog("stream", `stream ended: duration=${elapsed}ms yielded=${yieldCount} staleFiltered=${staleCount} staleRunIdFiltered=${staleRunIdCount} dropped=${this.droppedStreamMessages} gotResult=${gotResult}`);
692
+ if (!gotResult) {
693
+ sessionLog("stream", "WARNING: stream ended WITHOUT a result message -- transport may have closed unexpectedly");
694
+ }
695
+ }
696
+
697
+ private startBackgroundPump(): void {
698
+ if (this.pumpPromise) {
699
+ return;
700
+ }
701
+
702
+ this.pumpClosed = false;
703
+ this.pumpPromise = this.runBackgroundPump()
704
+ .catch((err) => {
705
+ sessionLog("pump", `ERROR: ${err instanceof Error ? err.message : String(err)}`);
706
+ })
707
+ .finally(() => {
708
+ this.pumpClosed = true;
709
+ this.resolveAllStreamWaiters(null);
710
+ });
711
+ }
712
+
713
+ private async runBackgroundPump(): Promise<void> {
714
+ sessionLog("pump", "background pump started");
715
+
716
+ // Tool call chunks are emitted immediately (no buffering).
717
+ // Consumers that need complete arguments should accumulate rawArguments
718
+ // across chunks sharing the same toolCallId on their side.
719
+ //
720
+ // Index-to-ID mapping: The Letta API follows the OpenAI streaming format
721
+ // for parallel tool calls -- only the first chunk per tool call includes
722
+ // tool_call_id, subsequent chunks identify themselves via index only.
723
+ // We maintain a lightweight index->id map so transformMessage() can
724
+ // resolve the ID for every chunk without buffering.
725
+ const indexToToolCallId = new Map<number, string>();
726
+
727
+ for await (const wireMsg of this.transport.messages()) {
728
+ const wireMsgAny = wireMsg as unknown as Record<string, unknown>;
729
+
730
+ if (wireMsg.type === "control_request") {
731
+ const handled = await this.handleControlRequest(wireMsg as ControlRequest);
732
+ if (!handled) {
733
+ sessionLog("pump", `DROPPED unsupported control_request: subtype=${(wireMsgAny.request as Record<string, unknown>)?.subtype || "N/A"}`);
734
+ }
735
+ continue;
736
+ }
737
+
738
+ // Route control_response to a registered waiter (e.g., from listMessages).
739
+ // Unmatched control_responses are logged and dropped — they never reach the stream.
740
+ if (wireMsg.type === "control_response") {
741
+ const respMsg = wireMsg as unknown as {
742
+ response: { subtype: string; request_id?: string; response?: unknown; error?: string };
743
+ };
744
+ const requestId = respMsg.response?.request_id;
745
+ if (requestId && this.controlResponseWaiters.has(requestId)) {
746
+ const resolve = this.controlResponseWaiters.get(requestId)!;
747
+ this.controlResponseWaiters.delete(requestId);
748
+ resolve(respMsg.response);
749
+ } else {
750
+ sessionLog("pump", `DROPPED unmatched control_response: request_id=${requestId ?? "N/A"}`);
751
+ }
752
+ continue;
753
+ }
754
+
755
+ // For tool_call_message chunks, resolve index-based IDs before transform.
756
+ // The first chunk has both tool_call_id and index; subsequent chunks have
757
+ // only index. Patch the wire message so transformMessage() always sees an ID.
758
+ const messageType = wireMsgAny.message_type as string | undefined;
759
+ if (wireMsg.type === "message" && (messageType === "tool_call_message" || messageType === "approval_request_message")) {
760
+ const toolCalls = wireMsgAny.tool_calls as Array<Record<string, unknown>> | undefined;
761
+ const toolCall = wireMsgAny.tool_call as Record<string, unknown> | undefined;
762
+ const tc = toolCalls?.[0] || toolCall;
763
+ if (tc) {
764
+ const fnObj = tc.function as Record<string, unknown> | undefined;
765
+ const tcId = (tc.tool_call_id as string | undefined) ?? (tc.id as string | undefined);
766
+ const tcIndex = tc.index as number | undefined;
767
+
768
+ if (tcId && tcIndex !== undefined) {
769
+ indexToToolCallId.set(tcIndex, tcId);
770
+ } else if (!tcId && tcIndex !== undefined) {
771
+ const resolvedId = indexToToolCallId.get(tcIndex);
772
+ if (resolvedId) {
773
+ // Patch the resolved ID into the wire message for transformMessage
774
+ if (fnObj) {
775
+ tc.id = resolvedId;
776
+ } else {
777
+ tc.tool_call_id = resolvedId;
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ const sdkMsg = this.transformMessage(wireMsg);
785
+ if (sdkMsg) {
786
+ this.enqueueStreamMessage(sdkMsg);
787
+ } else {
788
+ sessionLog("pump", `DROPPED wire message: type=${wireMsg.type} message_type=${wireMsgAny.message_type || "N/A"} subtype=${wireMsgAny.subtype || "N/A"}`);
789
+ }
790
+ }
791
+
792
+ sessionLog("pump", "background pump ended");
793
+ }
794
+
795
+ private async handleControlRequest(controlReq: ControlRequest): Promise<boolean> {
796
+ // Widen to string to allow SDK-extension subtypes not in the protocol union
797
+ const subtype: string = controlReq.request.subtype;
798
+ sessionLog("pump", `control_request: subtype=${subtype} tool=${(controlReq.request as CanUseToolControlRequest).tool_name || "N/A"}`);
799
+
800
+ if (subtype === "can_use_tool") {
801
+ await this.handleCanUseTool(
802
+ controlReq.request_id,
803
+ controlReq.request as CanUseToolControlRequest
804
+ );
805
+ return true;
806
+ }
807
+
808
+ if (subtype === "execute_external_tool") {
809
+ // SDK extension: not in protocol ControlRequestBody union, extract fields via Record
810
+ const rawReq = controlReq.request as Record<string, unknown>;
811
+ await this.handleExecuteExternalTool(
812
+ controlReq.request_id,
813
+ {
814
+ subtype: "execute_external_tool",
815
+ tool_call_id: rawReq.tool_call_id as string,
816
+ tool_name: rawReq.tool_name as string,
817
+ input: rawReq.input as Record<string, unknown>,
818
+ }
819
+ );
820
+ return true;
821
+ }
822
+
823
+ return false;
824
+ }
825
+
826
+ private enqueueStreamMessage(msg: SDKMessage): void {
827
+ const bufferedMsg: BufferedStreamMessage = {
828
+ message: msg,
829
+ generation: this.sendGeneration,
830
+ runId: this.getMessageRunId(msg),
831
+ };
832
+
833
+ if (this.streamResolvers.length > 0) {
834
+ const resolve = this.streamResolvers.shift()!;
835
+ resolve(bufferedMsg);
836
+ return;
837
+ }
838
+
839
+ if (this.streamQueue.length >= MAX_BUFFERED_STREAM_MESSAGES) {
840
+ this.streamQueue.shift();
841
+ this.droppedStreamMessages++;
842
+ sessionLog("pump", `stream queue overflow: dropped oldest message (total_dropped=${this.droppedStreamMessages}, max=${MAX_BUFFERED_STREAM_MESSAGES})`);
843
+ }
844
+
845
+ this.streamQueue.push(bufferedMsg);
846
+ }
847
+
848
+ private async nextBufferedMessage(): Promise<BufferedStreamMessage | null> {
849
+ if (this.streamQueue.length > 0) {
850
+ return this.streamQueue.shift()!;
851
+ }
852
+
853
+ if (this.pumpClosed) {
854
+ return null;
855
+ }
856
+
857
+ return new Promise((resolve) => {
858
+ this.streamResolvers.push(resolve);
859
+ });
860
+ }
861
+
862
+ private resolveAllStreamWaiters(msg: BufferedStreamMessage | null): void {
863
+ for (const resolve of this.streamResolvers) {
864
+ resolve(msg);
865
+ }
866
+ this.streamResolvers = [];
867
+ // Also cancel any in-flight control request waiters (e.g., listMessages)
868
+ for (const resolve of this.controlResponseWaiters.values()) {
869
+ resolve({ subtype: "error", error: "session closed" });
870
+ }
871
+ this.controlResponseWaiters.clear();
872
+ }
873
+
874
+ private getMessageRunId(msg: SDKMessage): string | undefined {
875
+ switch (msg.type) {
876
+ case "assistant":
877
+ case "tool_call":
878
+ case "tool_result":
879
+ case "reasoning":
880
+ case "error":
881
+ case "retry":
882
+ return msg.runId;
883
+ default:
884
+ return undefined;
885
+ }
886
+ }
887
+
888
+ private updateCompletedRunIds(
889
+ resultRunIds: string[] | undefined,
890
+ streamedRunIds: Set<string>,
891
+ ): void {
892
+ const nextRunIds = new Set<string>();
893
+
894
+ if (Array.isArray(resultRunIds)) {
895
+ for (const runId of resultRunIds) {
896
+ if (runId) {
897
+ nextRunIds.add(runId);
898
+ }
899
+ }
900
+ }
901
+
902
+ for (const runId of streamedRunIds) {
903
+ if (runId) {
904
+ nextRunIds.add(runId);
905
+ }
906
+ }
907
+
908
+ this.lastCompletedRunIds = nextRunIds;
909
+ }
910
+
911
+ /**
912
+ * Register external tools with the CLI
913
+ */
914
+ private async registerExternalTools(): Promise<void> {
915
+ const toolDefs = Array.from(this.externalTools.values()).map((tool) => ({
916
+ name: tool.name,
917
+ label: tool.label,
918
+ description: tool.description,
919
+ // Convert TypeBox schema to plain JSON Schema
920
+ parameters: this.schemaToJsonSchema(tool.parameters),
921
+ }));
922
+
923
+ sessionLog("registerTools", `registering ${toolDefs.length} external tools: ${toolDefs.map(t => t.name).join(", ")}`);
924
+
925
+ await this.transport.write({
926
+ type: "control_request",
927
+ request_id: `register_tools_${Date.now()}`,
928
+ request: {
929
+ subtype: "register_external_tools",
930
+ tools: toolDefs,
931
+ },
932
+ });
933
+ }
934
+
935
+ /**
936
+ * Convert TypeBox schema to JSON Schema
937
+ */
938
+ private schemaToJsonSchema(schema: unknown): Record<string, unknown> {
939
+ // TypeBox schemas are already JSON Schema compatible
940
+ // Just need to extract the schema object
941
+ if (schema && typeof schema === "object") {
942
+ // TypeBox schemas have these JSON Schema properties
943
+ const s = schema as Record<string, unknown>;
944
+ return {
945
+ type: s.type,
946
+ properties: s.properties,
947
+ required: s.required,
948
+ additionalProperties: s.additionalProperties,
949
+ description: s.description,
950
+ };
951
+ }
952
+ return { type: "object" };
953
+ }
954
+
955
+ /**
956
+ * Handle execute_external_tool control request from CLI
957
+ */
958
+ private async handleExecuteExternalTool(
959
+ requestId: string,
960
+ req: ExecuteExternalToolRequest
961
+ ): Promise<void> {
962
+ const tool = this.externalTools.get(req.tool_name);
963
+
964
+ if (!tool) {
965
+ // Tool not found - send error result
966
+ sessionLog("executeExternalTool", `ERROR: unknown tool ${req.tool_name}`);
967
+ await this.transport.write({
968
+ type: "control_response",
969
+ response: {
970
+ subtype: "external_tool_result",
971
+ request_id: requestId,
972
+ tool_call_id: req.tool_call_id,
973
+ content: [{ type: "text", text: `Unknown external tool: ${req.tool_name}` }],
974
+ is_error: true,
975
+ },
976
+ });
977
+ return;
978
+ }
979
+
980
+ try {
981
+ sessionLog("executeExternalTool", `executing ${req.tool_name} (call_id=${req.tool_call_id})`);
982
+ // Execute the tool
983
+ const result = await tool.execute(req.tool_call_id, req.input);
984
+
985
+ // Send success result
986
+ await this.transport.write({
987
+ type: "control_response",
988
+ response: {
989
+ subtype: "external_tool_result",
990
+ request_id: requestId,
991
+ tool_call_id: req.tool_call_id,
992
+ content: result.content,
993
+ is_error: false,
994
+ },
995
+ });
996
+ sessionLog("executeExternalTool", `${req.tool_name} completed successfully`);
997
+ } catch (err) {
998
+ // Send error result
999
+ const errorMessage = err instanceof Error ? err.message : String(err);
1000
+ sessionLog("executeExternalTool", `${req.tool_name} failed: ${errorMessage}`);
1001
+ await this.transport.write({
1002
+ type: "control_response",
1003
+ response: {
1004
+ subtype: "external_tool_result",
1005
+ request_id: requestId,
1006
+ tool_call_id: req.tool_call_id,
1007
+ content: [{ type: "text", text: `Tool execution error: ${errorMessage}` }],
1008
+ is_error: true,
1009
+ },
1010
+ });
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Handle can_use_tool control request from CLI (Claude SDK compatible format)
1016
+ */
1017
+ private async handleCanUseTool(
1018
+ requestId: string,
1019
+ req: CanUseToolControlRequest
1020
+ ): Promise<void> {
1021
+ let response: CanUseToolResponse;
1022
+ const toolName = req.tool_name;
1023
+ const hasCallback = typeof this.options.canUseTool === "function";
1024
+ const toolNeedsRuntimeUserInput = requiresRuntimeUserInput(toolName);
1025
+ const autoAllowWithoutCallback =
1026
+ isHeadlessAutoAllowTool(toolName);
1027
+
1028
+ sessionLog("canUseTool", `tool=${toolName} mode=${this.options.permissionMode || "standard"} requestId=${requestId}`);
1029
+
1030
+ // Tools that require runtime user input cannot be auto-allowed without a callback.
1031
+ if (toolNeedsRuntimeUserInput && !hasCallback) {
1032
+ response = {
1033
+ behavior: "deny",
1034
+ message: "No canUseTool callback registered",
1035
+ interrupt: false,
1036
+ };
1037
+ } else if (
1038
+ isUnrestrictedPermissionMode(this.options.permissionMode) &&
1039
+ !toolNeedsRuntimeUserInput
1040
+ ) {
1041
+ // unrestricted auto-allows non-interactive tools.
1042
+ sessionLog("canUseTool", `AUTO-ALLOW ${toolName} (unrestricted)`);
1043
+ response = {
1044
+ behavior: "allow",
1045
+ updatedInput: null,
1046
+ updatedPermissions: [],
1047
+ } satisfies CanUseToolResponseAllow;
1048
+ } else if (hasCallback) {
1049
+ try {
1050
+ const result = await this.options.canUseTool!(
1051
+ toolName,
1052
+ req.input,
1053
+ buildCanUseToolContext(req as unknown as Record<string, unknown>, requestId),
1054
+ );
1055
+ if (result.behavior === "allow") {
1056
+ response = {
1057
+ behavior: "allow",
1058
+ updatedInput: result.updatedInput ?? null,
1059
+ updatedPermissions: [], // TODO: not implemented
1060
+ } satisfies CanUseToolResponseAllow;
1061
+ } else {
1062
+ response = {
1063
+ behavior: "deny",
1064
+ message: result.message ?? "Denied by canUseTool callback",
1065
+ interrupt: false, // TODO: not wired up yet
1066
+ } satisfies CanUseToolResponseDeny;
1067
+ }
1068
+ } catch (err) {
1069
+ response = {
1070
+ behavior: "deny",
1071
+ message: err instanceof Error ? err.message : "Callback error",
1072
+ interrupt: false,
1073
+ };
1074
+ }
1075
+ } else if (autoAllowWithoutCallback) {
1076
+ // Default headless behavior matches Claude: EnterPlanMode can proceed
1077
+ // without requiring a callback in bidirectional mode.
1078
+ sessionLog("canUseTool", `AUTO-ALLOW ${toolName} (default behavior)`);
1079
+ response = {
1080
+ behavior: "allow",
1081
+ updatedInput: null,
1082
+ updatedPermissions: [],
1083
+ } satisfies CanUseToolResponseAllow;
1084
+ } else {
1085
+ // No callback registered - deny by default
1086
+ response = {
1087
+ behavior: "deny",
1088
+ message: "No canUseTool callback registered",
1089
+ interrupt: false,
1090
+ };
1091
+ }
1092
+
1093
+ // Send control_response (Claude SDK compatible format)
1094
+ const responseBehavior = "behavior" in response ? response.behavior : "unknown";
1095
+ sessionLog("canUseTool", `responding: requestId=${requestId} behavior=${responseBehavior}`);
1096
+ await this.transport.write({
1097
+ type: "control_response",
1098
+ response: {
1099
+ subtype: "success",
1100
+ request_id: requestId,
1101
+ response,
1102
+ },
1103
+ });
1104
+ sessionLog("canUseTool", `response sent for ${toolName}`);
1105
+ }
1106
+
1107
+ /**
1108
+ * Abort the current operation (interrupt without closing the session)
1109
+ */
1110
+ async abort(): Promise<void> {
1111
+ if (!this.initialized || this.pumpClosed) return;
1112
+ sessionLog("abort", `aborting session (agent=${this._agentId})`);
1113
+ await this.transport.write({
1114
+ type: "control_request",
1115
+ request_id: `interrupt-${Date.now()}`,
1116
+ request: { subtype: "interrupt" },
1117
+ });
1118
+ }
1119
+
1120
+ private async requestControlResponse(
1121
+ requestId: string,
1122
+ request: Record<string, unknown>,
1123
+ options: { timeoutMs?: number } = {},
1124
+ ): Promise<{ subtype: string; response?: unknown; error?: string }> {
1125
+ const responsePromise = new Promise<{
1126
+ subtype: string;
1127
+ response?: unknown;
1128
+ error?: string;
1129
+ }>((resolve) => {
1130
+ this.controlResponseWaiters.set(requestId, resolve);
1131
+ });
1132
+
1133
+ try {
1134
+ await this.transport.write({
1135
+ type: "control_request",
1136
+ request_id: requestId,
1137
+ request: request as unknown as ControlRequest["request"],
1138
+ });
1139
+ } catch (error) {
1140
+ this.controlResponseWaiters.delete(requestId);
1141
+ throw error;
1142
+ }
1143
+
1144
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
1145
+ try {
1146
+ if (typeof options.timeoutMs === "number") {
1147
+ return await Promise.race([
1148
+ responsePromise,
1149
+ new Promise<never>((_, reject) => {
1150
+ timeoutHandle = setTimeout(() => {
1151
+ this.controlResponseWaiters.delete(requestId);
1152
+ reject(
1153
+ new Error(
1154
+ `Timed out waiting for control_response (${requestId})`,
1155
+ ),
1156
+ );
1157
+ }, options.timeoutMs);
1158
+ }),
1159
+ ]);
1160
+ }
1161
+
1162
+ return await responsePromise;
1163
+ } finally {
1164
+ if (timeoutHandle) {
1165
+ clearTimeout(timeoutHandle);
1166
+ }
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Fetch a page of conversation messages via the CLI control protocol.
1172
+ *
1173
+ * The session must be initialized before calling this method.
1174
+ * Safe to call concurrently with an active stream() — the pump routes
1175
+ * matching control_response messages to this waiter without touching the
1176
+ * stream queue.
1177
+ */
1178
+ async listMessages(options: ListMessagesOptions = {}): Promise<ListMessagesResult> {
1179
+ if (!this.initialized) {
1180
+ throw new Error("Session must be initialized before calling listMessages()");
1181
+ }
1182
+
1183
+ const requestId = `list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
1184
+
1185
+ const resp = await this.requestControlResponse(requestId, {
1186
+ subtype: "list_messages",
1187
+ ...(options.conversationId ? { conversation_id: options.conversationId } : {}),
1188
+ ...(options.before ? { before: options.before } : {}),
1189
+ ...(options.after ? { after: options.after } : {}),
1190
+ ...(options.order ? { order: options.order } : {}),
1191
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
1192
+ });
1193
+
1194
+ if (!resp) {
1195
+ throw new Error("Session closed before listMessages response arrived");
1196
+ }
1197
+ if (resp.subtype === "error") {
1198
+ throw new Error(resp.error ?? "listMessages failed");
1199
+ }
1200
+
1201
+ const payload = resp.response as {
1202
+ messages?: unknown[];
1203
+ next_before?: string | null;
1204
+ has_more?: boolean;
1205
+ } | undefined;
1206
+
1207
+ return {
1208
+ messages: payload?.messages ?? [],
1209
+ nextBefore: payload?.next_before ?? null,
1210
+ hasMore: payload?.has_more ?? false,
1211
+ };
1212
+ }
1213
+
1214
+ async listModels(): Promise<ListModelsResult> {
1215
+ throw new Error(
1216
+ "listModels() is not supported by this session. Use a Cloud, Remote, or local agent session.",
1217
+ );
1218
+ }
1219
+
1220
+ async sendCommand(command: SDKProtocolCommand): Promise<void>;
1221
+ async sendCommand<TResponse extends SDKProtocolMessage = SDKProtocolMessage>(
1222
+ command: SDKProtocolCommand,
1223
+ options: SendCommandOptions,
1224
+ ): Promise<TResponse>;
1225
+ async sendCommand(
1226
+ _command?: SDKProtocolCommand,
1227
+ _options?: SendCommandOptions,
1228
+ ): Promise<void> {
1229
+ throw new Error(
1230
+ "sendCommand() is not supported by this session. Use a Cloud, Remote, or local agent session.",
1231
+ );
1232
+ }
1233
+
1234
+ async updateModel(_update: string | UpdateModelOptions): Promise<UpdateModelResult> {
1235
+ throw new Error(
1236
+ "updateModel() is not supported by this session. Use a Cloud, Remote, or local agent session.",
1237
+ );
1238
+ }
1239
+
1240
+ async changeDeviceState(_updates: ChangeDeviceStateOptions): Promise<void> {
1241
+ throw new Error(
1242
+ "changeDeviceState() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
1243
+ );
1244
+ }
1245
+
1246
+ async removeQueuedMessage(_itemId: string): Promise<RemoveQueuedMessageResult> {
1247
+ throw new Error(
1248
+ "removeQueuedMessage() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
1249
+ );
1250
+ }
1251
+
1252
+ async getDeviceStatus(_options?: GetDeviceStatusOptions): Promise<SessionDeviceStatus> {
1253
+ throw new Error(
1254
+ "getDeviceStatus() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
1255
+ );
1256
+ }
1257
+
1258
+ onDeviceStatus(_listener: (status: SessionDeviceStatus) => void): () => void {
1259
+ throw new Error(
1260
+ "onDeviceStatus() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
1261
+ );
1262
+ }
1263
+
1264
+ /**
1265
+ * Fetch all data needed to render the initial conversation view in one round-trip.
1266
+ *
1267
+ * Returns resolved session metadata + initial history page + pending approval flag
1268
+ * + optional timing breakdown. This is faster than separate initialize() + listMessages()
1269
+ * calls because the CLI collects and returns everything in a single control response.
1270
+ *
1271
+ * The session must be initialized before calling this method.
1272
+ */
1273
+ async bootstrapState(
1274
+ options: BootstrapStateOptions = {},
1275
+ ): Promise<BootstrapStateResult> {
1276
+ if (!this.initialized) {
1277
+ throw new Error(
1278
+ "Session must be initialized before calling bootstrapState()",
1279
+ );
1280
+ }
1281
+
1282
+ const requestId = `bootstrap-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
1283
+
1284
+ const resp = await this.requestControlResponse(requestId, {
1285
+ subtype: "bootstrap_session_state",
1286
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
1287
+ ...(options.order ? { order: options.order } : {}),
1288
+ });
1289
+
1290
+ if (!resp) {
1291
+ throw new Error("Session closed before bootstrapState response arrived");
1292
+ }
1293
+ if (resp.subtype === "error") {
1294
+ throw new Error(
1295
+ (resp as { error?: string }).error ?? "bootstrapState failed",
1296
+ );
1297
+ }
1298
+
1299
+ const payload = resp.response as {
1300
+ agent_id?: string;
1301
+ conversation_id?: string;
1302
+ model?: string;
1303
+ tools?: string[];
1304
+ memfs_enabled?: boolean;
1305
+ messages?: unknown[];
1306
+ next_before?: string | null;
1307
+ has_more?: boolean;
1308
+ has_pending_approval?: boolean;
1309
+ timings?: {
1310
+ resolve_ms: number;
1311
+ list_messages_ms: number;
1312
+ total_ms: number;
1313
+ };
1314
+ } | undefined;
1315
+
1316
+ const state: BootstrapStateResult = {
1317
+ agentId: payload?.agent_id ?? this._agentId ?? "",
1318
+ conversationId: payload?.conversation_id ?? this._conversationId ?? "",
1319
+ model: payload?.model,
1320
+ memfsEnabled: payload?.memfs_enabled ?? false,
1321
+ messages: payload?.messages ?? [],
1322
+ nextBefore: payload?.next_before ?? null,
1323
+ hasMore: payload?.has_more ?? false,
1324
+ hasPendingApproval: payload?.has_pending_approval ?? false,
1325
+ };
1326
+ if (payload?.tools !== undefined) state.tools = payload.tools;
1327
+ if (payload?.timings !== undefined) state.timings = payload.timings;
1328
+ return state;
1329
+ }
1330
+
1331
+ async updateToolset(_toolsetPreference: string): Promise<void> {
1332
+ throw new Error("updateToolset() is not supported by this session. Use a Cloud, Remote, or local agent session.");
1333
+ }
1334
+
1335
+ /**
1336
+ * Close the session
1337
+ */
1338
+ close(): void {
1339
+ if (this.closed) return;
1340
+ this.closed = true;
1341
+ sessionLog("close", `closing session (agent=${this._agentId}, conversation=${this._conversationId})`);
1342
+ this.transport.close();
1343
+ this.pumpClosed = true;
1344
+ this.resolveAllStreamWaiters(null);
1345
+ }
1346
+
1347
+ /**
1348
+ * Get the agent ID (available after initialization)
1349
+ */
1350
+ get agentId(): string | null {
1351
+ return this._agentId;
1352
+ }
1353
+
1354
+ /**
1355
+ * Get the session ID (available after initialization)
1356
+ */
1357
+ get sessionId(): string | null {
1358
+ return this._sessionId;
1359
+ }
1360
+
1361
+ /**
1362
+ * Get the conversation ID (available after initialization)
1363
+ */
1364
+ get conversationId(): string | null {
1365
+ return this._conversationId;
1366
+ }
1367
+
1368
+ /**
1369
+ * AsyncDisposable implementation for `await using`
1370
+ */
1371
+ async [Symbol.asyncDispose](): Promise<void> {
1372
+ this.close();
1373
+ }
1374
+
1375
+ /**
1376
+ * Transform wire message to SDK message
1377
+ */
1378
+ private transformMessage(wireMsg: WireMessage | MessageWire): SDKMessage | null {
1379
+ // Init message
1380
+ if (wireMsg.type === "system" && "subtype" in wireMsg && wireMsg.subtype === "init") {
1381
+ const msg = wireMsg as WireMessage & {
1382
+ agent_id: string;
1383
+ session_id: string;
1384
+ conversation_id: string;
1385
+ model: string;
1386
+ tools: string[];
1387
+ memfs_enabled?: boolean;
1388
+ skill_sources?: Array<"bundled" | "global" | "agent" | "project">;
1389
+ system_info_reminder_enabled?: boolean;
1390
+ reflection_trigger?: "off" | "step-count" | "compaction-event";
1391
+ reflection_behavior?: "reminder" | "auto-launch";
1392
+ reflection_step_count?: number;
1393
+ };
1394
+ return {
1395
+ type: "init",
1396
+ agentId: msg.agent_id,
1397
+ sessionId: msg.session_id,
1398
+ conversationId: msg.conversation_id,
1399
+ model: msg.model,
1400
+ tools: msg.tools,
1401
+ memfsEnabled: msg.memfs_enabled,
1402
+ skillSources: msg.skill_sources,
1403
+ systemInfoReminderEnabled: msg.system_info_reminder_enabled,
1404
+ dreaming:
1405
+ msg.reflection_trigger &&
1406
+ msg.reflection_behavior &&
1407
+ typeof msg.reflection_step_count === "number"
1408
+ ? {
1409
+ trigger: msg.reflection_trigger,
1410
+ behavior: msg.reflection_behavior,
1411
+ stepCount: msg.reflection_step_count,
1412
+ }
1413
+ : undefined,
1414
+ };
1415
+ }
1416
+
1417
+ // Handle message types (all have type: "message" with message_type field)
1418
+ if (wireMsg.type === "message" && "message_type" in wireMsg) {
1419
+ const msg = wireMsg as WireMessage & {
1420
+ message_type: string;
1421
+ uuid: string;
1422
+ run_id?: string;
1423
+ // assistant_message fields
1424
+ content?: string;
1425
+ // tool_call_message fields
1426
+ tool_call?: { name: string; arguments: string; tool_call_id: string };
1427
+ tool_calls?: Array<{ name: string; arguments: string; tool_call_id: string }>;
1428
+ // tool_return_message fields
1429
+ tool_call_id?: string;
1430
+ tool_return?: string;
1431
+ status?: "success" | "error";
1432
+ // reasoning_message fields
1433
+ reasoning?: string;
1434
+ };
1435
+
1436
+ const runId = msg.run_id || undefined;
1437
+
1438
+ // Assistant message
1439
+ if (msg.message_type === "assistant_message" && msg.content) {
1440
+ return {
1441
+ type: "assistant",
1442
+ content: msg.content,
1443
+ uuid: msg.uuid,
1444
+ runId,
1445
+ };
1446
+ }
1447
+
1448
+ // Tool call message (tool_call_message = auto-executed, approval_request_message = needs approval)
1449
+ if (msg.message_type === "tool_call_message" || msg.message_type === "approval_request_message") {
1450
+ const toolCallRaw = (msg.tool_calls?.[0] || msg.tool_call) as Record<string, unknown> | undefined;
1451
+ if (toolCallRaw) {
1452
+ const fnObj = toolCallRaw.function as Record<string, unknown> | undefined;
1453
+ const toolCallId =
1454
+ (toolCallRaw.tool_call_id as string | undefined) ??
1455
+ (toolCallRaw.id as string | undefined);
1456
+ if (!toolCallId) {
1457
+ const detail = `Missing tool_call_id in ${msg.message_type} (uuid=${msg.uuid || "unknown"})`;
1458
+ sessionLog("transform", detail);
1459
+ return {
1460
+ type: "error",
1461
+ message: detail,
1462
+ errorCode: "protocol_error",
1463
+ stopReason: "protocol_error",
1464
+ runId,
1465
+ apiError: {
1466
+ error_type: "protocol_error",
1467
+ detail,
1468
+ message_type: msg.message_type,
1469
+ },
1470
+ recoverable: false,
1471
+ errorDetail: detail,
1472
+ };
1473
+ }
1474
+
1475
+ const toolName =
1476
+ (toolCallRaw.name as string | undefined) ??
1477
+ (fnObj?.name as string | undefined) ??
1478
+ "?";
1479
+ const toolArgs =
1480
+ (toolCallRaw.arguments as string | undefined) ??
1481
+ (fnObj?.arguments as string | undefined) ??
1482
+ "";
1483
+
1484
+ let toolInput: Record<string, unknown> = {};
1485
+ try {
1486
+ toolInput = JSON.parse(toolArgs);
1487
+ } catch {
1488
+ toolInput = { raw: toolArgs };
1489
+ }
1490
+ return {
1491
+ type: "tool_call",
1492
+ toolCallId,
1493
+ toolName,
1494
+ toolInput,
1495
+ rawArguments: toolArgs || undefined,
1496
+ uuid: msg.uuid,
1497
+ runId,
1498
+ };
1499
+ }
1500
+ }
1501
+
1502
+ // Tool return message
1503
+ if (msg.message_type === "tool_return_message" && msg.tool_call_id) {
1504
+ return {
1505
+ type: "tool_result",
1506
+ toolCallId: msg.tool_call_id,
1507
+ content: msg.tool_return || "",
1508
+ isError: msg.status === "error",
1509
+ uuid: msg.uuid,
1510
+ runId,
1511
+ };
1512
+ }
1513
+
1514
+ // Reasoning message
1515
+ if (msg.message_type === "reasoning_message" && msg.reasoning) {
1516
+ return {
1517
+ type: "reasoning",
1518
+ content: msg.reasoning,
1519
+ uuid: msg.uuid,
1520
+ runId,
1521
+ };
1522
+ }
1523
+ }
1524
+
1525
+ // Stream event (partial message updates)
1526
+ if (wireMsg.type === "stream_event") {
1527
+ const msg = wireMsg as WireMessage & {
1528
+ event: unknown;
1529
+ uuid: string;
1530
+ };
1531
+ const eventPayload = (msg.event ?? {}) as SDKStreamEventPayload;
1532
+ return {
1533
+ type: "stream_event",
1534
+ event: eventPayload,
1535
+ uuid: msg.uuid,
1536
+ };
1537
+ }
1538
+
1539
+ // Result message
1540
+ if (wireMsg.type === "result") {
1541
+ const msg = wireMsg as WireMessage & {
1542
+ subtype: string;
1543
+ result?: string;
1544
+ duration_ms: number;
1545
+ total_cost_usd?: number;
1546
+ conversation_id: string;
1547
+ stop_reason?: string;
1548
+ run_ids?: unknown[];
1549
+ };
1550
+ const runIds = Array.isArray(msg.run_ids)
1551
+ ? msg.run_ids.filter((id): id is string => typeof id === "string")
1552
+ : undefined;
1553
+ const approvalConflict = isApprovalConflictSignal({
1554
+ stopReason: msg.stop_reason,
1555
+ });
1556
+ const legacyError = msg.subtype !== "success"
1557
+ ? (approvalConflict && msg.subtype === "error" ? "approval_conflict" : msg.subtype)
1558
+ : undefined;
1559
+ const errorCode = toSdkErrorCode(legacyError);
1560
+ return {
1561
+ type: "result",
1562
+ success: msg.subtype === "success",
1563
+ result: msg.result,
1564
+ error: legacyError,
1565
+ errorCode,
1566
+ ...(approvalConflict
1567
+ ? {
1568
+ approvalConflict: true as const,
1569
+ recoverable: true as const,
1570
+ }
1571
+ : {}),
1572
+ stopReason: msg.stop_reason,
1573
+ durationMs: msg.duration_ms,
1574
+ totalCostUsd: msg.total_cost_usd,
1575
+ conversationId: msg.conversation_id,
1576
+ runIds,
1577
+ };
1578
+ }
1579
+
1580
+ // Error message — carries the actual error detail from the CLI.
1581
+ // The subsequent type=result only has the opaque string "error";
1582
+ // this message has the human-readable description and API error.
1583
+ if (wireMsg.type === "error") {
1584
+ const msg = wireMsg as WireMessage & {
1585
+ message: string;
1586
+ stop_reason: string;
1587
+ run_id?: string;
1588
+ api_error?: Record<string, unknown>;
1589
+ };
1590
+ const errorDetail = extractApiErrorDetail(msg.api_error);
1591
+ const approvalConflict = isApprovalConflictSignal({
1592
+ detail: errorDetail,
1593
+ message: msg.message,
1594
+ stopReason: msg.stop_reason,
1595
+ });
1596
+ const errorCode = approvalConflict
1597
+ ? "approval_conflict"
1598
+ : toSdkErrorCode(msg.stop_reason);
1599
+ return {
1600
+ type: "error" as const,
1601
+ message: msg.message,
1602
+ ...(errorCode ? { errorCode } : {}),
1603
+ ...(approvalConflict
1604
+ ? {
1605
+ approvalConflict: true as const,
1606
+ recoverable: true as const,
1607
+ }
1608
+ : {}),
1609
+ ...(errorDetail ? { errorDetail } : {}),
1610
+ stopReason: msg.stop_reason,
1611
+ runId: msg.run_id,
1612
+ apiError: msg.api_error,
1613
+ };
1614
+ }
1615
+
1616
+ // Retry message — the CLI is retrying after a transient failure.
1617
+ if (wireMsg.type === "retry") {
1618
+ const msg = wireMsg as WireMessage & {
1619
+ reason: string;
1620
+ attempt: number;
1621
+ max_attempts: number;
1622
+ delay_ms: number;
1623
+ run_id?: string;
1624
+ };
1625
+ return {
1626
+ type: "retry" as const,
1627
+ reason: msg.reason,
1628
+ attempt: msg.attempt,
1629
+ maxAttempts: msg.max_attempts,
1630
+ delayMs: msg.delay_ms,
1631
+ runId: msg.run_id,
1632
+ };
1633
+ }
1634
+
1635
+ // Skip other message types (system_message, user_message, etc.)
1636
+ return null;
1637
+ }
1638
+ }