libretto 0.6.10 → 0.6.12

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 (119) hide show
  1. package/README.md +4 -0
  2. package/README.template.md +4 -0
  3. package/dist/cli/cli.js +4 -3
  4. package/dist/cli/commands/ai.js +3 -2
  5. package/dist/cli/commands/browser.js +17 -17
  6. package/dist/cli/commands/execution.js +254 -234
  7. package/dist/cli/commands/experiments.js +100 -0
  8. package/dist/cli/commands/setup.js +20 -34
  9. package/dist/cli/commands/shared.js +10 -0
  10. package/dist/cli/commands/snapshot.js +81 -9
  11. package/dist/cli/commands/status.js +5 -4
  12. package/dist/cli/core/ai-model.js +6 -3
  13. package/dist/cli/core/browser.js +300 -121
  14. package/dist/cli/core/config.js +4 -2
  15. package/dist/cli/core/context.js +4 -0
  16. package/dist/cli/core/daemon/config.js +0 -6
  17. package/dist/cli/core/daemon/daemon.js +535 -89
  18. package/dist/cli/core/daemon/ipc.js +170 -129
  19. package/dist/cli/core/daemon/snapshot.js +72 -6
  20. package/dist/cli/core/experiments.js +66 -0
  21. package/dist/cli/core/session.js +5 -4
  22. package/dist/cli/core/skill-version.js +2 -1
  23. package/dist/cli/core/snapshot-analyzer.js +4 -3
  24. package/dist/cli/core/workflow-runner/runner.js +147 -0
  25. package/dist/cli/core/workflow-runtime.js +60 -0
  26. package/dist/cli/router.js +4 -1
  27. package/dist/shared/debug/pause-handler.d.ts +9 -0
  28. package/dist/shared/debug/pause-handler.js +15 -0
  29. package/dist/shared/debug/pause.d.ts +1 -2
  30. package/dist/shared/debug/pause.js +13 -36
  31. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  32. package/dist/shared/ipc/child-process-transport.js +60 -0
  33. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  34. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  35. package/dist/shared/ipc/ipc.d.ts +46 -0
  36. package/dist/shared/ipc/ipc.js +165 -0
  37. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  38. package/dist/shared/ipc/ipc.spec.js +114 -0
  39. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  40. package/dist/shared/ipc/socket-transport.js +143 -0
  41. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  42. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  43. package/dist/shared/package-manager.d.ts +7 -0
  44. package/dist/shared/package-manager.js +60 -0
  45. package/dist/shared/paths/paths.d.ts +1 -8
  46. package/dist/shared/paths/paths.js +1 -49
  47. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  48. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  49. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  50. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  51. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  52. package/dist/shared/snapshot/render-snapshot.js +651 -0
  53. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  54. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  55. package/dist/shared/snapshot/types.d.ts +40 -0
  56. package/dist/shared/snapshot/types.js +0 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  58. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  59. package/dist/shared/state/session-state.d.ts +1 -0
  60. package/dist/shared/state/session-state.js +1 -0
  61. package/docs/experiments.md +67 -0
  62. package/package.json +4 -2
  63. package/skills/libretto/SKILL.md +3 -1
  64. package/skills/libretto-readonly/SKILL.md +1 -1
  65. package/src/cli/AGENTS.md +7 -0
  66. package/src/cli/cli.ts +4 -3
  67. package/src/cli/commands/ai.ts +3 -2
  68. package/src/cli/commands/browser.ts +13 -11
  69. package/src/cli/commands/execution.ts +303 -271
  70. package/src/cli/commands/experiments.ts +120 -0
  71. package/src/cli/commands/setup.ts +18 -36
  72. package/src/cli/commands/shared.ts +20 -0
  73. package/src/cli/commands/snapshot.ts +99 -11
  74. package/src/cli/commands/status.ts +5 -4
  75. package/src/cli/core/ai-model.ts +6 -3
  76. package/src/cli/core/browser.ts +369 -147
  77. package/src/cli/core/config.ts +3 -1
  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 +686 -106
  81. package/src/cli/core/daemon/ipc.ts +330 -214
  82. package/src/cli/core/daemon/snapshot.ts +106 -8
  83. package/src/cli/core/experiments.ts +85 -0
  84. package/src/cli/core/session.ts +5 -4
  85. package/src/cli/core/skill-version.ts +2 -1
  86. package/src/cli/core/snapshot-analyzer.ts +4 -3
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +85 -0
  89. package/src/cli/router.ts +4 -1
  90. package/src/shared/debug/pause-handler.ts +20 -0
  91. package/src/shared/debug/pause.ts +14 -48
  92. package/src/shared/ipc/AGENTS.md +24 -0
  93. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  94. package/src/shared/ipc/child-process-transport.ts +96 -0
  95. package/src/shared/ipc/ipc.spec.ts +161 -0
  96. package/src/shared/ipc/ipc.ts +288 -0
  97. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  98. package/src/shared/ipc/socket-transport.ts +189 -0
  99. package/src/shared/package-manager.ts +76 -0
  100. package/src/shared/paths/paths.ts +0 -72
  101. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  102. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  103. package/src/shared/snapshot/render-snapshot.ts +962 -0
  104. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  105. package/src/shared/snapshot/types.ts +43 -0
  106. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  107. package/src/shared/state/session-state.ts +1 -0
  108. package/dist/cli/core/daemon/index.js +0 -16
  109. package/dist/cli/core/daemon/spawn.js +0 -90
  110. package/dist/cli/core/pause-signals.js +0 -29
  111. package/dist/cli/workers/run-integration-runtime.js +0 -235
  112. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  113. package/dist/cli/workers/run-integration-worker.js +0 -64
  114. package/src/cli/core/daemon/index.ts +0 -24
  115. package/src/cli/core/daemon/spawn.ts +0 -171
  116. package/src/cli/core/pause-signals.ts +0 -35
  117. package/src/cli/workers/run-integration-runtime.ts +0 -326
  118. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  119. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -5,13 +5,16 @@ 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
9
  import { dirname, join } from "node:path";
10
10
  import { createServer } from "node:net";
11
11
  import type { LoggerApi } from "../../shared/logger/index.js";
12
12
  import type { SessionAccessMode } from "../../shared/state/index.js";
13
- import { PROFILES_DIR } from "./context.js";
13
+ import type { Experiments } from "./experiments.js";
14
+ import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
14
15
  import { readLibrettoConfig } from "./config.js";
16
+ import { librettoCommand } from "../../shared/package-manager.js";
17
+ import { getCloudProviderApi } from "./providers/index.js";
15
18
  import {
16
19
  assertSessionAvailableForStart,
17
20
  clearSessionState,
@@ -22,13 +25,14 @@ import {
22
25
  readSessionState,
23
26
  writeSessionState,
24
27
  } 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";
28
+ import { DaemonClient } from "./daemon/ipc.js";
28
29
 
29
30
  const CLOSE_WAIT_MS = 1_500;
31
+ const PROVIDER_CLOSE_WAIT_MS = 30_000;
30
32
  const FORCE_CLOSE_WAIT_MS = 300;
31
33
 
34
+ type CloseResult = { replayUrl?: string };
35
+
32
36
  async function pickFreePort(): Promise<number> {
33
37
  return await new Promise((resolve, reject) => {
34
38
  const server = createServer();
@@ -230,14 +234,14 @@ export async function connect(
230
234
  if (state.provider) {
231
235
  throw new Error(
232
236
  `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}`,
237
+ `The remote session may still be active. Try again, or close with: ${librettoCommand(`close --session ${session}`)}`,
234
238
  );
235
239
  }
236
240
 
237
241
  if (state.pid == null || !isPidRunning(state.pid)) {
238
242
  clearSessionState(session, logger);
239
243
  throw new Error(
240
- `No browser running for session "${session}". Run 'libretto open <url> --session ${session}' first.`,
244
+ `No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`,
241
245
  );
242
246
  }
243
247
 
@@ -273,7 +277,7 @@ export async function connect(
273
277
 
274
278
  if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
275
279
  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).`,
280
+ `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`,
277
281
  );
278
282
  }
279
283
 
@@ -283,7 +287,7 @@ export async function connect(
283
287
  : pageRefs[pageRefs.length - 1]!;
284
288
  if (!pageRef) {
285
289
  throw new Error(
286
- `Page "${options?.pageId}" was not found in session "${session}". Run "libretto pages --session ${session}" to list ids.`,
290
+ `Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`,
287
291
  );
288
292
  }
289
293
  const page = pageRef.page;
@@ -325,11 +329,15 @@ export async function runPages(
325
329
  if (!state.daemonSocketPath) {
326
330
  throw new Error(
327
331
  `Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
328
- `Close and reopen the session: libretto close --session ${session}`,
332
+ `Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`,
329
333
  );
330
334
  }
331
- const client = new DaemonClient(state.daemonSocketPath);
332
- pageSummaries = await client.pages();
335
+ const client = await DaemonClient.connect(state.daemonSocketPath);
336
+ try {
337
+ pageSummaries = await client.pages();
338
+ } finally {
339
+ client.destroy();
340
+ }
333
341
 
334
342
  if (pageSummaries.length === 0) {
335
343
  console.log("No pages found.");
@@ -387,10 +395,11 @@ export async function runOpen(
387
395
  headed: boolean,
388
396
  session: string,
389
397
  logger: LoggerApi,
390
- options?: {
398
+ options: {
391
399
  viewport?: { width: number; height: number };
392
400
  accessMode?: SessionAccessMode;
393
401
  authProfileDomain?: string;
402
+ experiments: Experiments;
394
403
  },
395
404
  ): Promise<void> {
396
405
  const parsedUrl = normalizeUrl(rawUrl);
@@ -423,8 +432,8 @@ export async function runOpen(
423
432
  if (!existsSync(authProfilePath)) {
424
433
  throw new Error(
425
434
  `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>`,
435
+ `Save one first: ${librettoCommand(`open https://${authDomain} --headed --session <name>`)}, ` +
436
+ `log in, then run: ${librettoCommand(`save ${authDomain} --session <name>`)}`,
428
437
  );
429
438
  }
430
439
  }
@@ -453,25 +462,30 @@ export async function runOpen(
453
462
  // Spawn daemon and wait for IPC readiness. The daemon launches
454
463
  // Chromium internally — IPC readiness implies the browser is up,
455
464
  // 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
- });
465
+ const { pid, socketPath: daemonSocketPath, client } =
466
+ await DaemonClient.spawn({
467
+ config: {
468
+ session,
469
+ experiments: options.experiments,
470
+ browser: {
471
+ kind: "launch",
472
+ headed,
473
+ viewport,
474
+ storageStatePath: useProfile ? profilePath : undefined,
475
+ windowPosition,
476
+ remoteDebuggingPort: port,
477
+ initialUrl: url,
478
+ },
479
+ },
480
+ logger,
481
+ logPath: runLogPath,
482
+ // The daemon launches Chromium, installs telemetry, navigates to
483
+ // the URL, and only then starts IPC. Navigation alone can take up
484
+ // to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
485
+ // must cover launch + navigation.
486
+ startupTimeoutMs: 60_000,
487
+ });
488
+ client.destroy();
475
489
 
476
490
  writeSessionState(
477
491
  {
@@ -500,10 +514,10 @@ export async function runOpen(
500
514
  export async function runOpenWithProvider(
501
515
  rawUrl: string,
502
516
  providerName: string,
503
- provider: ProviderApi,
504
517
  session: string,
505
518
  logger: LoggerApi,
506
- accessMode: SessionAccessMode = "write-access",
519
+ accessMode: SessionAccessMode,
520
+ experiments: Experiments,
507
521
  ): Promise<void> {
508
522
  const parsedUrl = normalizeUrl(rawUrl);
509
523
  const url = parsedUrl.href;
@@ -513,36 +527,48 @@ export async function runOpenWithProvider(
513
527
  `Creating ${providerName} browser session (session: ${session})...`,
514
528
  );
515
529
 
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
530
  console.log(`Connecting to ${providerName} browser...`);
529
531
 
530
532
  const runLogPath = logFileForSession(session);
531
- const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
533
+ const {
534
+ pid,
535
+ socketPath: daemonSocketPath,
536
+ provider: providerSession,
537
+ client,
538
+ } = await DaemonClient.spawn({
532
539
  config: {
533
- mode: "connect" as const,
534
540
  session,
535
- cdpEndpoint: providerSession.cdpEndpoint,
536
- url,
541
+ experiments,
542
+ browser: {
543
+ kind: "provider",
544
+ providerName,
545
+ initialUrl: url,
546
+ },
537
547
  },
538
- session,
539
548
  logger,
540
549
  logPath: runLogPath,
541
550
  // Remote CDP connection + navigation; must cover both.
542
- ipcTimeoutMs: 60_000,
543
- onFailure: () => provider.closeSession(providerSession.sessionId),
551
+ startupTimeoutMs: 60_000,
552
+ });
553
+ client.destroy();
554
+
555
+ if (!providerSession) {
556
+ throw new Error(
557
+ `Provider daemon did not return session metadata for ${providerName}.`,
558
+ );
559
+ }
560
+
561
+ logger.info("open-provider-session-created", {
562
+ provider: providerName,
563
+ sessionId: providerSession.sessionId,
564
+ cdpEndpoint: providerSession.cdpEndpoint,
565
+ liveViewUrl: providerSession.liveViewUrl,
544
566
  });
545
567
 
568
+ if (providerSession.liveViewUrl) {
569
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
570
+ }
571
+
546
572
  writeSessionState(
547
573
  {
548
574
  port: 0,
@@ -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})`);