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.
- package/README.md +26 -74
- package/README.template.md +26 -74
- package/dist/cli/commands/execution.js +13 -1
- package/dist/cli/commands/setup.js +12 -4
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/core/ai-model.js +12 -4
- package/dist/cli/core/browser-daemon.js +122 -0
- package/dist/cli/core/browser.js +54 -180
- package/dist/cli/core/config.js +1 -1
- package/dist/cli/core/providers/browserbase.js +1 -0
- package/dist/cli/core/providers/kernel.js +1 -0
- package/dist/cli/core/providers/libretto-cloud.js +9 -4
- package/dist/cli/core/resolve-model.js +20 -2
- package/dist/cli/workers/run-integration-runtime.js +3 -0
- package/dist/shared/dom-semantics.js +0 -1
- package/package.json +1 -1
- package/skills/libretto/SKILL.md +14 -3
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/commands/execution.ts +13 -1
- package/src/cli/commands/setup.ts +11 -3
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/core/ai-model.ts +10 -2
- package/src/cli/core/browser-daemon.ts +198 -0
- package/src/cli/core/browser.ts +50 -190
- package/src/cli/core/config.ts +1 -1
- package/src/cli/core/providers/browserbase.ts +1 -0
- package/src/cli/core/providers/kernel.ts +1 -0
- package/src/cli/core/providers/libretto-cloud.ts +18 -5
- package/src/cli/core/providers/types.ts +12 -1
- package/src/cli/core/resolve-model.ts +20 -2
- package/src/cli/workers/run-integration-runtime.ts +10 -0
- package/src/shared/dom-semantics.ts +0 -1
package/src/cli/core/ai-model.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/cli/core/browser.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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(
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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(
|
package/src/cli/core/config.ts
CHANGED
|
@@ -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"),
|
|
@@ -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({
|
|
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
|
-
|
|
37
|
-
|
|
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<
|
|
19
|
+
closeSession(sessionId: string): Promise<ProviderCloseResult>;
|
|
9
20
|
};
|