@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/index.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Letta Agent SDK
3
+ *
4
+ * Programmatic control of Letta Code CLI with persistent agent memory.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { LettaAgentClient, createAgent, createSession, resumeSession, prompt } from '@letta-ai/letta-agent-sdk';
9
+ *
10
+ * const client = new LettaAgentClient({ backend: 'local' });
11
+ * const agentId = await client.createAgent();
12
+ * const clientSession = client.resumeSession(agentId);
13
+ *
14
+ * // Start session with default agent + new conversation (like `letta`)
15
+ * const session = createSession();
16
+ *
17
+ * // Create a new agent explicitly
18
+ * const agentId = await createAgent();
19
+ *
20
+ * // Resume default conversation on an agent
21
+ * const session = resumeSession(agentId);
22
+ *
23
+ * // Resume specific conversation
24
+ * const session = resumeSession('conv-xxx');
25
+ *
26
+ * // Create new conversation on specific agent
27
+ * const session = createSession(agentId);
28
+ *
29
+ * // One-shot prompt (uses default agent)
30
+ * const result = await prompt('Hello');
31
+ * const result = await prompt('Hello', agentId); // specific agent
32
+ * ```
33
+ */
34
+
35
+ import { Session } from "./session.js";
36
+ import { LettaAgentClient } from "./client.js";
37
+ import type {
38
+ CreateSessionOptions,
39
+ CreateAgentOptions,
40
+ LettaCodeSession,
41
+ SDKInitMessage,
42
+ SDKResultMessage,
43
+ SendMessage,
44
+ } from "./types.js";
45
+ import { validateCreateSessionOptions, validateCreateAgentOptions } from "./validation.js";
46
+
47
+ // Re-export types
48
+ export type {
49
+ CreateSessionOptions,
50
+ CreateAgentOptions,
51
+ LettaCodePersonalityId,
52
+ LettaCodeBackend,
53
+ LettaCodeEnvironment,
54
+ LettaCodeLocalClientOptions,
55
+ LettaCodeLocalAppServerOptions,
56
+ LettaCodeRemoteClientOptions,
57
+ LettaCodeCloudClientOptions,
58
+ LettaCodeCloudSandboxOptions,
59
+ GitHubRepositoryRef,
60
+ LettaCodeClientOptions,
61
+ LettaCodeClientSessionOptions,
62
+ LettaCodeSession,
63
+ LettaCodeSocketLike,
64
+ LettaCodeSocketConstructor,
65
+ LettaCodeReactNativeSocketConstructor,
66
+ SDKMessage,
67
+ SDKInitMessage,
68
+ SDKAssistantMessage,
69
+ SDKToolCallMessage,
70
+ SDKToolResultMessage,
71
+ SDKReasoningMessage,
72
+ SDKResultMessage,
73
+ SDKErrorCode,
74
+ SDKStreamEventMessage,
75
+ SDKStreamEventPayload,
76
+ SDKStreamEventDeltaPayload,
77
+ SDKStreamEventMessagePayload,
78
+ SDKUnknownStreamEventPayload,
79
+ SDKErrorMessage,
80
+ SDKRetryMessage,
81
+ SDKQueueItem,
82
+ SDKQueueUpdateMessage,
83
+ SDKLoopStatusMessage,
84
+ SDKProtocolMessage,
85
+ SDKProtocolCommand,
86
+ SendCommandOptions,
87
+ RunTurnOptions,
88
+ RecoverPendingApprovalsOptions,
89
+ RecoverPendingApprovalsResult,
90
+ ChangeDeviceStateOptions,
91
+ RemoveQueuedMessageResult,
92
+ GetDeviceStatusOptions,
93
+ SessionDeviceStatus,
94
+ SessionPendingControlRequest,
95
+ SessionPermissionSuggestion,
96
+ SessionDiffHunkLine,
97
+ SessionDiffHunk,
98
+ SessionDiffPreview,
99
+ SkillSource,
100
+ DreamingOptions,
101
+ DreamingTrigger,
102
+ DreamingBehavior,
103
+ EffectiveDreamingSettings,
104
+ PermissionMode,
105
+ ReasoningEffort,
106
+ CanUseToolCallback,
107
+ CanUseToolContext,
108
+ CanUseToolPermissionSuggestion,
109
+ CanUseToolResponse,
110
+ CanUseToolResponseAllow,
111
+ CanUseToolResponseDeny,
112
+ // Multimodal content types
113
+ TextContent,
114
+ ImageContent,
115
+ MessageContentItem,
116
+ SendMessage,
117
+ // List messages API
118
+ ListMessagesOptions,
119
+ ListMessagesResult,
120
+ ListModelsResult,
121
+ LettaCodeModelEntry,
122
+ UpdateModelOptions,
123
+ UpdateModelResult,
124
+ Repository,
125
+ CreateRepositoryParams,
126
+ ListRepositoriesParams,
127
+ ListRepositoriesResult,
128
+ RepositoryResource,
129
+ RepositoryFileEntry,
130
+ ListRepositoryFilesParams,
131
+ ListRepositoryFilesResult,
132
+ CreateRepositoryFileParams,
133
+ RepositoryFile,
134
+ UpdateRepositoryFileParams,
135
+ RepositoryFileMutationResult,
136
+ DeleteRepositoryFileParams,
137
+ DeleteRepositoryFileResult,
138
+ RepositoryVersion,
139
+ ListRepositoryVersionsParams,
140
+ GetRepositoryVersionParams,
141
+ // Bootstrap API
142
+ BootstrapStateOptions,
143
+ BootstrapStateResult,
144
+ // Tool types
145
+ AgentTool,
146
+ AgentToolResult,
147
+ AgentToolResultContent,
148
+ AgentToolUpdateCallback,
149
+ AnyAgentTool,
150
+ } from "./types.js";
151
+ export type {
152
+ AgentsClient,
153
+ ConversationsClient,
154
+ LettaAgent,
155
+ LettaConversation,
156
+ LettaConversationMessage,
157
+ ModelsClient,
158
+ ListAgentsOptions,
159
+ UpdateAgentOptions,
160
+ ListConversationsOptions,
161
+ CreateConversationOptions,
162
+ UpdateConversationOptions,
163
+ ConversationMessagesOptions,
164
+ ConversationMessagesResult,
165
+ } from "./management-types.js";
166
+
167
+ export { Session } from "./session.js";
168
+ export { RepositoriesClient } from "./repositories.js";
169
+ export { LettaAgentClient } from "./client.js";
170
+ export { CloudManagedSandboxExpiredError } from "./cloud-session.js";
171
+ export { createReactNativeWebSocketConstructor } from "./websocket.js";
172
+
173
+ export { extractStreamTextDelta } from "./stream-events.js";
174
+
175
+ // Tool helpers
176
+ export {
177
+ jsonResult,
178
+ readStringParam,
179
+ readNumberParam,
180
+ readBooleanParam,
181
+ readStringArrayParam,
182
+ } from "./tool-helpers.js";
183
+
184
+ /**
185
+ * Create a new agent with a default conversation.
186
+ * Returns the agentId which can be used with resumeSession or createSession.
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * // Create agent with default settings.
191
+ * const agentId = await createAgent();
192
+ *
193
+ * // Create agent with custom memory
194
+ * const agentId = await createAgent({
195
+ * memory: ['persona', 'project'],
196
+ * persona: 'You are a helpful coding assistant',
197
+ * model: 'claude-sonnet-4',
198
+ * tags: ['project:docs']
199
+ * });
200
+ *
201
+ * // Then resume the default conversation:
202
+ * const session = resumeSession(agentId);
203
+ * ```
204
+ */
205
+ export async function createAgent(options: CreateAgentOptions = {}): Promise<string> {
206
+ validateCreateAgentOptions(options);
207
+ return new LettaAgentClient().createAgent(options);
208
+ }
209
+
210
+ /**
211
+ * Create a new conversation (session).
212
+ *
213
+ * - Without agentId: uses default/LRU agent with new conversation (like `letta`)
214
+ * - With agentId: creates new conversation on specified agent
215
+ *
216
+ * @example
217
+ * ```typescript
218
+ * // New conversation on default agent (like `letta`)
219
+ * await using session = createSession();
220
+ *
221
+ * // New conversation on specific agent
222
+ * await using session = createSession(agentId);
223
+ * ```
224
+ */
225
+ export function createSession(
226
+ agentId?: string,
227
+ options: CreateSessionOptions = {},
228
+ ): LettaCodeSession {
229
+ validateCreateSessionOptions(options);
230
+ if (agentId) {
231
+ return new LettaAgentClient().createSession(agentId, options);
232
+ }
233
+ // The app-server runtime_start protocol requires an explicit agent id. Keep
234
+ // the historical default/LRU-agent helper on the legacy stdio transport.
235
+ return new LettaAgentClient({ backend: "local", transport: "stdio" }).createSession(options);
236
+ }
237
+
238
+ /**
239
+ * Resume an existing session.
240
+ *
241
+ * - Pass an agent ID (agent-xxx) to resume the default conversation
242
+ * - Pass a conversation ID (conv-xxx) to resume a specific conversation
243
+ *
244
+ * The default conversation always exists after createAgent, so you can:
245
+ * `createAgent()` → `resumeSession(agentId)` without needing createSession first.
246
+ *
247
+ * @example
248
+ * ```typescript
249
+ * // Resume default conversation
250
+ * await using session = resumeSession(agentId);
251
+ *
252
+ * // Resume specific conversation
253
+ * await using session = resumeSession('conv-xxx');
254
+ * ```
255
+ */
256
+ export function resumeSession(
257
+ id: string,
258
+ options: CreateSessionOptions = {},
259
+ ): LettaCodeSession {
260
+ validateCreateSessionOptions(options);
261
+ return new LettaAgentClient().resumeSession(id, options);
262
+ }
263
+
264
+ /**
265
+ * One-shot prompt convenience function.
266
+ *
267
+ * - Without agentId: uses default agent (like `letta -p`), new conversation
268
+ * - With agentId: uses specific agent, new conversation
269
+ * - Uses a short-lived session and returns the final turn result.
270
+ *
271
+ * @example
272
+ * ```typescript
273
+ * const result = await prompt('What is 2+2?'); // default agent
274
+ * const result = await prompt('What is the capital of France?', agentId); // specific agent
275
+ * ```
276
+ */
277
+ type TurnSession = LettaCodeSession & {
278
+ runTurn(message: SendMessage): Promise<SDKResultMessage>;
279
+ };
280
+
281
+ type InitializableSession = LettaCodeSession & {
282
+ initialize(): Promise<SDKInitMessage>;
283
+ };
284
+
285
+ export async function prompt(
286
+ message: string,
287
+ agentId?: string
288
+ ): Promise<SDKResultMessage> {
289
+ // Use default agent behavior (like letta -p) when no agentId specified
290
+ const session = agentId
291
+ ? createSession(agentId)
292
+ : createSession();
293
+
294
+ try {
295
+ return await (session as TurnSession).runTurn(message);
296
+ } finally {
297
+ session.close();
298
+ }
299
+ }
300
+
301
+ // ═══════════════════════════════════════════════════════════════
302
+ // SESSIONLESS APIs
303
+ // ═══════════════════════════════════════════════════════════════
304
+
305
+ import type { ListMessagesOptions, ListMessagesResult } from "./types.js";
306
+
307
+ /**
308
+ * Fetch conversation messages without requiring a pre-existing session.
309
+ *
310
+ * Creates a transient CLI subprocess, fetches the requested message page, and
311
+ * closes the subprocess. Useful for prefetching conversation histories before
312
+ * opening a full session (e.g. desktop sidebar warm-up).
313
+ *
314
+ * Routing follows the same agent/conversation semantics as session history:
315
+ * - Pass a conv-xxx conversationId to read a specific conversation.
316
+ * - Omit conversationId to read the agent's default conversation.
317
+ *
318
+ * @param agentId - Agent ID to fetch messages for.
319
+ * @param options - Pagination / filtering options (same as ListMessagesOptions).
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * // Prefetch default conversation
324
+ * const { messages } = await listMessagesDirect(agentId);
325
+ *
326
+ * // Prefetch a specific conversation
327
+ * const { messages, hasMore, nextBefore } = await listMessagesDirect(agentId, {
328
+ * conversationId: 'conv-abc',
329
+ * limit: 20,
330
+ * order: 'desc',
331
+ * });
332
+ * ```
333
+ */
334
+ export async function listMessagesDirect(
335
+ agentId: string,
336
+ options: ListMessagesOptions = {},
337
+ ): Promise<ListMessagesResult> {
338
+ // resumeSession uses --default which maps to the agent's default conversation.
339
+ // The session is transient: we only need it long enough to list messages.
340
+ const session = new LettaAgentClient().resumeSession(agentId, {
341
+ permissionMode: "unrestricted",
342
+ });
343
+ await (session as InitializableSession).initialize();
344
+ try {
345
+ return await session.listMessages(options);
346
+ } finally {
347
+ session.close();
348
+ }
349
+ }
350
+
351
+ // ═══════════════════════════════════════════════════════════════
352
+ // IMAGE HELPERS
353
+ // ═══════════════════════════════════════════════════════════════
354
+
355
+ import { readFileSync } from "node:fs";
356
+ import type { ImageContent } from "./types.js";
357
+
358
+ /**
359
+ * Create image content from a file path.
360
+ *
361
+ * @example
362
+ * ```typescript
363
+ * await session.send([
364
+ * { type: "text", text: "What's in this image?" },
365
+ * imageFromFile("./screenshot.png")
366
+ * ]);
367
+ * ```
368
+ */
369
+ export function imageFromFile(filePath: string): ImageContent {
370
+ const data = readFileSync(filePath).toString("base64");
371
+ const ext = filePath.toLowerCase();
372
+ const media_type: ImageContent["source"]["media_type"] =
373
+ ext.endsWith(".png") ? "image/png"
374
+ : ext.endsWith(".gif") ? "image/gif"
375
+ : ext.endsWith(".webp") ? "image/webp"
376
+ : "image/jpeg";
377
+
378
+ return {
379
+ type: "image",
380
+ source: { type: "base64", media_type, data }
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Create image content from base64 data.
386
+ *
387
+ * @example
388
+ * ```typescript
389
+ * const base64 = fs.readFileSync("image.png").toString("base64");
390
+ * await session.send([
391
+ * { type: "text", text: "Describe this" },
392
+ * imageFromBase64(base64, "image/png")
393
+ * ]);
394
+ * ```
395
+ */
396
+ export function imageFromBase64(
397
+ data: string,
398
+ media_type: ImageContent["source"]["media_type"] = "image/png"
399
+ ): ImageContent {
400
+ return {
401
+ type: "image",
402
+ source: { type: "base64", media_type, data }
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Create image content from a URL.
408
+ * Fetches the image and converts to base64.
409
+ *
410
+ * @example
411
+ * ```typescript
412
+ * const img = await imageFromURL("https://example.com/image.png");
413
+ * await session.send([
414
+ * { type: "text", text: "What's this?" },
415
+ * img
416
+ * ]);
417
+ * ```
418
+ */
419
+ export async function imageFromURL(url: string): Promise<ImageContent> {
420
+ const response = await fetch(url);
421
+ const buffer = await response.arrayBuffer();
422
+ const data = Buffer.from(buffer).toString("base64");
423
+
424
+ // Detect media type from content-type header or URL
425
+ const contentType = response.headers.get("content-type");
426
+ let media_type: ImageContent["source"]["media_type"] = "image/png";
427
+
428
+ if (contentType?.includes("jpeg") || contentType?.includes("jpg") || url.match(/\.jpe?g$/i)) {
429
+ media_type = "image/jpeg";
430
+ } else if (contentType?.includes("gif") || url.endsWith(".gif")) {
431
+ media_type = "image/gif";
432
+ } else if (contentType?.includes("webp") || url.endsWith(".webp")) {
433
+ media_type = "image/webp";
434
+ }
435
+
436
+ return {
437
+ type: "image",
438
+ source: { type: "base64", media_type, data }
439
+ };
440
+ }
@@ -0,0 +1,62 @@
1
+ // Interactive tool policy for SDK permission callbacks.
2
+ // Centralizes behavior so transport/session logic doesn't hardcode names inline.
3
+
4
+ import type { CanUseToolContext, CanUseToolPermissionSuggestion } from "./types.js";
5
+
6
+ const INTERACTIVE_APPROVAL_TOOLS = new Set([
7
+ "AskUserQuestion",
8
+ "EnterPlanMode",
9
+ "ExitPlanMode",
10
+ ]);
11
+
12
+ const RUNTIME_USER_INPUT_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
13
+
14
+ const HEADLESS_AUTO_ALLOW_TOOLS = new Set(["EnterPlanMode"]);
15
+
16
+ export function isInteractiveApprovalTool(toolName: string): boolean {
17
+ return INTERACTIVE_APPROVAL_TOOLS.has(toolName);
18
+ }
19
+
20
+ export function requiresRuntimeUserInput(toolName: string): boolean {
21
+ return RUNTIME_USER_INPUT_TOOLS.has(toolName);
22
+ }
23
+
24
+ export function isHeadlessAutoAllowTool(toolName: string): boolean {
25
+ return HEADLESS_AUTO_ALLOW_TOOLS.has(toolName);
26
+ }
27
+
28
+ function normalizePermissionSuggestions(
29
+ value: unknown,
30
+ ): CanUseToolPermissionSuggestion[] | undefined {
31
+ if (!Array.isArray(value)) return undefined;
32
+ const suggestions: CanUseToolPermissionSuggestion[] = [];
33
+ for (const entry of value) {
34
+ if (!entry || typeof entry !== "object") continue;
35
+ const record = entry as Record<string, unknown>;
36
+ if (typeof record.id === "string" && typeof record.text === "string") {
37
+ suggestions.push({ id: record.id, text: record.text });
38
+ }
39
+ }
40
+ return suggestions;
41
+ }
42
+
43
+ /**
44
+ * Build the {@link CanUseToolContext} passed to canUseTool callbacks from a raw
45
+ * `can_use_tool` control request body. Fields absent from the wire request are
46
+ * left undefined so callbacks can distinguish "not provided" from empty values.
47
+ */
48
+ export function buildCanUseToolContext(
49
+ request: Record<string, unknown>,
50
+ requestId?: string,
51
+ ): CanUseToolContext {
52
+ const context: CanUseToolContext = {};
53
+ if (typeof requestId === "string") context.requestId = requestId;
54
+ if (typeof request.tool_call_id === "string") context.toolCallId = request.tool_call_id;
55
+ const suggestions = normalizePermissionSuggestions(request.permission_suggestions);
56
+ if (suggestions !== undefined) context.permissionSuggestions = suggestions;
57
+ if (typeof request.blocked_path === "string" || request.blocked_path === null) {
58
+ context.blockedPath = request.blocked_path as string | null;
59
+ }
60
+ if (Array.isArray(request.diffs)) context.diffs = request.diffs as unknown[];
61
+ return context;
62
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ AppServerSession,
3
+ type AppServerSessionMode,
4
+ type AppServerSessionOptions,
5
+ } from "./app-server-session.js";
6
+ import { startLocalAppServer } from "./local-app-server.js";
7
+ import type { LettaCodeLocalAppServerOptions } from "./types.js";
8
+
9
+ export function createLocalAppServerSession(
10
+ options: LettaCodeLocalAppServerOptions | undefined,
11
+ mode: AppServerSessionMode,
12
+ beforeConnect?: () => Promise<void>,
13
+ ): AppServerSession {
14
+ const appServer = options ?? {};
15
+ const sessionOptions: AppServerSessionOptions = {
16
+ ...(appServer.url !== undefined
17
+ ? { url: appServer.url }
18
+ : {
19
+ connect: (sessionEnv?: Record<string, string>) =>
20
+ startLocalAppServer({
21
+ listen: appServer.listen,
22
+ backend: appServer.harnessBackend ?? "local",
23
+ startupTimeoutMs: appServer.startupTimeoutMs,
24
+ env: sessionEnv,
25
+ }),
26
+ }),
27
+ ...(appServer.WebSocket !== undefined
28
+ ? { WebSocket: appServer.WebSocket }
29
+ : {}),
30
+ ...(appServer.requestTimeoutMs !== undefined
31
+ ? { requestTimeoutMs: appServer.requestTimeoutMs }
32
+ : {}),
33
+ ...(appServer.pinGlobalAgent !== undefined
34
+ ? { pinGlobalAgent: appServer.pinGlobalAgent }
35
+ : {}),
36
+ ...(beforeConnect ? { beforeConnect } : {}),
37
+ };
38
+ return new AppServerSession(sessionOptions, mode);
39
+ }
@@ -0,0 +1,137 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { findLettaCli } from "./cli-resolver.js";
3
+
4
+ export interface LocalAppServerHandle {
5
+ url: string;
6
+ close(): void;
7
+ }
8
+
9
+ export interface StartLocalAppServerOptions {
10
+ listen?: string;
11
+ backend?: string;
12
+ startupTimeoutMs?: number;
13
+ cliPath?: string;
14
+ env?: Record<string, string | undefined>;
15
+ }
16
+
17
+ const DEFAULT_LISTEN_URL = "ws://127.0.0.1:0";
18
+ const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
19
+ const LISTENING_RE = /^Listening on\s+(ws:\/\/\S+)\s*$/m;
20
+
21
+ function appendLine(buffer: string, chunk: unknown): string {
22
+ return buffer + String(chunk);
23
+ }
24
+
25
+ function tryExtractListeningUrl(output: string): string | null {
26
+ const match = output.match(LISTENING_RE);
27
+ return match?.[1] ?? null;
28
+ }
29
+
30
+ export function buildLocalAppServerArgs(
31
+ cliPath: string,
32
+ options: Pick<StartLocalAppServerOptions, "backend" | "listen"> = {},
33
+ ): string[] {
34
+ return [
35
+ cliPath,
36
+ ...(options.backend !== undefined ? ["--backend", options.backend] : []),
37
+ "app-server",
38
+ "--listen",
39
+ options.listen ?? DEFAULT_LISTEN_URL,
40
+ ];
41
+ }
42
+
43
+ function terminateProcess(child: ChildProcess): void {
44
+ if (child.exitCode !== null || child.signalCode !== null) return;
45
+ child.kill("SIGTERM");
46
+ setTimeout(() => {
47
+ if (child.exitCode === null && child.signalCode === null) {
48
+ child.kill("SIGKILL");
49
+ }
50
+ }, 1_000).unref?.();
51
+ }
52
+
53
+ /**
54
+ * Spawn an SDK-owned Letta Code app-server on an ephemeral loopback port.
55
+ */
56
+ export function startLocalAppServer(
57
+ options: StartLocalAppServerOptions = {},
58
+ ): Promise<LocalAppServerHandle> {
59
+ const cliPath = options.cliPath ?? findLettaCli();
60
+ const args = buildLocalAppServerArgs(cliPath, options);
61
+ const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
62
+
63
+ return new Promise((resolve, reject) => {
64
+ const child = spawn(process.execPath, args, {
65
+ stdio: ["ignore", "pipe", "pipe"],
66
+ env: { ...process.env, ...(options.env ?? {}) },
67
+ });
68
+
69
+ let settled = false;
70
+ let output = "";
71
+
72
+ const cleanup = () => {
73
+ child.stdout?.off("data", onStdout);
74
+ child.stderr?.off("data", onStderr);
75
+ child.off("error", onError);
76
+ child.off("exit", onExit);
77
+ clearTimeout(timeout);
78
+ };
79
+
80
+ const fail = (error: Error) => {
81
+ if (settled) return;
82
+ settled = true;
83
+ cleanup();
84
+ terminateProcess(child);
85
+ reject(error);
86
+ };
87
+
88
+ const succeed = (url: string) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ cleanup();
92
+ resolve({
93
+ url,
94
+ close: () => terminateProcess(child),
95
+ });
96
+ };
97
+
98
+ const onOutput = (chunk: unknown) => {
99
+ output = appendLine(output, chunk);
100
+ const url = tryExtractListeningUrl(output);
101
+ if (url) succeed(url);
102
+ };
103
+
104
+ const onStdout = (chunk: unknown) => onOutput(chunk);
105
+ const onStderr = (chunk: unknown) => {
106
+ // Startup failures are printed to stderr by the CLI. Keep stderr in the
107
+ // collected output so timeout/exit errors are actionable.
108
+ output = appendLine(output, chunk);
109
+ };
110
+ const onError = (error: Error) => fail(error);
111
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
112
+ if (settled) return;
113
+ fail(
114
+ new Error(
115
+ `Local Letta Code app-server exited before listening (code=${code ?? "null"}, signal=${signal ?? "null"}).${
116
+ output ? ` Output:\n${output.trim()}` : ""
117
+ }`,
118
+ ),
119
+ );
120
+ };
121
+
122
+ const timeout = setTimeout(() => {
123
+ fail(
124
+ new Error(
125
+ `Timed out waiting for local Letta Code app-server to start.${
126
+ output ? ` Output:\n${output.trim()}` : ""
127
+ }`,
128
+ ),
129
+ );
130
+ }, startupTimeoutMs);
131
+
132
+ child.stdout?.on("data", onStdout);
133
+ child.stderr?.on("data", onStderr);
134
+ child.once("error", onError);
135
+ child.once("exit", onExit);
136
+ });
137
+ }