libretto 0.6.11 → 0.6.13

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 (130) hide show
  1. package/README.md +7 -8
  2. package/README.template.md +7 -8
  3. package/dist/cli/cli.js +0 -22
  4. package/dist/cli/commands/browser.js +18 -24
  5. package/dist/cli/commands/execution.js +254 -234
  6. package/dist/cli/commands/experiments.js +100 -0
  7. package/dist/cli/commands/setup.js +3 -310
  8. package/dist/cli/commands/shared.js +10 -0
  9. package/dist/cli/commands/snapshot.js +46 -64
  10. package/dist/cli/commands/status.js +1 -40
  11. package/dist/cli/core/browser.js +303 -124
  12. package/dist/cli/core/config.js +5 -6
  13. package/dist/cli/core/context.js +4 -0
  14. package/dist/cli/core/daemon/config.js +0 -6
  15. package/dist/cli/core/daemon/daemon.js +497 -90
  16. package/dist/cli/core/daemon/ipc.js +170 -129
  17. package/dist/cli/core/daemon/snapshot.js +48 -9
  18. package/dist/cli/core/experiments.js +39 -0
  19. package/dist/cli/core/session.js +5 -4
  20. package/dist/cli/core/skill-version.js +2 -1
  21. package/dist/cli/core/workflow-runner/runner.js +147 -0
  22. package/dist/cli/core/workflow-runtime.js +60 -0
  23. package/dist/cli/index.js +0 -2
  24. package/dist/cli/router.js +4 -3
  25. package/dist/shared/debug/pause-handler.d.ts +9 -0
  26. package/dist/shared/debug/pause-handler.js +15 -0
  27. package/dist/shared/debug/pause.d.ts +1 -2
  28. package/dist/shared/debug/pause.js +13 -36
  29. package/dist/shared/instrumentation/instrument.js +4 -4
  30. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  31. package/dist/shared/ipc/child-process-transport.js +60 -0
  32. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  33. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  34. package/dist/shared/ipc/ipc.d.ts +46 -0
  35. package/dist/shared/ipc/ipc.js +165 -0
  36. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  37. package/dist/shared/ipc/ipc.spec.js +114 -0
  38. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  39. package/dist/shared/ipc/socket-transport.js +143 -0
  40. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  41. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  42. package/dist/shared/package-manager.d.ts +7 -0
  43. package/dist/shared/package-manager.js +60 -0
  44. package/dist/shared/paths/paths.d.ts +1 -8
  45. package/dist/shared/paths/paths.js +1 -49
  46. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  47. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  48. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  49. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  50. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  51. package/dist/shared/snapshot/render-snapshot.js +651 -0
  52. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  53. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  54. package/dist/shared/snapshot/types.d.ts +40 -0
  55. package/dist/shared/snapshot/types.js +0 -0
  56. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  58. package/dist/shared/state/session-state.d.ts +1 -0
  59. package/dist/shared/state/session-state.js +1 -0
  60. package/docs/experiments.md +67 -0
  61. package/docs/releasing.md +8 -6
  62. package/package.json +5 -2
  63. package/skills/libretto/SKILL.md +19 -19
  64. package/skills/libretto/references/configuration-file-reference.md +6 -12
  65. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  66. package/skills/libretto-readonly/SKILL.md +2 -9
  67. package/src/cli/AGENTS.md +7 -0
  68. package/src/cli/cli.ts +0 -23
  69. package/src/cli/commands/browser.ts +14 -18
  70. package/src/cli/commands/execution.ts +303 -271
  71. package/src/cli/commands/experiments.ts +120 -0
  72. package/src/cli/commands/setup.ts +3 -400
  73. package/src/cli/commands/shared.ts +20 -0
  74. package/src/cli/commands/snapshot.ts +54 -94
  75. package/src/cli/commands/status.ts +1 -48
  76. package/src/cli/core/browser.ts +372 -150
  77. package/src/cli/core/config.ts +4 -5
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +645 -107
  81. package/src/cli/core/daemon/ipc.ts +319 -214
  82. package/src/cli/core/daemon/snapshot.ts +71 -15
  83. package/src/cli/core/experiments.ts +56 -0
  84. package/src/cli/core/resolve-model.ts +5 -0
  85. package/src/cli/core/session.ts +5 -4
  86. package/src/cli/core/skill-version.ts +2 -1
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +86 -0
  89. package/src/cli/index.ts +0 -1
  90. package/src/cli/router.ts +4 -3
  91. package/src/shared/debug/pause-handler.ts +20 -0
  92. package/src/shared/debug/pause.ts +14 -48
  93. package/src/shared/instrumentation/instrument.ts +4 -4
  94. package/src/shared/ipc/AGENTS.md +24 -0
  95. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  96. package/src/shared/ipc/child-process-transport.ts +96 -0
  97. package/src/shared/ipc/ipc.spec.ts +161 -0
  98. package/src/shared/ipc/ipc.ts +288 -0
  99. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  100. package/src/shared/ipc/socket-transport.ts +189 -0
  101. package/src/shared/package-manager.ts +76 -0
  102. package/src/shared/paths/paths.ts +0 -72
  103. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  104. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  105. package/src/shared/snapshot/render-snapshot.ts +962 -0
  106. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  107. package/src/shared/snapshot/types.ts +43 -0
  108. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  109. package/src/shared/state/session-state.ts +1 -0
  110. package/dist/cli/commands/ai.js +0 -109
  111. package/dist/cli/core/ai-model.js +0 -192
  112. package/dist/cli/core/api-snapshot-analyzer.js +0 -86
  113. package/dist/cli/core/daemon/index.js +0 -16
  114. package/dist/cli/core/daemon/spawn.js +0 -90
  115. package/dist/cli/core/pause-signals.js +0 -29
  116. package/dist/cli/core/snapshot-analyzer.js +0 -666
  117. package/dist/cli/workers/run-integration-runtime.js +0 -235
  118. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  119. package/dist/cli/workers/run-integration-worker.js +0 -64
  120. package/scripts/summarize-evals.mjs +0 -135
  121. package/src/cli/commands/ai.ts +0 -143
  122. package/src/cli/core/ai-model.ts +0 -298
  123. package/src/cli/core/api-snapshot-analyzer.ts +0 -110
  124. package/src/cli/core/daemon/index.ts +0 -24
  125. package/src/cli/core/daemon/spawn.ts +0 -171
  126. package/src/cli/core/pause-signals.ts +0 -35
  127. package/src/cli/core/snapshot-analyzer.ts +0 -855
  128. package/src/cli/workers/run-integration-runtime.ts +0 -326
  129. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  130. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -5,13 +5,17 @@ import {
5
5
  type CDPSession,
6
6
  type Page,
7
7
  } from "playwright";
8
- import { existsSync, writeFileSync, unlinkSync } from "node:fs";
8
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
9
+ import { mkdir, writeFile } from "node:fs/promises";
9
10
  import { dirname, join } from "node:path";
10
11
  import { createServer } from "node:net";
11
12
  import type { LoggerApi } from "../../shared/logger/index.js";
12
13
  import type { SessionAccessMode } from "../../shared/state/index.js";
13
- import { PROFILES_DIR } from "./context.js";
14
+ import type { Experiments } from "./experiments.js";
15
+ import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
14
16
  import { readLibrettoConfig } from "./config.js";
17
+ import { librettoCommand } from "../../shared/package-manager.js";
18
+ import { getCloudProviderApi } from "./providers/index.js";
15
19
  import {
16
20
  assertSessionAvailableForStart,
17
21
  clearSessionState,
@@ -22,13 +26,14 @@ import {
22
26
  readSessionState,
23
27
  writeSessionState,
24
28
  } from "./session.js";
25
- import type { ProviderApi } from "./providers/types.js";
26
- import { getCloudProviderApi } from "./providers/index.js";
27
- import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
29
+ import { DaemonClient } from "./daemon/ipc.js";
28
30
 
29
31
  const CLOSE_WAIT_MS = 1_500;
32
+ const PROVIDER_CLOSE_WAIT_MS = 30_000;
30
33
  const FORCE_CLOSE_WAIT_MS = 300;
31
34
 
35
+ type CloseResult = { replayUrl?: string };
36
+
32
37
  async function pickFreePort(): Promise<number> {
33
38
  return await new Promise((resolve, reject) => {
34
39
  const server = createServer();
@@ -230,14 +235,14 @@ export async function connect(
230
235
  if (state.provider) {
231
236
  throw new Error(
232
237
  `Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. ` +
233
- `The remote session may still be active. Try again, or close with: libretto close --session ${session}`,
238
+ `The remote session may still be active. Try again, or close with: ${librettoCommand(`close --session ${session}`)}`,
234
239
  );
235
240
  }
236
241
 
237
242
  if (state.pid == null || !isPidRunning(state.pid)) {
238
243
  clearSessionState(session, logger);
239
244
  throw new Error(
240
- `No browser running for session "${session}". Run 'libretto open <url> --session ${session}' first.`,
245
+ `No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`,
241
246
  );
242
247
  }
243
248
 
@@ -273,7 +278,7 @@ export async function connect(
273
278
 
274
279
  if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
275
280
  throw new Error(
276
- `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "libretto pages --session ${session}" to list ids).`,
281
+ `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`,
277
282
  );
278
283
  }
279
284
 
@@ -283,7 +288,7 @@ export async function connect(
283
288
  : pageRefs[pageRefs.length - 1]!;
284
289
  if (!pageRef) {
285
290
  throw new Error(
286
- `Page "${options?.pageId}" was not found in session "${session}". Run "libretto pages --session ${session}" to list ids.`,
291
+ `Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`,
287
292
  );
288
293
  }
289
294
  const page = pageRef.page;
@@ -325,11 +330,15 @@ export async function runPages(
325
330
  if (!state.daemonSocketPath) {
326
331
  throw new Error(
327
332
  `Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
328
- `Close and reopen the session: libretto close --session ${session}`,
333
+ `Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`,
329
334
  );
330
335
  }
331
- const client = new DaemonClient(state.daemonSocketPath);
332
- pageSummaries = await client.pages();
336
+ const client = await DaemonClient.connect(state.daemonSocketPath);
337
+ try {
338
+ pageSummaries = await client.pages();
339
+ } finally {
340
+ client.destroy();
341
+ }
333
342
 
334
343
  if (pageSummaries.length === 0) {
335
344
  console.log("No pages found.");
@@ -387,10 +396,11 @@ export async function runOpen(
387
396
  headed: boolean,
388
397
  session: string,
389
398
  logger: LoggerApi,
390
- options?: {
399
+ options: {
391
400
  viewport?: { width: number; height: number };
392
401
  accessMode?: SessionAccessMode;
393
402
  authProfileDomain?: string;
403
+ experiments: Experiments;
394
404
  },
395
405
  ): Promise<void> {
396
406
  const parsedUrl = normalizeUrl(rawUrl);
@@ -423,8 +433,8 @@ export async function runOpen(
423
433
  if (!existsSync(authProfilePath)) {
424
434
  throw new Error(
425
435
  `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>`,
436
+ `Save one first: ${librettoCommand(`open https://${authDomain} --headed --session <name>`)}, ` +
437
+ `log in, then run: ${librettoCommand(`save ${authDomain} --session <name>`)}`,
428
438
  );
429
439
  }
430
440
  }
@@ -453,25 +463,30 @@ export async function runOpen(
453
463
  // Spawn daemon and wait for IPC readiness. The daemon launches
454
464
  // Chromium internally — IPC readiness implies the browser is up,
455
465
  // 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
- },
466
- session,
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
- });
466
+ const { pid, socketPath: daemonSocketPath, client } =
467
+ await DaemonClient.spawn({
468
+ config: {
469
+ session,
470
+ experiments: options.experiments,
471
+ browser: {
472
+ kind: "launch",
473
+ headed,
474
+ viewport,
475
+ storageStatePath: useProfile ? profilePath : undefined,
476
+ windowPosition,
477
+ remoteDebuggingPort: port,
478
+ initialUrl: url,
479
+ },
480
+ },
481
+ logger,
482
+ logPath: runLogPath,
483
+ // The daemon launches Chromium, installs telemetry, navigates to
484
+ // the URL, and only then starts IPC. Navigation alone can take up
485
+ // to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
486
+ // must cover launch + navigation.
487
+ startupTimeoutMs: 60_000,
488
+ });
489
+ client.destroy();
475
490
 
476
491
  writeSessionState(
477
492
  {
@@ -500,10 +515,10 @@ export async function runOpen(
500
515
  export async function runOpenWithProvider(
501
516
  rawUrl: string,
502
517
  providerName: string,
503
- provider: ProviderApi,
504
518
  session: string,
505
519
  logger: LoggerApi,
506
- accessMode: SessionAccessMode = "write-access",
520
+ accessMode: SessionAccessMode,
521
+ experiments: Experiments,
507
522
  ): Promise<void> {
508
523
  const parsedUrl = normalizeUrl(rawUrl);
509
524
  const url = parsedUrl.href;
@@ -513,36 +528,48 @@ export async function runOpenWithProvider(
513
528
  `Creating ${providerName} browser session (session: ${session})...`,
514
529
  );
515
530
 
516
- const providerSession = await provider.createSession();
517
- logger.info("open-provider-session-created", {
518
- provider: providerName,
519
- sessionId: providerSession.sessionId,
520
- cdpEndpoint: providerSession.cdpEndpoint,
521
- liveViewUrl: providerSession.liveViewUrl,
522
- });
523
-
524
- if (providerSession.liveViewUrl) {
525
- console.log(`View live session: ${providerSession.liveViewUrl}`);
526
- }
527
-
528
531
  console.log(`Connecting to ${providerName} browser...`);
529
532
 
530
533
  const runLogPath = logFileForSession(session);
531
- const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
534
+ const {
535
+ pid,
536
+ socketPath: daemonSocketPath,
537
+ provider: providerSession,
538
+ client,
539
+ } = await DaemonClient.spawn({
532
540
  config: {
533
- mode: "connect" as const,
534
541
  session,
535
- cdpEndpoint: providerSession.cdpEndpoint,
536
- url,
542
+ experiments,
543
+ browser: {
544
+ kind: "provider",
545
+ providerName,
546
+ initialUrl: url,
547
+ },
537
548
  },
538
- session,
539
549
  logger,
540
550
  logPath: runLogPath,
541
551
  // Remote CDP connection + navigation; must cover both.
542
- ipcTimeoutMs: 60_000,
543
- onFailure: () => provider.closeSession(providerSession.sessionId),
552
+ startupTimeoutMs: 60_000,
553
+ });
554
+ client.destroy();
555
+
556
+ if (!providerSession) {
557
+ throw new Error(
558
+ `Provider daemon did not return session metadata for ${providerName}.`,
559
+ );
560
+ }
561
+
562
+ logger.info("open-provider-session-created", {
563
+ provider: providerName,
564
+ sessionId: providerSession.sessionId,
565
+ cdpEndpoint: providerSession.cdpEndpoint,
566
+ liveViewUrl: providerSession.liveViewUrl,
544
567
  });
545
568
 
569
+ if (providerSession.liveViewUrl) {
570
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
571
+ }
572
+
546
573
  writeSessionState(
547
574
  {
548
575
  port: 0,
@@ -631,9 +658,8 @@ export async function runSave(
631
658
  }
632
659
 
633
660
  const state = { cookies, origins };
634
- const fs = await import("node:fs/promises");
635
- await fs.mkdir(dirname(profilePath), { recursive: true });
636
- await fs.writeFile(profilePath, JSON.stringify(state, null, 2));
661
+ await mkdir(dirname(profilePath), { recursive: true });
662
+ await writeFile(profilePath, JSON.stringify(state, null, 2));
637
663
 
638
664
  logger.info("save-success", {
639
665
  domain,
@@ -664,39 +690,62 @@ export async function runClose(
664
690
  return;
665
691
  }
666
692
 
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) {
693
+ let replayUrl: string | undefined;
694
+ if (state.daemonSocketPath && state.pid != null && isPidRunning(state.pid)) {
695
+ try {
696
+ const result = await closeDaemonSession(
697
+ {
698
+ session,
699
+ pid: state.pid,
700
+ port: state.port,
701
+ provider: state.provider,
702
+ daemonSocketPath: state.daemonSocketPath,
703
+ },
704
+ logger,
705
+ );
706
+ replayUrl = result.replayUrl;
707
+ if (!state.provider) {
708
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
709
+ }
710
+ } catch (error) {
711
+ if (state.provider) {
712
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
713
+ }
714
+ throw formatDaemonCloseFailure(session, state.provider?.name, error);
715
+ }
716
+ } else if (state.pid != null) {
671
717
  logger.info("close-killing", { session, pid: state.pid, port: state.port });
672
718
  sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
673
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
719
+ if (state.provider) {
720
+ await waitForProviderCloseResult(session, state.pid);
721
+ } else {
722
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
723
+ }
674
724
  }
675
725
 
676
- // Close provider session if applicable (tears down the remote browser).
677
- let replayUrl: string | undefined;
678
726
  if (state.provider) {
679
- logger.info("close-provider", {
727
+ logger.info("close-provider-daemon-owned", {
680
728
  session,
681
729
  provider: state.provider.name,
682
730
  sessionId: state.provider.sessionId,
683
731
  });
684
- try {
685
- const provider = getCloudProviderApi(state.provider.name);
686
- const result = await provider.closeSession(state.provider.sessionId);
687
- replayUrl = result.replayUrl;
688
- } catch (err) {
689
- logger.warn("close-provider-error", {
690
- session,
691
- provider: state.provider.name,
692
- sessionId: state.provider.sessionId,
693
- error: err,
694
- });
695
- writeSessionState({ ...state, status: "cleanup-failed" }, logger);
696
- throw new Error(
697
- `Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
698
- `State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
699
- );
732
+ if (!hasProviderCloseResult(session)) {
733
+ if (state.pid == null || !isPidRunning(state.pid)) {
734
+ try {
735
+ replayUrl = await closeProviderSessionDirectly(session, state.provider, logger);
736
+ } catch (error) {
737
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
738
+ throw error;
739
+ }
740
+ } else {
741
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
742
+ throw new Error(
743
+ `Failed to confirm remote ${state.provider.name} session cleanup for session "${session}". ` +
744
+ `State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`,
745
+ );
746
+ }
747
+ } else {
748
+ replayUrl = replayUrl ?? readProviderReplayUrl(session, logger);
700
749
  }
701
750
  }
702
751
 
@@ -717,10 +766,154 @@ type ClosableSession = {
717
766
  daemonSocketPath?: string;
718
767
  };
719
768
 
769
+ async function closeDaemonSession(
770
+ target: ClosableSession,
771
+ logger: LoggerApi,
772
+ ): Promise<CloseResult> {
773
+ if (!target.daemonSocketPath) {
774
+ throw new Error("session has no daemon socket path");
775
+ }
776
+
777
+ const timeoutMs = target.provider ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS;
778
+ logger.info("close-daemon-ipc-start", {
779
+ session: target.session,
780
+ pid: target.pid,
781
+ provider: target.provider?.name,
782
+ timeoutMs,
783
+ });
784
+
785
+ let client: DaemonClient | undefined;
786
+ try {
787
+ client = await DaemonClient.connect(target.daemonSocketPath);
788
+ const result = await withTimeout(
789
+ client.close(),
790
+ timeoutMs,
791
+ `Daemon did not respond to close within ${timeoutMs}ms.`,
792
+ );
793
+ logger.info("close-daemon-ipc-success", {
794
+ session: target.session,
795
+ replayUrl: result.replayUrl,
796
+ });
797
+ return result;
798
+ } finally {
799
+ client?.destroy();
800
+ }
801
+ }
802
+
803
+ async function withTimeout<T>(
804
+ promise: Promise<T>,
805
+ timeoutMs: number,
806
+ timeoutMessage: string,
807
+ ): Promise<T> {
808
+ let timeout: ReturnType<typeof setTimeout> | undefined;
809
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
810
+ timeout = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
811
+ });
812
+ try {
813
+ return await Promise.race([promise, timeoutPromise]);
814
+ } finally {
815
+ if (timeout) clearTimeout(timeout);
816
+ }
817
+ }
818
+
819
+ function formatDaemonCloseFailure(
820
+ session: string,
821
+ providerName: string | undefined,
822
+ error: unknown,
823
+ ): Error {
824
+ const message = error instanceof Error ? error.message : String(error);
825
+ const cleanupWarning = providerName
826
+ ? ` State preserved with status "cleanup-failed" because remote ${providerName} cleanup could not be confirmed.`
827
+ : " State preserved so you can retry or inspect the session.";
828
+ return new Error(
829
+ `Failed to close session "${session}" gracefully over daemon IPC: ${message}.${cleanupWarning} Retry with: ${librettoCommand(`close --session ${session}`)}`,
830
+ );
831
+ }
832
+
720
833
  function waitForCloseSignalWindow(ms: number): Promise<void> {
721
834
  return new Promise((r) => setTimeout(r, ms));
722
835
  }
723
836
 
837
+ async function waitForProviderCloseResult(
838
+ session: string,
839
+ pid: number,
840
+ ): Promise<void> {
841
+ const deadline = Date.now() + PROVIDER_CLOSE_WAIT_MS;
842
+ while (Date.now() < deadline) {
843
+ if (hasProviderCloseResult(session) || !isPidRunning(pid)) return;
844
+ await new Promise((resolve) => setTimeout(resolve, 100));
845
+ }
846
+ }
847
+
848
+ async function waitForCloseAllTargets(
849
+ targets: ReadonlyArray<ClosableSession>,
850
+ ): Promise<void> {
851
+ const hasProviderSession = targets.some((target) => target.provider);
852
+ const deadline =
853
+ Date.now() + (hasProviderSession ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS);
854
+ while (Date.now() < deadline) {
855
+ const stillWaiting = targets.some((target) => {
856
+ if (target.pid == null || !isPidRunning(target.pid)) return false;
857
+ return target.provider ? !hasProviderCloseResult(target.session) : true;
858
+ });
859
+ if (!stillWaiting) return;
860
+ await new Promise((resolve) => setTimeout(resolve, 100));
861
+ }
862
+ }
863
+
864
+ async function closeProviderSessionDirectly(
865
+ session: string,
866
+ providerState: { name: string; sessionId: string },
867
+ logger: LoggerApi,
868
+ ): Promise<string | undefined> {
869
+ try {
870
+ const provider = getCloudProviderApi(providerState.name);
871
+ const result = await provider.closeSession(providerState.sessionId);
872
+ logger.info("close-provider-direct-fallback-success", {
873
+ session,
874
+ provider: providerState.name,
875
+ sessionId: providerState.sessionId,
876
+ replayUrl: result.replayUrl,
877
+ });
878
+ return result.replayUrl;
879
+ } catch (error) {
880
+ logger.warn("close-provider-direct-fallback-failed", {
881
+ session,
882
+ provider: providerState.name,
883
+ sessionId: providerState.sessionId,
884
+ error,
885
+ });
886
+ throw new Error(
887
+ `Failed to close remote ${providerState.name} session "${providerState.sessionId}" for session "${session}". ` +
888
+ `State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`,
889
+ );
890
+ }
891
+ }
892
+
893
+ function readProviderReplayUrl(session: string, logger: LoggerApi): string | undefined {
894
+ const closePath = getSessionProviderClosePath(session);
895
+ if (!existsSync(closePath)) return undefined;
896
+ try {
897
+ const parsed = JSON.parse(readFileSync(closePath, "utf8")) as {
898
+ replayUrl?: unknown;
899
+ };
900
+ return typeof parsed.replayUrl === "string" && parsed.replayUrl.length > 0
901
+ ? parsed.replayUrl
902
+ : undefined;
903
+ } catch (err) {
904
+ logger.warn("provider-close-result-read-failed", {
905
+ session,
906
+ path: closePath,
907
+ error: err,
908
+ });
909
+ return undefined;
910
+ }
911
+ }
912
+
913
+ function hasProviderCloseResult(session: string): boolean {
914
+ return existsSync(getSessionProviderClosePath(session));
915
+ }
916
+
724
917
  function sendSignalToProcessGroupOrPid(
725
918
  pid: number,
726
919
  signal: NodeJS.Signals,
@@ -811,6 +1004,12 @@ function clearStoppedSessionStates(
811
1004
  return cleared;
812
1005
  }
813
1006
 
1007
+ function markProviderCleanupFailed(session: string, logger: LoggerApi): void {
1008
+ const state = readSessionState(session, logger);
1009
+ if (!state) return;
1010
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
1011
+ }
1012
+
814
1013
  export async function runCloseAll(
815
1014
  logger: LoggerApi,
816
1015
  options?: { force?: boolean },
@@ -828,75 +1027,80 @@ export async function runCloseAll(
828
1027
  return;
829
1028
  }
830
1029
 
831
- // Close provider sessions via their APIs
832
1030
  const failedProviderSessions = new Set<string>();
833
- const replayUrls: Array<{ session: string; replayUrl: string }> = [];
834
- for (const target of closable) {
835
- if (target.provider) {
836
- logger.info("close-all-provider", {
837
- session: target.session,
838
- provider: target.provider.name,
839
- sessionId: target.provider.sessionId,
840
- });
841
- try {
842
- const provider = getCloudProviderApi(target.provider.name);
843
- const result = await provider.closeSession(target.provider.sessionId);
844
- if (result.replayUrl) {
845
- replayUrls.push({
1031
+ const gracefulCloseFailures = new Map<string, Error>();
1032
+
1033
+ await Promise.all(
1034
+ closable.map(async (target) => {
1035
+ if (target.pid == null) return;
1036
+ if (target.daemonSocketPath && isPidRunning(target.pid)) {
1037
+ try {
1038
+ await closeDaemonSession(target, logger);
1039
+ return;
1040
+ } catch (error) {
1041
+ const closeError = formatDaemonCloseFailure(
1042
+ target.session,
1043
+ target.provider?.name,
1044
+ error,
1045
+ );
1046
+ gracefulCloseFailures.set(target.session, closeError);
1047
+ logger.warn("close-all-daemon-ipc-failed", {
846
1048
  session: target.session,
847
- replayUrl: result.replayUrl,
1049
+ pid: target.pid,
1050
+ error: closeError.message,
848
1051
  });
849
- }
850
- } catch (err) {
851
- logger.warn("close-all-provider-error", {
852
- session: target.session,
853
- provider: target.provider.name,
854
- sessionId: target.provider.sessionId,
855
- error: err,
856
- });
857
- failedProviderSessions.add(target.session);
858
- // Mark as cleanup-failed, preserving provider.sessionId for retry
859
- const state = readSessionState(target.session, logger);
860
- if (state) {
861
- writeSessionState({ ...state, status: "cleanup-failed" }, logger);
1052
+ if (!force) return;
862
1053
  }
863
1054
  }
864
- }
865
- }
866
1055
 
867
- // Send SIGTERM to all daemon processes (both local and provider sessions).
1056
+ logger.info("close-all-sigterm", {
1057
+ session: target.session,
1058
+ pid: target.pid,
1059
+ port: target.port,
1060
+ });
1061
+ sendSignalToProcessGroupOrPid(
1062
+ target.pid,
1063
+ "SIGTERM",
1064
+ logger,
1065
+ target.session,
1066
+ );
1067
+ }),
1068
+ );
1069
+
1070
+ await waitForCloseAllTargets(closable);
1071
+
868
1072
  for (const target of closable) {
869
- if (target.pid == null) continue;
870
- logger.info("close-all-sigterm", {
871
- session: target.session,
872
- pid: target.pid,
873
- port: target.port,
874
- });
875
- sendSignalToProcessGroupOrPid(
876
- target.pid,
877
- "SIGTERM",
878
- logger,
879
- target.session,
880
- );
1073
+ if (!target.provider || hasProviderCloseResult(target.session)) continue;
1074
+ if (target.pid != null && isPidRunning(target.pid)) continue;
1075
+ try {
1076
+ await closeProviderSessionDirectly(target.session, target.provider, logger);
1077
+ } catch {
1078
+ markProviderCleanupFailed(target.session, logger);
1079
+ failedProviderSessions.add(target.session);
1080
+ }
881
1081
  }
882
1082
 
883
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
884
-
885
1083
  let survivors = closable.filter(
886
1084
  (target) => target.pid != null && isPidRunning(target.pid),
887
1085
  );
888
- if (survivors.length > 0 && !force) {
1086
+ if ((survivors.length > 0 || gracefulCloseFailures.size > 0) && !force) {
889
1087
  const closed = clearStoppedSessionStates(
890
1088
  closable,
891
1089
  logger,
892
1090
  failedProviderSessions,
893
1091
  );
1092
+ const failedSessions = Array.from(
1093
+ new Set([
1094
+ ...survivors.map((survivor) => survivor.session),
1095
+ ...gracefulCloseFailures.keys(),
1096
+ ]),
1097
+ ).map((sessionName) => ({ session: sessionName }));
894
1098
 
895
1099
  throw new Error(
896
1100
  [
897
- `Failed to close ${survivors.length} session(s) gracefully: ${formatSessionList(survivors)}.`,
1101
+ `Failed to close ${failedSessions.length} session(s) gracefully: ${formatSessionList(failedSessions)}.`,
898
1102
  `Closed ${closed} session(s).`,
899
- `Retry with: libretto close --all --force`,
1103
+ `Retry with: ${librettoCommand("close --all --force")}`,
900
1104
  ].join("\n"),
901
1105
  );
902
1106
  }
@@ -937,13 +1141,14 @@ export async function runCloseAll(
937
1141
  }
938
1142
  }
939
1143
 
940
- clearStoppedSessionStates(closable, logger, failedProviderSessions);
1144
+ const replayUrls = closable
1145
+ .filter((target) => target.provider)
1146
+ .flatMap((target) => {
1147
+ const replayUrl = readProviderReplayUrl(target.session, logger);
1148
+ return replayUrl ? [{ session: target.session, replayUrl }] : [];
1149
+ });
941
1150
 
942
- if (failedProviderSessions.size > 0) {
943
- console.log(
944
- `Warning: ${failedProviderSessions.size} provider session(s) failed remote cleanup and were preserved with status "cleanup-failed".`,
945
- );
946
- }
1151
+ clearStoppedSessionStates(closable, logger, failedProviderSessions);
947
1152
 
948
1153
  if (clearedUnreadableStates > 0) {
949
1154
  console.log(
@@ -952,19 +1157,28 @@ export async function runCloseAll(
952
1157
  }
953
1158
  const closedCount = closable.length - failedProviderSessions.size;
954
1159
  console.log(`Closed ${closedCount} session(s).`);
1160
+ if (failedProviderSessions.size > 0) {
1161
+ console.warn(
1162
+ `Failed to confirm remote cleanup for ${failedProviderSessions.size} provider-backed session(s). ` +
1163
+ `State preserved with status "cleanup-failed". Retry with: ${librettoCommand("close --all")}`,
1164
+ );
1165
+ }
1166
+ for (const recording of replayUrls) {
1167
+ console.log(
1168
+ `View recording for session "${recording.session}": ${recording.replayUrl}`,
1169
+ );
1170
+ }
955
1171
  if (forceKilled > 0) {
956
1172
  console.log(`Force-killed ${forceKilled} session(s).`);
957
1173
  }
958
- for (const { session, replayUrl } of replayUrls) {
959
- console.log(`View recording (${session}): ${replayUrl}`);
960
- }
961
1174
  }
962
1175
 
963
1176
  export async function runConnect(
964
1177
  cdpUrl: string,
965
1178
  session: string,
966
1179
  logger: LoggerApi,
967
- accessMode: SessionAccessMode = "write-access",
1180
+ accessMode: SessionAccessMode,
1181
+ experiments: Experiments,
968
1182
  ): Promise<void> {
969
1183
  logger.info("connect-start", { cdpUrl, session, accessMode });
970
1184
  assertSessionAvailableForStart(session, logger);
@@ -978,11 +1192,11 @@ export async function runConnect(
978
1192
  `Invalid CDP URL: ${cdpUrl}`,
979
1193
  ``,
980
1194
  `Expected an HTTP or WebSocket URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
981
- ` libretto connect http://127.0.0.1:9222`,
982
- ` libretto connect http://remote-host:9222`,
983
- ` libretto connect http://remote-host:9222/devtools/browser/<id>`,
984
- ` libretto connect ws://remote-host:9222/devtools/browser/<id>`,
985
- ` libretto connect wss://remote-host/cdp-endpoint`,
1195
+ ` ${librettoCommand("connect http://127.0.0.1:9222")}`,
1196
+ ` ${librettoCommand("connect http://remote-host:9222")}`,
1197
+ ` ${librettoCommand("connect http://remote-host:9222/devtools/browser/<id>")}`,
1198
+ ` ${librettoCommand("connect ws://remote-host:9222/devtools/browser/<id>")}`,
1199
+ ` ${librettoCommand("connect wss://remote-host/cdp-endpoint")}`,
986
1200
  ].join("\n"),
987
1201
  );
988
1202
  }
@@ -1024,12 +1238,15 @@ export async function runConnect(
1024
1238
 
1025
1239
  const runLogPath = logFileForSession(session);
1026
1240
  const { pid, socketPath: daemonSocketPath, client } =
1027
- await spawnSessionDaemon({
1028
- config: { mode: "connect" as const, session, cdpEndpoint: endpoint },
1029
- session,
1241
+ await DaemonClient.spawn({
1242
+ config: {
1243
+ session,
1244
+ experiments,
1245
+ browser: { kind: "connect", cdpEndpoint: endpoint },
1246
+ },
1030
1247
  logger,
1031
1248
  logPath: runLogPath,
1032
- ipcTimeoutMs: 10_000,
1249
+ startupTimeoutMs: 10_000,
1033
1250
  });
1034
1251
 
1035
1252
  writeSessionState(
@@ -1047,7 +1264,12 @@ export async function runConnect(
1047
1264
  );
1048
1265
 
1049
1266
  // Query the daemon for discovered pages.
1050
- const pages = await client.pages();
1267
+ let pages: OpenPageSummary[];
1268
+ try {
1269
+ pages = await client.pages();
1270
+ } finally {
1271
+ client.destroy();
1272
+ }
1051
1273
 
1052
1274
  logger.info("connect-success", { cdpUrl: endpoint, session, port });
1053
1275
  console.log(`Connected to ${endpoint} (session: ${session})`);