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.
- package/README.md +27 -69
- package/README.template.md +27 -69
- package/dist/cli/commands/setup.js +12 -4
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/core/ai-model.js +17 -70
- package/dist/cli/core/browser-daemon.js +122 -0
- package/dist/cli/core/browser.js +31 -176
- package/dist/cli/core/config.js +1 -1
- package/dist/cli/core/providers/libretto-cloud.js +5 -3
- package/dist/cli/core/resolve-model.js +20 -2
- package/dist/cli/workers/run-integration-runtime.js +5 -2
- package/dist/shared/dom-semantics.js +0 -1
- package/dist/shared/env/load-env.d.ts +9 -4
- package/dist/shared/env/load-env.js +57 -36
- package/package.json +1 -1
- package/scripts/generate-changelog.ts +9 -6
- package/skills/libretto/SKILL.md +14 -3
- package/skills/libretto/references/configuration-file-reference.md +1 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/commands/setup.ts +11 -3
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/core/ai-model.ts +17 -89
- package/src/cli/core/browser-daemon.ts +198 -0
- package/src/cli/core/browser.ts +27 -187
- package/src/cli/core/config.ts +1 -1
- package/src/cli/core/providers/libretto-cloud.ts +8 -5
- package/src/cli/core/resolve-model.ts +20 -2
- package/src/cli/workers/run-integration-runtime.ts +12 -2
- package/src/shared/dom-semantics.ts +0 -1
- package/src/shared/env/load-env.ts +75 -53
|
@@ -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
|
|
package/src/cli/core/ai-model.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
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
|
|
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"),
|