libretto 0.6.8 → 0.6.10

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 (65) hide show
  1. package/dist/cli/cli.js +2 -0
  2. package/dist/cli/commands/auth.js +535 -0
  3. package/dist/cli/commands/billing.js +74 -0
  4. package/dist/cli/commands/browser.js +8 -3
  5. package/dist/cli/commands/deploy.js +2 -7
  6. package/dist/cli/commands/execution.js +112 -137
  7. package/dist/cli/commands/snapshot.js +38 -126
  8. package/dist/cli/core/ai-model.js +0 -3
  9. package/dist/cli/core/auth-fetch.js +195 -0
  10. package/dist/cli/core/auth-storage.js +52 -0
  11. package/dist/cli/core/browser.js +151 -206
  12. package/dist/cli/core/daemon/config.js +6 -0
  13. package/dist/cli/core/daemon/daemon.js +298 -0
  14. package/dist/cli/core/daemon/exec.js +86 -0
  15. package/dist/cli/core/daemon/index.js +16 -0
  16. package/dist/cli/core/daemon/ipc.js +171 -0
  17. package/dist/cli/core/daemon/pages.js +15 -0
  18. package/dist/cli/core/daemon/snapshot.js +86 -0
  19. package/dist/cli/core/daemon/spawn.js +90 -0
  20. package/dist/cli/core/exec-compiler.js +111 -0
  21. package/dist/cli/core/prompt.js +72 -0
  22. package/dist/cli/core/providers/browserbase.js +1 -0
  23. package/dist/cli/core/providers/kernel.js +1 -0
  24. package/dist/cli/core/providers/libretto-cloud.js +6 -7
  25. package/dist/cli/core/readonly-exec.js +1 -1
  26. package/dist/cli/router.js +4 -0
  27. package/dist/cli/workers/run-integration-runtime.js +0 -5
  28. package/dist/shared/state/session-state.d.ts +1 -0
  29. package/dist/shared/state/session-state.js +2 -1
  30. package/docs/browser-automation-approaches.md +435 -0
  31. package/docs/releasing.md +117 -0
  32. package/package.json +4 -3
  33. package/skills/libretto/SKILL.md +14 -1
  34. package/skills/libretto-readonly/SKILL.md +1 -1
  35. package/src/cli/cli.ts +2 -0
  36. package/src/cli/commands/auth.ts +787 -0
  37. package/src/cli/commands/billing.ts +133 -0
  38. package/src/cli/commands/browser.ts +8 -2
  39. package/src/cli/commands/deploy.ts +2 -7
  40. package/src/cli/commands/execution.ts +139 -187
  41. package/src/cli/commands/snapshot.ts +46 -143
  42. package/src/cli/core/ai-model.ts +4 -5
  43. package/src/cli/core/auth-fetch.ts +283 -0
  44. package/src/cli/core/auth-storage.ts +102 -0
  45. package/src/cli/core/browser.ts +182 -245
  46. package/src/cli/core/daemon/config.ts +46 -0
  47. package/src/cli/core/daemon/daemon.ts +429 -0
  48. package/src/cli/core/daemon/exec.ts +128 -0
  49. package/src/cli/core/daemon/index.ts +24 -0
  50. package/src/cli/core/daemon/ipc.ts +294 -0
  51. package/src/cli/core/daemon/pages.ts +21 -0
  52. package/src/cli/core/daemon/snapshot.ts +114 -0
  53. package/src/cli/core/daemon/spawn.ts +171 -0
  54. package/src/cli/core/exec-compiler.ts +169 -0
  55. package/src/cli/core/prompt.ts +94 -0
  56. package/src/cli/core/providers/browserbase.ts +1 -0
  57. package/src/cli/core/providers/kernel.ts +1 -0
  58. package/src/cli/core/providers/libretto-cloud.ts +13 -7
  59. package/src/cli/core/providers/types.ts +12 -1
  60. package/src/cli/core/readonly-exec.ts +2 -1
  61. package/src/cli/router.ts +4 -0
  62. package/src/cli/workers/run-integration-runtime.ts +0 -6
  63. package/src/shared/state/session-state.ts +1 -0
  64. package/dist/cli/core/browser-daemon.js +0 -122
  65. package/src/cli/core/browser-daemon.ts +0 -198
@@ -5,12 +5,9 @@ import {
5
5
  type CDPSession,
6
6
  type Page,
7
7
  } from "playwright";
8
- import { openSync, closeSync, existsSync, writeFileSync } from "node:fs";
8
+ import { existsSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
- import { fileURLToPath, pathToFileURL } from "node:url";
11
- import { createRequire } from "node:module";
12
10
  import { createServer } from "node:net";
13
- import { spawn } from "node:child_process";
14
11
  import type { LoggerApi } from "../../shared/logger/index.js";
15
12
  import type { SessionAccessMode } from "../../shared/state/index.js";
16
13
  import { PROFILES_DIR } from "./context.js";
@@ -27,6 +24,7 @@ import {
27
24
  } from "./session.js";
28
25
  import type { ProviderApi } from "./providers/types.js";
29
26
  import { getCloudProviderApi } from "./providers/index.js";
27
+ import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
30
28
 
31
29
  const CLOSE_WAIT_MS = 1_500;
32
30
  const FORCE_CLOSE_WAIT_MS = 300;
@@ -202,27 +200,6 @@ async function resolvePageReferences(pages: Page[]): Promise<PageReference[]> {
202
200
  return refs;
203
201
  }
204
202
 
205
- export async function listOpenPages(
206
- session: string,
207
- logger: LoggerApi,
208
- ): Promise<OpenPageSummary[]> {
209
- const { browser, page: activePage } = await connect(session, logger);
210
- try {
211
- const pages = browser
212
- .contexts()
213
- .flatMap((ctx) => ctx.pages())
214
- .filter(isOperationalPage);
215
- const pageRefs = await resolvePageReferences(pages);
216
- return pageRefs.map(({ id, page }) => ({
217
- id,
218
- url: page.url(),
219
- active: page === activePage,
220
- }));
221
- } finally {
222
- disconnectBrowser(browser, logger, session);
223
- }
224
- }
225
-
226
203
  export async function connect(
227
204
  session: string,
228
205
  logger: LoggerApi,
@@ -341,7 +318,18 @@ export async function runPages(
341
318
  logger: LoggerApi,
342
319
  ): Promise<void> {
343
320
  logger.info("pages-start", { session });
344
- const pageSummaries = await listOpenPages(session, logger);
321
+
322
+ const state = readSessionStateOrThrow(session);
323
+ let pageSummaries: OpenPageSummary[];
324
+
325
+ if (!state.daemonSocketPath) {
326
+ throw new Error(
327
+ `Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
328
+ `Close and reopen the session: libretto close --session ${session}`,
329
+ );
330
+ }
331
+ const client = new DaemonClient(state.daemonSocketPath);
332
+ pageSummaries = await client.pages();
345
333
 
346
334
  if (pageSummaries.length === 0) {
347
335
  console.log("No pages found.");
@@ -402,6 +390,7 @@ export async function runOpen(
402
390
  options?: {
403
391
  viewport?: { width: number; height: number };
404
392
  accessMode?: SessionAccessMode;
393
+ authProfileDomain?: string;
405
394
  },
406
395
  ): Promise<void> {
407
396
  const parsedUrl = normalizeUrl(rawUrl);
@@ -423,9 +412,26 @@ export async function runOpen(
423
412
  const runLogPath = logFileForSession(session);
424
413
 
425
414
  const browserMode = headed ? "headed" : "headless";
415
+
416
+ // When --auth-profile is provided, use that domain for profile lookup
417
+ // instead of deriving it from the URL.
418
+ const authDomain = options?.authProfileDomain
419
+ ? normalizeDomain(normalizeUrl(options.authProfileDomain))
420
+ : undefined;
421
+ if (authDomain) {
422
+ const authProfilePath = getProfilePath(authDomain);
423
+ if (!existsSync(authProfilePath)) {
424
+ throw new Error(
425
+ `No saved auth profile for "${authDomain}". ` +
426
+ `Save one first: libretto open https://${authDomain} --headed --session <name>, ` +
427
+ `log in, then run: libretto save ${authDomain} --session <name>`,
428
+ );
429
+ }
430
+ }
431
+
426
432
  const supportsSavedProfile =
427
433
  parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
428
- const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined;
434
+ const domain = authDomain ?? (supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined);
429
435
  const profilePath = domain ? getProfilePath(domain) : undefined;
430
436
  const useProfile = domain ? hasProfile(domain) : false;
431
437
 
@@ -444,129 +450,51 @@ export async function runOpen(
444
450
  }
445
451
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
446
452
 
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,
453
+ // Spawn daemon and wait for IPC readiness. The daemon launches
454
+ // Chromium internally — IPC readiness implies the browser is up,
455
+ // so no separate CDP polling is needed.
456
+ const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
457
+ config: {
458
+ port,
459
+ url,
460
+ session,
461
+ headed,
462
+ viewport,
463
+ storageStatePath: useProfile ? profilePath : undefined,
464
+ windowPosition,
465
+ },
455
466
  session,
456
- headed,
457
- viewport,
458
- storageStatePath: useProfile ? profilePath : undefined,
459
- windowPosition,
460
- };
461
-
462
- const childStderrFd = openSync(runLogPath, "a");
467
+ logger,
468
+ logPath: runLogPath,
469
+ // The daemon launches Chromium, installs telemetry, navigates to
470
+ // the URL, and only then starts IPC. Navigation alone can take up
471
+ // to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
472
+ // must cover launch + navigation.
473
+ ipcTimeoutMs: 60_000,
474
+ });
463
475
 
464
- const child = spawn(
465
- process.execPath,
466
- ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
476
+ writeSessionState(
467
477
  {
468
- detached: true,
469
- stdio: ["ignore", "ignore", childStderrFd],
478
+ port,
479
+ pid,
480
+ session,
481
+ startedAt: new Date().toISOString(),
482
+ status: "active",
483
+ mode: accessMode,
484
+ viewport,
485
+ daemonSocketPath,
470
486
  },
487
+ logger,
471
488
  );
472
- child.unref();
473
- closeSync(childStderrFd);
474
-
475
- logger.info("open-child-spawned", { pid: child.pid, port, session });
476
-
477
- let childSpawnError: Error | null = null;
478
- let childEarlyExit: {
479
- code: number | null;
480
- signal: NodeJS.Signals | null;
481
- } | null = null;
482
-
483
- child.on("error", (err) => {
484
- childSpawnError = err;
485
- logger.error("open-child-spawn-error", { error: err, session, port });
486
- });
487
-
488
- child.on("exit", (code, signal) => {
489
- childEarlyExit = { code, signal };
490
- logger.warn("open-child-exited", {
491
- code,
492
- signal,
493
- session,
494
- port,
495
- pid: child.pid,
496
- });
497
- });
498
489
 
499
- const cdpPollIntervalMs = 500;
500
- const cdpMaxAttempts = 30;
501
- const cdpStartupTimeoutMs = cdpPollIntervalMs * cdpMaxAttempts;
502
-
503
- for (let i = 0; i < cdpMaxAttempts; i++) {
504
- const spawnError = childSpawnError as Error | null;
505
- if (spawnError !== null) {
506
- const errWithCode = spawnError as Error & { code?: string };
507
- const hint =
508
- errWithCode.code === "ENOENT"
509
- ? " Ensure Node.js is available in PATH for child processes."
510
- : "";
511
- throw new Error(
512
- `Failed to launch browser child process: ${spawnError.message}.${hint} Check logs: ${runLogPath}`,
513
- );
514
- }
515
-
516
- const earlyExit = childEarlyExit as {
517
- code: number | null;
518
- signal: NodeJS.Signals | null;
519
- } | null;
520
- if (earlyExit !== null) {
521
- const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
522
- throw new Error(
523
- `Browser child process exited before startup (status: ${status}). Check logs: ${runLogPath}`,
524
- );
525
- }
526
-
527
- await new Promise((r) => setTimeout(r, cdpPollIntervalMs));
528
- const ready = await fetch(`http://127.0.0.1:${port}/json/version`)
529
- .then(() => true)
530
- .catch(() => false);
531
- if (i > 0 && i % 5 === 0) {
532
- logger.info("open-waiting-for-cdp", { attempt: i, port, session });
533
- }
534
- if (ready) {
535
- writeSessionState(
536
- {
537
- port,
538
- pid: child.pid!,
539
- session,
540
- startedAt: new Date().toISOString(),
541
- status: "active",
542
- mode: accessMode,
543
- viewport,
544
- },
545
- logger,
546
- );
547
- logger.info("open-success", {
548
- url,
549
- mode: browserMode,
550
- session,
551
- port,
552
- pid: child.pid,
553
- });
554
- console.log(`Browser open (${browserMode}): ${url}`);
555
-
556
- await new Promise((r) => setTimeout(r, 2000));
557
- return;
558
- }
559
- }
560
-
561
- logger.error("open-timeout", {
490
+ logger.info("open-success", {
491
+ url,
492
+ mode: browserMode,
562
493
  session,
563
494
  port,
564
- pid: child.pid,
565
- attempts: cdpMaxAttempts,
495
+ pid,
566
496
  });
567
- throw new Error(
568
- `Failed to connect to browser after ${Math.ceil(cdpStartupTimeoutMs / 1000)}s. Check startup logs: ${runLogPath}`,
569
- );
497
+ console.log(`Browser open (${browserMode}): ${url}`);
570
498
  }
571
499
 
572
500
  export async function runOpenWithProvider(
@@ -590,75 +518,48 @@ export async function runOpenWithProvider(
590
518
  provider: providerName,
591
519
  sessionId: providerSession.sessionId,
592
520
  cdpEndpoint: providerSession.cdpEndpoint,
521
+ liveViewUrl: providerSession.liveViewUrl,
593
522
  });
594
523
 
595
- console.log(`Connecting to ${providerName} browser...`);
596
-
597
- let browser: Browser | null = null;
598
- try {
599
- browser = await tryConnectToCDP(
600
- providerSession.cdpEndpoint,
601
- logger,
602
- 30_000,
603
- );
604
- if (!browser) {
605
- throw new Error(
606
- `Could not connect to ${providerName} browser at ${providerSession.cdpEndpoint}. The remote session was created but CDP connection failed.`,
607
- );
608
- }
609
-
610
- const contexts = browser.contexts();
611
- let page: Page;
612
- if (contexts.length > 0 && contexts[0].pages().length > 0) {
613
- page = contexts[0].pages()[0];
614
- } else {
615
- const context =
616
- contexts.length > 0 ? contexts[0] : await browser.newContext();
617
- page = await context.newPage();
618
- }
524
+ if (providerSession.liveViewUrl) {
525
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
526
+ }
619
527
 
620
- await page.goto(url);
621
- logger.info("open-provider-navigated", { url, session });
528
+ console.log(`Connecting to ${providerName} browser...`);
622
529
 
623
- // Cloud sessions have no local port. Reconnection uses cdpEndpoint directly.
624
- writeSessionState(
625
- {
626
- port: 0,
627
- cdpEndpoint: providerSession.cdpEndpoint,
628
- session,
629
- startedAt: new Date().toISOString(),
630
- status: "active",
631
- mode: accessMode,
632
- provider: {
633
- name: providerName,
634
- sessionId: providerSession.sessionId,
635
- },
636
- },
637
- logger,
638
- );
530
+ const runLogPath = logFileForSession(session);
531
+ const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
532
+ config: {
533
+ mode: "connect" as const,
534
+ session,
535
+ cdpEndpoint: providerSession.cdpEndpoint,
536
+ url,
537
+ },
538
+ session,
539
+ logger,
540
+ logPath: runLogPath,
541
+ // Remote CDP connection + navigation; must cover both.
542
+ ipcTimeoutMs: 60_000,
543
+ onFailure: () => provider.closeSession(providerSession.sessionId),
544
+ });
639
545
 
640
- disconnectBrowser(browser, logger, session);
641
- } catch (err) {
642
- if (browser) {
643
- disconnectBrowser(browser, logger, session);
644
- }
645
- // Clean up the remote session so it doesn't leak
646
- logger.warn("open-provider-cleanup-after-error", {
647
- provider: providerName,
648
- sessionId: providerSession.sessionId,
649
- error: err,
650
- });
651
- try {
652
- await provider.closeSession(providerSession.sessionId);
653
- } catch (cleanupErr) {
654
- logger.warn("open-provider-cleanup-failed", {
655
- provider: providerName,
546
+ writeSessionState(
547
+ {
548
+ port: 0,
549
+ pid,
550
+ cdpEndpoint: providerSession.cdpEndpoint,
551
+ session,
552
+ startedAt: new Date().toISOString(),
553
+ status: "active",
554
+ mode: accessMode,
555
+ daemonSocketPath,
556
+ provider: {
557
+ name: providerName,
656
558
  sessionId: providerSession.sessionId,
657
- error: cleanupErr,
658
- });
659
- }
660
- throw err;
661
- }
559
+ },
560
+ },
561
+ logger,
562
+ );
662
563
 
663
564
  logger.info("open-provider-success", {
664
565
  url,
@@ -763,8 +664,18 @@ export async function runClose(
763
664
  return;
764
665
  }
765
666
 
667
+ // Kill local daemon process if present (applies to both local and
668
+ // provider sessions — the daemon disconnects without closing the
669
+ // external browser).
670
+ if (state.pid != null) {
671
+ logger.info("close-killing", { session, pid: state.pid, port: state.port });
672
+ sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
673
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
674
+ }
675
+
676
+ // Close provider session if applicable (tears down the remote browser).
677
+ let replayUrl: string | undefined;
766
678
  if (state.provider) {
767
- // Cloud provider session — close via provider API, no local pid to kill
768
679
  logger.info("close-provider", {
769
680
  session,
770
681
  provider: state.provider.name,
@@ -772,7 +683,8 @@ export async function runClose(
772
683
  });
773
684
  try {
774
685
  const provider = getCloudProviderApi(state.provider.name);
775
- await provider.closeSession(state.provider.sessionId);
686
+ const result = await provider.closeSession(state.provider.sessionId);
687
+ replayUrl = result.replayUrl;
776
688
  } catch (err) {
777
689
  logger.warn("close-provider-error", {
778
690
  session,
@@ -780,25 +692,21 @@ export async function runClose(
780
692
  sessionId: state.provider.sessionId,
781
693
  error: err,
782
694
  });
783
- // Preserve state with cleanup-failed status so the user can retry.
784
- // The provider.sessionId is retained for manual or future cleanup.
785
695
  writeSessionState({ ...state, status: "cleanup-failed" }, logger);
786
696
  throw new Error(
787
697
  `Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
788
698
  `State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
789
699
  );
790
700
  }
791
- } else {
792
- logger.info("close-killing", { session, pid: state.pid, port: state.port });
793
- if (state.pid != null) {
794
- sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
795
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
796
- }
797
701
  }
798
702
 
703
+ unlinkDaemonSocket(state.daemonSocketPath, logger, session);
799
704
  clearSessionState(session, logger);
800
- logger.info("close-success", { session });
705
+ logger.info("close-success", { session, replayUrl });
801
706
  console.log(`Browser closed (session: ${session}).`);
707
+ if (replayUrl) {
708
+ console.log(`View recording: ${replayUrl}`);
709
+ }
802
710
  }
803
711
 
804
712
  type ClosableSession = {
@@ -806,6 +714,7 @@ type ClosableSession = {
806
714
  pid?: number;
807
715
  port: number;
808
716
  provider?: { name: string; sessionId: string };
717
+ daemonSocketPath?: string;
809
718
  };
810
719
 
811
720
  function waitForCloseSignalWindow(ms: number): Promise<void> {
@@ -859,12 +768,32 @@ function resolveClosableSessions(logger: LoggerApi): {
859
768
  pid: state.pid,
860
769
  port: state.port,
861
770
  provider: state.provider,
771
+ daemonSocketPath: state.daemonSocketPath,
862
772
  });
863
773
  }
864
774
 
865
775
  return { closable, clearedUnreadableStates };
866
776
  }
867
777
 
778
+ function unlinkDaemonSocket(
779
+ socketPath: string | undefined,
780
+ logger: LoggerApi,
781
+ session: string,
782
+ ): void {
783
+ if (!socketPath) return;
784
+ try {
785
+ unlinkSync(socketPath);
786
+ } catch (err) {
787
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
788
+ logger.warn("close-socket-unlink-failed", {
789
+ session,
790
+ socketPath,
791
+ error: err,
792
+ });
793
+ }
794
+ }
795
+ }
796
+
868
797
  function clearStoppedSessionStates(
869
798
  sessions: ReadonlyArray<ClosableSession>,
870
799
  logger: LoggerApi,
@@ -874,6 +803,7 @@ function clearStoppedSessionStates(
874
803
  for (const session of sessions) {
875
804
  if (skip?.has(session.session)) continue;
876
805
  if (session.pid == null || !isPidRunning(session.pid)) {
806
+ unlinkDaemonSocket(session.daemonSocketPath, logger, session.session);
877
807
  clearSessionState(session.session, logger);
878
808
  cleared += 1;
879
809
  }
@@ -900,6 +830,7 @@ export async function runCloseAll(
900
830
 
901
831
  // Close provider sessions via their APIs
902
832
  const failedProviderSessions = new Set<string>();
833
+ const replayUrls: Array<{ session: string; replayUrl: string }> = [];
903
834
  for (const target of closable) {
904
835
  if (target.provider) {
905
836
  logger.info("close-all-provider", {
@@ -909,7 +840,13 @@ export async function runCloseAll(
909
840
  });
910
841
  try {
911
842
  const provider = getCloudProviderApi(target.provider.name);
912
- await provider.closeSession(target.provider.sessionId);
843
+ const result = await provider.closeSession(target.provider.sessionId);
844
+ if (result.replayUrl) {
845
+ replayUrls.push({
846
+ session: target.session,
847
+ replayUrl: result.replayUrl,
848
+ });
849
+ }
913
850
  } catch (err) {
914
851
  logger.warn("close-all-provider-error", {
915
852
  session: target.session,
@@ -927,22 +864,20 @@ export async function runCloseAll(
927
864
  }
928
865
  }
929
866
 
930
- // Send SIGTERM to local sessions
867
+ // Send SIGTERM to all daemon processes (both local and provider sessions).
931
868
  for (const target of closable) {
932
- if (target.provider) continue; // already handled above
869
+ if (target.pid == null) continue;
933
870
  logger.info("close-all-sigterm", {
934
871
  session: target.session,
935
872
  pid: target.pid,
936
873
  port: target.port,
937
874
  });
938
- if (target.pid != null) {
939
- sendSignalToProcessGroupOrPid(
940
- target.pid,
941
- "SIGTERM",
942
- logger,
943
- target.session,
944
- );
945
- }
875
+ sendSignalToProcessGroupOrPid(
876
+ target.pid,
877
+ "SIGTERM",
878
+ logger,
879
+ target.session,
880
+ );
946
881
  }
947
882
 
948
883
  await waitForCloseSignalWindow(CLOSE_WAIT_MS);
@@ -1020,6 +955,9 @@ export async function runCloseAll(
1020
955
  if (forceKilled > 0) {
1021
956
  console.log(`Force-killed ${forceKilled} session(s).`);
1022
957
  }
958
+ for (const { session, replayUrl } of replayUrls) {
959
+ console.log(`View recording (${session}): ${replayUrl}`);
960
+ }
1023
961
  }
1024
962
 
1025
963
  export async function runConnect(
@@ -1062,8 +1000,9 @@ export async function runConnect(
1062
1000
  `Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
1063
1001
  );
1064
1002
 
1065
- // Verify the CDP endpoint is reachable (HTTP only — WebSocket
1066
- // endpoints are validated by the Playwright connect call below).
1003
+ // Fast-fail: verify the CDP endpoint is reachable before spawning
1004
+ // the daemon (HTTP only — WebSocket endpoints are validated by the
1005
+ // daemon's connectOverCDP call).
1067
1006
  if (!isWebSocket) {
1068
1007
  const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
1069
1008
  try {
@@ -1083,41 +1022,39 @@ export async function runConnect(
1083
1022
  });
1084
1023
  }
1085
1024
 
1086
- // Connect via CDP using the full endpoint URL
1087
- const browser = await tryConnectToCDP(endpoint, logger, 10_000);
1088
- if (!browser) {
1089
- throw new Error(
1090
- `CDP endpoint at ${endpoint} is reachable but Playwright could not connect. Check that the URL is a Chrome DevTools Protocol endpoint.`,
1091
- );
1092
- }
1093
-
1094
- const pages = resolveOperationalPages(browser);
1095
- logger.info("connect-pages", {
1096
- session,
1097
- pageCount: pages.length,
1098
- urls: pages.map((p) => p.url()),
1099
- });
1100
-
1101
- disconnectBrowser(browser, logger, session);
1025
+ const runLogPath = logFileForSession(session);
1026
+ const { pid, socketPath: daemonSocketPath, client } =
1027
+ await spawnSessionDaemon({
1028
+ config: { mode: "connect" as const, session, cdpEndpoint: endpoint },
1029
+ session,
1030
+ logger,
1031
+ logPath: runLogPath,
1032
+ ipcTimeoutMs: 10_000,
1033
+ });
1102
1034
 
1103
1035
  writeSessionState(
1104
1036
  {
1105
1037
  port,
1038
+ pid,
1106
1039
  cdpEndpoint: endpoint,
1107
1040
  session,
1108
1041
  startedAt: new Date().toISOString(),
1109
1042
  status: "active",
1110
1043
  mode: accessMode,
1044
+ daemonSocketPath,
1111
1045
  },
1112
1046
  logger,
1113
1047
  );
1114
1048
 
1049
+ // Query the daemon for discovered pages.
1050
+ const pages = await client.pages();
1051
+
1115
1052
  logger.info("connect-success", { cdpUrl: endpoint, session, port });
1116
1053
  console.log(`Connected to ${endpoint} (session: ${session})`);
1117
1054
  console.log(` Pages found: ${pages.length}`);
1118
1055
  if (pages.length > 0) {
1119
1056
  for (const p of pages.slice(0, 5)) {
1120
- console.log(` ${p.url()}`);
1057
+ console.log(` ${p.url}`);
1121
1058
  }
1122
1059
  if (pages.length > 5) {
1123
1060
  console.log(` ... and ${pages.length - 5} more`);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Configuration types for the browser daemon process.
3
+ *
4
+ * Serialized as JSON in `process.argv[2]` when spawning the daemon.
5
+ */
6
+
7
+ /**
8
+ * Config for daemon-managed browser launch (`libretto open`).
9
+ * The daemon owns the browser lifecycle and will close it on shutdown.
10
+ */
11
+ export type DaemonLaunchConfig = {
12
+ port: number;
13
+ url: string;
14
+ session: string;
15
+ headed: boolean;
16
+ viewport: { width: number; height: number };
17
+ storageStatePath?: string;
18
+ windowPosition?: { x: number; y: number };
19
+ };
20
+
21
+ /**
22
+ * Config for connecting to an externally managed browser (`libretto connect`).
23
+ * The daemon borrows the CDP connection and will disconnect (not close) on
24
+ * shutdown — the browser outlives the session.
25
+ */
26
+ export type DaemonConnectConfig = {
27
+ mode: "connect";
28
+ session: string;
29
+ cdpEndpoint: string;
30
+ /** If set, the daemon navigates to this URL after connecting. */
31
+ url?: string;
32
+ };
33
+
34
+ /**
35
+ * Discriminated union passed as JSON in `process.argv[2]`.
36
+ * Launch configs omit `mode` for backward compatibility with existing
37
+ * `runOpen()` callers — any config without `mode: "connect"` is treated
38
+ * as a launch config.
39
+ */
40
+ export type DaemonConfig = DaemonLaunchConfig | DaemonConnectConfig;
41
+
42
+ export function isConnectConfig(
43
+ config: DaemonConfig,
44
+ ): config is DaemonConnectConfig {
45
+ return "mode" in config && config.mode === "connect";
46
+ }