libretto 0.5.0 → 0.5.2

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 (122) hide show
  1. package/README.md +109 -35
  2. package/dist/cli/cli.js +22 -97
  3. package/dist/cli/commands/browser.js +86 -59
  4. package/dist/cli/commands/execution.js +199 -86
  5. package/dist/cli/commands/init.js +34 -29
  6. package/dist/cli/commands/logs.js +4 -5
  7. package/dist/cli/commands/shared.js +30 -29
  8. package/dist/cli/commands/snapshot.js +26 -39
  9. package/dist/cli/core/ai-config.js +21 -4
  10. package/dist/cli/core/api-snapshot-analyzer.js +15 -5
  11. package/dist/cli/core/browser.js +207 -37
  12. package/dist/cli/core/context.js +4 -1
  13. package/dist/cli/core/session-telemetry.js +434 -174
  14. package/dist/cli/core/session.js +21 -8
  15. package/dist/cli/core/snapshot-analyzer.js +14 -31
  16. package/dist/cli/core/snapshot-api-config.js +2 -6
  17. package/dist/cli/core/telemetry.js +20 -4
  18. package/dist/cli/framework/simple-cli.js +45 -25
  19. package/dist/cli/router.js +14 -21
  20. package/dist/cli/workers/run-integration-runtime.js +24 -5
  21. package/dist/cli/workers/run-integration-worker-protocol.js +3 -1
  22. package/dist/cli/workers/run-integration-worker.js +1 -4
  23. package/dist/index.d.ts +1 -2
  24. package/dist/index.js +7 -10
  25. package/dist/runtime/download/download.js +5 -1
  26. package/dist/runtime/extract/extract.js +11 -2
  27. package/dist/runtime/network/network.js +8 -1
  28. package/dist/runtime/recovery/agent.js +6 -2
  29. package/dist/runtime/recovery/errors.js +3 -1
  30. package/dist/runtime/recovery/recovery.js +3 -1
  31. package/dist/shared/condense-dom/condense-dom.js +17 -69
  32. package/dist/shared/config/config.d.ts +1 -9
  33. package/dist/shared/config/config.js +0 -18
  34. package/dist/shared/config/index.d.ts +2 -1
  35. package/dist/shared/config/index.js +0 -10
  36. package/dist/shared/debug/pause.js +9 -3
  37. package/dist/shared/dom-semantics.d.ts +8 -0
  38. package/dist/shared/dom-semantics.js +69 -0
  39. package/dist/shared/instrumentation/instrument.js +101 -5
  40. package/dist/shared/llm/ai-sdk-adapter.js +3 -1
  41. package/dist/shared/llm/client.js +3 -1
  42. package/dist/shared/logger/index.js +4 -1
  43. package/dist/shared/run/api.js +3 -1
  44. package/dist/shared/run/browser.js +47 -3
  45. package/dist/shared/state/session-state.d.ts +2 -1
  46. package/dist/shared/state/session-state.js +5 -2
  47. package/dist/shared/visualization/ghost-cursor.js +36 -14
  48. package/dist/shared/visualization/highlight.js +9 -6
  49. package/dist/shared/workflow/workflow.d.ts +4 -5
  50. package/dist/shared/workflow/workflow.js +3 -5
  51. package/package.json +6 -2
  52. package/scripts/check-skills-sync.mjs +25 -0
  53. package/scripts/compare-eval-summary.mjs +47 -0
  54. package/scripts/postinstall.mjs +15 -15
  55. package/scripts/prepare-release.sh +97 -0
  56. package/scripts/skills-libretto.mjs +103 -0
  57. package/scripts/summarize-evals.mjs +135 -0
  58. package/scripts/sync-skills.mjs +12 -0
  59. package/skills/libretto/SKILL.md +132 -54
  60. package/skills/libretto/references/action-logs.md +101 -0
  61. package/skills/libretto/references/auth-profiles.md +1 -2
  62. package/skills/libretto/references/code-generation-rules.md +210 -0
  63. package/skills/libretto/references/configuration-file-reference.md +53 -0
  64. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  65. package/skills/libretto/references/site-security-review.md +143 -0
  66. package/src/cli/cli.ts +23 -110
  67. package/src/cli/commands/browser.ts +94 -70
  68. package/src/cli/commands/execution.ts +233 -102
  69. package/src/cli/commands/init.ts +37 -33
  70. package/src/cli/commands/logs.ts +7 -7
  71. package/src/cli/commands/shared.ts +36 -37
  72. package/src/cli/commands/snapshot.ts +44 -59
  73. package/src/cli/core/ai-config.ts +24 -4
  74. package/src/cli/core/api-snapshot-analyzer.ts +17 -6
  75. package/src/cli/core/browser.ts +260 -49
  76. package/src/cli/core/context.ts +7 -2
  77. package/src/cli/core/session-telemetry.ts +449 -197
  78. package/src/cli/core/session.ts +21 -7
  79. package/src/cli/core/snapshot-analyzer.ts +26 -46
  80. package/src/cli/core/snapshot-api-config.ts +170 -175
  81. package/src/cli/core/telemetry.ts +39 -4
  82. package/src/cli/framework/simple-cli.ts +144 -77
  83. package/src/cli/router.ts +13 -21
  84. package/src/cli/workers/run-integration-runtime.ts +36 -9
  85. package/src/cli/workers/run-integration-worker-protocol.ts +2 -0
  86. package/src/cli/workers/run-integration-worker.ts +1 -4
  87. package/src/index.ts +73 -66
  88. package/src/runtime/download/download.ts +62 -58
  89. package/src/runtime/download/index.ts +5 -5
  90. package/src/runtime/extract/extract.ts +71 -61
  91. package/src/runtime/network/index.ts +3 -3
  92. package/src/runtime/network/network.ts +99 -93
  93. package/src/runtime/recovery/agent.ts +217 -212
  94. package/src/runtime/recovery/errors.ts +107 -104
  95. package/src/runtime/recovery/index.ts +3 -3
  96. package/src/runtime/recovery/recovery.ts +38 -35
  97. package/src/shared/condense-dom/condense-dom.ts +27 -82
  98. package/src/shared/config/config.ts +0 -19
  99. package/src/shared/config/index.ts +0 -5
  100. package/src/shared/debug/pause.ts +57 -51
  101. package/src/shared/dom-semantics.ts +68 -0
  102. package/src/shared/instrumentation/errors.ts +64 -62
  103. package/src/shared/instrumentation/index.ts +5 -5
  104. package/src/shared/instrumentation/instrument.ts +339 -209
  105. package/src/shared/llm/ai-sdk-adapter.ts +58 -55
  106. package/src/shared/llm/client.ts +181 -174
  107. package/src/shared/llm/types.ts +39 -39
  108. package/src/shared/logger/index.ts +11 -4
  109. package/src/shared/logger/logger.ts +312 -306
  110. package/src/shared/logger/sinks.ts +118 -114
  111. package/src/shared/paths/paths.ts +50 -49
  112. package/src/shared/paths/repo-root.ts +17 -17
  113. package/src/shared/run/api.ts +5 -1
  114. package/src/shared/run/browser.ts +65 -3
  115. package/src/shared/state/index.ts +9 -9
  116. package/src/shared/state/session-state.ts +46 -43
  117. package/src/shared/visualization/ghost-cursor.ts +180 -149
  118. package/src/shared/visualization/highlight.ts +89 -86
  119. package/src/shared/visualization/index.ts +13 -13
  120. package/src/shared/workflow/workflow.ts +19 -25
  121. package/skills/libretto/references/reverse-engineering-network-requests.md +0 -39
  122. package/skills/libretto/references/user-action-log.md +0 -31
@@ -1,4 +1,10 @@
1
- import { chromium, type Browser, type BrowserContext, type CDPSession, type Page } from "playwright";
1
+ import {
2
+ chromium,
3
+ type Browser,
4
+ type BrowserContext,
5
+ type CDPSession,
6
+ type Page,
7
+ } from "playwright";
2
8
  import { openSync, existsSync, writeFileSync } from "node:fs";
3
9
  import { basename, dirname, join, resolve } from "node:path";
4
10
  import { fileURLToPath } from "node:url";
@@ -6,6 +12,14 @@ import { createRequire } from "node:module";
6
12
  import { createServer } from "node:net";
7
13
  import { spawn } from "node:child_process";
8
14
  import type { LoggerApi } from "../../shared/logger/index.js";
15
+ import {
16
+ filterSemanticClasses,
17
+ INTERACTIVE_ROLE_NAMES,
18
+ INTERACTIVE_TAG_NAMES,
19
+ isObfuscatedClass,
20
+ TEST_ATTRIBUTE_NAMES,
21
+ TRUSTED_ATTRIBUTE_NAMES,
22
+ } from "../../shared/dom-semantics.js";
9
23
  import {
10
24
  getSessionActionsLogPath,
11
25
  getSessionNetworkLogPath,
@@ -66,13 +80,12 @@ export function hasProfile(domain: string): boolean {
66
80
  return existsSync(getProfilePath(domain));
67
81
  }
68
82
 
69
- async function tryConnectToPort(
70
- port: number,
83
+ async function tryConnectToCDP(
84
+ endpoint: string,
71
85
  logger: LoggerApi,
72
86
  timeoutMs: number = 5000,
73
87
  ): Promise<Browser | null> {
74
- const endpoint = `http://localhost:${port}`;
75
- logger.info("cdp-connect-attempt", { port, endpoint, timeoutMs });
88
+ logger.info("cdp-connect-attempt", { endpoint, timeoutMs });
76
89
  try {
77
90
  const connectPromise = chromium.connectOverCDP(endpoint);
78
91
  const timeoutPromise = new Promise<null>((resolve) =>
@@ -81,16 +94,15 @@ async function tryConnectToPort(
81
94
  const browser = await Promise.race([connectPromise, timeoutPromise]);
82
95
  if (browser) {
83
96
  logger.info("cdp-connect-success", {
84
- port,
85
97
  endpoint,
86
98
  contexts: browser.contexts().length,
87
99
  });
88
100
  } else {
89
- logger.warn("cdp-connect-timeout", { port, endpoint, timeoutMs });
101
+ logger.warn("cdp-connect-timeout", { endpoint, timeoutMs });
90
102
  }
91
103
  return browser;
92
104
  } catch (err) {
93
- logger.error("cdp-connect-error", { error: err, port, endpoint });
105
+ logger.error("cdp-connect-error", { error: err, endpoint });
94
106
  return null;
95
107
  }
96
108
  }
@@ -135,10 +147,12 @@ async function resolvePageId(page: Page): Promise<string> {
135
147
  const cdpSession: CDPSession = await page.context().newCDPSession(page);
136
148
  try {
137
149
  const targetInfo = await cdpSession.send("Target.getTargetInfo");
138
- const targetId = (targetInfo as { targetInfo?: { targetId?: unknown } })?.targetInfo
139
- ?.targetId;
150
+ const targetId = (targetInfo as { targetInfo?: { targetId?: unknown } })
151
+ ?.targetInfo?.targetId;
140
152
  if (typeof targetId !== "string" || targetId.length === 0) {
141
- throw new Error(`Could not resolve target id for page at URL "${page.url()}".`);
153
+ throw new Error(
154
+ `Could not resolve target id for page at URL "${page.url()}".`,
155
+ );
142
156
  }
143
157
  return targetId;
144
158
  } finally {
@@ -162,7 +176,10 @@ export async function listOpenPages(
162
176
  ): Promise<OpenPageSummary[]> {
163
177
  const { browser, page: activePage } = await connect(session, logger);
164
178
  try {
165
- const pages = browser.contexts().flatMap((ctx) => ctx.pages()).filter(isOperationalPage);
179
+ const pages = browser
180
+ .contexts()
181
+ .flatMap((ctx) => ctx.pages())
182
+ .filter(isOperationalPage);
166
183
  const pageRefs = await resolvePageReferences(pages);
167
184
  return pageRefs.map(({ id, page }) => ({
168
185
  id,
@@ -190,14 +207,15 @@ export async function connect(
190
207
  }> {
191
208
  logger.info("connect", { session, timeoutMs });
192
209
  const state = readSessionStateOrThrow(session);
193
- const browser = await tryConnectToPort(state.port, logger, timeoutMs);
210
+ const endpoint = state.cdpEndpoint ?? `http://localhost:${state.port}`;
211
+ const browser = await tryConnectToCDP(endpoint, logger, timeoutMs);
194
212
  if (!browser) {
195
213
  logger.error("connect-no-browser", {
196
214
  session,
197
- port: state.port,
215
+ endpoint,
198
216
  pid: state.pid,
199
217
  });
200
- if (!isPidRunning(state.pid)) {
218
+ if (state.pid == null || !isPidRunning(state.pid)) {
201
219
  clearSessionState(session, logger);
202
220
  throw new Error(
203
221
  `No browser running for session "${session}". Run 'libretto open <url> --session ${session}' first.`,
@@ -205,7 +223,7 @@ export async function connect(
205
223
  }
206
224
 
207
225
  throw new Error(
208
- `Could not connect to the browser for session "${session}" at http://127.0.0.1:${state.port}, but the session process (pid ${state.pid}) is still running. Try the command again, or close and reopen the session if it stays stuck.`,
226
+ `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.`,
209
227
  );
210
228
  }
211
229
 
@@ -276,7 +294,10 @@ export async function connect(
276
294
  return { browser, context, page, pageId: pageRef.id };
277
295
  }
278
296
 
279
- export async function runPages(session: string, logger: LoggerApi): Promise<void> {
297
+ export async function runPages(
298
+ session: string,
299
+ logger: LoggerApi,
300
+ ): Promise<void> {
280
301
  logger.info("pages-start", { session });
281
302
  const pageSummaries = await listOpenPages(session, logger);
282
303
 
@@ -294,7 +315,7 @@ export async function runPages(session: string, logger: LoggerApi): Promise<void
294
315
 
295
316
  const DEFAULT_VIEWPORT = { width: 1366, height: 768 } as const;
296
317
 
297
- function resolveViewport(
318
+ export function resolveViewport(
298
319
  cliViewport: { width: number; height: number } | undefined,
299
320
  logger: LoggerApi,
300
321
  ): { width: number; height: number } {
@@ -304,13 +325,33 @@ function resolveViewport(
304
325
  }
305
326
  const config = readLibrettoConfig();
306
327
  if (config.viewport) {
307
- logger.info("viewport-source", { source: "config", viewport: config.viewport });
328
+ logger.info("viewport-source", {
329
+ source: "config",
330
+ viewport: config.viewport,
331
+ });
308
332
  return config.viewport;
309
333
  }
310
- logger.info("viewport-source", { source: "default", viewport: DEFAULT_VIEWPORT });
334
+ logger.info("viewport-source", {
335
+ source: "default",
336
+ viewport: DEFAULT_VIEWPORT,
337
+ });
311
338
  return DEFAULT_VIEWPORT;
312
339
  }
313
340
 
341
+ function resolveWindowPosition(
342
+ logger: LoggerApi,
343
+ ): { x: number; y: number } | undefined {
344
+ const config = readLibrettoConfig();
345
+ if (config.windowPosition) {
346
+ logger.info("window-position-source", {
347
+ source: "config",
348
+ windowPosition: config.windowPosition,
349
+ });
350
+ return config.windowPosition;
351
+ }
352
+ return undefined;
353
+ }
354
+
314
355
  export async function runOpen(
315
356
  rawUrl: string,
316
357
  headed: boolean,
@@ -320,7 +361,8 @@ export async function runOpen(
320
361
  ): Promise<void> {
321
362
  const url = normalizeUrl(rawUrl);
322
363
  const viewport = resolveViewport(options?.viewport, logger);
323
- logger.info("open-start", { url, headed, session, viewport });
364
+ const windowPosition = headed ? resolveWindowPosition(logger) : undefined;
365
+ logger.info("open-start", { url, headed, session, viewport, windowPosition });
324
366
  assertSessionAvailableForStart(session, logger);
325
367
 
326
368
  const port = await pickFreePort();
@@ -363,6 +405,49 @@ export async function runOpen(
363
405
  const escapedActionsLogPath = actionsLogPath
364
406
  .replace(/\\/g, "\\\\")
365
407
  .replace(/'/g, "\\'");
408
+ const windowPositionArg = windowPosition
409
+ ? `, '--window-position=${windowPosition.x},${windowPosition.y}'`
410
+ : "";
411
+ const windowBoundsSetupCode = windowPosition
412
+ ? `
413
+ const requestedWindowBounds = { left: ${windowPosition.x}, top: ${windowPosition.y}, windowState: 'normal' };
414
+ const pageCdp = await context.newCDPSession(page);
415
+ let browserCdp;
416
+ try {
417
+ const targetInfo = await pageCdp.send('Target.getTargetInfo');
418
+ const targetId = targetInfo?.targetInfo?.targetId;
419
+ browserCdp = await browser.newBrowserCDPSession();
420
+ const windowResult = await browserCdp.send(
421
+ 'Browser.getWindowForTarget',
422
+ targetId ? { targetId } : {},
423
+ );
424
+ await browserCdp.send('Browser.setWindowBounds', {
425
+ windowId: windowResult.windowId,
426
+ bounds: requestedWindowBounds,
427
+ });
428
+ await new Promise((resolve) => setTimeout(resolve, 250));
429
+ const actualWindow = await browserCdp.send('Browser.getWindowBounds', {
430
+ windowId: windowResult.windowId,
431
+ });
432
+ childLog('info', 'window-bounds-set', {
433
+ windowId: windowResult.windowId,
434
+ requestedBounds: requestedWindowBounds,
435
+ actualBounds: actualWindow.bounds,
436
+ });
437
+ } catch (error) {
438
+ childLog('warn', 'window-bounds-set-failed', {
439
+ requestedBounds: requestedWindowBounds,
440
+ message: error instanceof Error ? error.message : String(error),
441
+ stack: error instanceof Error ? error.stack : undefined,
442
+ });
443
+ } finally {
444
+ await pageCdp.detach().catch(() => {});
445
+ if (browserCdp) {
446
+ await browserCdp.detach().catch(() => {});
447
+ }
448
+ }
449
+ `
450
+ : "";
366
451
 
367
452
  const launcherCode = `
368
453
  import { chromium } from 'playwright';
@@ -375,14 +460,21 @@ const ACTIONS_LOG = '${escapedActionsLogPath}';
375
460
  mkdirSync(dirname(NETWORK_LOG), { recursive: true });
376
461
 
377
462
  // tsx/esbuild may emit __name() wrappers in Function#toString output.
378
- const __name = (target, value) =>
379
- Object.defineProperty(target, 'name', { value, configurable: true });
463
+ const __name = (target, value) =>
464
+ Object.defineProperty(target, 'name', { value, configurable: true });
380
465
 
381
- ${installSessionTelemetry.toString()}
466
+ const TEST_ATTRIBUTE_NAMES = ${JSON.stringify([...TEST_ATTRIBUTE_NAMES])};
467
+ const TRUSTED_ATTRIBUTE_NAMES = ${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])};
468
+ const INTERACTIVE_TAG_NAMES = ${JSON.stringify([...INTERACTIVE_TAG_NAMES])};
469
+ const INTERACTIVE_ROLE_NAMES = ${JSON.stringify([...INTERACTIVE_ROLE_NAMES])};
470
+ const filterSemanticClasses = ${filterSemanticClasses.toString()};
471
+ const isObfuscatedClass = ${isObfuscatedClass.toString()};
382
472
 
383
- function logAction(entry) {
384
- appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
385
- }
473
+ ${installSessionTelemetry.toString()}
474
+
475
+ function logAction(entry) {
476
+ appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
477
+ }
386
478
 
387
479
  function logNetwork(entry) {
388
480
  appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
@@ -404,7 +496,7 @@ function childLog(level, event, data = {}) {
404
496
 
405
497
  const browser = await chromium.launch({
406
498
  headless: ${!headed},
407
- args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'],
499
+ args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'${windowPositionArg}],
408
500
  });
409
501
 
410
502
  browser.on('disconnected', () => {
@@ -418,6 +510,7 @@ const context = await browser.newContext({
418
510
  });
419
511
 
420
512
  const page = await context.newPage();
513
+ ${windowBoundsSetupCode}
421
514
  page.setDefaultTimeout(30000);
422
515
  page.setDefaultNavigationTimeout(45000);
423
516
 
@@ -474,8 +567,10 @@ await new Promise(() => {});
474
567
  logger.info("open-child-spawned", { pid: child.pid, port, session });
475
568
 
476
569
  let childSpawnError: Error | null = null;
477
- let childEarlyExit: { code: number | null; signal: NodeJS.Signals | null } | null =
478
- null;
570
+ let childEarlyExit: {
571
+ code: number | null;
572
+ signal: NodeJS.Signals | null;
573
+ } | null = null;
479
574
 
480
575
  child.on("error", (err) => {
481
576
  childSpawnError = err;
@@ -529,14 +624,17 @@ await new Promise(() => {});
529
624
  logger.info("open-waiting-for-cdp", { attempt: i, port, session });
530
625
  }
531
626
  if (ready) {
532
- writeSessionState({
533
- port,
534
- pid: child.pid!,
535
- session,
536
- startedAt: new Date().toISOString(),
537
- status: "active",
538
- viewport,
539
- }, logger);
627
+ writeSessionState(
628
+ {
629
+ port,
630
+ pid: child.pid!,
631
+ session,
632
+ startedAt: new Date().toISOString(),
633
+ status: "active",
634
+ viewport,
635
+ },
636
+ logger,
637
+ );
540
638
  logger.info("open-success", {
541
639
  url,
542
640
  mode: browserMode,
@@ -644,7 +742,10 @@ export async function runSave(
644
742
  }
645
743
  }
646
744
 
647
- export async function runClose(session: string, logger: LoggerApi): Promise<void> {
745
+ export async function runClose(
746
+ session: string,
747
+ logger: LoggerApi,
748
+ ): Promise<void> {
648
749
  logger.info("close-start", { session });
649
750
  const state = readSessionState(session, logger);
650
751
  if (!state) {
@@ -655,9 +756,10 @@ export async function runClose(session: string, logger: LoggerApi): Promise<void
655
756
 
656
757
  logger.info("close-killing", { session, pid: state.pid, port: state.port });
657
758
 
658
- sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
659
-
660
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
759
+ if (state.pid != null) {
760
+ sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
761
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
762
+ }
661
763
 
662
764
  clearSessionState(session, logger);
663
765
  logger.info("close-success", { session });
@@ -666,7 +768,7 @@ export async function runClose(session: string, logger: LoggerApi): Promise<void
666
768
 
667
769
  type ClosableSession = {
668
770
  session: string;
669
- pid: number;
771
+ pid?: number;
670
772
  port: number;
671
773
  };
672
774
 
@@ -705,7 +807,9 @@ function sendSignalToProcessGroupOrPid(
705
807
  }
706
808
  }
707
809
 
708
- function formatSessionList(targets: ReadonlyArray<{ session: string }>): string {
810
+ function formatSessionList(
811
+ targets: ReadonlyArray<{ session: string }>,
812
+ ): string {
709
813
  return targets.map((target) => `"${target.session}"`).join(", ");
710
814
  }
711
815
 
@@ -739,7 +843,7 @@ function clearStoppedSessionStates(
739
843
  ): number {
740
844
  let cleared = 0;
741
845
  for (const session of sessions) {
742
- if (!isPidRunning(session.pid)) {
846
+ if (session.pid == null || !isPidRunning(session.pid)) {
743
847
  clearSessionState(session.session, logger);
744
848
  cleared += 1;
745
849
  }
@@ -770,12 +874,21 @@ export async function runCloseAll(
770
874
  pid: target.pid,
771
875
  port: target.port,
772
876
  });
773
- sendSignalToProcessGroupOrPid(target.pid, "SIGTERM", logger, target.session);
877
+ if (target.pid != null) {
878
+ sendSignalToProcessGroupOrPid(
879
+ target.pid,
880
+ "SIGTERM",
881
+ logger,
882
+ target.session,
883
+ );
884
+ }
774
885
  }
775
886
 
776
887
  await waitForCloseSignalWindow(CLOSE_WAIT_MS);
777
888
 
778
- let survivors = closable.filter((target) => isPidRunning(target.pid));
889
+ let survivors = closable.filter(
890
+ (target) => target.pid != null && isPidRunning(target.pid),
891
+ );
779
892
  if (survivors.length > 0 && !force) {
780
893
  const closed = clearStoppedSessionStates(closable, logger);
781
894
 
@@ -795,11 +908,20 @@ export async function runCloseAll(
795
908
  session: survivor.session,
796
909
  pid: survivor.pid,
797
910
  });
798
- sendSignalToProcessGroupOrPid(survivor.pid, "SIGKILL", logger, survivor.session);
911
+ if (survivor.pid != null) {
912
+ sendSignalToProcessGroupOrPid(
913
+ survivor.pid,
914
+ "SIGKILL",
915
+ logger,
916
+ survivor.session,
917
+ );
918
+ }
799
919
  forceKilled += 1;
800
920
  }
801
921
  await waitForCloseSignalWindow(FORCE_CLOSE_WAIT_MS);
802
- survivors = survivors.filter((target) => isPidRunning(target.pid));
922
+ survivors = survivors.filter(
923
+ (target) => target.pid != null && isPidRunning(target.pid),
924
+ );
803
925
  if (survivors.length > 0) {
804
926
  const closed = clearStoppedSessionStates(closable, logger);
805
927
  throw new Error(
@@ -824,6 +946,95 @@ export async function runCloseAll(
824
946
  }
825
947
  }
826
948
 
949
+ export async function runConnect(
950
+ cdpUrl: string,
951
+ session: string,
952
+ logger: LoggerApi,
953
+ ): Promise<void> {
954
+ logger.info("connect-start", { cdpUrl, session });
955
+ assertSessionAvailableForStart(session, logger);
956
+
957
+ let parsedUrl: URL;
958
+ try {
959
+ parsedUrl = new URL(cdpUrl);
960
+ } catch {
961
+ throw new Error(
962
+ [
963
+ `Invalid CDP URL: ${cdpUrl}`,
964
+ ``,
965
+ `Expected an HTTP URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
966
+ ` libretto connect http://127.0.0.1:9222`,
967
+ ` libretto connect http://remote-host:9222`,
968
+ ` libretto connect http://remote-host:9222/devtools/browser/<id>`,
969
+ ].join("\n"),
970
+ );
971
+ }
972
+
973
+ const endpoint = parsedUrl.href;
974
+ const port = parsedUrl.port
975
+ ? Number(parsedUrl.port)
976
+ : parsedUrl.protocol === "https:"
977
+ ? 443
978
+ : 80;
979
+
980
+ console.log(
981
+ `Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
982
+ );
983
+
984
+ // Verify the CDP endpoint is reachable
985
+ const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
986
+ try {
987
+ const resp = await fetch(versionUrl);
988
+ const versionInfo = await resp.json();
989
+ logger.info("connect-version-ok", { versionUrl, versionInfo });
990
+ } catch (err) {
991
+ logger.error("connect-version-failed", { versionUrl, error: err });
992
+ throw new Error(
993
+ `Cannot reach CDP endpoint at ${versionUrl}. Make sure the target is running and accessible at ${parsedUrl.host}.`,
994
+ );
995
+ }
996
+
997
+ // Connect via CDP using the full endpoint URL
998
+ const browser = await tryConnectToCDP(endpoint, logger, 10_000);
999
+ if (!browser) {
1000
+ throw new Error(
1001
+ `CDP endpoint at ${endpoint} is reachable but Playwright could not connect. Check that the URL is a Chrome DevTools Protocol endpoint.`,
1002
+ );
1003
+ }
1004
+
1005
+ const pages = resolveOperationalPages(browser);
1006
+ logger.info("connect-pages", {
1007
+ session,
1008
+ pageCount: pages.length,
1009
+ urls: pages.map((p) => p.url()),
1010
+ });
1011
+
1012
+ disconnectBrowser(browser, logger, session);
1013
+
1014
+ writeSessionState(
1015
+ {
1016
+ port,
1017
+ cdpEndpoint: endpoint,
1018
+ session,
1019
+ startedAt: new Date().toISOString(),
1020
+ status: "active",
1021
+ },
1022
+ logger,
1023
+ );
1024
+
1025
+ logger.info("connect-success", { cdpUrl: endpoint, session, port });
1026
+ console.log(`Connected to ${endpoint} (session: ${session})`);
1027
+ console.log(` Pages found: ${pages.length}`);
1028
+ if (pages.length > 0) {
1029
+ for (const p of pages.slice(0, 5)) {
1030
+ console.log(` ${p.url()}`);
1031
+ }
1032
+ if (pages.length > 5) {
1033
+ console.log(` ... and ${pages.length - 5} more`);
1034
+ }
1035
+ }
1036
+ }
1037
+
827
1038
  export function resolvePath(filePath: string): string {
828
1039
  return join(process.cwd(), filePath);
829
1040
  }
@@ -66,10 +66,15 @@ export function createLoggerForSession(session: string): Logger {
66
66
  const sessionDir = getSessionDir(session);
67
67
  mkdirSync(sessionDir, { recursive: true });
68
68
  const logFilePath = getSessionLogsPath(session);
69
- return new Logger(["libretto"], [createFileLogSink({ filePath: logFilePath })]);
69
+ return new Logger(
70
+ ["libretto"],
71
+ [createFileLogSink({ filePath: logFilePath })],
72
+ );
70
73
  }
71
74
 
72
- export async function closeLogger(logger: Logger | null | undefined): Promise<void> {
75
+ export async function closeLogger(
76
+ logger: Logger | null | undefined,
77
+ ): Promise<void> {
73
78
  if (!logger) return;
74
79
  await logger.close();
75
80
  }