libretto 0.6.9 → 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 (60) 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 +99 -136
  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 +128 -202
  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/libretto-cloud.js +2 -6
  23. package/dist/cli/core/readonly-exec.js +1 -1
  24. package/dist/cli/router.js +4 -0
  25. package/dist/cli/workers/run-integration-runtime.js +0 -5
  26. package/dist/shared/state/session-state.d.ts +1 -0
  27. package/dist/shared/state/session-state.js +2 -1
  28. package/docs/browser-automation-approaches.md +435 -0
  29. package/docs/releasing.md +117 -0
  30. package/package.json +4 -3
  31. package/skills/libretto/SKILL.md +14 -1
  32. package/skills/libretto-readonly/SKILL.md +1 -1
  33. package/src/cli/cli.ts +2 -0
  34. package/src/cli/commands/auth.ts +787 -0
  35. package/src/cli/commands/billing.ts +133 -0
  36. package/src/cli/commands/browser.ts +8 -2
  37. package/src/cli/commands/deploy.ts +2 -7
  38. package/src/cli/commands/execution.ts +126 -186
  39. package/src/cli/commands/snapshot.ts +46 -143
  40. package/src/cli/core/ai-model.ts +4 -5
  41. package/src/cli/core/auth-fetch.ts +283 -0
  42. package/src/cli/core/auth-storage.ts +102 -0
  43. package/src/cli/core/browser.ts +159 -242
  44. package/src/cli/core/daemon/config.ts +46 -0
  45. package/src/cli/core/daemon/daemon.ts +429 -0
  46. package/src/cli/core/daemon/exec.ts +128 -0
  47. package/src/cli/core/daemon/index.ts +24 -0
  48. package/src/cli/core/daemon/ipc.ts +294 -0
  49. package/src/cli/core/daemon/pages.ts +21 -0
  50. package/src/cli/core/daemon/snapshot.ts +114 -0
  51. package/src/cli/core/daemon/spawn.ts +171 -0
  52. package/src/cli/core/exec-compiler.ts +169 -0
  53. package/src/cli/core/prompt.ts +94 -0
  54. package/src/cli/core/providers/libretto-cloud.ts +2 -6
  55. package/src/cli/core/readonly-exec.ts +2 -1
  56. package/src/cli/router.ts +4 -0
  57. package/src/cli/workers/run-integration-runtime.ts +0 -6
  58. package/src/shared/state/session-state.ts +1 -0
  59. package/dist/cli/core/browser-daemon.js +0 -122
  60. 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
-
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
489
 
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(
@@ -599,71 +527,39 @@ export async function runOpenWithProvider(
599
527
 
600
528
  console.log(`Connecting to ${providerName} browser...`);
601
529
 
602
- let browser: Browser | null = null;
603
- try {
604
- browser = await tryConnectToCDP(
605
- providerSession.cdpEndpoint,
606
- logger,
607
- 30_000,
608
- );
609
- if (!browser) {
610
- throw new Error(
611
- `Could not connect to ${providerName} browser at ${providerSession.cdpEndpoint}. The remote session was created but CDP connection failed.`,
612
- );
613
- }
614
-
615
- const contexts = browser.contexts();
616
- let page: Page;
617
- if (contexts.length > 0 && contexts[0].pages().length > 0) {
618
- page = contexts[0].pages()[0];
619
- } else {
620
- const context =
621
- contexts.length > 0 ? contexts[0] : await browser.newContext();
622
- page = await context.newPage();
623
- }
624
-
625
- await page.goto(url);
626
- logger.info("open-provider-navigated", { url, session });
627
-
628
- // Cloud sessions have no local port. Reconnection uses cdpEndpoint directly.
629
- writeSessionState(
630
- {
631
- port: 0,
632
- cdpEndpoint: providerSession.cdpEndpoint,
633
- session,
634
- startedAt: new Date().toISOString(),
635
- status: "active",
636
- mode: accessMode,
637
- provider: {
638
- name: providerName,
639
- sessionId: providerSession.sessionId,
640
- },
641
- },
642
- logger,
643
- );
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
+ });
644
545
 
645
- disconnectBrowser(browser, logger, session);
646
- } catch (err) {
647
- if (browser) {
648
- disconnectBrowser(browser, logger, session);
649
- }
650
- // Clean up the remote session so it doesn't leak
651
- logger.warn("open-provider-cleanup-after-error", {
652
- provider: providerName,
653
- sessionId: providerSession.sessionId,
654
- error: err,
655
- });
656
- try {
657
- await provider.closeSession(providerSession.sessionId);
658
- } catch (cleanupErr) {
659
- logger.warn("open-provider-cleanup-failed", {
660
- 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,
661
558
  sessionId: providerSession.sessionId,
662
- error: cleanupErr,
663
- });
664
- }
665
- throw err;
666
- }
559
+ },
560
+ },
561
+ logger,
562
+ );
667
563
 
668
564
  logger.info("open-provider-success", {
669
565
  url,
@@ -768,9 +664,18 @@ export async function runClose(
768
664
  return;
769
665
  }
770
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).
771
677
  let replayUrl: string | undefined;
772
678
  if (state.provider) {
773
- // Cloud provider session — close via provider API, no local pid to kill
774
679
  logger.info("close-provider", {
775
680
  session,
776
681
  provider: state.provider.name,
@@ -787,22 +692,15 @@ export async function runClose(
787
692
  sessionId: state.provider.sessionId,
788
693
  error: err,
789
694
  });
790
- // Preserve state with cleanup-failed status so the user can retry.
791
- // The provider.sessionId is retained for manual or future cleanup.
792
695
  writeSessionState({ ...state, status: "cleanup-failed" }, logger);
793
696
  throw new Error(
794
697
  `Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
795
698
  `State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
796
699
  );
797
700
  }
798
- } else {
799
- logger.info("close-killing", { session, pid: state.pid, port: state.port });
800
- if (state.pid != null) {
801
- sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
802
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
803
- }
804
701
  }
805
702
 
703
+ unlinkDaemonSocket(state.daemonSocketPath, logger, session);
806
704
  clearSessionState(session, logger);
807
705
  logger.info("close-success", { session, replayUrl });
808
706
  console.log(`Browser closed (session: ${session}).`);
@@ -816,6 +714,7 @@ type ClosableSession = {
816
714
  pid?: number;
817
715
  port: number;
818
716
  provider?: { name: string; sessionId: string };
717
+ daemonSocketPath?: string;
819
718
  };
820
719
 
821
720
  function waitForCloseSignalWindow(ms: number): Promise<void> {
@@ -869,12 +768,32 @@ function resolveClosableSessions(logger: LoggerApi): {
869
768
  pid: state.pid,
870
769
  port: state.port,
871
770
  provider: state.provider,
771
+ daemonSocketPath: state.daemonSocketPath,
872
772
  });
873
773
  }
874
774
 
875
775
  return { closable, clearedUnreadableStates };
876
776
  }
877
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
+
878
797
  function clearStoppedSessionStates(
879
798
  sessions: ReadonlyArray<ClosableSession>,
880
799
  logger: LoggerApi,
@@ -884,6 +803,7 @@ function clearStoppedSessionStates(
884
803
  for (const session of sessions) {
885
804
  if (skip?.has(session.session)) continue;
886
805
  if (session.pid == null || !isPidRunning(session.pid)) {
806
+ unlinkDaemonSocket(session.daemonSocketPath, logger, session.session);
887
807
  clearSessionState(session.session, logger);
888
808
  cleared += 1;
889
809
  }
@@ -944,22 +864,20 @@ export async function runCloseAll(
944
864
  }
945
865
  }
946
866
 
947
- // Send SIGTERM to local sessions
867
+ // Send SIGTERM to all daemon processes (both local and provider sessions).
948
868
  for (const target of closable) {
949
- if (target.provider) continue; // already handled above
869
+ if (target.pid == null) continue;
950
870
  logger.info("close-all-sigterm", {
951
871
  session: target.session,
952
872
  pid: target.pid,
953
873
  port: target.port,
954
874
  });
955
- if (target.pid != null) {
956
- sendSignalToProcessGroupOrPid(
957
- target.pid,
958
- "SIGTERM",
959
- logger,
960
- target.session,
961
- );
962
- }
875
+ sendSignalToProcessGroupOrPid(
876
+ target.pid,
877
+ "SIGTERM",
878
+ logger,
879
+ target.session,
880
+ );
963
881
  }
964
882
 
965
883
  await waitForCloseSignalWindow(CLOSE_WAIT_MS);
@@ -1082,8 +1000,9 @@ export async function runConnect(
1082
1000
  `Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
1083
1001
  );
1084
1002
 
1085
- // Verify the CDP endpoint is reachable (HTTP only — WebSocket
1086
- // 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).
1087
1006
  if (!isWebSocket) {
1088
1007
  const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
1089
1008
  try {
@@ -1103,41 +1022,39 @@ export async function runConnect(
1103
1022
  });
1104
1023
  }
1105
1024
 
1106
- // Connect via CDP using the full endpoint URL
1107
- const browser = await tryConnectToCDP(endpoint, logger, 10_000);
1108
- if (!browser) {
1109
- throw new Error(
1110
- `CDP endpoint at ${endpoint} is reachable but Playwright could not connect. Check that the URL is a Chrome DevTools Protocol endpoint.`,
1111
- );
1112
- }
1113
-
1114
- const pages = resolveOperationalPages(browser);
1115
- logger.info("connect-pages", {
1116
- session,
1117
- pageCount: pages.length,
1118
- urls: pages.map((p) => p.url()),
1119
- });
1120
-
1121
- 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
+ });
1122
1034
 
1123
1035
  writeSessionState(
1124
1036
  {
1125
1037
  port,
1038
+ pid,
1126
1039
  cdpEndpoint: endpoint,
1127
1040
  session,
1128
1041
  startedAt: new Date().toISOString(),
1129
1042
  status: "active",
1130
1043
  mode: accessMode,
1044
+ daemonSocketPath,
1131
1045
  },
1132
1046
  logger,
1133
1047
  );
1134
1048
 
1049
+ // Query the daemon for discovered pages.
1050
+ const pages = await client.pages();
1051
+
1135
1052
  logger.info("connect-success", { cdpUrl: endpoint, session, port });
1136
1053
  console.log(`Connected to ${endpoint} (session: ${session})`);
1137
1054
  console.log(` Pages found: ${pages.length}`);
1138
1055
  if (pages.length > 0) {
1139
1056
  for (const p of pages.slice(0, 5)) {
1140
- console.log(` ${p.url()}`);
1057
+ console.log(` ${p.url}`);
1141
1058
  }
1142
1059
  if (pages.length > 5) {
1143
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
+ }