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.
@@ -1,24 +1,13 @@
1
1
  import {
2
2
  chromium
3
3
  } from "playwright";
4
- import { openSync, existsSync } from "node:fs";
5
- import { dirname, join, resolve } from "node:path";
6
- import { fileURLToPath } from "node:url";
4
+ import { openSync, closeSync, existsSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { createRequire } from "node:module";
7
8
  import { createServer } from "node:net";
8
9
  import { spawn } from "node:child_process";
9
- import {
10
- filterSemanticClasses,
11
- INTERACTIVE_ROLE_NAMES,
12
- INTERACTIVE_TAG_NAMES,
13
- isObfuscatedClass,
14
- TEST_ATTRIBUTE_NAMES,
15
- TRUSTED_ATTRIBUTE_NAMES
16
- } from "../../shared/dom-semantics.js";
17
- import {
18
- getSessionActionsLogPath,
19
- getSessionNetworkLogPath,
20
- PROFILES_DIR
21
- } from "./context.js";
10
+ import { PROFILES_DIR } from "./context.js";
22
11
  import { readLibrettoConfig } from "./config.js";
23
12
  import {
24
13
  assertSessionAvailableForStart,
@@ -31,11 +20,10 @@ import {
31
20
  writeSessionState
32
21
  } from "./session.js";
33
22
  import { getCloudProviderApi } from "./providers/index.js";
34
- import { installSessionTelemetry } from "./session-telemetry.js";
35
23
  const CLOSE_WAIT_MS = 1500;
36
24
  const FORCE_CLOSE_WAIT_MS = 300;
37
25
  async function pickFreePort() {
38
- return await new Promise((resolve2, reject) => {
26
+ return await new Promise((resolve, reject) => {
39
27
  const server = createServer();
40
28
  server.listen(0, "127.0.0.1", () => {
41
29
  const address = server.address();
@@ -44,7 +32,7 @@ async function pickFreePort() {
44
32
  return;
45
33
  }
46
34
  const port = address.port;
47
- server.close(() => resolve2(port));
35
+ server.close(() => resolve(port));
48
36
  });
49
37
  server.on("error", reject);
50
38
  });
@@ -99,7 +87,7 @@ async function tryConnectToCDP(endpoint, logger, timeoutMs = 5e3) {
99
87
  try {
100
88
  const connectPromise = chromium.connectOverCDP(endpoint);
101
89
  const timeoutPromise = new Promise(
102
- (resolve2) => setTimeout(() => resolve2(null), timeoutMs)
90
+ (resolve) => setTimeout(() => resolve(null), timeoutMs)
103
91
  );
104
92
  const browser = await Promise.race([connectPromise, timeoutPromise]);
105
93
  if (browser) {
@@ -313,8 +301,6 @@ async function runOpen(rawUrl, headed, session, logger, options) {
313
301
  assertSessionAvailableForStart(session, logger);
314
302
  const port = await pickFreePort();
315
303
  const runLogPath = logFileForSession(session);
316
- const networkLogPath = getSessionNetworkLogPath(session);
317
- const actionsLogPath = getSessionActionsLogPath(session);
318
304
  const browserMode = headed ? "headed" : "headless";
319
305
  const supportsSavedProfile = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
320
306
  const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : void 0;
@@ -333,162 +319,31 @@ async function runOpen(rawUrl, headed, session, logger, options) {
333
319
  console.log(`Loading saved profile for ${domain}`);
334
320
  }
335
321
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
336
- const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
337
- const storageStateCode = useProfile ? `storageState: '${profilePath.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}',` : "";
338
- const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
339
- const escapedNetworkLogPath = networkLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
340
- const escapedActionsLogPath = actionsLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
341
- const windowPositionArg = windowPosition ? `, '--window-position=${windowPosition.x},${windowPosition.y}'` : "";
342
- const windowBoundsSetupCode = windowPosition ? `
343
- const requestedWindowBounds = { left: ${windowPosition.x}, top: ${windowPosition.y}, windowState: 'normal' };
344
- const pageCdp = await context.newCDPSession(page);
345
- let browserCdp;
346
- try {
347
- const targetInfo = await pageCdp.send('Target.getTargetInfo');
348
- const targetId = targetInfo?.targetInfo?.targetId;
349
- browserCdp = await browser.newBrowserCDPSession();
350
- const windowResult = await browserCdp.send(
351
- 'Browser.getWindowForTarget',
352
- targetId ? { targetId } : {},
353
- );
354
- await browserCdp.send('Browser.setWindowBounds', {
355
- windowId: windowResult.windowId,
356
- bounds: requestedWindowBounds,
357
- });
358
- await new Promise((resolve) => setTimeout(resolve, 250));
359
- const actualWindow = await browserCdp.send('Browser.getWindowBounds', {
360
- windowId: windowResult.windowId,
361
- });
362
- childLog('info', 'window-bounds-set', {
363
- windowId: windowResult.windowId,
364
- requestedBounds: requestedWindowBounds,
365
- actualBounds: actualWindow.bounds,
366
- });
367
- } catch (error) {
368
- childLog('warn', 'window-bounds-set-failed', {
369
- requestedBounds: requestedWindowBounds,
370
- message: error instanceof Error ? error.message : String(error),
371
- stack: error instanceof Error ? error.stack : undefined,
372
- });
373
- } finally {
374
- await pageCdp.detach().catch(() => {});
375
- if (browserCdp) {
376
- await browserCdp.detach().catch(() => {});
377
- }
378
- }
379
- ` : "";
380
- const launcherCode = `
381
- import { chromium } from 'playwright';
382
- import { appendFileSync, mkdirSync } from 'node:fs';
383
- import { dirname } from 'node:path';
384
-
385
- const LOG_FILE = '${escapedLogPath}';
386
- const NETWORK_LOG = '${escapedNetworkLogPath}';
387
- const ACTIONS_LOG = '${escapedActionsLogPath}';
388
- mkdirSync(dirname(NETWORK_LOG), { recursive: true });
389
-
390
- // tsx/esbuild may emit __name() wrappers in Function#toString output.
391
- const __name = (target, value) =>
392
- Object.defineProperty(target, 'name', { value, configurable: true });
393
-
394
- const TEST_ATTRIBUTE_NAMES = ${JSON.stringify([...TEST_ATTRIBUTE_NAMES])};
395
- const TRUSTED_ATTRIBUTE_NAMES = ${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])};
396
- const INTERACTIVE_TAG_NAMES = ${JSON.stringify([...INTERACTIVE_TAG_NAMES])};
397
- const INTERACTIVE_ROLE_NAMES = ${JSON.stringify([...INTERACTIVE_ROLE_NAMES])};
398
- const filterSemanticClasses = ${filterSemanticClasses.toString()};
399
- const isObfuscatedClass = ${isObfuscatedClass.toString()};
400
-
401
- ${installSessionTelemetry.toString()}
402
-
403
- function logAction(entry) {
404
- appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
405
- }
406
-
407
- function logNetwork(entry) {
408
- appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
409
- }
410
-
411
- function childLog(level, event, data = {}) {
412
- try {
413
- const entry = JSON.stringify({
414
- timestamp: new Date().toISOString(),
415
- id: Math.random().toString(36).slice(2, 10),
416
- level,
417
- scope: 'libretto.child',
418
- event,
419
- data,
420
- });
421
- appendFileSync(LOG_FILE, entry + '\\n');
422
- } catch {}
423
- }
424
-
425
- const browser = await chromium.launch({
426
- headless: ${!headed},
427
- args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'${windowPositionArg}],
428
- });
429
-
430
- browser.on('disconnected', () => {
431
- childLog('warn', 'browser-disconnected', { port: ${port} });
432
- });
433
-
434
- const context = await browser.newContext({
435
- ${storageStateCode}
436
- viewport: { width: ${viewport.width}, height: ${viewport.height} },
437
- 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',
438
- });
439
-
440
- const page = await context.newPage();
441
- ${windowBoundsSetupCode}
442
- page.setDefaultTimeout(30000);
443
- page.setDefaultNavigationTimeout(45000);
444
-
445
- await installSessionTelemetry({
446
- context,
447
- initialPage: page,
448
- includeUserDomActions: true,
449
- logAction,
450
- logNetwork,
451
- });
452
-
453
-
454
- await page.goto('${escapedUrl}');
455
-
456
- process.on('SIGTERM', async () => {
457
- childLog('info', 'child-sigterm');
458
- await browser.close();
459
- process.exit(0);
460
- });
461
-
462
- process.on('SIGINT', async () => {
463
- childLog('info', 'child-sigint');
464
- await browser.close();
465
- process.exit(0);
466
- });
467
-
468
- process.on('uncaughtException', (err) => {
469
- childLog('error', 'uncaught-exception', { message: err.message, stack: err.stack });
470
- process.exit(1);
471
- });
472
-
473
- process.on('unhandledRejection', (reason) => {
474
- childLog('warn', 'unhandled-rejection', { reason: String(reason) });
475
- });
476
-
477
- process.on('exit', (code) => {
478
- childLog('info', 'child-exit', { code, pid: process.pid, port: ${port} });
479
- });
480
-
481
- childLog('info', 'child-launched', { port: ${port}, pid: process.pid, session: '${session}' });
482
-
483
- await new Promise(() => {});
484
- `;
322
+ const daemonEntryPath = fileURLToPath(
323
+ new URL("./browser-daemon.js", import.meta.url)
324
+ );
325
+ const require2 = createRequire(import.meta.url);
326
+ const tsxImportPath = pathToFileURL(require2.resolve("tsx/esm")).href;
327
+ const daemonConfig = {
328
+ port,
329
+ url,
330
+ session,
331
+ headed,
332
+ viewport,
333
+ storageStatePath: useProfile ? profilePath : void 0,
334
+ windowPosition
335
+ };
485
336
  const childStderrFd = openSync(runLogPath, "a");
486
- const child = spawn("node", ["--input-type=module", "-e", launcherCode], {
487
- detached: true,
488
- stdio: ["ignore", "ignore", childStderrFd],
489
- cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../../..")
490
- });
337
+ const child = spawn(
338
+ process.execPath,
339
+ ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
340
+ {
341
+ detached: true,
342
+ stdio: ["ignore", "ignore", childStderrFd]
343
+ }
344
+ );
491
345
  child.unref();
346
+ closeSync(childStderrFd);
492
347
  logger.info("open-child-spawned", { pid: child.pid, port, session });
493
348
  let childSpawnError = null;
494
349
  let childEarlyExit = null;
@@ -576,8 +431,12 @@ async function runOpenWithProvider(rawUrl, providerName, provider, session, logg
576
431
  logger.info("open-provider-session-created", {
577
432
  provider: providerName,
578
433
  sessionId: providerSession.sessionId,
579
- cdpEndpoint: providerSession.cdpEndpoint
434
+ cdpEndpoint: providerSession.cdpEndpoint,
435
+ liveViewUrl: providerSession.liveViewUrl
580
436
  });
437
+ if (providerSession.liveViewUrl) {
438
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
439
+ }
581
440
  console.log(`Connecting to ${providerName} browser...`);
582
441
  let browser = null;
583
442
  try {
@@ -717,6 +576,7 @@ async function runClose(session, logger) {
717
576
  console.log(`No browser running for session "${session}".`);
718
577
  return;
719
578
  }
579
+ let replayUrl;
720
580
  if (state.provider) {
721
581
  logger.info("close-provider", {
722
582
  session,
@@ -725,7 +585,8 @@ async function runClose(session, logger) {
725
585
  });
726
586
  try {
727
587
  const provider = getCloudProviderApi(state.provider.name);
728
- await provider.closeSession(state.provider.sessionId);
588
+ const result = await provider.closeSession(state.provider.sessionId);
589
+ replayUrl = result.replayUrl;
729
590
  } catch (err) {
730
591
  logger.warn("close-provider-error", {
731
592
  session,
@@ -746,8 +607,11 @@ async function runClose(session, logger) {
746
607
  }
747
608
  }
748
609
  clearSessionState(session, logger);
749
- logger.info("close-success", { session });
610
+ logger.info("close-success", { session, replayUrl });
750
611
  console.log(`Browser closed (session: ${session}).`);
612
+ if (replayUrl) {
613
+ console.log(`View recording: ${replayUrl}`);
614
+ }
751
615
  }
752
616
  function waitForCloseSignalWindow(ms) {
753
617
  return new Promise((r) => setTimeout(r, ms));
@@ -816,6 +680,7 @@ async function runCloseAll(logger, options) {
816
680
  return;
817
681
  }
818
682
  const failedProviderSessions = /* @__PURE__ */ new Set();
683
+ const replayUrls = [];
819
684
  for (const target of closable) {
820
685
  if (target.provider) {
821
686
  logger.info("close-all-provider", {
@@ -825,7 +690,13 @@ async function runCloseAll(logger, options) {
825
690
  });
826
691
  try {
827
692
  const provider = getCloudProviderApi(target.provider.name);
828
- await provider.closeSession(target.provider.sessionId);
693
+ const result = await provider.closeSession(target.provider.sessionId);
694
+ if (result.replayUrl) {
695
+ replayUrls.push({
696
+ session: target.session,
697
+ replayUrl: result.replayUrl
698
+ });
699
+ }
829
700
  } catch (err) {
830
701
  logger.warn("close-all-provider-error", {
831
702
  session: target.session,
@@ -926,6 +797,9 @@ async function runCloseAll(logger, options) {
926
797
  if (forceKilled > 0) {
927
798
  console.log(`Force-killed ${forceKilled} session(s).`);
928
799
  }
800
+ for (const { session, replayUrl } of replayUrls) {
801
+ console.log(`View recording (${session}): ${replayUrl}`);
802
+ }
929
803
  }
930
804
  async function runConnect(cdpUrl, session, logger, accessMode = "write-access") {
931
805
  logger.info("connect-start", { cdpUrl, session, accessMode });
@@ -54,7 +54,7 @@ ${detail}` : null,
54
54
  ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
55
55
  ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
56
56
  "Fix the file to match this shape, or delete it and rerun:",
57
- ` npx libretto ai configure openai | anthropic | gemini | vertex`
57
+ ` npx libretto ai configure openai | anthropic | gemini | vertex | openrouter`
58
58
  ].filter(Boolean).join("\n")
59
59
  );
60
60
  }
@@ -45,6 +45,7 @@ function createBrowserbaseProvider() {
45
45
  `Browserbase API error closing session ${sessionId} (${resp.status}): ${body}`
46
46
  );
47
47
  }
48
+ return {};
48
49
  }
49
50
  };
50
51
  }
@@ -38,6 +38,7 @@ function createKernelProvider() {
38
38
  `Kernel API error closing session ${sessionId} (${resp.status}): ${body}`
39
39
  );
40
40
  }
41
+ return {};
41
42
  }
42
43
  };
43
44
  }
@@ -21,7 +21,9 @@ function createLibrettoCloudProvider() {
21
21
  "x-api-key": apiKey,
22
22
  "Content-Type": "application/json"
23
23
  },
24
- body: JSON.stringify({ timeout_seconds: timeoutSeconds })
24
+ body: JSON.stringify({
25
+ json: { timeout_seconds: timeoutSeconds }
26
+ })
25
27
  });
26
28
  if (!resp.ok) {
27
29
  const body = await resp.text();
@@ -29,10 +31,11 @@ function createLibrettoCloudProvider() {
29
31
  `Libretto Cloud API error (${resp.status}): ${body}`
30
32
  );
31
33
  }
32
- const json = await resp.json();
34
+ const { json } = await resp.json();
33
35
  return {
34
36
  sessionId: json.session_id,
35
- cdpEndpoint: json.cdp_url
37
+ cdpEndpoint: json.cdp_url,
38
+ liveViewUrl: json.live_view_url ?? void 0
36
39
  };
37
40
  },
38
41
  async closeSession(sessionId) {
@@ -42,7 +45,7 @@ function createLibrettoCloudProvider() {
42
45
  "x-api-key": apiKey,
43
46
  "Content-Type": "application/json"
44
47
  },
45
- body: JSON.stringify({ session_id: sessionId })
48
+ body: JSON.stringify({ json: { session_id: sessionId } })
46
49
  });
47
50
  if (!resp.ok) {
48
51
  const body = await resp.text();
@@ -50,6 +53,8 @@ function createLibrettoCloudProvider() {
50
53
  `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`
51
54
  );
52
55
  }
56
+ const { json } = await resp.json();
57
+ return { replayUrl: json.replay_url ?? void 0 };
53
58
  }
54
59
  };
55
60
  }
@@ -12,7 +12,8 @@ const SUPPORTED_PROVIDER_ALIASES = {
12
12
  vertex: "vertex",
13
13
  anthropic: "anthropic",
14
14
  codex: "openai",
15
- openai: "openai"
15
+ openai: "openai",
16
+ openrouter: "openrouter"
16
17
  };
17
18
  function readFirstEnvValue(env, names) {
18
19
  for (const name of names) {
@@ -33,7 +34,7 @@ function parseModel(model) {
33
34
  const modelId = model.slice(slashIndex + 1);
34
35
  if (!provider) {
35
36
  throw new Error(
36
- `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), and vertex.`
37
+ `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), vertex, and openrouter.`
37
38
  );
38
39
  }
39
40
  return { provider, modelId };
@@ -48,6 +49,8 @@ function hasProviderCredentials(provider, env = process.env) {
48
49
  return Boolean(env.ANTHROPIC_API_KEY?.trim());
49
50
  case "openai":
50
51
  return Boolean(env.OPENAI_API_KEY?.trim());
52
+ case "openrouter":
53
+ return Boolean(env.OPENROUTER_API_KEY?.trim());
51
54
  }
52
55
  }
53
56
  function missingProviderCredentialsMessage(provider) {
@@ -62,6 +65,9 @@ function missingProviderCredentialsMessage(provider) {
62
65
  case "openai": {
63
66
  return "OpenAI API key is missing. Set OPENAI_API_KEY.";
64
67
  }
68
+ case "openrouter": {
69
+ return "OpenRouter API key is missing. Set OPENROUTER_API_KEY.";
70
+ }
65
71
  }
66
72
  }
67
73
  async function getProviderModel(provider, modelId) {
@@ -105,6 +111,18 @@ async function getProviderModel(provider, modelId) {
105
111
  const openai = createOpenAI({ apiKey });
106
112
  return openai(modelId);
107
113
  }
114
+ case "openrouter": {
115
+ const apiKey = process.env.OPENROUTER_API_KEY?.trim();
116
+ if (!apiKey) {
117
+ throw new Error(missingProviderCredentialsMessage(provider));
118
+ }
119
+ const { createOpenAI } = await import("@ai-sdk/openai");
120
+ const openrouter = createOpenAI({
121
+ apiKey,
122
+ baseURL: "https://openrouter.ai/api/v1"
123
+ });
124
+ return openrouter(modelId);
125
+ }
108
126
  }
109
127
  }
110
128
  async function resolveModel(model) {
@@ -186,6 +186,9 @@ async function runIntegrationInternal(args, options) {
186
186
  appendFileSync(networkLogPath, JSON.stringify(entry) + "\n");
187
187
  }
188
188
  });
189
+ await browserSession.context.addInitScript(() => {
190
+ globalThis.__name = (target, value) => Object.defineProperty(target, "name", { value, configurable: true });
191
+ });
189
192
  const workflowContext = {
190
193
  session: args.session,
191
194
  page: browserSession.page
@@ -53,7 +53,6 @@ function isObfuscatedClass(cls) {
53
53
  if (cls.length > 80) return true;
54
54
  if (/^_?[0-9a-f]{6,}$/i.test(cls)) return true;
55
55
  if (/^[a-z]+_[0-9a-f]{4,}$/i.test(cls)) return true;
56
- if (/^[a-z]{1,2}[0-9]{2,}$/i.test(cls)) return true;
57
56
  const digits = (cls.match(/[0-9]/g) || []).length;
58
57
  const letters = (cls.match(/[a-zA-Z]/g) || []).length;
59
58
  if (cls.length >= 6 && digits >= letters * 0.5 && digits >= 2) return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libretto",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "AI-powered browser automation library and CLI built on Playwright",
5
5
  "license": "MIT",
6
6
  "homepage": "https://libretto.sh",
@@ -4,7 +4,7 @@ description: "Browser automation CLI for building, maintaining, and running brow
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.6.7"
7
+ version: "0.6.9"
8
8
  ---
9
9
 
10
10
  ## How Libretto Works
@@ -34,6 +34,7 @@ metadata:
34
34
  - Defer repo/code review until you begin generating code, unless the user explicitly asks for it earlier.
35
35
  - Read and follow guidelines in `references/code-generation-rules.md` before generating or editing production workflow code.
36
36
  - Validation requires a successful clean `run --headless` with confirmation of the actual returned output, not just process success. If the user wants to watch the finished workflow, do a final headed `run` after headless validation succeeds.
37
+ - After validation, always show the user: (1) the output/results from the headless validation run, and (2) a headed version of the same command so they can re-run it themselves and watch the browser (e.g. replace `--headless` with `--headed`). Include any `--params` or `--auth-profile` flags the workflow needs.
37
38
  - Treat exploration sessions as disposable unless the user explicitly wants one kept open.
38
39
  - Get explicit user confirmation before mutating actions or replaying network requests that may have side effects.
39
40
  - Never run multiple `exec` commands at the same time.
@@ -214,7 +215,12 @@ Assistant: [Reads `references/site-security-review.md` before choosing between p
214
215
  Assistant: [Runs `npx libretto snapshot --objective "Find the next required action" --context "We are starting the workflow from the landing page and need the first meaningful step."`]
215
216
  Assistant: [Uses `snapshot` and `exec` as needed to understand the site and decide the implementation path]
216
217
  Assistant: [Reads `references/code-generation-rules.md` before writing production workflow code]
217
- Assistant: I found the working path. I'll now update the workflow file outside Libretto and verify it with `npx libretto run ...`.
218
+ Assistant: I found the working path. I'll now create the workflow file and verify it.
219
+ Assistant: [Creates or edits `target-workflow.ts` following `references/code-generation-rules.md`]
220
+ Assistant: [Runs `npx libretto run ./target-workflow.ts --headless --params '{"status":"open"}'` to validate]
221
+ Assistant: Validation passed. Here are the results:
222
+ [Shows the output/results from the headless run]
223
+ To watch it yourself in a browser, run: npx libretto run ./target-workflow.ts --headed --params '{"status":"open"}'
218
224
  </example>
219
225
  ```
220
226
 
@@ -229,7 +235,12 @@ Assistant: The workflow failed and Libretto kept the browser open. I'll inspect
229
235
  Assistant: [Runs `npx libretto snapshot --session debug-flow --objective "Find the blocking error or broken selector target" --context "The workflow just failed after trying to continue from the review step, and I need to identify the visible blocker on the current page."`]
230
236
  Assistant: [Runs `npx libretto exec --session debug-flow "...focused inspection or prototype..."`]
231
237
  Assistant: [Reads `references/code-generation-rules.md` before patching the workflow file]
232
- Assistant: I found the issue. I'll patch the workflow code, then rerun `npx libretto run ...` to verify the fix.
238
+ Assistant: I found the issue. I'll patch the workflow code and verify.
239
+ Assistant: [Edits `integration.ts` following `references/code-generation-rules.md`]
240
+ Assistant: [Runs `npx libretto run ./integration.ts --headless` to validate the fix]
241
+ Assistant: Fix verified. Here are the results:
242
+ [Shows the output/results from the headless run]
243
+ To watch it yourself in a browser, run: npx libretto run ./integration.ts --headed
233
244
  </example>
234
245
  ```
235
246
 
@@ -4,7 +4,7 @@ description: "Read-only Libretto workflow for diagnosing live browser state with
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.6.7"
7
+ version: "0.6.9"
8
8
  ---
9
9
 
10
10
  ## How Libretto Read-Only Works
@@ -882,6 +882,15 @@ export const runCommand = SimpleCLI.command({
882
882
  `Creating ${providerName} browser session (session: ${ctx.session})...`,
883
883
  );
884
884
  const providerSession = await provider.createSession();
885
+ ctx.logger.info("run-provider-session-created", {
886
+ provider: providerName,
887
+ sessionId: providerSession.sessionId,
888
+ cdpEndpoint: providerSession.cdpEndpoint,
889
+ liveViewUrl: providerSession.liveViewUrl,
890
+ });
891
+ if (providerSession.liveViewUrl) {
892
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
893
+ }
885
894
  console.log(`Connecting to ${providerName} browser...`);
886
895
  cdpEndpoint = providerSession.cdpEndpoint;
887
896
  providerInfo = {
@@ -910,7 +919,10 @@ export const runCommand = SimpleCLI.command({
910
919
  } finally {
911
920
  if (provider && providerInfo) {
912
921
  try {
913
- await provider.closeSession(providerInfo.sessionId);
922
+ const result = await provider.closeSession(providerInfo.sessionId);
923
+ if (result.replayUrl) {
924
+ console.log(`View recording: ${result.replayUrl}`);
925
+ }
914
926
  } catch (cleanupErr) {
915
927
  console.error(
916
928
  `Failed to clean up ${providerInfo.name} session ${providerInfo.sessionId}:`,
@@ -27,6 +27,7 @@ const PROVIDER_SDK_PACKAGES: Record<Provider, string> = {
27
27
  anthropic: "@ai-sdk/anthropic",
28
28
  google: "@ai-sdk/google",
29
29
  vertex: "@ai-sdk/google-vertex",
30
+ openrouter: "@ai-sdk/openai",
30
31
  };
31
32
 
32
33
  function detectPackageManager(): string {
@@ -120,6 +121,13 @@ export const PROVIDER_CHOICES: ProviderChoice[] = [
120
121
  envHint:
121
122
  "Requires `gcloud auth application-default login` and a GCP project ID",
122
123
  },
124
+ {
125
+ key: "5",
126
+ label: "OpenRouter",
127
+ provider: "openrouter",
128
+ envVar: "OPENROUTER_API_KEY",
129
+ envHint: "Get your key at https://openrouter.ai/settings/keys",
130
+ },
123
131
  ];
124
132
 
125
133
  function promptUser(
@@ -169,7 +177,7 @@ function printHealthySummary(status: AiSetupStatus & { kind: "ready" }): void {
169
177
  console.log(`✓ Using ${providerLabel(status.provider)} (${status.model}).`);
170
178
  }
171
179
  console.log(
172
- "To change: npx libretto ai configure openai | anthropic | gemini | vertex",
180
+ "To change: npx libretto ai configure openai | anthropic | gemini | vertex | openrouter",
173
181
  );
174
182
  }
175
183
 
@@ -267,7 +275,7 @@ function printSnapshotApiStatus(): boolean {
267
275
  " GOOGLE_CLOUD_PROJECT=... # plus application default credentials for Vertex",
268
276
  );
269
277
  console.log(
270
- " Or run `npx libretto ai configure openai | anthropic | gemini | vertex` to set a specific model.",
278
+ " Or run `npx libretto ai configure openai | anthropic | gemini | vertex | openrouter` to set a specific model.",
271
279
  );
272
280
  console.log(
273
281
  " Run `npx libretto setup` interactively to set up credentials.",
@@ -323,7 +331,7 @@ function printSkipMessage(): void {
323
331
  console.log(" ANTHROPIC_API_KEY=...");
324
332
  console.log(" GEMINI_API_KEY=...");
325
333
  console.log(
326
- " Or run `npx libretto ai configure openai | anthropic | gemini | vertex` to set a specific model.",
334
+ " Or run `npx libretto ai configure openai | anthropic | gemini | vertex | openrouter` to set a specific model.",
327
335
  );
328
336
  }
329
337
 
@@ -17,7 +17,7 @@ function printAiStatus(status: AiSetupStatus): void {
17
17
  console.log(` Source: ${status.source}`);
18
18
  }
19
19
  console.log(
20
- " To change: npx libretto ai configure openai | anthropic | gemini | vertex",
20
+ " To change: npx libretto ai configure openai | anthropic | gemini | vertex | openrouter",
21
21
  );
22
22
  break;
23
23