libretto 0.6.7 → 0.6.9

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.
@@ -17,6 +17,7 @@ export const DEFAULT_SNAPSHOT_MODELS = {
17
17
  anthropic: "anthropic/claude-sonnet-4-6",
18
18
  google: "google/gemini-3-flash-preview",
19
19
  vertex: "vertex/gemini-2.5-flash",
20
+ openrouter: "openrouter/free",
20
21
  } as const satisfies Record<Provider, string>;
21
22
 
22
23
  // ── Source detection ────────────────────────────────────────────────────────
@@ -44,6 +45,8 @@ function detectProviderEnvVar(
44
45
  if (env.GOOGLE_CLOUD_PROJECT?.trim()) return "GOOGLE_CLOUD_PROJECT";
45
46
  if (env.GCLOUD_PROJECT?.trim()) return "GCLOUD_PROJECT";
46
47
  return null;
48
+ case "openrouter":
49
+ return env.OPENROUTER_API_KEY?.trim() ? "OPENROUTER_API_KEY" : null;
47
50
  }
48
51
  }
49
52
 
@@ -72,11 +75,13 @@ function providerSetupSentence(provider: Provider): string {
72
75
  return "Add GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY to .env or as a shell environment variable.";
73
76
  case "vertex":
74
77
  return "Add GOOGLE_CLOUD_PROJECT or GCLOUD_PROJECT to .env or as a shell environment variable, and make sure application default credentials are configured.";
78
+ case "openrouter":
79
+ return "Add OPENROUTER_API_KEY to .env or as a shell environment variable.";
75
80
  }
76
81
  }
77
82
 
78
83
  function defaultModelCommandLine(): string {
79
- return "npx libretto ai configure openai | anthropic | gemini | vertex";
84
+ return "npx libretto ai configure openai | anthropic | gemini | vertex | openrouter";
80
85
  }
81
86
 
82
87
  function providerMissingCredentialSummary(provider: Provider): string {
@@ -89,13 +94,15 @@ function providerMissingCredentialSummary(provider: Provider): string {
89
94
  return "GEMINI_API_KEY and GOOGLE_GENERATIVE_AI_API_KEY are missing";
90
95
  case "vertex":
91
96
  return "GOOGLE_CLOUD_PROJECT and GCLOUD_PROJECT are missing";
97
+ case "openrouter":
98
+ return "OPENROUTER_API_KEY is missing";
92
99
  }
93
100
  }
94
101
 
95
102
  function noSnapshotApiConfiguredMessage(): string {
96
103
  return [
97
104
  "Failed to analyze snapshot because no snapshot analyzer is configured.",
98
- `Add OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY, or GOOGLE_CLOUD_PROJECT to .env or as a shell environment variable, or choose a default model with \`${defaultModelCommandLine()}\`.`,
105
+ `Add OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY, GOOGLE_CLOUD_PROJECT, or OPENROUTER_API_KEY to .env or as a shell environment variable, or choose a default model with \`${defaultModelCommandLine()}\`.`,
99
106
  "For more info, run `npx libretto setup`.",
100
107
  ].join(" ");
101
108
  }
@@ -122,6 +129,7 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
122
129
  "anthropic",
123
130
  "google",
124
131
  "vertex",
132
+ "openrouter",
125
133
  ];
126
134
 
127
135
  for (const provider of providersInPriorityOrder) {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Browser daemon process.
3
+ *
4
+ * Launched as a detached child process by `runOpen()` in `browser.ts`.
5
+ * Receives configuration as a JSON string in `process.argv[2]`.
6
+ *
7
+ * Responsibilities:
8
+ * - Launch Chromium with the specified settings
9
+ * - Create a browser context and page
10
+ * - Install session telemetry (network/action logging)
11
+ * - Navigate to the requested URL
12
+ * - Stay alive until the browser disconnects or a signal is received
13
+ */
14
+
15
+ import { chromium } from "playwright";
16
+ import { mkdir, unlink } from "node:fs/promises";
17
+ import { appendFileSync } from "node:fs";
18
+ import { installSessionTelemetry } from "./session-telemetry.js";
19
+ import {
20
+ getSessionDir,
21
+ getSessionLogsPath,
22
+ getSessionNetworkLogPath,
23
+ getSessionActionsLogPath,
24
+ getSessionStatePath,
25
+ } from "./context.js";
26
+
27
+ // ── Config schema ──────────────────────────────────────────────────────
28
+
29
+ type DaemonConfig = {
30
+ port: number;
31
+ url: string;
32
+ session: string;
33
+ headed: boolean;
34
+ viewport: { width: number; height: number };
35
+ storageStatePath?: string;
36
+ windowPosition?: { x: number; y: number };
37
+ };
38
+
39
+ const config: DaemonConfig = JSON.parse(process.argv[2]);
40
+
41
+ // ── Derived paths ──────────────────────────────────────────────────────
42
+
43
+ const sessionDir = getSessionDir(config.session);
44
+ await mkdir(sessionDir, { recursive: true });
45
+
46
+ const logFile = getSessionLogsPath(config.session);
47
+ const networkLogFile = getSessionNetworkLogPath(config.session);
48
+ const actionsLogFile = getSessionActionsLogPath(config.session);
49
+
50
+ type TelemetryEntry = Record<string, unknown>;
51
+
52
+ function childLog(
53
+ level: string,
54
+ event: string,
55
+ data: Record<string, unknown> = {},
56
+ ): void {
57
+ const entry = JSON.stringify({
58
+ timestamp: new Date().toISOString(),
59
+ id: Math.random().toString(36).slice(2, 10),
60
+ level,
61
+ scope: "libretto.child",
62
+ event,
63
+ data,
64
+ });
65
+ appendFileSync(logFile, entry + "\n");
66
+ }
67
+
68
+ function logAction(entry: TelemetryEntry): void {
69
+ appendFileSync(actionsLogFile, JSON.stringify(entry) + "\n");
70
+ }
71
+
72
+ function logNetwork(entry: TelemetryEntry): void {
73
+ appendFileSync(networkLogFile, JSON.stringify(entry) + "\n");
74
+ }
75
+
76
+ // ── Launch browser ─────────────────────────────────────────────────────
77
+
78
+ const windowPositionArg = config.windowPosition
79
+ ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
80
+ : undefined;
81
+
82
+ const launchArgs = [
83
+ "--disable-blink-features=AutomationControlled",
84
+ `--remote-debugging-port=${config.port}`,
85
+ "--remote-debugging-address=127.0.0.1",
86
+ "--no-focus-on-check",
87
+ ...(windowPositionArg ? [windowPositionArg] : []),
88
+ ];
89
+
90
+ const browser = await chromium.launch({
91
+ headless: !config.headed,
92
+ args: launchArgs,
93
+ });
94
+
95
+ async function cleanupSessionState(): Promise<void> {
96
+ const sessionStatePath = getSessionStatePath(config.session);
97
+ try {
98
+ await unlink(sessionStatePath);
99
+ } catch (err) {
100
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
101
+ }
102
+ }
103
+
104
+ let shuttingDown = false;
105
+ let wakeDaemon: () => void;
106
+ const sleepPromise = new Promise<void>((resolve) => {
107
+ wakeDaemon = resolve;
108
+ });
109
+
110
+ async function shutdown(
111
+ reason: string,
112
+ closeBrowser: boolean,
113
+ ): Promise<void> {
114
+ if (shuttingDown) return;
115
+ shuttingDown = true;
116
+ try {
117
+ childLog("info", reason, { port: config.port });
118
+ await cleanupSessionState();
119
+ if (closeBrowser) await browser.close();
120
+ } finally {
121
+ wakeDaemon();
122
+ }
123
+ }
124
+
125
+ browser.on("disconnected", () => {
126
+ void shutdown("browser-disconnected-exiting", false);
127
+ });
128
+
129
+ // ── Create context & page ──────────────────────────────────────────────
130
+
131
+ const context = await browser.newContext({
132
+ ...(config.storageStatePath ? { storageState: config.storageStatePath } : {}),
133
+ viewport: {
134
+ width: config.viewport.width,
135
+ height: config.viewport.height,
136
+ },
137
+ userAgent:
138
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
139
+ });
140
+
141
+ const page = await context.newPage();
142
+
143
+ // ── Page defaults & telemetry ──────────────────────────────────────────
144
+
145
+ page.setDefaultTimeout(30000);
146
+ page.setDefaultNavigationTimeout(45000);
147
+
148
+ await installSessionTelemetry({
149
+ context,
150
+ initialPage: page,
151
+ includeUserDomActions: true,
152
+ logAction,
153
+ logNetwork,
154
+ });
155
+
156
+ // ── Navigate ───────────────────────────────────────────────────────────
157
+
158
+ await page.goto(config.url);
159
+
160
+ // ── Process lifecycle ──────────────────────────────────────────────────
161
+
162
+ process.on("SIGTERM", () => {
163
+ void shutdown("child-sigterm", true);
164
+ });
165
+
166
+ process.on("SIGINT", () => {
167
+ void shutdown("child-sigint", true);
168
+ });
169
+
170
+ process.on("uncaughtException", (err) => {
171
+ childLog("error", "uncaught-exception", {
172
+ message: err.message,
173
+ stack: err.stack,
174
+ });
175
+ process.exit(1);
176
+ });
177
+
178
+ process.on("unhandledRejection", (reason) => {
179
+ childLog("warn", "unhandled-rejection", { reason: String(reason) });
180
+ });
181
+
182
+ process.on("exit", (code) => {
183
+ childLog("info", "child-exit", {
184
+ code,
185
+ pid: process.pid,
186
+ port: config.port,
187
+ });
188
+ });
189
+
190
+ childLog("info", "child-launched", {
191
+ port: config.port,
192
+ pid: process.pid,
193
+ session: config.session,
194
+ });
195
+
196
+ // Keep the daemon alive until the browser disconnects or a signal arrives.
197
+ await sleepPromise;
198
+ process.exit(0);
@@ -5,27 +5,15 @@ import {
5
5
  type CDPSession,
6
6
  type Page,
7
7
  } from "playwright";
8
- import { openSync, existsSync, writeFileSync } from "node:fs";
9
- import { basename, dirname, join, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
8
+ import { openSync, closeSync, existsSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { createRequire } from "node:module";
12
12
  import { createServer } from "node:net";
13
13
  import { spawn } from "node:child_process";
14
14
  import type { LoggerApi } from "../../shared/logger/index.js";
15
15
  import type { SessionAccessMode } from "../../shared/state/index.js";
16
- import {
17
- filterSemanticClasses,
18
- INTERACTIVE_ROLE_NAMES,
19
- INTERACTIVE_TAG_NAMES,
20
- isObfuscatedClass,
21
- TEST_ATTRIBUTE_NAMES,
22
- TRUSTED_ATTRIBUTE_NAMES,
23
- } from "../../shared/dom-semantics.js";
24
- import {
25
- getSessionActionsLogPath,
26
- getSessionNetworkLogPath,
27
- PROFILES_DIR,
28
- } from "./context.js";
16
+ import { PROFILES_DIR } from "./context.js";
29
17
  import { readLibrettoConfig } from "./config.js";
30
18
  import {
31
19
  assertSessionAvailableForStart,
@@ -39,7 +27,6 @@ import {
39
27
  } from "./session.js";
40
28
  import type { ProviderApi } from "./providers/types.js";
41
29
  import { getCloudProviderApi } from "./providers/index.js";
42
- import { installSessionTelemetry } from "./session-telemetry.js";
43
30
 
44
31
  const CLOSE_WAIT_MS = 1_500;
45
32
  const FORCE_CLOSE_WAIT_MS = 300;
@@ -434,8 +421,6 @@ export async function runOpen(
434
421
 
435
422
  const port = await pickFreePort();
436
423
  const runLogPath = logFileForSession(session);
437
- const networkLogPath = getSessionNetworkLogPath(session);
438
- const actionsLogPath = getSessionActionsLogPath(session);
439
424
 
440
425
  const browserMode = headed ? "headed" : "headless";
441
426
  const supportsSavedProfile =
@@ -459,178 +444,33 @@ export async function runOpen(
459
444
  }
460
445
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
461
446
 
462
- const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
463
- const storageStateCode = useProfile
464
- ? `storageState: '${profilePath!
465
- .replace(/\\/g, "\\\\")
466
- .replace(/'/g, "\\'")}',`
467
- : "";
468
-
469
- const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
470
- const escapedNetworkLogPath = networkLogPath
471
- .replace(/\\/g, "\\\\")
472
- .replace(/'/g, "\\'");
473
- const escapedActionsLogPath = actionsLogPath
474
- .replace(/\\/g, "\\\\")
475
- .replace(/'/g, "\\'");
476
- const windowPositionArg = windowPosition
477
- ? `, '--window-position=${windowPosition.x},${windowPosition.y}'`
478
- : "";
479
- const windowBoundsSetupCode = windowPosition
480
- ? `
481
- const requestedWindowBounds = { left: ${windowPosition.x}, top: ${windowPosition.y}, windowState: 'normal' };
482
- const pageCdp = await context.newCDPSession(page);
483
- let browserCdp;
484
- try {
485
- const targetInfo = await pageCdp.send('Target.getTargetInfo');
486
- const targetId = targetInfo?.targetInfo?.targetId;
487
- browserCdp = await browser.newBrowserCDPSession();
488
- const windowResult = await browserCdp.send(
489
- 'Browser.getWindowForTarget',
490
- targetId ? { targetId } : {},
491
- );
492
- await browserCdp.send('Browser.setWindowBounds', {
493
- windowId: windowResult.windowId,
494
- bounds: requestedWindowBounds,
495
- });
496
- await new Promise((resolve) => setTimeout(resolve, 250));
497
- const actualWindow = await browserCdp.send('Browser.getWindowBounds', {
498
- windowId: windowResult.windowId,
499
- });
500
- childLog('info', 'window-bounds-set', {
501
- windowId: windowResult.windowId,
502
- requestedBounds: requestedWindowBounds,
503
- actualBounds: actualWindow.bounds,
504
- });
505
- } catch (error) {
506
- childLog('warn', 'window-bounds-set-failed', {
507
- requestedBounds: requestedWindowBounds,
508
- message: error instanceof Error ? error.message : String(error),
509
- stack: error instanceof Error ? error.stack : undefined,
510
- });
511
- } finally {
512
- await pageCdp.detach().catch(() => {});
513
- if (browserCdp) {
514
- await browserCdp.detach().catch(() => {});
515
- }
516
- }
517
- `
518
- : "";
519
-
520
- const launcherCode = `
521
- import { chromium } from 'playwright';
522
- import { appendFileSync, mkdirSync } from 'node:fs';
523
- import { dirname } from 'node:path';
524
-
525
- const LOG_FILE = '${escapedLogPath}';
526
- const NETWORK_LOG = '${escapedNetworkLogPath}';
527
- const ACTIONS_LOG = '${escapedActionsLogPath}';
528
- mkdirSync(dirname(NETWORK_LOG), { recursive: true });
529
-
530
- // tsx/esbuild may emit __name() wrappers in Function#toString output.
531
- const __name = (target, value) =>
532
- Object.defineProperty(target, 'name', { value, configurable: true });
533
-
534
- const TEST_ATTRIBUTE_NAMES = ${JSON.stringify([...TEST_ATTRIBUTE_NAMES])};
535
- const TRUSTED_ATTRIBUTE_NAMES = ${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])};
536
- const INTERACTIVE_TAG_NAMES = ${JSON.stringify([...INTERACTIVE_TAG_NAMES])};
537
- const INTERACTIVE_ROLE_NAMES = ${JSON.stringify([...INTERACTIVE_ROLE_NAMES])};
538
- const filterSemanticClasses = ${filterSemanticClasses.toString()};
539
- const isObfuscatedClass = ${isObfuscatedClass.toString()};
540
-
541
- ${installSessionTelemetry.toString()}
542
-
543
- function logAction(entry) {
544
- appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
545
- }
546
-
547
- function logNetwork(entry) {
548
- appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
549
- }
550
-
551
- function childLog(level, event, data = {}) {
552
- try {
553
- const entry = JSON.stringify({
554
- timestamp: new Date().toISOString(),
555
- id: Math.random().toString(36).slice(2, 10),
556
- level,
557
- scope: 'libretto.child',
558
- event,
559
- data,
560
- });
561
- appendFileSync(LOG_FILE, entry + '\\n');
562
- } catch {}
563
- }
564
-
565
- const browser = await chromium.launch({
566
- headless: ${!headed},
567
- args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'${windowPositionArg}],
568
- });
569
-
570
- browser.on('disconnected', () => {
571
- childLog('warn', 'browser-disconnected', { port: ${port} });
572
- });
573
-
574
- const context = await browser.newContext({
575
- ${storageStateCode}
576
- viewport: { width: ${viewport.width}, height: ${viewport.height} },
577
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
578
- });
579
-
580
- const page = await context.newPage();
581
- ${windowBoundsSetupCode}
582
- page.setDefaultTimeout(30000);
583
- page.setDefaultNavigationTimeout(45000);
584
-
585
- await installSessionTelemetry({
586
- context,
587
- initialPage: page,
588
- includeUserDomActions: true,
589
- logAction,
590
- logNetwork,
591
- });
592
-
593
-
594
- await page.goto('${escapedUrl}');
595
-
596
- process.on('SIGTERM', async () => {
597
- childLog('info', 'child-sigterm');
598
- await browser.close();
599
- process.exit(0);
600
- });
601
-
602
- process.on('SIGINT', async () => {
603
- childLog('info', 'child-sigint');
604
- await browser.close();
605
- process.exit(0);
606
- });
607
-
608
- process.on('uncaughtException', (err) => {
609
- childLog('error', 'uncaught-exception', { message: err.message, stack: err.stack });
610
- process.exit(1);
611
- });
612
-
613
- process.on('unhandledRejection', (reason) => {
614
- childLog('warn', 'unhandled-rejection', { reason: String(reason) });
615
- });
616
-
617
- process.on('exit', (code) => {
618
- childLog('info', 'child-exit', { code, pid: process.pid, port: ${port} });
619
- });
620
-
621
- childLog('info', 'child-launched', { port: ${port}, pid: process.pid, session: '${session}' });
622
-
623
- await new Promise(() => {});
624
- `;
447
+ const daemonEntryPath = fileURLToPath(
448
+ new URL("./browser-daemon.js", import.meta.url),
449
+ );
450
+ const require = createRequire(import.meta.url);
451
+ const tsxImportPath = pathToFileURL(require.resolve("tsx/esm")).href;
452
+ const daemonConfig = {
453
+ port,
454
+ url,
455
+ session,
456
+ headed,
457
+ viewport,
458
+ storageStatePath: useProfile ? profilePath : undefined,
459
+ windowPosition,
460
+ };
625
461
 
626
462
  const childStderrFd = openSync(runLogPath, "a");
627
463
 
628
- const child = spawn("node", ["--input-type=module", "-e", launcherCode], {
629
- detached: true,
630
- stdio: ["ignore", "ignore", childStderrFd],
631
- cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../../.."),
632
- });
464
+ const child = spawn(
465
+ process.execPath,
466
+ ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
467
+ {
468
+ detached: true,
469
+ stdio: ["ignore", "ignore", childStderrFd],
470
+ },
471
+ );
633
472
  child.unref();
473
+ closeSync(childStderrFd);
634
474
 
635
475
  logger.info("open-child-spawned", { pid: child.pid, port, session });
636
476
 
@@ -750,8 +590,13 @@ export async function runOpenWithProvider(
750
590
  provider: providerName,
751
591
  sessionId: providerSession.sessionId,
752
592
  cdpEndpoint: providerSession.cdpEndpoint,
593
+ liveViewUrl: providerSession.liveViewUrl,
753
594
  });
754
595
 
596
+ if (providerSession.liveViewUrl) {
597
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
598
+ }
599
+
755
600
  console.log(`Connecting to ${providerName} browser...`);
756
601
 
757
602
  let browser: Browser | null = null;
@@ -923,6 +768,7 @@ export async function runClose(
923
768
  return;
924
769
  }
925
770
 
771
+ let replayUrl: string | undefined;
926
772
  if (state.provider) {
927
773
  // Cloud provider session — close via provider API, no local pid to kill
928
774
  logger.info("close-provider", {
@@ -932,7 +778,8 @@ export async function runClose(
932
778
  });
933
779
  try {
934
780
  const provider = getCloudProviderApi(state.provider.name);
935
- await provider.closeSession(state.provider.sessionId);
781
+ const result = await provider.closeSession(state.provider.sessionId);
782
+ replayUrl = result.replayUrl;
936
783
  } catch (err) {
937
784
  logger.warn("close-provider-error", {
938
785
  session,
@@ -957,8 +804,11 @@ export async function runClose(
957
804
  }
958
805
 
959
806
  clearSessionState(session, logger);
960
- logger.info("close-success", { session });
807
+ logger.info("close-success", { session, replayUrl });
961
808
  console.log(`Browser closed (session: ${session}).`);
809
+ if (replayUrl) {
810
+ console.log(`View recording: ${replayUrl}`);
811
+ }
962
812
  }
963
813
 
964
814
  type ClosableSession = {
@@ -1060,6 +910,7 @@ export async function runCloseAll(
1060
910
 
1061
911
  // Close provider sessions via their APIs
1062
912
  const failedProviderSessions = new Set<string>();
913
+ const replayUrls: Array<{ session: string; replayUrl: string }> = [];
1063
914
  for (const target of closable) {
1064
915
  if (target.provider) {
1065
916
  logger.info("close-all-provider", {
@@ -1069,7 +920,13 @@ export async function runCloseAll(
1069
920
  });
1070
921
  try {
1071
922
  const provider = getCloudProviderApi(target.provider.name);
1072
- await provider.closeSession(target.provider.sessionId);
923
+ const result = await provider.closeSession(target.provider.sessionId);
924
+ if (result.replayUrl) {
925
+ replayUrls.push({
926
+ session: target.session,
927
+ replayUrl: result.replayUrl,
928
+ });
929
+ }
1073
930
  } catch (err) {
1074
931
  logger.warn("close-all-provider-error", {
1075
932
  session: target.session,
@@ -1180,6 +1037,9 @@ export async function runCloseAll(
1180
1037
  if (forceKilled > 0) {
1181
1038
  console.log(`Force-killed ${forceKilled} session(s).`);
1182
1039
  }
1040
+ for (const { session, replayUrl } of replayUrls) {
1041
+ console.log(`View recording (${session}): ${replayUrl}`);
1042
+ }
1183
1043
  }
1184
1044
 
1185
1045
  export async function runConnect(
@@ -67,7 +67,7 @@ function invalidConfigError(configPath: string, detail?: string): Error {
67
67
  ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
68
68
  ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
69
69
  "Fix the file to match this shape, or delete it and rerun:",
70
- ` npx libretto ai configure openai | anthropic | gemini | vertex`,
70
+ ` npx libretto ai configure openai | anthropic | gemini | vertex | openrouter`,
71
71
  ]
72
72
  .filter(Boolean)
73
73
  .join("\n"),
@@ -52,6 +52,7 @@ export function createBrowserbaseProvider(): ProviderApi {
52
52
  `Browserbase API error closing session ${sessionId} (${resp.status}): ${body}`,
53
53
  );
54
54
  }
55
+ return {};
55
56
  },
56
57
  };
57
58
  }
@@ -44,6 +44,7 @@ export function createKernelProvider(): ProviderApi {
44
44
  `Kernel API error closing session ${sessionId} (${resp.status}): ${body}`,
45
45
  );
46
46
  }
47
+ return {};
47
48
  },
48
49
  };
49
50
  }
@@ -13,6 +13,8 @@ export function createLibrettoCloudProvider(): ProviderApi {
13
13
  );
14
14
  const endpoint = apiUrl.replace(/\/$/, "");
15
15
 
16
+ // The Libretto Cloud API is an oRPC RPCHandler, not plain REST, so inputs
17
+ // must be wrapped as { json: ... } and outputs arrive the same way.
16
18
  return {
17
19
  async createSession() {
18
20
  const timeoutSeconds = Number(
@@ -24,7 +26,9 @@ export function createLibrettoCloudProvider(): ProviderApi {
24
26
  "x-api-key": apiKey,
25
27
  "Content-Type": "application/json",
26
28
  },
27
- body: JSON.stringify({ timeout_seconds: timeoutSeconds }),
29
+ body: JSON.stringify({
30
+ json: { timeout_seconds: timeoutSeconds },
31
+ }),
28
32
  });
29
33
  if (!resp.ok) {
30
34
  const body = await resp.text();
@@ -32,13 +36,18 @@ export function createLibrettoCloudProvider(): ProviderApi {
32
36
  `Libretto Cloud API error (${resp.status}): ${body}`,
33
37
  );
34
38
  }
35
- const json = (await resp.json()) as {
36
- session_id: string;
37
- cdp_url: string;
39
+ const { json } = (await resp.json()) as {
40
+ json: {
41
+ session_id: string;
42
+ cdp_url: string;
43
+ live_view_url: string | null;
44
+ recording_url: string | null;
45
+ };
38
46
  };
39
47
  return {
40
48
  sessionId: json.session_id,
41
49
  cdpEndpoint: json.cdp_url,
50
+ liveViewUrl: json.live_view_url ?? undefined,
42
51
  };
43
52
  },
44
53
  async closeSession(sessionId) {
@@ -48,7 +57,7 @@ export function createLibrettoCloudProvider(): ProviderApi {
48
57
  "x-api-key": apiKey,
49
58
  "Content-Type": "application/json",
50
59
  },
51
- body: JSON.stringify({ session_id: sessionId }),
60
+ body: JSON.stringify({ json: { session_id: sessionId } }),
52
61
  });
53
62
  if (!resp.ok) {
54
63
  const body = await resp.text();
@@ -56,6 +65,10 @@ export function createLibrettoCloudProvider(): ProviderApi {
56
65
  `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
57
66
  );
58
67
  }
68
+ const { json } = (await resp.json()) as {
69
+ json: { replay_url: string | null };
70
+ };
71
+ return { replayUrl: json.replay_url ?? undefined };
59
72
  },
60
73
  };
61
74
  }
@@ -1,9 +1,20 @@
1
1
  export type ProviderSession = {
2
2
  sessionId: string; // remote session id for cleanup
3
3
  cdpEndpoint: string; // CDP WebSocket URL
4
+ // Provider-hosted URL for watching the session live while it's running.
5
+ // Only libretto-cloud surfaces this today; direct-SDK providers leave it
6
+ // undefined.
7
+ liveViewUrl?: string;
8
+ };
9
+
10
+ export type ProviderCloseResult = {
11
+ // Provider-hosted URL for playback of the session recording, surfaced on
12
+ // successful close. Undefined when the provider didn't capture a
13
+ // recording or doesn't return one on close.
14
+ replayUrl?: string;
4
15
  };
5
16
 
6
17
  export type ProviderApi = {
7
18
  createSession(): Promise<ProviderSession>;
8
- closeSession(sessionId: string): Promise<void>;
19
+ closeSession(sessionId: string): Promise<ProviderCloseResult>;
9
20
  };