libretto 0.4.4 → 0.5.1

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.
Files changed (194) hide show
  1. package/README.md +106 -36
  2. package/dist/cli/cli.js +39 -113
  3. package/dist/cli/commands/ai.js +1 -1
  4. package/dist/cli/commands/browser.js +87 -60
  5. package/dist/cli/commands/execution.js +201 -88
  6. package/dist/cli/commands/init.js +30 -8
  7. package/dist/cli/commands/logs.js +5 -6
  8. package/dist/cli/commands/shared.js +30 -29
  9. package/dist/cli/commands/snapshot.js +26 -39
  10. package/dist/cli/core/ai-config.js +9 -2
  11. package/dist/cli/core/api-snapshot-analyzer.js +15 -5
  12. package/dist/cli/core/browser.js +141 -33
  13. package/dist/cli/core/context.js +7 -18
  14. package/dist/cli/core/session-telemetry.js +5 -2
  15. package/dist/cli/core/session.js +23 -10
  16. package/dist/cli/core/snapshot-analyzer.js +16 -33
  17. package/dist/cli/core/snapshot-api-config.js +2 -6
  18. package/dist/cli/core/telemetry.js +10 -2
  19. package/dist/cli/framework/simple-cli.js +45 -25
  20. package/dist/cli/router.js +14 -21
  21. package/dist/cli/workers/run-integration-runtime.js +26 -7
  22. package/dist/cli/workers/run-integration-worker-protocol.js +3 -1
  23. package/dist/cli/workers/run-integration-worker.js +1 -4
  24. package/dist/index.d.ts +1 -2
  25. package/dist/index.js +7 -10
  26. package/dist/runtime/download/download.js +5 -1
  27. package/dist/runtime/extract/extract.js +11 -2
  28. package/dist/runtime/network/network.js +8 -1
  29. package/dist/runtime/recovery/agent.js +6 -2
  30. package/dist/runtime/recovery/errors.js +3 -1
  31. package/dist/runtime/recovery/recovery.js +3 -1
  32. package/dist/shared/condense-dom/condense-dom.js +6 -13
  33. package/dist/shared/config/config.d.ts +1 -9
  34. package/dist/shared/config/config.js +0 -18
  35. package/dist/shared/config/index.d.ts +2 -1
  36. package/dist/shared/config/index.js +0 -10
  37. package/dist/shared/debug/pause.js +9 -3
  38. package/dist/shared/instrumentation/instrument.js +101 -5
  39. package/dist/shared/llm/ai-sdk-adapter.js +3 -1
  40. package/dist/shared/llm/client.js +3 -1
  41. package/dist/shared/logger/index.js +4 -1
  42. package/dist/shared/paths/paths.js +2 -1
  43. package/dist/shared/paths/repo-root.d.ts +3 -0
  44. package/dist/shared/paths/repo-root.js +24 -0
  45. package/dist/shared/run/api.js +3 -1
  46. package/dist/shared/run/browser.js +7 -2
  47. package/dist/shared/state/session-state.d.ts +2 -1
  48. package/dist/shared/state/session-state.js +5 -2
  49. package/dist/shared/visualization/ghost-cursor.js +19 -10
  50. package/dist/shared/visualization/highlight.js +9 -6
  51. package/dist/shared/workflow/workflow.d.ts +4 -5
  52. package/dist/shared/workflow/workflow.js +3 -5
  53. package/package.json +11 -8
  54. package/scripts/check-skills-sync.mjs +25 -0
  55. package/scripts/compare-eval-summary.mjs +47 -0
  56. package/scripts/postinstall.mjs +26 -17
  57. package/scripts/prepare-release.sh +97 -0
  58. package/scripts/skills-libretto.mjs +103 -0
  59. package/scripts/summarize-evals.mjs +135 -0
  60. package/scripts/sync-skills.mjs +12 -0
  61. package/skills/libretto/SKILL.md +130 -377
  62. package/skills/libretto/references/auth-profiles.md +30 -0
  63. package/skills/libretto/{code-generation-rules.md → references/code-generation-rules.md} +27 -42
  64. package/skills/libretto/references/configuration-file-reference.md +53 -0
  65. package/skills/libretto/references/pages-and-page-targeting.md +29 -0
  66. package/skills/libretto/references/site-security-review.md +143 -0
  67. package/src/cli/cli.ts +86 -0
  68. package/src/cli/commands/ai.ts +35 -0
  69. package/src/cli/commands/browser.ts +189 -0
  70. package/src/cli/commands/execution.ts +822 -0
  71. package/src/cli/commands/init.ts +350 -0
  72. package/src/cli/commands/logs.ts +128 -0
  73. package/src/cli/commands/shared.ts +69 -0
  74. package/src/cli/commands/snapshot.ts +312 -0
  75. package/src/cli/core/ai-config.ts +264 -0
  76. package/src/cli/core/api-snapshot-analyzer.ts +108 -0
  77. package/src/cli/core/browser.ts +976 -0
  78. package/src/cli/core/context.ts +127 -0
  79. package/src/cli/core/pause-signals.ts +35 -0
  80. package/src/cli/core/session-telemetry.ts +564 -0
  81. package/src/cli/core/session.ts +223 -0
  82. package/src/cli/core/snapshot-analyzer.ts +855 -0
  83. package/src/cli/core/snapshot-api-config.ts +231 -0
  84. package/src/cli/core/telemetry.ts +459 -0
  85. package/src/cli/framework/simple-cli.ts +1340 -0
  86. package/src/cli/index.ts +13 -0
  87. package/src/cli/router.ts +20 -0
  88. package/src/cli/workers/run-integration-runtime.ts +338 -0
  89. package/src/cli/workers/run-integration-worker-protocol.ts +16 -0
  90. package/src/cli/workers/run-integration-worker.ts +72 -0
  91. package/src/index.ts +127 -0
  92. package/src/runtime/download/download.ts +104 -0
  93. package/src/runtime/download/index.ts +7 -0
  94. package/src/runtime/extract/extract.ts +102 -0
  95. package/src/runtime/extract/index.ts +1 -0
  96. package/src/runtime/network/index.ts +5 -0
  97. package/src/runtime/network/network.ts +119 -0
  98. package/{dist/runtime/recovery/agent.cjs → src/runtime/recovery/agent.ts} +114 -76
  99. package/src/runtime/recovery/errors.ts +155 -0
  100. package/src/runtime/recovery/index.ts +7 -0
  101. package/src/runtime/recovery/recovery.ts +53 -0
  102. package/{dist/shared/condense-dom/condense-dom.cjs → src/shared/condense-dom/condense-dom.ts} +249 -124
  103. package/src/shared/config/config.ts +3 -0
  104. package/src/shared/config/index.ts +0 -0
  105. package/src/shared/debug/index.ts +1 -0
  106. package/src/shared/debug/pause.ts +91 -0
  107. package/src/shared/instrumentation/errors.ts +84 -0
  108. package/src/shared/instrumentation/index.ts +9 -0
  109. package/src/shared/instrumentation/instrument.ts +406 -0
  110. package/src/shared/llm/ai-sdk-adapter.ts +81 -0
  111. package/{dist/shared/llm/client.cjs → src/shared/llm/client.ts} +86 -80
  112. package/src/shared/llm/index.ts +3 -0
  113. package/src/shared/llm/types.ts +63 -0
  114. package/src/shared/logger/index.ts +13 -0
  115. package/src/shared/logger/logger.ts +358 -0
  116. package/src/shared/logger/sinks.ts +148 -0
  117. package/src/shared/paths/paths.ts +110 -0
  118. package/src/shared/paths/repo-root.ts +27 -0
  119. package/src/shared/run/api.ts +6 -0
  120. package/src/shared/run/browser.ts +107 -0
  121. package/src/shared/state/index.ts +11 -0
  122. package/src/shared/state/session-state.ts +77 -0
  123. package/src/shared/visualization/ghost-cursor.ts +213 -0
  124. package/src/shared/visualization/highlight.ts +149 -0
  125. package/src/shared/visualization/index.ts +18 -0
  126. package/src/shared/workflow/workflow.ts +36 -0
  127. package/dist/index.cjs +0 -144
  128. package/dist/index.d.cts +0 -21
  129. package/dist/runtime/download/download.cjs +0 -70
  130. package/dist/runtime/download/download.d.cts +0 -35
  131. package/dist/runtime/download/index.cjs +0 -30
  132. package/dist/runtime/download/index.d.cts +0 -3
  133. package/dist/runtime/extract/extract.cjs +0 -88
  134. package/dist/runtime/extract/extract.d.cts +0 -23
  135. package/dist/runtime/extract/index.cjs +0 -28
  136. package/dist/runtime/extract/index.d.cts +0 -5
  137. package/dist/runtime/network/index.cjs +0 -28
  138. package/dist/runtime/network/index.d.cts +0 -4
  139. package/dist/runtime/network/network.cjs +0 -91
  140. package/dist/runtime/network/network.d.cts +0 -28
  141. package/dist/runtime/recovery/agent.d.cts +0 -13
  142. package/dist/runtime/recovery/errors.cjs +0 -124
  143. package/dist/runtime/recovery/errors.d.cts +0 -31
  144. package/dist/runtime/recovery/index.cjs +0 -34
  145. package/dist/runtime/recovery/index.d.cts +0 -7
  146. package/dist/runtime/recovery/recovery.cjs +0 -55
  147. package/dist/runtime/recovery/recovery.d.cts +0 -12
  148. package/dist/shared/condense-dom/condense-dom.d.cts +0 -34
  149. package/dist/shared/config/config.cjs +0 -44
  150. package/dist/shared/config/config.d.cts +0 -10
  151. package/dist/shared/config/index.cjs +0 -32
  152. package/dist/shared/config/index.d.cts +0 -1
  153. package/dist/shared/debug/index.cjs +0 -28
  154. package/dist/shared/debug/index.d.cts +0 -1
  155. package/dist/shared/debug/pause.cjs +0 -86
  156. package/dist/shared/debug/pause.d.cts +0 -12
  157. package/dist/shared/instrumentation/errors.cjs +0 -81
  158. package/dist/shared/instrumentation/errors.d.cts +0 -12
  159. package/dist/shared/instrumentation/index.cjs +0 -35
  160. package/dist/shared/instrumentation/index.d.cts +0 -6
  161. package/dist/shared/instrumentation/instrument.cjs +0 -206
  162. package/dist/shared/instrumentation/instrument.d.cts +0 -32
  163. package/dist/shared/llm/ai-sdk-adapter.cjs +0 -71
  164. package/dist/shared/llm/ai-sdk-adapter.d.cts +0 -22
  165. package/dist/shared/llm/client.d.cts +0 -13
  166. package/dist/shared/llm/index.cjs +0 -31
  167. package/dist/shared/llm/index.d.cts +0 -5
  168. package/dist/shared/llm/types.cjs +0 -16
  169. package/dist/shared/llm/types.d.cts +0 -67
  170. package/dist/shared/logger/index.cjs +0 -37
  171. package/dist/shared/logger/index.d.cts +0 -2
  172. package/dist/shared/logger/logger.cjs +0 -232
  173. package/dist/shared/logger/logger.d.cts +0 -86
  174. package/dist/shared/logger/sinks.cjs +0 -160
  175. package/dist/shared/logger/sinks.d.cts +0 -9
  176. package/dist/shared/paths/paths.cjs +0 -104
  177. package/dist/shared/paths/paths.d.cts +0 -10
  178. package/dist/shared/run/api.cjs +0 -28
  179. package/dist/shared/run/api.d.cts +0 -2
  180. package/dist/shared/run/browser.cjs +0 -98
  181. package/dist/shared/run/browser.d.cts +0 -22
  182. package/dist/shared/state/index.cjs +0 -38
  183. package/dist/shared/state/index.d.cts +0 -2
  184. package/dist/shared/state/session-state.cjs +0 -92
  185. package/dist/shared/state/session-state.d.cts +0 -40
  186. package/dist/shared/visualization/ghost-cursor.cjs +0 -174
  187. package/dist/shared/visualization/ghost-cursor.d.cts +0 -37
  188. package/dist/shared/visualization/highlight.cjs +0 -134
  189. package/dist/shared/visualization/highlight.d.cts +0 -22
  190. package/dist/shared/visualization/index.cjs +0 -45
  191. package/dist/shared/visualization/index.d.cts +0 -3
  192. package/dist/shared/workflow/workflow.cjs +0 -47
  193. package/dist/shared/workflow/workflow.d.cts +0 -21
  194. package/skills/libretto/integration-approach-selection.md +0 -174
@@ -0,0 +1,976 @@
1
+ import {
2
+ chromium,
3
+ type Browser,
4
+ type BrowserContext,
5
+ type CDPSession,
6
+ type Page,
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";
11
+ import { createRequire } from "node:module";
12
+ import { createServer } from "node:net";
13
+ import { spawn } from "node:child_process";
14
+ import type { LoggerApi } from "../../shared/logger/index.js";
15
+ import {
16
+ getSessionActionsLogPath,
17
+ getSessionNetworkLogPath,
18
+ PROFILES_DIR,
19
+ } from "./context.js";
20
+ import { readLibrettoConfig } from "./ai-config.js";
21
+ import {
22
+ assertSessionAvailableForStart,
23
+ clearSessionState,
24
+ listSessionsWithStateFile,
25
+ readSessionStateOrThrow,
26
+ logFileForSession,
27
+ readSessionState,
28
+ writeSessionState,
29
+ } from "./session.js";
30
+ import { installSessionTelemetry } from "./session-telemetry.js";
31
+
32
+ const CLOSE_WAIT_MS = 1_500;
33
+ const FORCE_CLOSE_WAIT_MS = 300;
34
+
35
+ async function pickFreePort(): Promise<number> {
36
+ return await new Promise((resolve, reject) => {
37
+ const server = createServer();
38
+ server.listen(0, "127.0.0.1", () => {
39
+ const address = server.address();
40
+ if (!address || typeof address === "string") {
41
+ reject(new Error("Failed to pick free port"));
42
+ return;
43
+ }
44
+ const port = address.port;
45
+ server.close(() => resolve(port));
46
+ });
47
+ server.on("error", reject);
48
+ });
49
+ }
50
+
51
+ export function normalizeUrl(url: string): string {
52
+ if (!/^https?:\/\//i.test(url)) {
53
+ return `https://${url}`;
54
+ }
55
+ return url;
56
+ }
57
+
58
+ export function normalizeDomain(url: string): string {
59
+ try {
60
+ const u = new URL(normalizeUrl(url));
61
+ return u.hostname.replace(/^www\./, "");
62
+ } catch {
63
+ return url.replace(/^www\./, "");
64
+ }
65
+ }
66
+
67
+ export function getProfilePath(domain: string): string {
68
+ return join(PROFILES_DIR, `${domain}.json`);
69
+ }
70
+
71
+ export function hasProfile(domain: string): boolean {
72
+ return existsSync(getProfilePath(domain));
73
+ }
74
+
75
+ async function tryConnectToCDP(
76
+ endpoint: string,
77
+ logger: LoggerApi,
78
+ timeoutMs: number = 5000,
79
+ ): Promise<Browser | null> {
80
+ logger.info("cdp-connect-attempt", { endpoint, timeoutMs });
81
+ try {
82
+ const connectPromise = chromium.connectOverCDP(endpoint);
83
+ const timeoutPromise = new Promise<null>((resolve) =>
84
+ setTimeout(() => resolve(null), timeoutMs),
85
+ );
86
+ const browser = await Promise.race([connectPromise, timeoutPromise]);
87
+ if (browser) {
88
+ logger.info("cdp-connect-success", {
89
+ endpoint,
90
+ contexts: browser.contexts().length,
91
+ });
92
+ } else {
93
+ logger.warn("cdp-connect-timeout", { endpoint, timeoutMs });
94
+ }
95
+ return browser;
96
+ } catch (err) {
97
+ logger.error("cdp-connect-error", { error: err, endpoint });
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function isOperationalPage(page: Page): boolean {
103
+ const url = page.url();
104
+ return !url.startsWith("devtools://") && !url.startsWith("chrome-error://");
105
+ }
106
+
107
+ export function disconnectBrowser(
108
+ browser: Browser,
109
+ logger: LoggerApi,
110
+ session?: string,
111
+ ): void {
112
+ logger.info("cdp-disconnect", { session });
113
+ try {
114
+ (browser as any)._connection?.close();
115
+ } catch (err) {
116
+ logger.warn("cdp-disconnect-already-closed", { error: err });
117
+ }
118
+ }
119
+
120
+ function resolveOperationalPages(browser: Browser): Page[] {
121
+ return browser
122
+ .contexts()
123
+ .flatMap((context) => context.pages())
124
+ .filter(isOperationalPage);
125
+ }
126
+
127
+ type PageReference = {
128
+ id: string;
129
+ page: Page;
130
+ };
131
+
132
+ export type OpenPageSummary = {
133
+ id: string;
134
+ url: string;
135
+ active: boolean;
136
+ };
137
+
138
+ async function resolvePageId(page: Page): Promise<string> {
139
+ const cdpSession: CDPSession = await page.context().newCDPSession(page);
140
+ try {
141
+ const targetInfo = await cdpSession.send("Target.getTargetInfo");
142
+ const targetId = (targetInfo as { targetInfo?: { targetId?: unknown } })
143
+ ?.targetInfo?.targetId;
144
+ if (typeof targetId !== "string" || targetId.length === 0) {
145
+ throw new Error(
146
+ `Could not resolve target id for page at URL "${page.url()}".`,
147
+ );
148
+ }
149
+ return targetId;
150
+ } finally {
151
+ await cdpSession.detach();
152
+ }
153
+ }
154
+
155
+ async function resolvePageReferences(pages: Page[]): Promise<PageReference[]> {
156
+ const refs = await Promise.all(
157
+ pages.map(async (page) => {
158
+ const id = await resolvePageId(page);
159
+ return { id, page };
160
+ }),
161
+ );
162
+ return refs;
163
+ }
164
+
165
+ export async function listOpenPages(
166
+ session: string,
167
+ logger: LoggerApi,
168
+ ): Promise<OpenPageSummary[]> {
169
+ const { browser, page: activePage } = await connect(session, logger);
170
+ try {
171
+ const pages = browser
172
+ .contexts()
173
+ .flatMap((ctx) => ctx.pages())
174
+ .filter(isOperationalPage);
175
+ const pageRefs = await resolvePageReferences(pages);
176
+ return pageRefs.map(({ id, page }) => ({
177
+ id,
178
+ url: page.url(),
179
+ active: page === activePage,
180
+ }));
181
+ } finally {
182
+ disconnectBrowser(browser, logger, session);
183
+ }
184
+ }
185
+
186
+ export async function connect(
187
+ session: string,
188
+ logger: LoggerApi,
189
+ timeoutMs: number = 10000,
190
+ options?: {
191
+ pageId?: string;
192
+ requireSinglePage?: boolean;
193
+ },
194
+ ): Promise<{
195
+ browser: Browser;
196
+ context: BrowserContext;
197
+ page: Page;
198
+ pageId: string;
199
+ }> {
200
+ logger.info("connect", { session, timeoutMs });
201
+ const state = readSessionStateOrThrow(session);
202
+ const endpoint = state.cdpEndpoint ?? `http://localhost:${state.port}`;
203
+ const browser = await tryConnectToCDP(endpoint, logger, timeoutMs);
204
+ if (!browser) {
205
+ logger.error("connect-no-browser", {
206
+ session,
207
+ endpoint,
208
+ pid: state.pid,
209
+ });
210
+ if (state.pid == null || !isPidRunning(state.pid)) {
211
+ clearSessionState(session, logger);
212
+ throw new Error(
213
+ `No browser running for session "${session}". Run 'libretto open <url> --session ${session}' first.`,
214
+ );
215
+ }
216
+
217
+ throw new Error(
218
+ `Could not connect to the browser for session "${session}" at ${endpoint}, but the session process (pid ${state.pid}) is still running. Try the command again, or close and reopen the session if it stays stuck.`,
219
+ );
220
+ }
221
+
222
+ const contexts = browser.contexts();
223
+ logger.info("connect-contexts", { session, contextCount: contexts.length });
224
+ if (contexts.length === 0) {
225
+ logger.error("connect-no-contexts", { session });
226
+ throw new Error("No browser context found.");
227
+ }
228
+
229
+ const allPages = contexts.flatMap((c) => c.pages());
230
+ const pages = resolveOperationalPages(browser);
231
+
232
+ logger.info("connect-pages", {
233
+ session,
234
+ totalPages: allPages.length,
235
+ filteredPages: pages.length,
236
+ urls: allPages.map((p) => p.url()),
237
+ });
238
+
239
+ if (pages.length === 0) {
240
+ logger.error("connect-no-pages", {
241
+ session,
242
+ allPageUrls: allPages.map((p) => p.url()),
243
+ });
244
+ throw new Error("No pages found.");
245
+ }
246
+
247
+ if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
248
+ throw new Error(
249
+ `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "libretto pages --session ${session}" to list ids).`,
250
+ );
251
+ }
252
+
253
+ const pageRefs = await resolvePageReferences(pages);
254
+ const pageRef = options?.pageId
255
+ ? (pageRefs.find((ref) => ref.id === options.pageId) ?? null)
256
+ : pageRefs[pageRefs.length - 1]!;
257
+ if (!pageRef) {
258
+ throw new Error(
259
+ `Page "${options?.pageId}" was not found in session "${session}". Run "libretto pages --session ${session}" to list ids.`,
260
+ );
261
+ }
262
+ const page = pageRef.page;
263
+ const context = page.context();
264
+
265
+ page.on("close", () => {
266
+ logger.error("page-closed-during-command", {
267
+ session,
268
+ url: page.url(),
269
+ trace: new Error("page-closed-trace").stack,
270
+ });
271
+ });
272
+ page.on("crash", () => {
273
+ logger.error("page-crashed-during-command", {
274
+ session,
275
+ url: page.url(),
276
+ });
277
+ });
278
+ browser.on("disconnected", () => {
279
+ logger.error("browser-disconnected-during-command", {
280
+ session,
281
+ trace: new Error("browser-disconnected-trace").stack,
282
+ });
283
+ });
284
+
285
+ logger.info("connect-success", { session, pageUrl: page.url() });
286
+ return { browser, context, page, pageId: pageRef.id };
287
+ }
288
+
289
+ export async function runPages(
290
+ session: string,
291
+ logger: LoggerApi,
292
+ ): Promise<void> {
293
+ logger.info("pages-start", { session });
294
+ const pageSummaries = await listOpenPages(session, logger);
295
+
296
+ if (pageSummaries.length === 0) {
297
+ console.log("No pages found.");
298
+ return;
299
+ }
300
+
301
+ console.log("Open pages:");
302
+ pageSummaries.forEach((pageSummary) => {
303
+ const activeSuffix = pageSummary.active ? " active=true" : "";
304
+ console.log(` id=${pageSummary.id} url=${pageSummary.url}${activeSuffix}`);
305
+ });
306
+ }
307
+
308
+ const DEFAULT_VIEWPORT = { width: 1366, height: 768 } as const;
309
+
310
+ export function resolveViewport(
311
+ cliViewport: { width: number; height: number } | undefined,
312
+ logger: LoggerApi,
313
+ ): { width: number; height: number } {
314
+ if (cliViewport) {
315
+ logger.info("viewport-source", { source: "cli", viewport: cliViewport });
316
+ return cliViewport;
317
+ }
318
+ const config = readLibrettoConfig();
319
+ if (config.viewport) {
320
+ logger.info("viewport-source", {
321
+ source: "config",
322
+ viewport: config.viewport,
323
+ });
324
+ return config.viewport;
325
+ }
326
+ logger.info("viewport-source", {
327
+ source: "default",
328
+ viewport: DEFAULT_VIEWPORT,
329
+ });
330
+ return DEFAULT_VIEWPORT;
331
+ }
332
+
333
+ export async function runOpen(
334
+ rawUrl: string,
335
+ headed: boolean,
336
+ session: string,
337
+ logger: LoggerApi,
338
+ options?: { viewport?: { width: number; height: number } },
339
+ ): Promise<void> {
340
+ const url = normalizeUrl(rawUrl);
341
+ const viewport = resolveViewport(options?.viewport, logger);
342
+ logger.info("open-start", { url, headed, session, viewport });
343
+ assertSessionAvailableForStart(session, logger);
344
+
345
+ const port = await pickFreePort();
346
+ const runLogPath = logFileForSession(session);
347
+ const networkLogPath = getSessionNetworkLogPath(session);
348
+ const actionsLogPath = getSessionActionsLogPath(session);
349
+
350
+ const browserMode = headed ? "headed" : "headless";
351
+ const domain = normalizeDomain(url);
352
+ const profilePath = getProfilePath(domain);
353
+ const useProfile = hasProfile(domain);
354
+
355
+ logger.info("open-launching", {
356
+ url,
357
+ mode: browserMode,
358
+ session,
359
+ port,
360
+ domain,
361
+ useProfile,
362
+ profilePath: useProfile ? profilePath : undefined,
363
+ });
364
+
365
+ if (useProfile) {
366
+ console.log(`Loading saved profile for ${domain}`);
367
+ }
368
+ console.log(`Launching ${browserMode} browser (session: ${session})...`);
369
+
370
+ const escapedProfilePath = profilePath
371
+ .replace(/\\/g, "\\\\")
372
+ .replace(/'/g, "\\'");
373
+ const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
374
+ const storageStateCode = useProfile
375
+ ? `storageState: '${escapedProfilePath}',`
376
+ : "";
377
+
378
+ const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
379
+ const escapedNetworkLogPath = networkLogPath
380
+ .replace(/\\/g, "\\\\")
381
+ .replace(/'/g, "\\'");
382
+ const escapedActionsLogPath = actionsLogPath
383
+ .replace(/\\/g, "\\\\")
384
+ .replace(/'/g, "\\'");
385
+
386
+ const launcherCode = `
387
+ import { chromium } from 'playwright';
388
+ import { appendFileSync, mkdirSync } from 'node:fs';
389
+ import { dirname } from 'node:path';
390
+
391
+ const LOG_FILE = '${escapedLogPath}';
392
+ const NETWORK_LOG = '${escapedNetworkLogPath}';
393
+ const ACTIONS_LOG = '${escapedActionsLogPath}';
394
+ mkdirSync(dirname(NETWORK_LOG), { recursive: true });
395
+
396
+ // tsx/esbuild may emit __name() wrappers in Function#toString output.
397
+ const __name = (target, value) =>
398
+ Object.defineProperty(target, 'name', { value, configurable: true });
399
+
400
+ ${installSessionTelemetry.toString()}
401
+
402
+ function logAction(entry) {
403
+ appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
404
+ }
405
+
406
+ function logNetwork(entry) {
407
+ appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
408
+ }
409
+
410
+ function childLog(level, event, data = {}) {
411
+ try {
412
+ const entry = JSON.stringify({
413
+ timestamp: new Date().toISOString(),
414
+ id: Math.random().toString(36).slice(2, 10),
415
+ level,
416
+ scope: 'libretto.child',
417
+ event,
418
+ data,
419
+ });
420
+ appendFileSync(LOG_FILE, entry + '\\n');
421
+ } catch {}
422
+ }
423
+
424
+ const browser = await chromium.launch({
425
+ headless: ${!headed},
426
+ args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'],
427
+ });
428
+
429
+ browser.on('disconnected', () => {
430
+ childLog('warn', 'browser-disconnected', { port: ${port} });
431
+ });
432
+
433
+ const context = await browser.newContext({
434
+ ${storageStateCode}
435
+ viewport: { width: ${viewport.width}, height: ${viewport.height} },
436
+ 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',
437
+ });
438
+
439
+ const page = await context.newPage();
440
+ page.setDefaultTimeout(30000);
441
+ page.setDefaultNavigationTimeout(45000);
442
+
443
+ await installSessionTelemetry({
444
+ context,
445
+ initialPage: page,
446
+ includeUserDomActions: true,
447
+ logAction,
448
+ logNetwork,
449
+ });
450
+
451
+
452
+ await page.goto('${escapedUrl}');
453
+
454
+ process.on('SIGTERM', async () => {
455
+ childLog('info', 'child-sigterm');
456
+ await browser.close();
457
+ process.exit(0);
458
+ });
459
+
460
+ process.on('SIGINT', async () => {
461
+ childLog('info', 'child-sigint');
462
+ await browser.close();
463
+ process.exit(0);
464
+ });
465
+
466
+ process.on('uncaughtException', (err) => {
467
+ childLog('error', 'uncaught-exception', { message: err.message, stack: err.stack });
468
+ process.exit(1);
469
+ });
470
+
471
+ process.on('unhandledRejection', (reason) => {
472
+ childLog('warn', 'unhandled-rejection', { reason: String(reason) });
473
+ });
474
+
475
+ process.on('exit', (code) => {
476
+ childLog('info', 'child-exit', { code, pid: process.pid, port: ${port} });
477
+ });
478
+
479
+ childLog('info', 'child-launched', { port: ${port}, pid: process.pid, session: '${session}' });
480
+
481
+ await new Promise(() => {});
482
+ `;
483
+
484
+ const childStderrFd = openSync(runLogPath, "a");
485
+
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
+ });
491
+ child.unref();
492
+
493
+ logger.info("open-child-spawned", { pid: child.pid, port, session });
494
+
495
+ let childSpawnError: Error | null = null;
496
+ let childEarlyExit: {
497
+ code: number | null;
498
+ signal: NodeJS.Signals | null;
499
+ } | null = null;
500
+
501
+ child.on("error", (err) => {
502
+ childSpawnError = err;
503
+ logger.error("open-child-spawn-error", { error: err, session, port });
504
+ });
505
+
506
+ child.on("exit", (code, signal) => {
507
+ childEarlyExit = { code, signal };
508
+ logger.warn("open-child-exited", {
509
+ code,
510
+ signal,
511
+ session,
512
+ port,
513
+ pid: child.pid,
514
+ });
515
+ });
516
+
517
+ const cdpPollIntervalMs = 500;
518
+ const cdpMaxAttempts = 30;
519
+ const cdpStartupTimeoutMs = cdpPollIntervalMs * cdpMaxAttempts;
520
+
521
+ for (let i = 0; i < cdpMaxAttempts; i++) {
522
+ const spawnError = childSpawnError as Error | null;
523
+ if (spawnError !== null) {
524
+ const errWithCode = spawnError as Error & { code?: string };
525
+ const hint =
526
+ errWithCode.code === "ENOENT"
527
+ ? " Ensure Node.js is available in PATH for child processes."
528
+ : "";
529
+ throw new Error(
530
+ `Failed to launch browser child process: ${spawnError.message}.${hint} Check logs: ${runLogPath}`,
531
+ );
532
+ }
533
+
534
+ const earlyExit = childEarlyExit as {
535
+ code: number | null;
536
+ signal: NodeJS.Signals | null;
537
+ } | null;
538
+ if (earlyExit !== null) {
539
+ const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
540
+ throw new Error(
541
+ `Browser child process exited before startup (status: ${status}). Check logs: ${runLogPath}`,
542
+ );
543
+ }
544
+
545
+ await new Promise((r) => setTimeout(r, cdpPollIntervalMs));
546
+ const ready = await fetch(`http://127.0.0.1:${port}/json/version`)
547
+ .then(() => true)
548
+ .catch(() => false);
549
+ if (i > 0 && i % 5 === 0) {
550
+ logger.info("open-waiting-for-cdp", { attempt: i, port, session });
551
+ }
552
+ if (ready) {
553
+ writeSessionState(
554
+ {
555
+ port,
556
+ pid: child.pid!,
557
+ session,
558
+ startedAt: new Date().toISOString(),
559
+ status: "active",
560
+ viewport,
561
+ },
562
+ logger,
563
+ );
564
+ logger.info("open-success", {
565
+ url,
566
+ mode: browserMode,
567
+ session,
568
+ port,
569
+ pid: child.pid,
570
+ });
571
+ console.log(`Browser open (${browserMode}): ${url}`);
572
+
573
+ await new Promise((r) => setTimeout(r, 2000));
574
+ return;
575
+ }
576
+ }
577
+
578
+ logger.error("open-timeout", {
579
+ session,
580
+ port,
581
+ pid: child.pid,
582
+ attempts: cdpMaxAttempts,
583
+ });
584
+ throw new Error(
585
+ `Failed to connect to browser after ${Math.ceil(cdpStartupTimeoutMs / 1000)}s. Check startup logs: ${runLogPath}`,
586
+ );
587
+ }
588
+
589
+ export async function runSave(
590
+ urlOrDomain: string,
591
+ session: string,
592
+ logger: LoggerApi,
593
+ ): Promise<void> {
594
+ logger.info("save-start", { urlOrDomain, session });
595
+ const { browser, context, page } = await connect(session, logger);
596
+
597
+ try {
598
+ await new Promise((r) => setTimeout(r, 500));
599
+
600
+ const domain = normalizeDomain(urlOrDomain);
601
+ const profilePath = getProfilePath(domain);
602
+
603
+ const cdpSession = await context.newCDPSession(page);
604
+ const { cookies: rawCookies } = await cdpSession.send(
605
+ "Network.getAllCookies",
606
+ );
607
+
608
+ const cookies = rawCookies.map((c: any) => {
609
+ const cookie = { ...c };
610
+ if (cookie.partitionKey && typeof cookie.partitionKey === "object") {
611
+ delete cookie.partitionKey;
612
+ }
613
+ return cookie;
614
+ });
615
+
616
+ await cdpSession.detach();
617
+
618
+ const origins: Array<{
619
+ origin: string;
620
+ localStorage: Array<{ name: string; value: string }>;
621
+ }> = [];
622
+
623
+ for (const ctx of browser.contexts()) {
624
+ for (const pg of ctx.pages()) {
625
+ try {
626
+ const origin = new URL(pg.url()).origin;
627
+ const localStorage = await pg.evaluate(() => {
628
+ const items: Array<{ name: string; value: string }> = [];
629
+ for (let i = 0; i < window.localStorage.length; i++) {
630
+ const key = window.localStorage.key(i);
631
+ if (key) {
632
+ items.push({
633
+ name: key,
634
+ value: window.localStorage.getItem(key) || "",
635
+ });
636
+ }
637
+ }
638
+ return items;
639
+ });
640
+ if (localStorage.length > 0) {
641
+ origins.push({ origin, localStorage });
642
+ }
643
+ } catch {
644
+ // Skip pages that can't be accessed.
645
+ }
646
+ }
647
+ }
648
+
649
+ const state = { cookies, origins };
650
+ const fs = await import("node:fs/promises");
651
+ await fs.mkdir(dirname(profilePath), { recursive: true });
652
+ await fs.writeFile(profilePath, JSON.stringify(state, null, 2));
653
+
654
+ logger.info("save-success", {
655
+ domain,
656
+ profilePath,
657
+ cookieCount: cookies.length,
658
+ originCount: origins.length,
659
+ });
660
+ console.log(`Profile saved for ${domain}`);
661
+ console.log(` Location: ${profilePath}`);
662
+ console.log(` Cookies: ${cookies.length}, Origins: ${origins.length}`);
663
+ } catch (err) {
664
+ logger.error("save-error", { error: err, urlOrDomain, session });
665
+ throw err;
666
+ } finally {
667
+ disconnectBrowser(browser, logger, session);
668
+ }
669
+ }
670
+
671
+ export async function runClose(
672
+ session: string,
673
+ logger: LoggerApi,
674
+ ): Promise<void> {
675
+ logger.info("close-start", { session });
676
+ const state = readSessionState(session, logger);
677
+ if (!state) {
678
+ logger.info("close-no-session", { session });
679
+ console.log(`No browser running for session "${session}".`);
680
+ return;
681
+ }
682
+
683
+ logger.info("close-killing", { session, pid: state.pid, port: state.port });
684
+
685
+ if (state.pid != null) {
686
+ sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
687
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
688
+ }
689
+
690
+ clearSessionState(session, logger);
691
+ logger.info("close-success", { session });
692
+ console.log(`Browser closed (session: ${session}).`);
693
+ }
694
+
695
+ type ClosableSession = {
696
+ session: string;
697
+ pid?: number;
698
+ port: number;
699
+ };
700
+
701
+ function waitForCloseSignalWindow(ms: number): Promise<void> {
702
+ return new Promise((r) => setTimeout(r, ms));
703
+ }
704
+
705
+ function isPidRunning(pid: number): boolean {
706
+ try {
707
+ process.kill(pid, 0);
708
+ return true;
709
+ } catch {
710
+ return false;
711
+ }
712
+ }
713
+
714
+ function sendSignalToProcessGroupOrPid(
715
+ pid: number,
716
+ signal: NodeJS.Signals,
717
+ logger: LoggerApi,
718
+ session: string,
719
+ ): void {
720
+ try {
721
+ process.kill(pid, signal);
722
+ logger.info("close-signal-pid", { session, pid, signal });
723
+ } catch (pidErr) {
724
+ const pidCode = (pidErr as NodeJS.ErrnoException).code;
725
+ if (pidCode !== "ESRCH") {
726
+ logger.warn("close-signal-pid-failed", {
727
+ session,
728
+ pid,
729
+ signal,
730
+ error: pidErr,
731
+ });
732
+ }
733
+ }
734
+ }
735
+
736
+ function formatSessionList(
737
+ targets: ReadonlyArray<{ session: string }>,
738
+ ): string {
739
+ return targets.map((target) => `"${target.session}"`).join(", ");
740
+ }
741
+
742
+ function resolveClosableSessions(logger: LoggerApi): {
743
+ closable: ClosableSession[];
744
+ clearedUnreadableStates: number;
745
+ } {
746
+ const sessions = listSessionsWithStateFile();
747
+ const closable: ClosableSession[] = [];
748
+ let clearedUnreadableStates = 0;
749
+ for (const session of sessions) {
750
+ const state = readSessionState(session, logger);
751
+ if (!state) {
752
+ clearSessionState(session, logger);
753
+ clearedUnreadableStates += 1;
754
+ continue;
755
+ }
756
+ closable.push({
757
+ session,
758
+ pid: state.pid,
759
+ port: state.port,
760
+ });
761
+ }
762
+
763
+ return { closable, clearedUnreadableStates };
764
+ }
765
+
766
+ function clearStoppedSessionStates(
767
+ sessions: ReadonlyArray<ClosableSession>,
768
+ logger: LoggerApi,
769
+ ): number {
770
+ let cleared = 0;
771
+ for (const session of sessions) {
772
+ if (session.pid == null || !isPidRunning(session.pid)) {
773
+ clearSessionState(session.session, logger);
774
+ cleared += 1;
775
+ }
776
+ }
777
+ return cleared;
778
+ }
779
+
780
+ export async function runCloseAll(
781
+ logger: LoggerApi,
782
+ options?: { force?: boolean },
783
+ ): Promise<void> {
784
+ const force = Boolean(options?.force);
785
+ logger.info("close-all-start", { force });
786
+ const { closable, clearedUnreadableStates } = resolveClosableSessions(logger);
787
+ if (closable.length === 0) {
788
+ if (clearedUnreadableStates > 0) {
789
+ console.log(
790
+ `Cleared ${clearedUnreadableStates} unreadable session state file(s).`,
791
+ );
792
+ }
793
+ console.log("No browser sessions found.");
794
+ return;
795
+ }
796
+
797
+ for (const target of closable) {
798
+ logger.info("close-all-sigterm", {
799
+ session: target.session,
800
+ pid: target.pid,
801
+ port: target.port,
802
+ });
803
+ if (target.pid != null) {
804
+ sendSignalToProcessGroupOrPid(
805
+ target.pid,
806
+ "SIGTERM",
807
+ logger,
808
+ target.session,
809
+ );
810
+ }
811
+ }
812
+
813
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
814
+
815
+ let survivors = closable.filter(
816
+ (target) => target.pid != null && isPidRunning(target.pid),
817
+ );
818
+ if (survivors.length > 0 && !force) {
819
+ const closed = clearStoppedSessionStates(closable, logger);
820
+
821
+ throw new Error(
822
+ [
823
+ `Failed to close ${survivors.length} session(s) gracefully: ${formatSessionList(survivors)}.`,
824
+ `Closed ${closed} session(s).`,
825
+ `Retry with: libretto close --all --force`,
826
+ ].join("\n"),
827
+ );
828
+ }
829
+
830
+ let forceKilled = 0;
831
+ if (survivors.length > 0) {
832
+ for (const survivor of survivors) {
833
+ logger.warn("close-all-sigkill", {
834
+ session: survivor.session,
835
+ pid: survivor.pid,
836
+ });
837
+ if (survivor.pid != null) {
838
+ sendSignalToProcessGroupOrPid(
839
+ survivor.pid,
840
+ "SIGKILL",
841
+ logger,
842
+ survivor.session,
843
+ );
844
+ }
845
+ forceKilled += 1;
846
+ }
847
+ await waitForCloseSignalWindow(FORCE_CLOSE_WAIT_MS);
848
+ survivors = survivors.filter(
849
+ (target) => target.pid != null && isPidRunning(target.pid),
850
+ );
851
+ if (survivors.length > 0) {
852
+ const closed = clearStoppedSessionStates(closable, logger);
853
+ throw new Error(
854
+ [
855
+ `Failed to force-close ${survivors.length} session(s): ${formatSessionList(survivors)}.`,
856
+ `Closed ${closed} session(s).`,
857
+ ].join("\n"),
858
+ );
859
+ }
860
+ }
861
+
862
+ clearStoppedSessionStates(closable, logger);
863
+
864
+ if (clearedUnreadableStates > 0) {
865
+ console.log(
866
+ `Cleared ${clearedUnreadableStates} unreadable session state file(s).`,
867
+ );
868
+ }
869
+ console.log(`Closed ${closable.length} session(s).`);
870
+ if (forceKilled > 0) {
871
+ console.log(`Force-killed ${forceKilled} session(s).`);
872
+ }
873
+ }
874
+
875
+ export async function runConnect(
876
+ cdpUrl: string,
877
+ session: string,
878
+ logger: LoggerApi,
879
+ ): Promise<void> {
880
+ logger.info("connect-start", { cdpUrl, session });
881
+ assertSessionAvailableForStart(session, logger);
882
+
883
+ let parsedUrl: URL;
884
+ try {
885
+ parsedUrl = new URL(cdpUrl);
886
+ } catch {
887
+ throw new Error(
888
+ [
889
+ `Invalid CDP URL: ${cdpUrl}`,
890
+ ``,
891
+ `Expected an HTTP URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
892
+ ` libretto connect http://127.0.0.1:9222`,
893
+ ` libretto connect http://remote-host:9222`,
894
+ ` libretto connect http://remote-host:9222/devtools/browser/<id>`,
895
+ ].join("\n"),
896
+ );
897
+ }
898
+
899
+ const endpoint = parsedUrl.href;
900
+ const port = parsedUrl.port
901
+ ? Number(parsedUrl.port)
902
+ : parsedUrl.protocol === "https:"
903
+ ? 443
904
+ : 80;
905
+
906
+ console.log(
907
+ `Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
908
+ );
909
+
910
+ // Verify the CDP endpoint is reachable
911
+ const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
912
+ try {
913
+ const resp = await fetch(versionUrl);
914
+ const versionInfo = await resp.json();
915
+ logger.info("connect-version-ok", { versionUrl, versionInfo });
916
+ } catch (err) {
917
+ logger.error("connect-version-failed", { versionUrl, error: err });
918
+ throw new Error(
919
+ `Cannot reach CDP endpoint at ${versionUrl}. Make sure the target is running and accessible at ${parsedUrl.host}.`,
920
+ );
921
+ }
922
+
923
+ // Connect via CDP using the full endpoint URL
924
+ const browser = await tryConnectToCDP(endpoint, logger, 10_000);
925
+ if (!browser) {
926
+ throw new Error(
927
+ `CDP endpoint at ${endpoint} is reachable but Playwright could not connect. Check that the URL is a Chrome DevTools Protocol endpoint.`,
928
+ );
929
+ }
930
+
931
+ const pages = resolveOperationalPages(browser);
932
+ logger.info("connect-pages", {
933
+ session,
934
+ pageCount: pages.length,
935
+ urls: pages.map((p) => p.url()),
936
+ });
937
+
938
+ disconnectBrowser(browser, logger, session);
939
+
940
+ writeSessionState(
941
+ {
942
+ port,
943
+ cdpEndpoint: endpoint,
944
+ session,
945
+ startedAt: new Date().toISOString(),
946
+ status: "active",
947
+ },
948
+ logger,
949
+ );
950
+
951
+ logger.info("connect-success", { cdpUrl: endpoint, session, port });
952
+ console.log(`Connected to ${endpoint} (session: ${session})`);
953
+ console.log(` Pages found: ${pages.length}`);
954
+ if (pages.length > 0) {
955
+ for (const p of pages.slice(0, 5)) {
956
+ console.log(` ${p.url()}`);
957
+ }
958
+ if (pages.length > 5) {
959
+ console.log(` ... and ${pages.length - 5} more`);
960
+ }
961
+ }
962
+ }
963
+
964
+ export function resolvePath(filePath: string): string {
965
+ return join(process.cwd(), filePath);
966
+ }
967
+
968
+ export function getScreenshotBaseName(title: string): string {
969
+ const sanitizedTitle = title
970
+ .replace(/[^a-zA-Z0-9\s-]/g, "")
971
+ .replace(/\s+/g, "-")
972
+ .toLowerCase()
973
+ .slice(0, 50);
974
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
975
+ return `${sanitizedTitle}-${timestamp}`;
976
+ }