libretto 0.6.11 → 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
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  chromium
3
3
  } from "playwright";
4
- import { existsSync, unlinkSync } from "node:fs";
4
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { createServer } from "node:net";
7
- import { PROFILES_DIR } from "./context.js";
7
+ import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
8
8
  import { readLibrettoConfig } from "./config.js";
9
+ import { librettoCommand } from "../../shared/package-manager.js";
10
+ import { getCloudProviderApi } from "./providers/index.js";
9
11
  import {
10
12
  assertSessionAvailableForStart,
11
13
  clearSessionState,
@@ -16,9 +18,9 @@ import {
16
18
  readSessionState,
17
19
  writeSessionState
18
20
  } from "./session.js";
19
- import { getCloudProviderApi } from "./providers/index.js";
20
- import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
21
+ import { DaemonClient } from "./daemon/ipc.js";
21
22
  const CLOSE_WAIT_MS = 1500;
23
+ const PROVIDER_CLOSE_WAIT_MS = 3e4;
22
24
  const FORCE_CLOSE_WAIT_MS = 300;
23
25
  async function pickFreePort() {
24
26
  return await new Promise((resolve, reject) => {
@@ -154,13 +156,13 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
154
156
  });
155
157
  if (state.provider) {
156
158
  throw new Error(
157
- `Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. The remote session may still be active. Try again, or close with: libretto close --session ${session}`
159
+ `Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. The remote session may still be active. Try again, or close with: ${librettoCommand(`close --session ${session}`)}`
158
160
  );
159
161
  }
160
162
  if (state.pid == null || !isPidRunning(state.pid)) {
161
163
  clearSessionState(session, logger);
162
164
  throw new Error(
163
- `No browser running for session "${session}". Run 'libretto open <url> --session ${session}' first.`
165
+ `No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`
164
166
  );
165
167
  }
166
168
  throw new Error(
@@ -190,14 +192,14 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
190
192
  }
191
193
  if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
192
194
  throw new Error(
193
- `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "libretto pages --session ${session}" to list ids).`
195
+ `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`
194
196
  );
195
197
  }
196
198
  const pageRefs = await resolvePageReferences(pages);
197
199
  const pageRef = options?.pageId ? pageRefs.find((ref) => ref.id === options.pageId) ?? null : pageRefs[pageRefs.length - 1];
198
200
  if (!pageRef) {
199
201
  throw new Error(
200
- `Page "${options?.pageId}" was not found in session "${session}". Run "libretto pages --session ${session}" to list ids.`
202
+ `Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`
201
203
  );
202
204
  }
203
205
  const page = pageRef.page;
@@ -230,11 +232,15 @@ async function runPages(session, logger) {
230
232
  let pageSummaries;
231
233
  if (!state.daemonSocketPath) {
232
234
  throw new Error(
233
- `Session "${session}" has no daemon socket. The browser daemon may have crashed. Close and reopen the session: libretto close --session ${session}`
235
+ `Session "${session}" has no daemon socket. The browser daemon may have crashed. Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`
234
236
  );
235
237
  }
236
- const client = new DaemonClient(state.daemonSocketPath);
237
- pageSummaries = await client.pages();
238
+ const client = await DaemonClient.connect(state.daemonSocketPath);
239
+ try {
240
+ pageSummaries = await client.pages();
241
+ } finally {
242
+ client.destroy();
243
+ }
238
244
  if (pageSummaries.length === 0) {
239
245
  console.log("No pages found.");
240
246
  return;
@@ -299,7 +305,7 @@ async function runOpen(rawUrl, headed, session, logger, options) {
299
305
  const authProfilePath = getProfilePath(authDomain);
300
306
  if (!existsSync(authProfilePath)) {
301
307
  throw new Error(
302
- `No saved auth profile for "${authDomain}". Save one first: libretto open https://${authDomain} --headed --session <name>, log in, then run: libretto save ${authDomain} --session <name>`
308
+ `No saved auth profile for "${authDomain}". Save one first: ${librettoCommand(`open https://${authDomain} --headed --session <name>`)}, log in, then run: ${librettoCommand(`save ${authDomain} --session <name>`)}`
303
309
  );
304
310
  }
305
311
  }
@@ -320,25 +326,29 @@ async function runOpen(rawUrl, headed, session, logger, options) {
320
326
  console.log(`Loading saved profile for ${domain}`);
321
327
  }
322
328
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
323
- const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
329
+ const { pid, socketPath: daemonSocketPath, client } = await DaemonClient.spawn({
324
330
  config: {
325
- port,
326
- url,
327
331
  session,
328
- headed,
329
- viewport,
330
- storageStatePath: useProfile ? profilePath : void 0,
331
- windowPosition
332
+ experiments: options.experiments,
333
+ browser: {
334
+ kind: "launch",
335
+ headed,
336
+ viewport,
337
+ storageStatePath: useProfile ? profilePath : void 0,
338
+ windowPosition,
339
+ remoteDebuggingPort: port,
340
+ initialUrl: url
341
+ }
332
342
  },
333
- session,
334
343
  logger,
335
344
  logPath: runLogPath,
336
345
  // The daemon launches Chromium, installs telemetry, navigates to
337
346
  // the URL, and only then starts IPC. Navigation alone can take up
338
347
  // to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
339
348
  // must cover launch + navigation.
340
- ipcTimeoutMs: 6e4
349
+ startupTimeoutMs: 6e4
341
350
  });
351
+ client.destroy();
342
352
  writeSessionState(
343
353
  {
344
354
  port,
@@ -361,39 +371,50 @@ async function runOpen(rawUrl, headed, session, logger, options) {
361
371
  });
362
372
  console.log(`Browser open (${browserMode}): ${url}`);
363
373
  }
364
- async function runOpenWithProvider(rawUrl, providerName, provider, session, logger, accessMode = "write-access") {
374
+ async function runOpenWithProvider(rawUrl, providerName, session, logger, accessMode, experiments) {
365
375
  const parsedUrl = normalizeUrl(rawUrl);
366
376
  const url = parsedUrl.href;
367
377
  logger.info("open-provider-start", { url, provider: providerName, session });
368
378
  console.log(
369
379
  `Creating ${providerName} browser session (session: ${session})...`
370
380
  );
371
- const providerSession = await provider.createSession();
372
- logger.info("open-provider-session-created", {
373
- provider: providerName,
374
- sessionId: providerSession.sessionId,
375
- cdpEndpoint: providerSession.cdpEndpoint,
376
- liveViewUrl: providerSession.liveViewUrl
377
- });
378
- if (providerSession.liveViewUrl) {
379
- console.log(`View live session: ${providerSession.liveViewUrl}`);
380
- }
381
381
  console.log(`Connecting to ${providerName} browser...`);
382
382
  const runLogPath = logFileForSession(session);
383
- const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
383
+ const {
384
+ pid,
385
+ socketPath: daemonSocketPath,
386
+ provider: providerSession,
387
+ client
388
+ } = await DaemonClient.spawn({
384
389
  config: {
385
- mode: "connect",
386
390
  session,
387
- cdpEndpoint: providerSession.cdpEndpoint,
388
- url
391
+ experiments,
392
+ browser: {
393
+ kind: "provider",
394
+ providerName,
395
+ initialUrl: url
396
+ }
389
397
  },
390
- session,
391
398
  logger,
392
399
  logPath: runLogPath,
393
400
  // Remote CDP connection + navigation; must cover both.
394
- ipcTimeoutMs: 6e4,
395
- onFailure: () => provider.closeSession(providerSession.sessionId)
401
+ startupTimeoutMs: 6e4
396
402
  });
403
+ client.destroy();
404
+ if (!providerSession) {
405
+ throw new Error(
406
+ `Provider daemon did not return session metadata for ${providerName}.`
407
+ );
408
+ }
409
+ logger.info("open-provider-session-created", {
410
+ provider: providerName,
411
+ sessionId: providerSession.sessionId,
412
+ cdpEndpoint: providerSession.cdpEndpoint,
413
+ liveViewUrl: providerSession.liveViewUrl
414
+ });
415
+ if (providerSession.liveViewUrl) {
416
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
417
+ }
397
418
  writeSessionState(
398
419
  {
399
420
  port: 0,
@@ -491,33 +512,60 @@ async function runClose(session, logger) {
491
512
  console.log(`No browser running for session "${session}".`);
492
513
  return;
493
514
  }
494
- if (state.pid != null) {
515
+ let replayUrl;
516
+ if (state.daemonSocketPath && state.pid != null && isPidRunning(state.pid)) {
517
+ try {
518
+ const result = await closeDaemonSession(
519
+ {
520
+ session,
521
+ pid: state.pid,
522
+ port: state.port,
523
+ provider: state.provider,
524
+ daemonSocketPath: state.daemonSocketPath
525
+ },
526
+ logger
527
+ );
528
+ replayUrl = result.replayUrl;
529
+ if (!state.provider) {
530
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
531
+ }
532
+ } catch (error) {
533
+ if (state.provider) {
534
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
535
+ }
536
+ throw formatDaemonCloseFailure(session, state.provider?.name, error);
537
+ }
538
+ } else if (state.pid != null) {
495
539
  logger.info("close-killing", { session, pid: state.pid, port: state.port });
496
540
  sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
497
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
541
+ if (state.provider) {
542
+ await waitForProviderCloseResult(session, state.pid);
543
+ } else {
544
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
545
+ }
498
546
  }
499
- let replayUrl;
500
547
  if (state.provider) {
501
- logger.info("close-provider", {
548
+ logger.info("close-provider-daemon-owned", {
502
549
  session,
503
550
  provider: state.provider.name,
504
551
  sessionId: state.provider.sessionId
505
552
  });
506
- try {
507
- const provider = getCloudProviderApi(state.provider.name);
508
- const result = await provider.closeSession(state.provider.sessionId);
509
- replayUrl = result.replayUrl;
510
- } catch (err) {
511
- logger.warn("close-provider-error", {
512
- session,
513
- provider: state.provider.name,
514
- sessionId: state.provider.sessionId,
515
- error: err
516
- });
517
- writeSessionState({ ...state, status: "cleanup-failed" }, logger);
518
- throw new Error(
519
- `Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`
520
- );
553
+ if (!hasProviderCloseResult(session)) {
554
+ if (state.pid == null || !isPidRunning(state.pid)) {
555
+ try {
556
+ replayUrl = await closeProviderSessionDirectly(session, state.provider, logger);
557
+ } catch (error) {
558
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
559
+ throw error;
560
+ }
561
+ } else {
562
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
563
+ throw new Error(
564
+ `Failed to confirm remote ${state.provider.name} session cleanup for session "${session}". State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`
565
+ );
566
+ }
567
+ } else {
568
+ replayUrl = replayUrl ?? readProviderReplayUrl(session, logger);
521
569
  }
522
570
  }
523
571
  unlinkDaemonSocket(state.daemonSocketPath, logger, session);
@@ -528,9 +576,115 @@ async function runClose(session, logger) {
528
576
  console.log(`View recording: ${replayUrl}`);
529
577
  }
530
578
  }
579
+ async function closeDaemonSession(target, logger) {
580
+ if (!target.daemonSocketPath) {
581
+ throw new Error("session has no daemon socket path");
582
+ }
583
+ const timeoutMs = target.provider ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS;
584
+ logger.info("close-daemon-ipc-start", {
585
+ session: target.session,
586
+ pid: target.pid,
587
+ provider: target.provider?.name,
588
+ timeoutMs
589
+ });
590
+ let client;
591
+ try {
592
+ client = await DaemonClient.connect(target.daemonSocketPath);
593
+ const result = await withTimeout(
594
+ client.close(),
595
+ timeoutMs,
596
+ `Daemon did not respond to close within ${timeoutMs}ms.`
597
+ );
598
+ logger.info("close-daemon-ipc-success", {
599
+ session: target.session,
600
+ replayUrl: result.replayUrl
601
+ });
602
+ return result;
603
+ } finally {
604
+ client?.destroy();
605
+ }
606
+ }
607
+ async function withTimeout(promise, timeoutMs, timeoutMessage) {
608
+ let timeout;
609
+ const timeoutPromise = new Promise((_resolve, reject) => {
610
+ timeout = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
611
+ });
612
+ try {
613
+ return await Promise.race([promise, timeoutPromise]);
614
+ } finally {
615
+ if (timeout) clearTimeout(timeout);
616
+ }
617
+ }
618
+ function formatDaemonCloseFailure(session, providerName, error) {
619
+ const message = error instanceof Error ? error.message : String(error);
620
+ const cleanupWarning = providerName ? ` State preserved with status "cleanup-failed" because remote ${providerName} cleanup could not be confirmed.` : " State preserved so you can retry or inspect the session.";
621
+ return new Error(
622
+ `Failed to close session "${session}" gracefully over daemon IPC: ${message}.${cleanupWarning} Retry with: ${librettoCommand(`close --session ${session}`)}`
623
+ );
624
+ }
531
625
  function waitForCloseSignalWindow(ms) {
532
626
  return new Promise((r) => setTimeout(r, ms));
533
627
  }
628
+ async function waitForProviderCloseResult(session, pid) {
629
+ const deadline = Date.now() + PROVIDER_CLOSE_WAIT_MS;
630
+ while (Date.now() < deadline) {
631
+ if (hasProviderCloseResult(session) || !isPidRunning(pid)) return;
632
+ await new Promise((resolve) => setTimeout(resolve, 100));
633
+ }
634
+ }
635
+ async function waitForCloseAllTargets(targets) {
636
+ const hasProviderSession = targets.some((target) => target.provider);
637
+ const deadline = Date.now() + (hasProviderSession ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS);
638
+ while (Date.now() < deadline) {
639
+ const stillWaiting = targets.some((target) => {
640
+ if (target.pid == null || !isPidRunning(target.pid)) return false;
641
+ return target.provider ? !hasProviderCloseResult(target.session) : true;
642
+ });
643
+ if (!stillWaiting) return;
644
+ await new Promise((resolve) => setTimeout(resolve, 100));
645
+ }
646
+ }
647
+ async function closeProviderSessionDirectly(session, providerState, logger) {
648
+ try {
649
+ const provider = getCloudProviderApi(providerState.name);
650
+ const result = await provider.closeSession(providerState.sessionId);
651
+ logger.info("close-provider-direct-fallback-success", {
652
+ session,
653
+ provider: providerState.name,
654
+ sessionId: providerState.sessionId,
655
+ replayUrl: result.replayUrl
656
+ });
657
+ return result.replayUrl;
658
+ } catch (error) {
659
+ logger.warn("close-provider-direct-fallback-failed", {
660
+ session,
661
+ provider: providerState.name,
662
+ sessionId: providerState.sessionId,
663
+ error
664
+ });
665
+ throw new Error(
666
+ `Failed to close remote ${providerState.name} session "${providerState.sessionId}" for session "${session}". State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`
667
+ );
668
+ }
669
+ }
670
+ function readProviderReplayUrl(session, logger) {
671
+ const closePath = getSessionProviderClosePath(session);
672
+ if (!existsSync(closePath)) return void 0;
673
+ try {
674
+ const parsed = JSON.parse(readFileSync(closePath, "utf8"));
675
+ return typeof parsed.replayUrl === "string" && parsed.replayUrl.length > 0 ? parsed.replayUrl : void 0;
676
+ } catch (err) {
677
+ logger.warn("provider-close-result-read-failed", {
678
+ session,
679
+ path: closePath,
680
+ error: err
681
+ });
682
+ return void 0;
683
+ }
684
+ }
685
+ function hasProviderCloseResult(session) {
686
+ return existsSync(getSessionProviderClosePath(session));
687
+ }
534
688
  function sendSignalToProcessGroupOrPid(pid, signal, logger, session) {
535
689
  try {
536
690
  process.kill(pid, signal);
@@ -597,6 +751,11 @@ function clearStoppedSessionStates(sessions, logger, skip) {
597
751
  }
598
752
  return cleared;
599
753
  }
754
+ function markProviderCleanupFailed(session, logger) {
755
+ const state = readSessionState(session, logger);
756
+ if (!state) return;
757
+ writeSessionState({ ...state, status: "cleanup-failed" }, logger);
758
+ }
600
759
  async function runCloseAll(logger, options) {
601
760
  const force = Boolean(options?.force);
602
761
  logger.info("close-all-start", { force });
@@ -611,67 +770,73 @@ async function runCloseAll(logger, options) {
611
770
  return;
612
771
  }
613
772
  const failedProviderSessions = /* @__PURE__ */ new Set();
614
- const replayUrls = [];
615
- for (const target of closable) {
616
- if (target.provider) {
617
- logger.info("close-all-provider", {
618
- session: target.session,
619
- provider: target.provider.name,
620
- sessionId: target.provider.sessionId
621
- });
622
- try {
623
- const provider = getCloudProviderApi(target.provider.name);
624
- const result = await provider.closeSession(target.provider.sessionId);
625
- if (result.replayUrl) {
626
- replayUrls.push({
773
+ const gracefulCloseFailures = /* @__PURE__ */ new Map();
774
+ await Promise.all(
775
+ closable.map(async (target) => {
776
+ if (target.pid == null) return;
777
+ if (target.daemonSocketPath && isPidRunning(target.pid)) {
778
+ try {
779
+ await closeDaemonSession(target, logger);
780
+ return;
781
+ } catch (error) {
782
+ const closeError = formatDaemonCloseFailure(
783
+ target.session,
784
+ target.provider?.name,
785
+ error
786
+ );
787
+ gracefulCloseFailures.set(target.session, closeError);
788
+ logger.warn("close-all-daemon-ipc-failed", {
627
789
  session: target.session,
628
- replayUrl: result.replayUrl
790
+ pid: target.pid,
791
+ error: closeError.message
629
792
  });
630
- }
631
- } catch (err) {
632
- logger.warn("close-all-provider-error", {
633
- session: target.session,
634
- provider: target.provider.name,
635
- sessionId: target.provider.sessionId,
636
- error: err
637
- });
638
- failedProviderSessions.add(target.session);
639
- const state = readSessionState(target.session, logger);
640
- if (state) {
641
- writeSessionState({ ...state, status: "cleanup-failed" }, logger);
793
+ if (!force) return;
642
794
  }
643
795
  }
644
- }
645
- }
796
+ logger.info("close-all-sigterm", {
797
+ session: target.session,
798
+ pid: target.pid,
799
+ port: target.port
800
+ });
801
+ sendSignalToProcessGroupOrPid(
802
+ target.pid,
803
+ "SIGTERM",
804
+ logger,
805
+ target.session
806
+ );
807
+ })
808
+ );
809
+ await waitForCloseAllTargets(closable);
646
810
  for (const target of closable) {
647
- if (target.pid == null) continue;
648
- logger.info("close-all-sigterm", {
649
- session: target.session,
650
- pid: target.pid,
651
- port: target.port
652
- });
653
- sendSignalToProcessGroupOrPid(
654
- target.pid,
655
- "SIGTERM",
656
- logger,
657
- target.session
658
- );
811
+ if (!target.provider || hasProviderCloseResult(target.session)) continue;
812
+ if (target.pid != null && isPidRunning(target.pid)) continue;
813
+ try {
814
+ await closeProviderSessionDirectly(target.session, target.provider, logger);
815
+ } catch {
816
+ markProviderCleanupFailed(target.session, logger);
817
+ failedProviderSessions.add(target.session);
818
+ }
659
819
  }
660
- await waitForCloseSignalWindow(CLOSE_WAIT_MS);
661
820
  let survivors = closable.filter(
662
821
  (target) => target.pid != null && isPidRunning(target.pid)
663
822
  );
664
- if (survivors.length > 0 && !force) {
823
+ if ((survivors.length > 0 || gracefulCloseFailures.size > 0) && !force) {
665
824
  const closed = clearStoppedSessionStates(
666
825
  closable,
667
826
  logger,
668
827
  failedProviderSessions
669
828
  );
829
+ const failedSessions = Array.from(
830
+ /* @__PURE__ */ new Set([
831
+ ...survivors.map((survivor) => survivor.session),
832
+ ...gracefulCloseFailures.keys()
833
+ ])
834
+ ).map((sessionName) => ({ session: sessionName }));
670
835
  throw new Error(
671
836
  [
672
- `Failed to close ${survivors.length} session(s) gracefully: ${formatSessionList(survivors)}.`,
837
+ `Failed to close ${failedSessions.length} session(s) gracefully: ${formatSessionList(failedSessions)}.`,
673
838
  `Closed ${closed} session(s).`,
674
- `Retry with: libretto close --all --force`
839
+ `Retry with: ${librettoCommand("close --all --force")}`
675
840
  ].join("\n")
676
841
  );
677
842
  }
@@ -710,12 +875,11 @@ async function runCloseAll(logger, options) {
710
875
  );
711
876
  }
712
877
  }
878
+ const replayUrls = closable.filter((target) => target.provider).flatMap((target) => {
879
+ const replayUrl = readProviderReplayUrl(target.session, logger);
880
+ return replayUrl ? [{ session: target.session, replayUrl }] : [];
881
+ });
713
882
  clearStoppedSessionStates(closable, logger, failedProviderSessions);
714
- if (failedProviderSessions.size > 0) {
715
- console.log(
716
- `Warning: ${failedProviderSessions.size} provider session(s) failed remote cleanup and were preserved with status "cleanup-failed".`
717
- );
718
- }
719
883
  if (clearedUnreadableStates > 0) {
720
884
  console.log(
721
885
  `Cleared ${clearedUnreadableStates} unreadable session state file(s).`
@@ -723,14 +887,21 @@ async function runCloseAll(logger, options) {
723
887
  }
724
888
  const closedCount = closable.length - failedProviderSessions.size;
725
889
  console.log(`Closed ${closedCount} session(s).`);
890
+ if (failedProviderSessions.size > 0) {
891
+ console.warn(
892
+ `Failed to confirm remote cleanup for ${failedProviderSessions.size} provider-backed session(s). State preserved with status "cleanup-failed". Retry with: ${librettoCommand("close --all")}`
893
+ );
894
+ }
895
+ for (const recording of replayUrls) {
896
+ console.log(
897
+ `View recording for session "${recording.session}": ${recording.replayUrl}`
898
+ );
899
+ }
726
900
  if (forceKilled > 0) {
727
901
  console.log(`Force-killed ${forceKilled} session(s).`);
728
902
  }
729
- for (const { session, replayUrl } of replayUrls) {
730
- console.log(`View recording (${session}): ${replayUrl}`);
731
- }
732
903
  }
733
- async function runConnect(cdpUrl, session, logger, accessMode = "write-access") {
904
+ async function runConnect(cdpUrl, session, logger, accessMode, experiments) {
734
905
  logger.info("connect-start", { cdpUrl, session, accessMode });
735
906
  assertSessionAvailableForStart(session, logger);
736
907
  let parsedUrl;
@@ -742,11 +913,11 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
742
913
  `Invalid CDP URL: ${cdpUrl}`,
743
914
  ``,
744
915
  `Expected an HTTP or WebSocket URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
745
- ` libretto connect http://127.0.0.1:9222`,
746
- ` libretto connect http://remote-host:9222`,
747
- ` libretto connect http://remote-host:9222/devtools/browser/<id>`,
748
- ` libretto connect ws://remote-host:9222/devtools/browser/<id>`,
749
- ` libretto connect wss://remote-host/cdp-endpoint`
916
+ ` ${librettoCommand("connect http://127.0.0.1:9222")}`,
917
+ ` ${librettoCommand("connect http://remote-host:9222")}`,
918
+ ` ${librettoCommand("connect http://remote-host:9222/devtools/browser/<id>")}`,
919
+ ` ${librettoCommand("connect ws://remote-host:9222/devtools/browser/<id>")}`,
920
+ ` ${librettoCommand("connect wss://remote-host/cdp-endpoint")}`
750
921
  ].join("\n")
751
922
  );
752
923
  }
@@ -775,12 +946,15 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
775
946
  });
776
947
  }
777
948
  const runLogPath = logFileForSession(session);
778
- const { pid, socketPath: daemonSocketPath, client } = await spawnSessionDaemon({
779
- config: { mode: "connect", session, cdpEndpoint: endpoint },
780
- session,
949
+ const { pid, socketPath: daemonSocketPath, client } = await DaemonClient.spawn({
950
+ config: {
951
+ session,
952
+ experiments,
953
+ browser: { kind: "connect", cdpEndpoint: endpoint }
954
+ },
781
955
  logger,
782
956
  logPath: runLogPath,
783
- ipcTimeoutMs: 1e4
957
+ startupTimeoutMs: 1e4
784
958
  });
785
959
  writeSessionState(
786
960
  {
@@ -795,7 +969,12 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
795
969
  },
796
970
  logger
797
971
  );
798
- const pages = await client.pages();
972
+ let pages;
973
+ try {
974
+ pages = await client.pages();
975
+ } finally {
976
+ client.destroy();
977
+ }
799
978
  logger.info("connect-success", { cdpUrl: endpoint, session, port });
800
979
  console.log(`Connected to ${endpoint} (session: ${session})`);
801
980
  console.log(` Pages found: ${pages.length}`);
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { SessionAccessModeSchema } from "../../shared/state/index.js";
5
5
  import { LIBRETTO_CONFIG_PATH } from "./context.js";
6
+ import { librettoCommand } from "../../shared/package-manager.js";
6
7
  const CURRENT_CONFIG_VERSION = 1;
7
8
  const ViewportConfigSchema = z.object({
8
9
  width: z.number().int().min(1),
@@ -18,7 +19,8 @@ const LibrettoConfigSchema = z.object({
18
19
  viewport: ViewportConfigSchema.optional(),
19
20
  windowPosition: WindowPositionConfigSchema.optional(),
20
21
  provider: z.string().optional(),
21
- sessionMode: SessionAccessModeSchema.optional()
22
+ sessionMode: SessionAccessModeSchema.optional(),
23
+ experiments: z.record(z.string(), z.boolean()).optional()
22
24
  }).passthrough();
23
25
  function formatConfigIssues(error) {
24
26
  return error.issues.map((issue) => ` - ${issue.path.join(".") || "root"}: ${issue.message}`).join("\n");
@@ -54,7 +56,7 @@ ${detail}` : null,
54
56
  ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
55
57
  ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
56
58
  "Fix the file to match this shape, or delete it and rerun:",
57
- ` npx libretto ai configure openai | anthropic | gemini | vertex | openrouter`
59
+ ` ${librettoCommand("ai configure openai | anthropic | gemini | vertex | openrouter")}`
58
60
  ].filter(Boolean).join("\n")
59
61
  );
60
62
  }
@@ -30,6 +30,9 @@ function getSessionNetworkLogPath(session) {
30
30
  function getSessionActionsLogPath(session) {
31
31
  return join(getSessionDir(session), "actions.jsonl");
32
32
  }
33
+ function getSessionProviderClosePath(session) {
34
+ return join(getSessionDir(session), "provider-close.json");
35
+ }
33
36
  function getSessionSnapshotsDir(session) {
34
37
  return join(getSessionDir(session), "snapshots");
35
38
  }
@@ -75,6 +78,7 @@ export {
75
78
  getSessionDir,
76
79
  getSessionLogsPath,
77
80
  getSessionNetworkLogPath,
81
+ getSessionProviderClosePath,
78
82
  getSessionSnapshotRunDir,
79
83
  getSessionSnapshotsDir,
80
84
  getSessionStatePath,
@@ -1,6 +0,0 @@
1
- function isConnectConfig(config) {
2
- return "mode" in config && config.mode === "connect";
3
- }
4
- export {
5
- isConnectConfig
6
- };