libretto 0.6.12 → 0.6.14

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 (82) hide show
  1. package/README.md +3 -8
  2. package/README.template.md +3 -8
  3. package/dist/cli/cli.js +0 -23
  4. package/dist/cli/commands/auth.js +24 -33
  5. package/dist/cli/commands/billing.js +3 -5
  6. package/dist/cli/commands/browser.js +4 -13
  7. package/dist/cli/commands/deploy.js +54 -45
  8. package/dist/cli/commands/execution.js +6 -3
  9. package/dist/cli/commands/experiments.js +1 -1
  10. package/dist/cli/commands/setup.js +2 -295
  11. package/dist/cli/commands/shared.js +1 -1
  12. package/dist/cli/commands/snapshot.js +10 -100
  13. package/dist/cli/commands/status.js +2 -42
  14. package/dist/cli/core/auth-fetch.js +11 -6
  15. package/dist/cli/core/browser.js +13 -8
  16. package/dist/cli/core/config.js +3 -6
  17. package/dist/cli/core/daemon/daemon.js +88 -74
  18. package/dist/cli/core/daemon/exec-repl.js +133 -0
  19. package/dist/cli/core/daemon/exec.js +6 -21
  20. package/dist/cli/core/daemon/ipc.js +47 -4
  21. package/dist/cli/core/daemon/ipc.spec.js +21 -0
  22. package/dist/cli/core/daemon/snapshot.js +2 -29
  23. package/dist/cli/core/exec-compiler.js +8 -3
  24. package/dist/cli/core/experiments.js +1 -28
  25. package/dist/cli/core/providers/index.js +13 -4
  26. package/dist/cli/core/providers/libretto-cloud.js +178 -26
  27. package/dist/cli/index.js +0 -2
  28. package/dist/cli/router.js +9 -6
  29. package/dist/shared/instrumentation/instrument.js +4 -4
  30. package/dist/shared/ipc/socket-transport.d.ts +2 -1
  31. package/dist/shared/ipc/socket-transport.js +16 -5
  32. package/dist/shared/ipc/socket-transport.spec.js +5 -0
  33. package/docs/releasing.md +8 -6
  34. package/package.json +3 -2
  35. package/skills/libretto/SKILL.md +49 -47
  36. package/skills/libretto/references/code-generation-rules.md +6 -0
  37. package/skills/libretto/references/configuration-file-reference.md +14 -12
  38. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  39. package/skills/libretto/references/site-security-review.md +6 -6
  40. package/skills/libretto-readonly/SKILL.md +2 -9
  41. package/src/cli/cli.ts +0 -24
  42. package/src/cli/commands/auth.ts +24 -33
  43. package/src/cli/commands/billing.ts +3 -5
  44. package/src/cli/commands/browser.ts +6 -16
  45. package/src/cli/commands/deploy.ts +55 -49
  46. package/src/cli/commands/execution.ts +6 -3
  47. package/src/cli/commands/experiments.ts +1 -1
  48. package/src/cli/commands/setup.ts +2 -381
  49. package/src/cli/commands/shared.ts +1 -1
  50. package/src/cli/commands/snapshot.ts +9 -137
  51. package/src/cli/commands/status.ts +2 -50
  52. package/src/cli/core/auth-fetch.ts +9 -4
  53. package/src/cli/core/browser.ts +15 -8
  54. package/src/cli/core/config.ts +3 -6
  55. package/src/cli/core/daemon/daemon.ts +106 -76
  56. package/src/cli/core/daemon/exec-repl.ts +189 -0
  57. package/src/cli/core/daemon/exec.ts +8 -43
  58. package/src/cli/core/daemon/ipc.spec.ts +27 -0
  59. package/src/cli/core/daemon/ipc.ts +81 -23
  60. package/src/cli/core/daemon/snapshot.ts +1 -43
  61. package/src/cli/core/exec-compiler.ts +8 -3
  62. package/src/cli/core/experiments.ts +9 -38
  63. package/src/cli/core/providers/index.ts +17 -4
  64. package/src/cli/core/providers/libretto-cloud.ts +224 -36
  65. package/src/cli/core/resolve-model.ts +5 -0
  66. package/src/cli/core/workflow-runtime.ts +1 -0
  67. package/src/cli/index.ts +0 -1
  68. package/src/cli/router.ts +9 -6
  69. package/src/shared/instrumentation/instrument.ts +4 -4
  70. package/src/shared/ipc/socket-transport.spec.ts +6 -0
  71. package/src/shared/ipc/socket-transport.ts +20 -5
  72. package/dist/cli/commands/ai.js +0 -110
  73. package/dist/cli/core/ai-model.js +0 -195
  74. package/dist/cli/core/api-snapshot-analyzer.js +0 -86
  75. package/dist/cli/core/snapshot-analyzer.js +0 -667
  76. package/dist/cli/framework/simple-cli.js +0 -880
  77. package/scripts/summarize-evals.mjs +0 -135
  78. package/src/cli/commands/ai.ts +0 -144
  79. package/src/cli/core/ai-model.ts +0 -301
  80. package/src/cli/core/api-snapshot-analyzer.ts +0 -110
  81. package/src/cli/core/snapshot-analyzer.ts +0 -856
  82. package/src/cli/framework/simple-cli.ts +0 -1459
@@ -3,6 +3,7 @@ import type { ChildProcess } from "node:child_process";
3
3
  import { spawn } from "node:child_process";
4
4
  import { openSync, closeSync } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
+ import { homedir, userInfo } from "node:os";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { createIpcPeer, type IpcPeer } from "../../../shared/ipc/ipc.js";
8
9
  import { connectToIpcSocket } from "../../../shared/ipc/socket-transport.js";
@@ -25,9 +26,10 @@ export type DaemonExecArgs = {
25
26
 
26
27
  export type DaemonReadonlyExecArgs = { code: string; pageId?: string };
27
28
 
28
- export type DaemonSnapshotArgs =
29
- | { mode?: "legacy"; pageId?: string; useCachedSnapshot?: false }
30
- | { mode: "compact"; pageId?: string; useCachedSnapshot?: boolean };
29
+ export type DaemonSnapshotArgs = {
30
+ pageId?: string;
31
+ useCachedSnapshot?: boolean;
32
+ };
31
33
 
32
34
  export type DaemonExecSuccess = {
33
35
  result: unknown;
@@ -35,24 +37,12 @@ export type DaemonExecSuccess = {
35
37
  snapshotDiff?: SnapshotDiff;
36
38
  };
37
39
 
38
- export type DaemonLegacySnapshotResult = {
39
- pngPath: string;
40
- htmlPath: string;
41
- snapshotRunId: string;
42
- pageUrl: string;
43
- title: string;
44
- };
45
-
46
- export type DaemonCompactSnapshotResult = {
40
+ export type DaemonSnapshotResult = {
47
41
  mode: "compact";
48
42
  pngPath: string;
49
43
  snapshot: Snapshot;
50
44
  };
51
45
 
52
- export type DaemonSnapshotResult =
53
- | DaemonLegacySnapshotResult
54
- | DaemonCompactSnapshotResult;
55
-
56
46
  export type DaemonCloseResult = { replayUrl?: string };
57
47
 
58
48
  export type DaemonCommandResult<T> =
@@ -116,6 +106,11 @@ export type DaemonStartupErrorMessage = {
116
106
  message: string;
117
107
  };
118
108
 
109
+ export type DaemonStartupStatusMessage = {
110
+ type: "startup-status";
111
+ message: string;
112
+ };
113
+
119
114
  function isDaemonReadyMessage(message: unknown): message is DaemonReadyMessage {
120
115
  if (typeof message !== "object" || message === null) return false;
121
116
  const candidate = message as { type?: unknown; socketPath?: unknown };
@@ -132,6 +127,16 @@ function isDaemonStartupErrorMessage(
132
127
  );
133
128
  }
134
129
 
130
+ function isDaemonStartupStatusMessage(
131
+ message: unknown,
132
+ ): message is DaemonStartupStatusMessage {
133
+ if (typeof message !== "object" || message === null) return false;
134
+ const candidate = message as { type?: unknown; message?: unknown };
135
+ return (
136
+ candidate.type === "startup-status" && typeof candidate.message === "string"
137
+ );
138
+ }
139
+
135
140
  export type DaemonClientSpawnOptions = {
136
141
  config: DaemonConfig;
137
142
  logger: LoggerApi;
@@ -153,18 +158,43 @@ export type DaemonClientSpawnResult = {
153
158
  // ---------------------------------------------------------------------------
154
159
 
155
160
  /**
156
- * Deterministic Unix domain socket path for a given session.
161
+ * Deterministic IPC endpoint for a given session.
162
+ *
163
+ * Unix-like platforms use a socket path in `/tmp` to stay well under the macOS
164
+ * 104-byte Unix socket path limit. Windows uses a named pipe path because
165
+ * filesystem Unix domain socket paths are not portable there.
157
166
  *
158
- * The path lives in `/tmp` to stay well under the macOS 104-byte Unix socket
159
- * path limit. The hash combines `REPO_ROOT` and the session name so different
160
- * repos (or sessions within the same repo) never collide.
167
+ * The hash combines `REPO_ROOT`, the session name, and a user key so different
168
+ * repos, sessions, or local users never collide.
161
169
  */
162
- export function getDaemonSocketPath(session: string): string {
170
+ export function getDaemonSocketPath(
171
+ session: string,
172
+ platform: NodeJS.Platform = process.platform,
173
+ ): string {
174
+ const userKey = getDaemonUserKey();
163
175
  const hash = createHash("sha256")
164
- .update(`${REPO_ROOT}:${session}`)
176
+ .update(`${REPO_ROOT}:${session}:${userKey}`)
165
177
  .digest("hex")
166
178
  .slice(0, 12);
167
- return `/tmp/libretto-${process.getuid!()}-${hash}.sock`;
179
+
180
+ if (platform === "win32") {
181
+ return `\\\\.\\pipe\\libretto-${hash}`;
182
+ }
183
+
184
+ return `/tmp/libretto-${userKey}-${hash}.sock`;
185
+ }
186
+
187
+ function getDaemonUserKey(): string {
188
+ if (typeof process.getuid === "function") return String(process.getuid());
189
+
190
+ try {
191
+ const info = userInfo();
192
+ if (info.username) return info.username;
193
+ } catch {
194
+ // Fall back below.
195
+ }
196
+
197
+ return createHash("sha256").update(homedir()).digest("hex").slice(0, 12);
168
198
  }
169
199
 
170
200
  // ---------------------------------------------------------------------------
@@ -268,6 +298,13 @@ export class DaemonClient {
268
298
  onExit: (code, signal, ready) => {
269
299
  logger.warn("daemon-exit", { code, signal, session, pid, ready });
270
300
  },
301
+ onStatus: (message) => {
302
+ logger.info("daemon-startup-status", {
303
+ session,
304
+ message: message.message,
305
+ });
306
+ console.log(message.message);
307
+ },
271
308
  }).catch(async (error: unknown) => {
272
309
  try {
273
310
  process.kill(pid, "SIGTERM");
@@ -303,6 +340,7 @@ export class DaemonClient {
303
340
  signal: NodeJS.Signals | null,
304
341
  ready: boolean,
305
342
  ) => void;
343
+ onStatus?: (message: DaemonStartupStatusMessage) => void;
306
344
  }): Promise<DaemonReadyMessage> {
307
345
  const {
308
346
  child,
@@ -313,6 +351,7 @@ export class DaemonClient {
313
351
  onReady,
314
352
  onSpawnError,
315
353
  onExit,
354
+ onStatus,
316
355
  } = args;
317
356
 
318
357
  return new Promise<DaemonReadyMessage>((resolve, reject) => {
@@ -324,6 +363,8 @@ export class DaemonClient {
324
363
  child.off("message", onMessage);
325
364
  child.off("error", onError);
326
365
  child.off("exit", onChildExit);
366
+ process.off("SIGINT", onParentSigint);
367
+ process.off("SIGTERM", onParentSigterm);
327
368
  };
328
369
 
329
370
  const fail = (error: Error): void => {
@@ -338,6 +379,10 @@ export class DaemonClient {
338
379
  fail(new Error(message.message));
339
380
  return;
340
381
  }
382
+ if (isDaemonStartupStatusMessage(message)) {
383
+ onStatus?.(message);
384
+ return;
385
+ }
341
386
  if (!isDaemonReadyMessage(message)) return;
342
387
  ready = true;
343
388
  cleanup();
@@ -359,9 +404,22 @@ export class DaemonClient {
359
404
  fail(formatExitError(code, signal));
360
405
  };
361
406
 
407
+ const forwardSignalToChild = (signal: NodeJS.Signals): void => {
408
+ try {
409
+ child.kill(signal);
410
+ } catch {
411
+ // Child may have already exited.
412
+ }
413
+ };
414
+
415
+ const onParentSigint = (): void => forwardSignalToChild("SIGINT");
416
+ const onParentSigterm = (): void => forwardSignalToChild("SIGTERM");
417
+
362
418
  child.on("message", onMessage);
363
419
  child.on("error", onError);
364
420
  child.on("exit", onChildExit);
421
+ process.once("SIGINT", onParentSigint);
422
+ process.once("SIGTERM", onParentSigterm);
365
423
  });
366
424
  }
367
425
 
@@ -1,5 +1,5 @@
1
1
  import type { Page } from "playwright";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync } from "node:fs";
3
3
  import type { LoggerApi } from "../../../shared/logger/index.js";
4
4
  import { getSessionSnapshotRunDir } from "../context.js";
5
5
  import {
@@ -25,48 +25,6 @@ type SnapshotScreenshot = {
25
25
  title: string;
26
26
  };
27
27
 
28
- export async function handleSnapshot(
29
- targetPage: Page,
30
- session: string,
31
- logger: LoggerApi,
32
- pageId?: string,
33
- ): Promise<{
34
- pngPath: string;
35
- htmlPath: string;
36
- snapshotRunId: string;
37
- pageUrl: string;
38
- title: string;
39
- }> {
40
- const screenshot = await captureSnapshotScreenshot(
41
- targetPage,
42
- session,
43
- logger,
44
- pageId,
45
- );
46
- const htmlPath = `${getSessionSnapshotRunDir(
47
- session,
48
- screenshot.snapshotRunId,
49
- )}/page.html`;
50
-
51
- // Capture HTML content.
52
- const htmlContent = await targetPage.content();
53
- writeFileSync(htmlPath, htmlContent);
54
-
55
- logger.info("screenshot-success", {
56
- session,
57
- pageUrl: screenshot.pageUrl,
58
- title: screenshot.title,
59
- pngPath: screenshot.pngPath,
60
- htmlPath,
61
- snapshotRunId: screenshot.snapshotRunId,
62
- });
63
-
64
- return {
65
- ...screenshot,
66
- htmlPath,
67
- };
68
- }
69
-
70
28
  export async function handleCompactSnapshot(
71
29
  targetPage: Page,
72
30
  session: string,
@@ -19,6 +19,13 @@ const stripTypeScriptTypes = (
19
19
  moduleBuiltin as { stripTypeScriptTypes?: StripTypeScriptTypesFn }
20
20
  ).stripTypeScriptTypes;
21
21
 
22
+ export function stripTypeScriptExecCode(code: string): string {
23
+ if (!stripTypeScriptTypes) return code;
24
+ return withSuppressedStripTypeScriptWarning(() =>
25
+ stripTypeScriptTypes(code, { mode: "strip" }),
26
+ );
27
+ }
28
+
22
29
  export function withSuppressedStripTypeScriptWarning<T>(action: () => T): T {
23
30
  type EmitWarningFn = (...args: unknown[]) => void;
24
31
  const mutableProcess = process as unknown as { emitWarning: EmitWarningFn };
@@ -66,9 +73,7 @@ export function compileTypeScriptExecFunction(
66
73
  if (!stripTypeScriptTypes) return null;
67
74
 
68
75
  const wrappedSource = `(async function __librettoExec(${helperNames.join(", ")}) {\n${code}\n})`;
69
- const jsSource = withSuppressedStripTypeScriptWarning(() =>
70
- stripTypeScriptTypes(wrappedSource, { mode: "strip" }),
71
- );
76
+ const jsSource = stripTypeScriptExecCode(wrappedSource);
72
77
  const createFunction = new Function(
73
78
  `return ${jsSource}`,
74
79
  ) as () => ExecFunction;
@@ -4,45 +4,16 @@ import {
4
4
  writeLibrettoConfig,
5
5
  } from "./config.js";
6
6
 
7
- export const EXPERIMENTS = {
8
- "compact-snapshot-format": {
9
- title: "Compact snapshot format",
10
- oneSentenceDescription:
11
- "Use compact accessibility snapshots and exec page-change diffs without an AI sub-agent.",
12
- docs: [
13
- "Compact snapshot format changes how agents should use snapshot and exec after the experiment is enabled.",
14
- "",
15
- "Compared with the skill's documented behavior:",
16
- " - Run libretto snapshot --session <name> without --objective or --context.",
17
- " - Snapshot output is a screenshot path plus a compact accessibility tree; it does not use the PNG + HTML + AI analysis path.",
18
- " - Run libretto snapshot <ref> --session <name> to inspect a subtree from the latest full compact snapshot.",
19
- " - Run libretto exec normally; after successful mutations, Libretto prints page-change diffs from compact snapshots without AI analysis.",
20
- " - If a session was already open before enabling the experiment, close and reopen it before relying on this behavior.",
21
- "",
22
- "Full compact snapshot:",
23
- " libretto snapshot --session <name>",
24
- "",
25
- "Cached subtree snapshot:",
26
- " libretto snapshot <ref> --session <name>",
27
- "",
28
- "Run an unscoped snapshot before using refs. Subtree snapshots capture a fresh screenshot but reuse the latest cached tree.",
29
- "",
30
- "Notes:",
31
- " - Use ref forms printed in the tree, such as l16. Numeric-suffix aliases such as e16 also match l16.",
32
- ].join("\n"),
33
- defaultValue: false,
34
- },
35
- } as const satisfies Record<
36
- string,
37
- {
38
- title: string;
39
- oneSentenceDescription: string;
40
- docs?: string;
41
- defaultValue: boolean;
42
- }
43
- >;
7
+ export type ExperimentMetadata = {
8
+ title: string;
9
+ oneSentenceDescription: string;
10
+ docs?: string;
11
+ defaultValue: boolean;
12
+ };
13
+
14
+ export const EXPERIMENTS: Readonly<Record<string, ExperimentMetadata>> = {};
44
15
 
45
- export type ExperimentName = keyof typeof EXPERIMENTS;
16
+ export type ExperimentName = string;
46
17
  export type Experiments = Record<ExperimentName, boolean>;
47
18
 
48
19
  export function isExperimentName(name: string): name is ExperimentName {
@@ -4,9 +4,16 @@ import { createKernelProvider } from "./kernel.js";
4
4
  import { createLibrettoCloudProvider } from "./libretto-cloud.js";
5
5
  import type { ProviderApi } from "./types.js";
6
6
 
7
- const VALID_PROVIDERS = new Set(["local", "kernel", "browserbase", "libretto-cloud"] as const);
7
+ const VALID_PROVIDERS = new Set([
8
+ "local",
9
+ "kernel",
10
+ "browserbase",
11
+ "libretto-cloud",
12
+ ] as const);
8
13
  export type ProviderName =
9
14
  typeof VALID_PROVIDERS extends Set<infer T> ? T : never;
15
+ const DEFAULT_PROVIDER_STARTUP_TIMEOUT_MS = 60_000;
16
+ const LIBRETTO_CLOUD_STARTUP_TIMEOUT_MS = 10 * 60_000;
10
17
 
11
18
  function assertValidProviderName(value: string, source: string): ProviderName {
12
19
  if (!VALID_PROVIDERS.has(value as ProviderName)) {
@@ -50,9 +57,7 @@ export function getCloudProviderApi(name: string): ProviderApi {
50
57
  case "browserbase":
51
58
  return createBrowserbaseProvider();
52
59
  case "libretto-cloud":
53
- console.warn(
54
- "Note: The libretto-cloud provider is in alpha.",
55
- );
60
+ console.warn("Note: The libretto-cloud provider is in alpha.");
56
61
  return createLibrettoCloudProvider();
57
62
  default:
58
63
  throw new Error(
@@ -60,3 +65,11 @@ export function getCloudProviderApi(name: string): ProviderApi {
60
65
  );
61
66
  }
62
67
  }
68
+
69
+ export function getProviderStartupTimeoutMs(
70
+ providerName: string | undefined,
71
+ ): number {
72
+ return providerName === "libretto-cloud"
73
+ ? LIBRETTO_CLOUD_STARTUP_TIMEOUT_MS
74
+ : DEFAULT_PROVIDER_STARTUP_TIMEOUT_MS;
75
+ }
@@ -1,20 +1,33 @@
1
- import { HOSTED_API_URL } from "../auth-fetch.js";
1
+ import { resolveHostedApiUrl } from "../auth-fetch.js";
2
2
  import type { ProviderApi } from "./types.js";
3
3
 
4
+ type CloudSessionResponse = {
5
+ session_id: string;
6
+ status: string;
7
+ cdp_url: string | null;
8
+ live_view_url: string | null;
9
+ recording_url: string | null;
10
+ };
11
+
12
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
13
+ const DEFAULT_BROWSER_SESSION_TIMEOUT_SECONDS = 3_600;
14
+ const QUEUE_WAIT_TIMEOUT_MS = 10 * 60_000;
15
+
4
16
  export function createLibrettoCloudProvider(): ProviderApi {
5
17
  const apiKey = process.env.LIBRETTO_API_KEY;
6
18
  if (!apiKey)
7
19
  throw new Error(
8
20
  "LIBRETTO_API_KEY is required for the Libretto Cloud provider.",
9
21
  );
10
- const endpoint = HOSTED_API_URL;
22
+ const endpoint = resolveHostedApiUrl();
11
23
 
12
24
  // The Libretto Cloud API is an oRPC RPCHandler, not plain REST, so inputs
13
25
  // must be wrapped as { json: ... } and outputs arrive the same way.
14
26
  return {
15
27
  async createSession() {
16
- const timeoutSeconds = Number(
17
- process.env.LIBRETTO_TIMEOUT_SECONDS ?? 7200,
28
+ const browserSessionTimeoutSeconds = readPositiveNumberEnv(
29
+ "LIBRETTO_TIMEOUT_SECONDS",
30
+ DEFAULT_BROWSER_SESSION_TIMEOUT_SECONDS,
18
31
  );
19
32
  const resp = await fetch(`${endpoint}/v1/sessions/create`, {
20
33
  method: "POST",
@@ -23,48 +36,223 @@ export function createLibrettoCloudProvider(): ProviderApi {
23
36
  "Content-Type": "application/json",
24
37
  },
25
38
  body: JSON.stringify({
26
- json: { timeout_seconds: timeoutSeconds },
39
+ json: { timeout_seconds: browserSessionTimeoutSeconds },
27
40
  }),
28
41
  });
29
42
  if (!resp.ok) {
30
43
  const body = await resp.text();
31
- throw new Error(
32
- `Libretto Cloud API error (${resp.status}): ${body}`,
33
- );
44
+ throw new Error(`Libretto Cloud API error (${resp.status}): ${body}`);
45
+ }
46
+ const { json } = (await resp.json()) as { json: CloudSessionResponse };
47
+ const startupCleanup = createStartupSessionCleanup(
48
+ endpoint,
49
+ apiKey,
50
+ json.session_id,
51
+ );
52
+ let readySession: CloudSessionResponse & { cdp_url: string };
53
+ try {
54
+ readySession = await waitForCloudSessionReady({
55
+ endpoint,
56
+ apiKey,
57
+ session: json,
58
+ timeoutMs: QUEUE_WAIT_TIMEOUT_MS,
59
+ isCancelled: startupCleanup.isCancelled,
60
+ });
61
+ } catch (error) {
62
+ if (startupCleanup.isCancelled()) {
63
+ await startupCleanup.waitForClose();
64
+ } else {
65
+ await closeCloudSession(endpoint, apiKey, json.session_id).catch(
66
+ () => {},
67
+ );
68
+ }
69
+ throw error;
70
+ } finally {
71
+ startupCleanup.dispose();
34
72
  }
35
- const { json } = (await resp.json()) as {
36
- json: {
37
- session_id: string;
38
- cdp_url: string;
39
- live_view_url: string | null;
40
- recording_url: string | null;
41
- };
42
- };
43
73
  return {
44
- sessionId: json.session_id,
45
- cdpEndpoint: json.cdp_url,
46
- liveViewUrl: json.live_view_url ?? undefined,
74
+ sessionId: readySession.session_id,
75
+ cdpEndpoint: readySession.cdp_url,
76
+ liveViewUrl: readySession.live_view_url ?? undefined,
47
77
  };
48
78
  },
49
79
  async closeSession(sessionId) {
50
- const resp = await fetch(`${endpoint}/v1/sessions/close`, {
51
- method: "POST",
52
- headers: {
53
- "x-api-key": apiKey,
54
- "Content-Type": "application/json",
55
- },
56
- body: JSON.stringify({ json: { session_id: sessionId } }),
57
- });
58
- if (!resp.ok) {
59
- const body = await resp.text();
60
- throw new Error(
61
- `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
62
- );
63
- }
64
- const { json } = (await resp.json()) as {
65
- json: { replay_url: string | null };
66
- };
80
+ const json = await closeCloudSession(endpoint, apiKey, sessionId);
67
81
  return { replayUrl: json.replay_url ?? undefined };
68
82
  },
69
83
  };
70
84
  }
85
+
86
+ async function waitForCloudSessionReady(args: {
87
+ endpoint: string;
88
+ apiKey: string;
89
+ session: CloudSessionResponse;
90
+ timeoutMs: number;
91
+ isCancelled?: () => boolean;
92
+ }): Promise<CloudSessionResponse & { cdp_url: string }> {
93
+ let session = args.session;
94
+ if (args.isCancelled?.()) {
95
+ throw new Error(
96
+ `Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
97
+ );
98
+ }
99
+ if (session.cdp_url) {
100
+ return { ...session, cdp_url: session.cdp_url };
101
+ }
102
+
103
+ sendStartupStatus(
104
+ `Libretto Cloud browser session queued (session: ${session.session_id}). Waiting for browser capacity...`,
105
+ );
106
+
107
+ const pollIntervalMs = readPositiveNumberEnv(
108
+ "LIBRETTO_CLOUD_SESSION_POLL_INTERVAL_MS",
109
+ DEFAULT_POLL_INTERVAL_MS,
110
+ );
111
+ const deadline = Date.now() + args.timeoutMs;
112
+
113
+ while (Date.now() < deadline) {
114
+ if (args.isCancelled?.()) {
115
+ throw new Error(
116
+ `Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
117
+ );
118
+ }
119
+ await sleep(pollIntervalMs);
120
+ if (args.isCancelled?.()) {
121
+ throw new Error(
122
+ `Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
123
+ );
124
+ }
125
+ session = await getCloudSession(
126
+ args.endpoint,
127
+ args.apiKey,
128
+ session.session_id,
129
+ );
130
+ if (session.cdp_url) {
131
+ sendStartupStatus(
132
+ `Libretto Cloud browser capacity available (session: ${session.session_id}). Connecting...`,
133
+ );
134
+ return { ...session, cdp_url: session.cdp_url };
135
+ }
136
+ if (!["queued", "starting"].includes(session.status)) {
137
+ throw new Error(
138
+ `Libretto Cloud session ${session.session_id} entered status "${session.status}" before a CDP URL was available.`,
139
+ );
140
+ }
141
+ }
142
+
143
+ throw new Error(
144
+ `Timed out waiting for Libretto Cloud browser capacity after ${Math.ceil(args.timeoutMs / 1_000)}s (session: ${session.session_id}).`,
145
+ );
146
+ }
147
+
148
+ async function getCloudSession(
149
+ endpoint: string,
150
+ apiKey: string,
151
+ sessionId: string,
152
+ ): Promise<CloudSessionResponse> {
153
+ const resp = await fetch(`${endpoint}/v1/sessions/get`, {
154
+ method: "POST",
155
+ headers: {
156
+ "x-api-key": apiKey,
157
+ "Content-Type": "application/json",
158
+ },
159
+ body: JSON.stringify({ json: { session_id: sessionId } }),
160
+ });
161
+ if (!resp.ok) {
162
+ const body = await resp.text();
163
+ throw new Error(
164
+ `Libretto Cloud API error reading session ${sessionId} (${resp.status}): ${body}`,
165
+ );
166
+ }
167
+ const { json } = (await resp.json()) as { json: CloudSessionResponse };
168
+ return json;
169
+ }
170
+
171
+ async function closeCloudSession(
172
+ endpoint: string,
173
+ apiKey: string,
174
+ sessionId: string,
175
+ ): Promise<{ replay_url: string | null }> {
176
+ const resp = await fetch(`${endpoint}/v1/sessions/close`, {
177
+ method: "POST",
178
+ headers: {
179
+ "x-api-key": apiKey,
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify({ json: { session_id: sessionId } }),
183
+ });
184
+ if (!resp.ok) {
185
+ const body = await resp.text();
186
+ throw new Error(
187
+ `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
188
+ );
189
+ }
190
+ const { json } = (await resp.json()) as {
191
+ json: { replay_url: string | null };
192
+ };
193
+ return json;
194
+ }
195
+
196
+ function createStartupSessionCleanup(
197
+ endpoint: string,
198
+ apiKey: string,
199
+ sessionId: string,
200
+ ): {
201
+ isCancelled: () => boolean;
202
+ waitForClose: () => Promise<void>;
203
+ dispose: () => void;
204
+ } {
205
+ let cancelled = false;
206
+ let closePromise: Promise<void> | null = null;
207
+
208
+ const requestClose = (reason: string): void => {
209
+ if (cancelled) return;
210
+ cancelled = true;
211
+ sendStartupStatus(
212
+ `Libretto Cloud browser session cancelled (${reason}). Cleaning up queued session...`,
213
+ );
214
+ closePromise = closeCloudSession(endpoint, apiKey, sessionId).then(
215
+ () => {},
216
+ () => {},
217
+ );
218
+ };
219
+
220
+ const onDisconnect = (): void => requestClose("parent command disconnected");
221
+ const onSigint = (): void => requestClose("received SIGINT");
222
+ const onSigterm = (): void => requestClose("received SIGTERM");
223
+
224
+ if (typeof process.send === "function") {
225
+ process.once("disconnect", onDisconnect);
226
+ }
227
+ process.once("SIGINT", onSigint);
228
+ process.once("SIGTERM", onSigterm);
229
+
230
+ return {
231
+ isCancelled: () => cancelled,
232
+ waitForClose: async () => {
233
+ await closePromise;
234
+ },
235
+ dispose: () => {
236
+ process.off("disconnect", onDisconnect);
237
+ process.off("SIGINT", onSigint);
238
+ process.off("SIGTERM", onSigterm);
239
+ },
240
+ };
241
+ }
242
+
243
+ function readPositiveNumberEnv(name: string, fallback: number): number {
244
+ const raw = process.env[name];
245
+ if (!raw) return fallback;
246
+ const parsed = Number(raw);
247
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
248
+ }
249
+
250
+ function sendStartupStatus(message: string): void {
251
+ if (typeof process.send === "function") {
252
+ process.send({ type: "startup-status", message });
253
+ }
254
+ }
255
+
256
+ function sleep(ms: number): Promise<void> {
257
+ return new Promise((resolve) => setTimeout(resolve, ms));
258
+ }