libretto 0.6.7-experimental.0 → 0.6.8

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.
@@ -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
 
@@ -1,12 +1,14 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
1
  import { readSnapshotModel } from "./config.js";
4
- import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
2
+ import { LIBRETTO_CONFIG_PATH } from "./context.js";
5
3
  import {
6
4
  hasProviderCredentials,
7
5
  parseModel,
8
6
  type Provider,
9
7
  } from "./resolve-model.js";
8
+ import { loadEnv } from "../../shared/env/load-env.js";
9
+
10
+ // Re-export so existing consumers (e.g. tests) don't break.
11
+ export { parseDotEnvAssignment } from "../../shared/env/load-env.js";
10
12
 
11
13
  // ── Default models ──────────────────────────────────────────────────────────
12
14
 
@@ -15,6 +17,7 @@ export const DEFAULT_SNAPSHOT_MODELS = {
15
17
  anthropic: "anthropic/claude-sonnet-4-6",
16
18
  google: "google/gemini-3-flash-preview",
17
19
  vertex: "vertex/gemini-2.5-flash",
20
+ openrouter: "openrouter/free",
18
21
  } as const satisfies Record<Provider, string>;
19
22
 
20
23
  // ── Source detection ────────────────────────────────────────────────────────
@@ -42,6 +45,8 @@ function detectProviderEnvVar(
42
45
  if (env.GOOGLE_CLOUD_PROJECT?.trim()) return "GOOGLE_CLOUD_PROJECT";
43
46
  if (env.GCLOUD_PROJECT?.trim()) return "GCLOUD_PROJECT";
44
47
  return null;
48
+ case "openrouter":
49
+ return env.OPENROUTER_API_KEY?.trim() ? "OPENROUTER_API_KEY" : null;
45
50
  }
46
51
  }
47
52
 
@@ -70,11 +75,13 @@ function providerSetupSentence(provider: Provider): string {
70
75
  return "Add GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY to .env or as a shell environment variable.";
71
76
  case "vertex":
72
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.";
73
80
  }
74
81
  }
75
82
 
76
83
  function defaultModelCommandLine(): string {
77
- return "npx libretto ai configure openai | anthropic | gemini | vertex";
84
+ return "npx libretto ai configure openai | anthropic | gemini | vertex | openrouter";
78
85
  }
79
86
 
80
87
  function providerMissingCredentialSummary(provider: Provider): string {
@@ -87,13 +94,15 @@ function providerMissingCredentialSummary(provider: Provider): string {
87
94
  return "GEMINI_API_KEY and GOOGLE_GENERATIVE_AI_API_KEY are missing";
88
95
  case "vertex":
89
96
  return "GOOGLE_CLOUD_PROJECT and GCLOUD_PROJECT are missing";
97
+ case "openrouter":
98
+ return "OPENROUTER_API_KEY is missing";
90
99
  }
91
100
  }
92
101
 
93
102
  function noSnapshotApiConfiguredMessage(): string {
94
103
  return [
95
104
  "Failed to analyze snapshot because no snapshot analyzer is configured.",
96
- `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()}\`.`,
97
106
  "For more info, run `npx libretto setup`.",
98
107
  ].join(" ");
99
108
  }
@@ -112,88 +121,6 @@ function missingProviderSnapshotMessage(
112
121
  ].join(" ");
113
122
  }
114
123
 
115
- // ── Dotenv loading ──────────────────────────────────────────────────────────
116
-
117
- function readWorktreeEnvPath(): string | null {
118
- const gitPath = join(REPO_ROOT, ".git");
119
- if (!existsSync(gitPath)) return null;
120
-
121
- try {
122
- const gitPointer = readFileSync(gitPath, "utf-8").trim();
123
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
124
- if (!match?.[1]) return null;
125
- const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
126
- const commonGitDir = resolve(worktreeGitDir, "..", "..");
127
- return join(dirname(commonGitDir), ".env");
128
- } catch {
129
- return null;
130
- }
131
- }
132
-
133
- export function loadSnapshotEnv(): void {
134
- if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
135
-
136
- const envPathCandidates = [
137
- join(REPO_ROOT, ".env"),
138
- readWorktreeEnvPath(),
139
- ].filter((value): value is string => Boolean(value));
140
-
141
- const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
142
- if (!envPath) return;
143
-
144
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
145
- const parsed = parseDotEnvAssignment(line);
146
- if (!parsed) continue;
147
- if (!(parsed.key in process.env)) {
148
- process.env[parsed.key] = parsed.value;
149
- }
150
- }
151
- }
152
-
153
- export function parseDotEnvAssignment(
154
- line: string,
155
- ): { key: string; value: string } | null {
156
- const trimmed = line.trim();
157
- if (!trimmed || trimmed.startsWith("#")) return null;
158
-
159
- const withoutExport = trimmed.startsWith("export ")
160
- ? trimmed.slice("export ".length).trimStart()
161
- : trimmed;
162
- const eqIdx = withoutExport.indexOf("=");
163
- if (eqIdx < 1) return null;
164
-
165
- const key = withoutExport.slice(0, eqIdx).trim();
166
- if (!key) return null;
167
-
168
- const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
169
- if (!rawValue) {
170
- return { key, value: "" };
171
- }
172
-
173
- if (rawValue.startsWith('"')) {
174
- const closeIdx = rawValue.indexOf('"', 1);
175
- if (closeIdx > 0) {
176
- return { key, value: rawValue.slice(1, closeIdx) };
177
- }
178
- return { key, value: rawValue.slice(1) };
179
- }
180
-
181
- if (rawValue.startsWith("'")) {
182
- const closeIdx = rawValue.indexOf("'", 1);
183
- if (closeIdx > 0) {
184
- return { key, value: rawValue.slice(1, closeIdx) };
185
- }
186
- return { key, value: rawValue.slice(1) };
187
- }
188
-
189
- const inlineCommentIndex = rawValue.search(/\s#/);
190
- const value =
191
- inlineCommentIndex >= 0
192
- ? rawValue.slice(0, inlineCommentIndex).trimEnd()
193
- : rawValue.trim();
194
- return { key, value };
195
- }
196
-
197
124
  // ── Model resolution ────────────────────────────────────────────────────────
198
125
 
199
126
  function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
@@ -202,6 +129,7 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
202
129
  "anthropic",
203
130
  "google",
204
131
  "vertex",
132
+ "openrouter",
205
133
  ];
206
134
 
207
135
  for (const provider of providersInPriorityOrder) {
@@ -227,7 +155,7 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
227
155
  export function resolveSnapshotApiModel(
228
156
  snapshotModel: string | null = readSnapshotModel(),
229
157
  ): SnapshotApiModelSelection | null {
230
- loadSnapshotEnv();
158
+ loadEnv();
231
159
 
232
160
  if (snapshotModel) {
233
161
  const { provider } = parseModel(snapshotModel);
@@ -318,7 +246,7 @@ function readSnapshotModelSafely(
318
246
  export function resolveAiSetupStatus(
319
247
  configPath: string = LIBRETTO_CONFIG_PATH,
320
248
  ): AiSetupStatus {
321
- loadSnapshotEnv();
249
+ loadEnv();
322
250
 
323
251
  const result = readSnapshotModelSafely(configPath);
324
252
 
@@ -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
 
@@ -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"),