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
@@ -1,32 +1,41 @@
1
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { spawn } from "node:child_process";
1
+ import { readFileSync } from "node:fs";
3
2
  import * as moduleBuiltin from "node:module";
4
- import { fileURLToPath } from "node:url";
5
3
  import { z } from "zod";
6
4
  import { installInstrumentation } from "../../shared/instrumentation/index.js";
7
5
  import {
8
6
  connect,
9
7
  disconnectBrowser,
8
+ getProfilePath,
9
+ hasProfile,
10
+ normalizeDomain,
11
+ normalizeUrl,
12
+ runClose,
10
13
  resolveViewport
11
14
  } from "../core/browser.js";
12
15
  import { parseViewportArg } from "./browser.js";
13
- import { getPauseSignalPaths } from "../core/pause-signals.js";
14
16
  import {
15
17
  assertSessionAvailableForStart,
16
18
  assertSessionAllowsCommand,
17
19
  clearSessionState,
20
+ logFileForSession,
18
21
  readSessionState,
19
22
  readSessionStateOrThrow,
20
- setSessionStatus
23
+ setSessionStatus,
24
+ writeSessionState
21
25
  } from "../core/session.js";
22
26
  import { warnIfInstalledSkillOutOfDate } from "../core/skill-version.js";
23
27
  import { readLibrettoConfig } from "../core/config.js";
24
- import { resolveProviderName, getCloudProviderApi } from "../core/providers/index.js";
28
+ import { librettoCommand } from "../../shared/package-manager.js";
29
+ import { renderSnapshotDiff } from "../../shared/snapshot/diff-snapshots.js";
30
+ import { resolveProviderName } from "../core/providers/index.js";
31
+ import { getAbsoluteIntegrationPath } from "../core/workflow-runtime.js";
25
32
  import {
26
33
  compileExecFunction,
27
34
  stripEmptyCatchHandlers
28
35
  } from "../core/exec-compiler.js";
29
- import { DaemonClient } from "../core/daemon/index.js";
36
+ import {
37
+ DaemonClient
38
+ } from "../core/daemon/ipc.js";
30
39
  import { createReadonlyExecHelpers } from "../core/readonly-exec.js";
31
40
  import {
32
41
  readActionLog,
@@ -38,10 +47,10 @@ import {
38
47
  pageOption,
39
48
  sessionOption,
40
49
  withAutoSession,
50
+ withExperiments,
41
51
  withRequiredSession
42
52
  } from "./shared.js";
43
53
  const require2 = moduleBuiltin.createRequire(import.meta.url);
44
- const tsxCliPath = require2.resolve("tsx/cli");
45
54
  function writeDaemonExecOutput(output) {
46
55
  if (output?.stdout) {
47
56
  process.stdout.write(output.stdout);
@@ -50,6 +59,13 @@ function writeDaemonExecOutput(output) {
50
59
  process.stderr.write(output.stderr);
51
60
  }
52
61
  }
62
+ function writeDaemonSnapshotDiff(snapshotDiff) {
63
+ if (!snapshotDiff) return;
64
+ const renderedDiff = renderSnapshotDiff(snapshotDiff);
65
+ if (!renderedDiff) return;
66
+ console.log("Page changes:");
67
+ console.log(renderedDiff);
68
+ }
53
69
  async function execViaDaemon(code, session, daemonSocketPath, logger, options) {
54
70
  const mode = options.mode ?? "exec";
55
71
  const { cleaned: cleanedCode, strippedCount } = stripEmptyCatchHandlers(code);
@@ -64,20 +80,25 @@ async function execViaDaemon(code, session, daemonSocketPath, logger, options) {
64
80
  pageId: options.pageId,
65
81
  via: "daemon"
66
82
  });
67
- const client = new DaemonClient(daemonSocketPath);
68
- const response = mode === "exec" ? await client.exec({
69
- code: cleanedCode,
70
- pageId: options.pageId,
71
- visualize: options.visualize
72
- }) : await client.readonlyExec({
73
- code: cleanedCode,
74
- pageId: options.pageId
75
- });
83
+ const client = await DaemonClient.connect(daemonSocketPath);
84
+ let response;
85
+ try {
86
+ response = mode === "exec" ? await client.exec({
87
+ code: cleanedCode,
88
+ pageId: options.pageId,
89
+ visualize: options.visualize
90
+ }) : await client.readonlyExec({
91
+ code: cleanedCode,
92
+ pageId: options.pageId
93
+ });
94
+ } finally {
95
+ client.destroy();
96
+ }
76
97
  if (!response.ok) {
77
98
  writeDaemonExecOutput(response.output);
78
99
  throw new Error(response.message);
79
100
  }
80
- const { result, output } = response.data;
101
+ const { result, output, snapshotDiff } = response.data;
81
102
  writeDaemonExecOutput(output);
82
103
  logger.info(`${mode}-success`, {
83
104
  session,
@@ -91,6 +112,7 @@ async function execViaDaemon(code, session, daemonSocketPath, logger, options) {
91
112
  } else {
92
113
  console.log("Executed successfully");
93
114
  }
115
+ writeDaemonSnapshotDiff(snapshotDiff);
94
116
  }
95
117
  async function execViaCdpFallback(code, session, logger, options) {
96
118
  const visualize = options.visualize ?? false;
@@ -237,8 +259,22 @@ async function stopExistingFailedRunSession(session, logger) {
237
259
  pid: existingState.pid,
238
260
  port: existingState.port
239
261
  });
240
- clearSessionState(session, logger);
241
- if (existingState.pid == null) return;
262
+ if (existingState.pid == null) {
263
+ clearSessionState(session, logger);
264
+ return;
265
+ }
266
+ try {
267
+ process.kill(existingState.pid, "SIGTERM");
268
+ } catch (error) {
269
+ const code = error.code;
270
+ if (code !== "ESRCH") {
271
+ logger.warn("run-release-existing-failed-session-signal-failed", {
272
+ session,
273
+ pid: existingState.pid,
274
+ error
275
+ });
276
+ }
277
+ }
242
278
  const stopDeadline = Date.now() + 3e3;
243
279
  while (isProcessRunning(existingState.pid) && Date.now() < stopDeadline) {
244
280
  await new Promise((resolveWait) => setTimeout(resolveWait, 100));
@@ -248,148 +284,115 @@ async function stopExistingFailedRunSession(session, logger) {
248
284
  session,
249
285
  pid: existingState.pid
250
286
  });
251
- console.warn(
252
- `Existing failed workflow process for session "${session}" (pid ${existingState.pid}) is still shutting down; continuing.`
287
+ throw new Error(
288
+ `Existing failed workflow process for session "${session}" (pid ${existingState.pid}) is still running. Close it with: ${librettoCommand(`close --session ${session}`)}`
253
289
  );
254
- return;
255
290
  }
291
+ clearSessionState(session, logger);
256
292
  console.log(
257
293
  `Closed existing failed workflow process for session "${session}" (pid ${existingState.pid}).`
258
294
  );
259
295
  }
260
- function readJsonFileIfExists(path) {
261
- if (!existsSync(path)) return null;
262
- try {
263
- return JSON.parse(readFileSync(path, "utf8"));
264
- } catch {
265
- return null;
266
- }
296
+ function createDeferred() {
297
+ let resolve;
298
+ const promise = new Promise((resolvePromise) => {
299
+ resolve = resolvePromise;
300
+ });
301
+ return { promise, resolve };
267
302
  }
268
- function readFailureDetails(path) {
269
- const raw = readJsonFileIfExists(path);
270
- if (!raw || typeof raw !== "object") return null;
271
- const message = raw.message;
272
- const phase = raw.phase;
303
+ function createWorkflowHandlers(settleOutcome) {
273
304
  return {
274
- message: typeof message === "string" ? message : void 0,
275
- phase: phase === "setup" || phase === "workflow" ? phase : void 0
305
+ workflowOutput: (event) => {
306
+ const stream = event.stream === "stdout" ? process.stdout : process.stderr;
307
+ stream.write(event.text);
308
+ },
309
+ workflowPaused: () => {
310
+ settleOutcome({ status: "paused" });
311
+ },
312
+ workflowFinished: (event) => {
313
+ if (event.result === "completed") {
314
+ settleOutcome({ status: "completed" });
315
+ return;
316
+ }
317
+ settleOutcome({
318
+ status: "failed",
319
+ message: event.message,
320
+ phase: event.phase
321
+ });
322
+ }
276
323
  };
277
324
  }
278
- async function waitForFailureDetails(path, timeoutMs = 1e3) {
279
- const deadline = Date.now() + timeoutMs;
280
- while (Date.now() < deadline) {
281
- const details = readFailureDetails(path);
282
- if (details?.message) return details;
283
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
284
- }
285
- return readFailureDetails(path);
286
- }
287
- function streamOutputSince(path, offset) {
288
- if (!existsSync(path)) return offset;
289
- const output = readFileSync(path);
290
- if (output.length <= offset) return output.length;
291
- process.stdout.write(output.subarray(offset));
292
- return output.length;
293
- }
294
- function clearSignalIfExists(path) {
295
- if (!existsSync(path)) return;
325
+ async function waitForWorkflowOutcome(pid, outcomePromise) {
326
+ let processExitInterval;
327
+ const processExitPromise = new Promise((resolve) => {
328
+ if (pid <= 0 || !isProcessRunning(pid)) {
329
+ resolve({ status: "exited" });
330
+ return;
331
+ }
332
+ processExitInterval = setInterval(() => {
333
+ if (!isProcessRunning(pid)) {
334
+ resolve({ status: "exited" });
335
+ }
336
+ }, 250);
337
+ });
296
338
  try {
297
- unlinkSync(path);
298
- } catch {
339
+ return await Promise.race([outcomePromise, processExitPromise]);
340
+ } finally {
341
+ if (processExitInterval) clearInterval(processExitInterval);
299
342
  }
300
343
  }
301
- async function waitForWorkflowOutcome(args) {
302
- const signalPaths = getPauseSignalPaths(args.session);
303
- if (args.pid <= 0) {
304
- return { status: "exited" };
305
- }
306
- let outputOffset = 0;
307
- while (true) {
308
- outputOffset = streamOutputSince(
309
- signalPaths.outputSignalPath,
310
- outputOffset
344
+ async function runResume(session, logger, sessionState) {
345
+ if (sessionState.pid == null || !isProcessRunning(sessionState.pid)) {
346
+ throw new Error(
347
+ `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid ?? "unknown"} is not running).`
311
348
  );
312
- if (existsSync(signalPaths.failedSignalPath)) {
313
- outputOffset = streamOutputSince(
314
- signalPaths.outputSignalPath,
315
- outputOffset
316
- );
317
- const failureDetails = await waitForFailureDetails(
318
- signalPaths.failedSignalPath
319
- );
320
- return {
321
- status: "failed",
322
- message: failureDetails?.message,
323
- phase: failureDetails?.phase
324
- };
325
- }
326
- if (existsSync(signalPaths.completedSignalPath)) {
327
- outputOffset = streamOutputSince(
328
- signalPaths.outputSignalPath,
329
- outputOffset
330
- );
331
- return { status: "completed" };
332
- }
333
- if (existsSync(signalPaths.pausedSignalPath)) {
334
- outputOffset = streamOutputSince(
335
- signalPaths.outputSignalPath,
336
- outputOffset
337
- );
338
- return { status: "paused" };
339
- }
340
- if (!isProcessRunning(args.pid)) {
341
- outputOffset = streamOutputSince(
342
- signalPaths.outputSignalPath,
343
- outputOffset
344
- );
345
- return { status: "exited" };
346
- }
347
- await new Promise((resolveWait) => setTimeout(resolveWait, 250));
348
349
  }
349
- }
350
- async function runResume(session, logger, sessionState) {
351
- const {
352
- pausedSignalPath,
353
- resumeSignalPath,
354
- completedSignalPath,
355
- failedSignalPath,
356
- outputSignalPath
357
- } = getPauseSignalPaths(session);
358
- if (!existsSync(pausedSignalPath)) {
350
+ if (!sessionState.daemonSocketPath) {
359
351
  throw new Error(
360
- `Session "${session}" is not paused. Run "libretto run ... --session ${session}" and call pause("${session}") first.`
352
+ `No active paused workflow found for session "${session}" (daemon socket is missing).`
361
353
  );
362
354
  }
363
- if (sessionState.pid == null || !isProcessRunning(sessionState.pid)) {
355
+ const workflowOutcome = createDeferred();
356
+ const handlers = createWorkflowHandlers(workflowOutcome.resolve);
357
+ let client;
358
+ try {
359
+ client = await DaemonClient.connect(
360
+ sessionState.daemonSocketPath,
361
+ handlers
362
+ );
363
+ } catch {
364
364
  throw new Error(
365
- `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid ?? "unknown"} is not running).`
365
+ `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid} is not running).`
366
366
  );
367
367
  }
368
- clearSignalIfExists(pausedSignalPath);
369
- clearSignalIfExists(outputSignalPath);
370
- clearSignalIfExists(completedSignalPath);
371
- clearSignalIfExists(failedSignalPath);
372
- setSessionStatus(session, "active", logger);
373
- writeFileSync(
374
- resumeSignalPath,
375
- JSON.stringify(
376
- {
377
- resumedAt: (/* @__PURE__ */ new Date()).toISOString(),
378
- sourcePid: process.pid
379
- },
380
- null,
381
- 2
382
- ),
383
- "utf8"
384
- );
385
- console.log(`Resume signal sent for session "${session}".`);
386
- const outcome = await waitForWorkflowOutcome({
387
- session,
388
- pid: sessionState.pid
389
- });
368
+ let outcome;
369
+ try {
370
+ const status = await client.getWorkflowStatus();
371
+ if (status.state !== "paused") {
372
+ throw new Error(
373
+ `Session "${session}" is not paused. Run "${librettoCommand(`run ... --session ${session}`)}" and call pause("${session}") first.`
374
+ );
375
+ }
376
+ await client.resumeWorkflow();
377
+ setSessionStatus(session, "active", logger);
378
+ console.log(`Resume requested for session "${session}".`);
379
+ outcome = await waitForWorkflowOutcome(
380
+ sessionState.pid,
381
+ workflowOutcome.promise
382
+ );
383
+ } finally {
384
+ client.destroy();
385
+ }
390
386
  if (outcome.status === "completed") {
391
387
  setSessionStatus(session, "completed", logger);
392
388
  console.log("Integration completed.");
389
+ if (sessionState.stayOpenOnSuccess) {
390
+ console.log(
391
+ `Browser is still open for session "${session}". Close it with: libretto close --session ${session}`
392
+ );
393
+ } else {
394
+ await runClose(session, logger);
395
+ }
393
396
  return;
394
397
  }
395
398
  if (outcome.status === "failed") {
@@ -401,7 +404,7 @@ async function runResume(session, logger, sessionState) {
401
404
  if (outcome.status === "exited") {
402
405
  setSessionStatus(session, "exited", logger);
403
406
  throw new Error(
404
- `Workflow process for session "${session}" exited before reporting completion or pause.`
407
+ outcome.message ?? `Workflow process for session "${session}" exited before reporting completion or pause.`
405
408
  );
406
409
  }
407
410
  setSessionStatus(session, "paused", logger);
@@ -409,50 +412,85 @@ async function runResume(session, logger, sessionState) {
409
412
  }
410
413
  async function runIntegrationFromFile(args, logger) {
411
414
  await stopExistingFailedRunSession(args.session, logger);
412
- const signalPaths = getPauseSignalPaths(args.session);
413
- clearSignalIfExists(signalPaths.pausedSignalPath);
414
- clearSignalIfExists(signalPaths.resumeSignalPath);
415
- clearSignalIfExists(signalPaths.completedSignalPath);
416
- clearSignalIfExists(signalPaths.failedSignalPath);
417
- clearSignalIfExists(signalPaths.outputSignalPath);
418
- const workerEntryPath = fileURLToPath(
419
- new URL("../workers/run-integration-worker.js", import.meta.url)
415
+ const absoluteIntegrationPath = getAbsoluteIntegrationPath(
416
+ args.integrationPath
420
417
  );
421
- const payload = JSON.stringify({
422
- integrationPath: args.integrationPath,
423
- session: args.session,
424
- params: args.params,
425
- headless: args.headless,
426
- visualize: args.visualize,
427
- authProfileDomain: args.authProfileDomain,
428
- viewport: args.viewport,
429
- accessMode: args.accessMode,
430
- cdpEndpoint: args.cdpEndpoint,
431
- provider: args.provider
418
+ if (args.authProfileDomain) {
419
+ const normalizedDomain = normalizeDomain(normalizeUrl(args.authProfileDomain));
420
+ if (!hasProfile(normalizedDomain)) {
421
+ const profilePath = getProfilePath(normalizedDomain);
422
+ throw new Error(
423
+ [
424
+ `Local auth profile not found for domain "${normalizedDomain}".`,
425
+ `Expected profile file: ${profilePath}`,
426
+ "To create it:",
427
+ ` 1. ${librettoCommand(`open https://${normalizedDomain} --headed --session ${args.session}`)}`,
428
+ " 2. Log in manually in the browser window.",
429
+ ` 3. ${librettoCommand(`save ${normalizedDomain} --session ${args.session}`)}`
430
+ ].join("\n")
431
+ );
432
+ }
433
+ }
434
+ const runLogPath = logFileForSession(args.session);
435
+ const workflowOutcome = createDeferred();
436
+ const handlers = createWorkflowHandlers(workflowOutcome.resolve);
437
+ const {
438
+ pid,
439
+ socketPath: daemonSocketPath,
440
+ provider,
441
+ client
442
+ } = await DaemonClient.spawn({
443
+ config: {
444
+ session: args.session,
445
+ experiments: args.experiments,
446
+ browser: args.providerName ? { kind: "provider", providerName: args.providerName } : {
447
+ kind: "launch",
448
+ headed: !args.headless,
449
+ viewport: args.viewport ?? { width: 1366, height: 768 }
450
+ },
451
+ workflow: {
452
+ integrationPath: absoluteIntegrationPath,
453
+ params: args.params,
454
+ visualize: args.visualize,
455
+ stayOpenOnSuccess: args.stayOpenOnSuccess,
456
+ tsconfigPath: args.tsconfigPath,
457
+ authProfileDomain: args.authProfileDomain
458
+ }
459
+ },
460
+ logger,
461
+ logPath: runLogPath,
462
+ startupTimeoutMs: 6e4,
463
+ handlers
432
464
  });
433
- const worker = spawn(
434
- process.execPath,
435
- [
436
- tsxCliPath,
437
- ...args.tsconfigPath ? ["--tsconfig", args.tsconfigPath] : [],
438
- workerEntryPath,
439
- payload
440
- ],
465
+ writeSessionState(
441
466
  {
442
- detached: true,
443
- stdio: "ignore",
444
- env: process.env
445
- }
467
+ port: 0,
468
+ pid,
469
+ cdpEndpoint: provider?.cdpEndpoint,
470
+ session: args.session,
471
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
472
+ status: "active",
473
+ mode: args.accessMode,
474
+ viewport: args.viewport,
475
+ stayOpenOnSuccess: args.stayOpenOnSuccess,
476
+ daemonSocketPath,
477
+ provider: provider ? { name: provider.name, sessionId: provider.sessionId } : void 0
478
+ },
479
+ logger
446
480
  );
447
- worker.unref();
448
- const outcome = await waitForWorkflowOutcome({
449
- session: args.session,
450
- pid: worker.pid ?? 0
451
- });
481
+ if (provider?.liveViewUrl) {
482
+ console.log(`View live session: ${provider.liveViewUrl}`);
483
+ }
484
+ let outcome;
485
+ try {
486
+ outcome = await waitForWorkflowOutcome(pid, workflowOutcome.promise);
487
+ } finally {
488
+ client.destroy();
489
+ }
452
490
  if (outcome.status === "paused") {
453
491
  setSessionStatus(args.session, "paused", logger);
454
492
  console.log("Workflow paused.");
455
- return;
493
+ return "paused";
456
494
  }
457
495
  if (outcome.status === "failed") {
458
496
  setSessionStatus(args.session, "failed", logger);
@@ -467,11 +505,19 @@ Browser is still open. You can use \`exec\` to inspect it. Call \`run\` to re-ru
467
505
  if (outcome.status === "exited") {
468
506
  setSessionStatus(args.session, "exited", logger);
469
507
  throw new Error(
470
- "Workflow process exited before reporting completion or pause during run."
508
+ outcome.message ?? "Workflow process exited before reporting completion or pause during run."
471
509
  );
472
510
  }
473
511
  setSessionStatus(args.session, "completed", logger);
474
512
  console.log("Integration completed.");
513
+ if (args.stayOpenOnSuccess) {
514
+ console.log(
515
+ `Browser is still open for session "${args.session}". Close it with: libretto close --session ${args.session}`
516
+ );
517
+ } else {
518
+ await runClose(args.session, logger);
519
+ }
520
+ return "completed";
475
521
  }
476
522
  function readStdinSync() {
477
523
  if (process.stdin.isTTY === true) return null;
@@ -497,8 +543,8 @@ const execInput = SimpleCLI.input({
497
543
  }
498
544
  }).refine(
499
545
  (input) => input.code !== void 0,
500
- `Usage: libretto exec <code|-> [--session <name>] [--visualize]
501
- echo '<code>' | libretto exec - [--session <name>] [--visualize]`
546
+ `Usage: ${librettoCommand("exec <code|-> [--session <name>] [--visualize]")}
547
+ echo '<code>' | ${librettoCommand("exec - [--session <name>] [--visualize]")}`
502
548
  );
503
549
  const execCommand = SimpleCLI.command({
504
550
  description: "Execute Playwright TypeScript code"
@@ -534,8 +580,8 @@ const readonlyExecInput = SimpleCLI.input({
534
580
  }
535
581
  }).refine(
536
582
  (input) => input.code !== void 0,
537
- `Usage: libretto readonly-exec <code|-> [--session <name>] [--page <id>]
538
- echo '<code>' | libretto readonly-exec - [--session <name>] [--page <id>]`
583
+ `Usage: ${librettoCommand("readonly-exec <code|-> [--session <name>] [--page <id>]")}
584
+ echo '<code>' | ${librettoCommand("readonly-exec - [--session <name>] [--page <id>]")}`
539
585
  );
540
586
  const readonlyExecCommand = SimpleCLI.command({
541
587
  description: "Execute read-only Playwright inspection code"
@@ -552,7 +598,7 @@ const readonlyExecCommand = SimpleCLI.command({
552
598
  mode: "readonly-exec"
553
599
  });
554
600
  });
555
- const runUsage = `Usage: libretto run <integrationFile> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--read-only|--write-access] [--no-visualize] [--viewport WxH]`;
601
+ const runUsage = `Usage: ${librettoCommand("run <integrationFile> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--read-only|--write-access] [--no-visualize] [--stay-open-on-success] [--viewport WxH]")}`;
556
602
  const runInput = SimpleCLI.input({
557
603
  positionals: [
558
604
  SimpleCLI.positional("integrationFile", z.string().optional(), {
@@ -585,6 +631,10 @@ const runInput = SimpleCLI.input({
585
631
  name: "no-visualize",
586
632
  help: "Disable ghost cursor + highlight visualization in headed mode"
587
633
  }),
634
+ stayOpenOnSuccess: SimpleCLI.flag({
635
+ name: "stay-open-on-success",
636
+ help: "Keep the browser session open after the workflow completes successfully"
637
+ }),
588
638
  authProfile: SimpleCLI.option(z.string().optional(), {
589
639
  name: "auth-profile",
590
640
  help: "Domain for local auth profile (e.g. apps.example.com)"
@@ -629,7 +679,7 @@ function resolveRunParams(rawInlineParams, paramsFile) {
629
679
  }
630
680
  const runCommand = SimpleCLI.command({
631
681
  description: "Run the default-exported Libretto workflow from a file"
632
- }).input(runInput).use(withAutoSession()).handle(async ({ input, ctx }) => {
682
+ }).input(runInput).use(withAutoSession()).use(withExperiments()).handle(async ({ input, ctx }) => {
633
683
  warnIfInstalledSkillOutOfDate();
634
684
  await stopExistingFailedRunSession(ctx.session, ctx.logger);
635
685
  assertSessionAvailableForStart(ctx.session, ctx.logger);
@@ -641,63 +691,33 @@ const runCommand = SimpleCLI.command({
641
691
  ctx.logger
642
692
  );
643
693
  const providerName = resolveProviderName(input.provider);
644
- let cdpEndpoint;
645
- let providerInfo;
646
- let provider;
647
- if (providerName !== "local") {
648
- provider = getCloudProviderApi(providerName);
694
+ const daemonProviderName = providerName === "local" ? void 0 : providerName;
695
+ if (daemonProviderName) {
649
696
  console.log(
650
697
  `Creating ${providerName} browser session (session: ${ctx.session})...`
651
698
  );
652
- const providerSession = await provider.createSession();
653
- ctx.logger.info("run-provider-session-created", {
654
- provider: providerName,
655
- sessionId: providerSession.sessionId,
656
- cdpEndpoint: providerSession.cdpEndpoint,
657
- liveViewUrl: providerSession.liveViewUrl
699
+ ctx.logger.info("run-provider-session-requested", {
700
+ provider: providerName
658
701
  });
659
- if (providerSession.liveViewUrl) {
660
- console.log(`View live session: ${providerSession.liveViewUrl}`);
661
- }
662
702
  console.log(`Connecting to ${providerName} browser...`);
663
- cdpEndpoint = providerSession.cdpEndpoint;
664
- providerInfo = {
665
- name: providerName,
666
- sessionId: providerSession.sessionId
667
- };
668
- }
669
- try {
670
- await runIntegrationFromFile(
671
- {
672
- integrationPath: input.integrationFile,
673
- session: ctx.session,
674
- params,
675
- tsconfigPath: input.tsconfig,
676
- headless: cdpEndpoint ? true : headlessMode ?? false,
677
- visualize,
678
- authProfileDomain: input.authProfile,
679
- viewport,
680
- accessMode: input.readOnly ? "read-only" : input.writeAccess ? "write-access" : readLibrettoConfig().sessionMode ?? "write-access",
681
- cdpEndpoint,
682
- provider: providerInfo
683
- },
684
- ctx.logger
685
- );
686
- } finally {
687
- if (provider && providerInfo) {
688
- try {
689
- const result = await provider.closeSession(providerInfo.sessionId);
690
- if (result.replayUrl) {
691
- console.log(`View recording: ${result.replayUrl}`);
692
- }
693
- } catch (cleanupErr) {
694
- console.error(
695
- `Failed to clean up ${providerInfo.name} session ${providerInfo.sessionId}:`,
696
- cleanupErr instanceof Error ? cleanupErr.message : cleanupErr
697
- );
698
- }
699
- }
700
703
  }
704
+ await runIntegrationFromFile(
705
+ {
706
+ integrationPath: input.integrationFile,
707
+ session: ctx.session,
708
+ params,
709
+ tsconfigPath: input.tsconfig,
710
+ headless: daemonProviderName ? true : headlessMode ?? false,
711
+ visualize,
712
+ authProfileDomain: input.authProfile,
713
+ viewport,
714
+ accessMode: input.readOnly ? "read-only" : input.writeAccess ? "write-access" : readLibrettoConfig().sessionMode ?? "write-access",
715
+ providerName: daemonProviderName,
716
+ stayOpenOnSuccess: input.stayOpenOnSuccess,
717
+ experiments: ctx.experiments
718
+ },
719
+ ctx.logger
720
+ );
701
721
  });
702
722
  const resumeInput = SimpleCLI.input({
703
723
  positionals: [],