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
@@ -1,11 +1,14 @@
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
+ import { mkdir, writeFile } from "node:fs/promises";
5
6
  import { dirname, join } from "node:path";
6
7
  import { createServer } from "node:net";
7
- import { PROFILES_DIR } from "./context.js";
8
+ import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
8
9
  import { readLibrettoConfig } from "./config.js";
10
+ import { librettoCommand } from "../../shared/package-manager.js";
11
+ import { getCloudProviderApi } from "./providers/index.js";
9
12
  import {
10
13
  assertSessionAvailableForStart,
11
14
  clearSessionState,
@@ -16,9 +19,9 @@ import {
16
19
  readSessionState,
17
20
  writeSessionState
18
21
  } from "./session.js";
19
- import { getCloudProviderApi } from "./providers/index.js";
20
- import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
22
+ import { DaemonClient } from "./daemon/ipc.js";
21
23
  const CLOSE_WAIT_MS = 1500;
24
+ const PROVIDER_CLOSE_WAIT_MS = 3e4;
22
25
  const FORCE_CLOSE_WAIT_MS = 300;
23
26
  async function pickFreePort() {
24
27
  return await new Promise((resolve, reject) => {
@@ -154,13 +157,13 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
154
157
  });
155
158
  if (state.provider) {
156
159
  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}`
160
+ `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
161
  );
159
162
  }
160
163
  if (state.pid == null || !isPidRunning(state.pid)) {
161
164
  clearSessionState(session, logger);
162
165
  throw new Error(
163
- `No browser running for session "${session}". Run 'libretto open <url> --session ${session}' first.`
166
+ `No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`
164
167
  );
165
168
  }
166
169
  throw new Error(
@@ -190,14 +193,14 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
190
193
  }
191
194
  if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
192
195
  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).`
196
+ `Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`
194
197
  );
195
198
  }
196
199
  const pageRefs = await resolvePageReferences(pages);
197
200
  const pageRef = options?.pageId ? pageRefs.find((ref) => ref.id === options.pageId) ?? null : pageRefs[pageRefs.length - 1];
198
201
  if (!pageRef) {
199
202
  throw new Error(
200
- `Page "${options?.pageId}" was not found in session "${session}". Run "libretto pages --session ${session}" to list ids.`
203
+ `Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`
201
204
  );
202
205
  }
203
206
  const page = pageRef.page;
@@ -230,11 +233,15 @@ async function runPages(session, logger) {
230
233
  let pageSummaries;
231
234
  if (!state.daemonSocketPath) {
232
235
  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}`
236
+ `Session "${session}" has no daemon socket. The browser daemon may have crashed. Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`
234
237
  );
235
238
  }
236
- const client = new DaemonClient(state.daemonSocketPath);
237
- pageSummaries = await client.pages();
239
+ const client = await DaemonClient.connect(state.daemonSocketPath);
240
+ try {
241
+ pageSummaries = await client.pages();
242
+ } finally {
243
+ client.destroy();
244
+ }
238
245
  if (pageSummaries.length === 0) {
239
246
  console.log("No pages found.");
240
247
  return;
@@ -299,7 +306,7 @@ async function runOpen(rawUrl, headed, session, logger, options) {
299
306
  const authProfilePath = getProfilePath(authDomain);
300
307
  if (!existsSync(authProfilePath)) {
301
308
  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>`
309
+ `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
310
  );
304
311
  }
305
312
  }
@@ -320,25 +327,29 @@ async function runOpen(rawUrl, headed, session, logger, options) {
320
327
  console.log(`Loading saved profile for ${domain}`);
321
328
  }
322
329
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
323
- const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
330
+ const { pid, socketPath: daemonSocketPath, client } = await DaemonClient.spawn({
324
331
  config: {
325
- port,
326
- url,
327
332
  session,
328
- headed,
329
- viewport,
330
- storageStatePath: useProfile ? profilePath : void 0,
331
- windowPosition
333
+ experiments: options.experiments,
334
+ browser: {
335
+ kind: "launch",
336
+ headed,
337
+ viewport,
338
+ storageStatePath: useProfile ? profilePath : void 0,
339
+ windowPosition,
340
+ remoteDebuggingPort: port,
341
+ initialUrl: url
342
+ }
332
343
  },
333
- session,
334
344
  logger,
335
345
  logPath: runLogPath,
336
346
  // The daemon launches Chromium, installs telemetry, navigates to
337
347
  // the URL, and only then starts IPC. Navigation alone can take up
338
348
  // to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
339
349
  // must cover launch + navigation.
340
- ipcTimeoutMs: 6e4
350
+ startupTimeoutMs: 6e4
341
351
  });
352
+ client.destroy();
342
353
  writeSessionState(
343
354
  {
344
355
  port,
@@ -361,39 +372,50 @@ async function runOpen(rawUrl, headed, session, logger, options) {
361
372
  });
362
373
  console.log(`Browser open (${browserMode}): ${url}`);
363
374
  }
364
- async function runOpenWithProvider(rawUrl, providerName, provider, session, logger, accessMode = "write-access") {
375
+ async function runOpenWithProvider(rawUrl, providerName, session, logger, accessMode, experiments) {
365
376
  const parsedUrl = normalizeUrl(rawUrl);
366
377
  const url = parsedUrl.href;
367
378
  logger.info("open-provider-start", { url, provider: providerName, session });
368
379
  console.log(
369
380
  `Creating ${providerName} browser session (session: ${session})...`
370
381
  );
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
382
  console.log(`Connecting to ${providerName} browser...`);
382
383
  const runLogPath = logFileForSession(session);
383
- const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
384
+ const {
385
+ pid,
386
+ socketPath: daemonSocketPath,
387
+ provider: providerSession,
388
+ client
389
+ } = await DaemonClient.spawn({
384
390
  config: {
385
- mode: "connect",
386
391
  session,
387
- cdpEndpoint: providerSession.cdpEndpoint,
388
- url
392
+ experiments,
393
+ browser: {
394
+ kind: "provider",
395
+ providerName,
396
+ initialUrl: url
397
+ }
389
398
  },
390
- session,
391
399
  logger,
392
400
  logPath: runLogPath,
393
401
  // Remote CDP connection + navigation; must cover both.
394
- ipcTimeoutMs: 6e4,
395
- onFailure: () => provider.closeSession(providerSession.sessionId)
402
+ startupTimeoutMs: 6e4
396
403
  });
404
+ client.destroy();
405
+ if (!providerSession) {
406
+ throw new Error(
407
+ `Provider daemon did not return session metadata for ${providerName}.`
408
+ );
409
+ }
410
+ logger.info("open-provider-session-created", {
411
+ provider: providerName,
412
+ sessionId: providerSession.sessionId,
413
+ cdpEndpoint: providerSession.cdpEndpoint,
414
+ liveViewUrl: providerSession.liveViewUrl
415
+ });
416
+ if (providerSession.liveViewUrl) {
417
+ console.log(`View live session: ${providerSession.liveViewUrl}`);
418
+ }
397
419
  writeSessionState(
398
420
  {
399
421
  port: 0,
@@ -464,9 +486,8 @@ async function runSave(urlOrDomain, session, logger) {
464
486
  }
465
487
  }
466
488
  const state = { cookies, origins };
467
- const fs = await import("node:fs/promises");
468
- await fs.mkdir(dirname(profilePath), { recursive: true });
469
- await fs.writeFile(profilePath, JSON.stringify(state, null, 2));
489
+ await mkdir(dirname(profilePath), { recursive: true });
490
+ await writeFile(profilePath, JSON.stringify(state, null, 2));
470
491
  logger.info("save-success", {
471
492
  domain,
472
493
  profilePath,
@@ -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}`);
@@ -18,7 +18,8 @@ const LibrettoConfigSchema = z.object({
18
18
  viewport: ViewportConfigSchema.optional(),
19
19
  windowPosition: WindowPositionConfigSchema.optional(),
20
20
  provider: z.string().optional(),
21
- sessionMode: SessionAccessModeSchema.optional()
21
+ sessionMode: SessionAccessModeSchema.optional(),
22
+ experiments: z.record(z.string(), z.boolean()).optional()
22
23
  }).passthrough();
23
24
  function formatConfigIssues(error) {
24
25
  return error.issues.map((issue) => ` - ${issue.path.join(".") || "root"}: ${issue.message}`).join("\n");
@@ -27,7 +28,6 @@ function formatExpectedConfigExample() {
27
28
  return JSON.stringify(
28
29
  {
29
30
  version: CURRENT_CONFIG_VERSION,
30
- snapshotModel: "openai/gpt-5.4",
31
31
  viewport: {
32
32
  width: 1280,
33
33
  height: 800
@@ -51,10 +51,9 @@ ${detail}` : null,
51
51
  "Expected config example:",
52
52
  formatExpectedConfigExample(),
53
53
  "Notes:",
54
- ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
55
- ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
56
- "Fix the file to match this shape, or delete it and rerun:",
57
- ` npx libretto ai configure openai | anthropic | gemini | vertex | openrouter`
54
+ ' - "viewport", "windowPosition", and "sessionMode" are optional.',
55
+ ' - "snapshotModel" is deprecated and ignored by snapshot.',
56
+ "Fix the file to match this shape, or delete it and rerun setup."
58
57
  ].filter(Boolean).join("\n")
59
58
  );
60
59
  }
@@ -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
- };