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,56 +1,80 @@
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 type { LoggerApi } from "../../shared/logger/index.js";
8
6
  import {
9
7
  connect,
10
8
  disconnectBrowser,
9
+ getProfilePath,
10
+ hasProfile,
11
+ normalizeDomain,
12
+ normalizeUrl,
13
+ runClose,
11
14
  resolveViewport,
12
15
  } from "../core/browser.js";
13
16
  import { parseViewportArg } from "./browser.js";
14
- import { getPauseSignalPaths } from "../core/pause-signals.js";
15
17
  import {
16
18
  assertSessionAvailableForStart,
17
19
  assertSessionAllowsCommand,
18
20
  clearSessionState,
21
+ logFileForSession,
19
22
  readSessionState,
20
23
  readSessionStateOrThrow,
21
24
  setSessionStatus,
25
+ writeSessionState,
22
26
  type SessionState,
23
27
  } from "../core/session.js";
24
28
  import { warnIfInstalledSkillOutOfDate } from "../core/skill-version.js";
25
29
  import { readLibrettoConfig } from "../core/config.js";
26
- import { resolveProviderName, getCloudProviderApi } from "../core/providers/index.js";
30
+ import { librettoCommand } from "../../shared/package-manager.js";
31
+ import { renderSnapshotDiff } from "../../shared/snapshot/diff-snapshots.js";
32
+ import { resolveProviderName } from "../core/providers/index.js";
33
+ import { getAbsoluteIntegrationPath } from "../core/workflow-runtime.js";
27
34
  import {
28
35
  compileExecFunction,
29
36
  stripEmptyCatchHandlers,
30
37
  } from "../core/exec-compiler.js";
31
- import { DaemonClient } from "../core/daemon/index.js";
38
+ import {
39
+ DaemonClient,
40
+ type DaemonExecSuccess,
41
+ type DaemonExecResult,
42
+ type DaemonToCliApi,
43
+ } from "../core/daemon/ipc.js";
32
44
  import { createReadonlyExecHelpers } from "../core/readonly-exec.js";
33
45
  import {
34
46
  readActionLog,
35
47
  readNetworkLog,
36
48
  wrapPageForActionLogging,
37
49
  } from "../core/telemetry.js";
38
- import type { RunIntegrationWorkerRequest } from "../workers/run-integration-worker-protocol.js";
50
+ import type { SessionAccessMode } from "../../shared/state/index.js";
51
+ import type { Experiments } from "../core/experiments.js";
39
52
  import { SimpleCLI } from "../framework/simple-cli.js";
40
53
  import {
41
54
  pageOption,
42
55
  sessionOption,
43
56
  withAutoSession,
57
+ withExperiments,
44
58
  withRequiredSession,
45
59
  } from "./shared.js";
46
60
 
47
- type RunIntegrationCommandRequest = RunIntegrationWorkerRequest & {
61
+ type RunIntegrationCommandRequest = {
62
+ integrationPath: string;
63
+ session: string;
64
+ params: unknown;
65
+ headless: boolean;
66
+ visualize: boolean;
67
+ viewport?: { width: number; height: number };
68
+ accessMode: SessionAccessMode;
69
+ authProfileDomain?: string;
70
+ providerName?: string;
71
+ stayOpenOnSuccess: boolean;
48
72
  tsconfigPath?: string;
73
+ experiments: Experiments;
49
74
  };
50
75
  type ExecMode = "exec" | "readonly-exec";
51
76
 
52
77
  const require = moduleBuiltin.createRequire(import.meta.url);
53
- const tsxCliPath = require.resolve("tsx/cli");
54
78
 
55
79
  function writeDaemonExecOutput(output?: { stdout: string; stderr: string }) {
56
80
  if (output?.stdout) {
@@ -61,6 +85,16 @@ function writeDaemonExecOutput(output?: { stdout: string; stderr: string }) {
61
85
  }
62
86
  }
63
87
 
88
+ function writeDaemonSnapshotDiff(
89
+ snapshotDiff: DaemonExecSuccess["snapshotDiff"],
90
+ ) {
91
+ if (!snapshotDiff) return;
92
+ const renderedDiff = renderSnapshotDiff(snapshotDiff);
93
+ if (!renderedDiff) return;
94
+ console.log("Page changes:");
95
+ console.log(renderedDiff);
96
+ }
97
+
64
98
  async function execViaDaemon(
65
99
  code: string,
66
100
  session: string,
@@ -86,26 +120,30 @@ async function execViaDaemon(
86
120
  via: "daemon",
87
121
  });
88
122
 
89
- const client = new DaemonClient(daemonSocketPath);
90
-
91
- const response =
92
- mode === "exec"
93
- ? await client.exec({
94
- code: cleanedCode,
95
- pageId: options.pageId,
96
- visualize: options.visualize,
97
- })
98
- : await client.readonlyExec({
99
- code: cleanedCode,
100
- pageId: options.pageId,
101
- });
123
+ const client = await DaemonClient.connect(daemonSocketPath);
124
+ let response: DaemonExecResult;
125
+ try {
126
+ response =
127
+ mode === "exec"
128
+ ? await client.exec({
129
+ code: cleanedCode,
130
+ pageId: options.pageId,
131
+ visualize: options.visualize,
132
+ })
133
+ : await client.readonlyExec({
134
+ code: cleanedCode,
135
+ pageId: options.pageId,
136
+ });
137
+ } finally {
138
+ client.destroy();
139
+ }
102
140
 
103
141
  if (!response.ok) {
104
142
  writeDaemonExecOutput(response.output);
105
143
  throw new Error(response.message);
106
144
  }
107
145
 
108
- const { result, output } = response.data;
146
+ const { result, output, snapshotDiff } = response.data;
109
147
  writeDaemonExecOutput(output);
110
148
 
111
149
  logger.info(`${mode}-success`, {
@@ -120,6 +158,7 @@ async function execViaDaemon(
120
158
  } else {
121
159
  console.log("Executed successfully");
122
160
  }
161
+ writeDaemonSnapshotDiff(snapshotDiff);
123
162
  }
124
163
 
125
164
  async function execViaCdpFallback(
@@ -276,10 +315,9 @@ async function runExec(
276
315
  ): Promise<void> {
277
316
  const state = readSessionStateOrThrow(session);
278
317
  if (!state.daemonSocketPath) {
279
- // Compatibility fallback for failed runs created before `run` became
280
- // daemon-backed: those session states can have a live CDP endpoint/port but
281
- // no daemon socket. Keep `exec` inspection working until such sessions are
282
- // gone. Context: https://www.notion.so/Make-libretto-run-daemon-backed-for-failed-workflow-inspection-352ac9fb35f181c1b7d3f08c0a735e9d
318
+ // Compatibility fallback for older sessions that predate daemon-backed
319
+ // command handling. Keep `exec` inspection working when state has a live
320
+ // CDP endpoint/port but no daemon socket.
283
321
  logger.warn(`${options.mode ?? "exec"}-daemon-socket-missing-cdp-fallback`, {
284
322
  session,
285
323
  hasCdpEndpoint: Boolean(state.cdpEndpoint),
@@ -322,9 +360,23 @@ async function stopExistingFailedRunSession(
322
360
  pid: existingState.pid,
323
361
  port: existingState.port,
324
362
  });
325
- clearSessionState(session, logger);
363
+ if (existingState.pid == null) {
364
+ clearSessionState(session, logger);
365
+ return;
366
+ }
326
367
 
327
- if (existingState.pid == null) return;
368
+ try {
369
+ process.kill(existingState.pid, "SIGTERM");
370
+ } catch (error) {
371
+ const code = (error as NodeJS.ErrnoException).code;
372
+ if (code !== "ESRCH") {
373
+ logger.warn("run-release-existing-failed-session-signal-failed", {
374
+ session,
375
+ pid: existingState.pid,
376
+ error,
377
+ });
378
+ }
379
+ }
328
380
 
329
381
  const stopDeadline = Date.now() + 3_000;
330
382
  while (isProcessRunning(existingState.pid) && Date.now() < stopDeadline) {
@@ -335,69 +387,17 @@ async function stopExistingFailedRunSession(
335
387
  session,
336
388
  pid: existingState.pid,
337
389
  });
338
- console.warn(
339
- `Existing failed workflow process for session "${session}" (pid ${existingState.pid}) is still shutting down; continuing.`,
390
+ throw new Error(
391
+ `Existing failed workflow process for session "${session}" (pid ${existingState.pid}) is still running. Close it with: ${librettoCommand(`close --session ${session}`)}`,
340
392
  );
341
- return;
342
393
  }
394
+ clearSessionState(session, logger);
343
395
  console.log(
344
396
  `Closed existing failed workflow process for session "${session}" (pid ${existingState.pid}).`,
345
397
  );
346
398
  }
347
399
 
348
- function readJsonFileIfExists(path: string): unknown {
349
- if (!existsSync(path)) return null;
350
- try {
351
- return JSON.parse(readFileSync(path, "utf8")) as unknown;
352
- } catch {
353
- return null;
354
- }
355
- }
356
-
357
- function readFailureDetails(path: string): {
358
- message?: string;
359
- phase?: "setup" | "workflow";
360
- } | null {
361
- const raw = readJsonFileIfExists(path);
362
- if (!raw || typeof raw !== "object") return null;
363
-
364
- const message = (raw as { message?: unknown }).message;
365
- const phase = (raw as { phase?: unknown }).phase;
366
-
367
- return {
368
- message: typeof message === "string" ? message : undefined,
369
- phase: phase === "setup" || phase === "workflow" ? phase : undefined,
370
- };
371
- }
372
-
373
- async function waitForFailureDetails(
374
- path: string,
375
- timeoutMs = 1_000,
376
- ): Promise<{
377
- message?: string;
378
- phase?: "setup" | "workflow";
379
- } | null> {
380
- const deadline = Date.now() + timeoutMs;
381
- while (Date.now() < deadline) {
382
- const details = readFailureDetails(path);
383
- if (details?.message) return details;
384
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
385
- }
386
- return readFailureDetails(path);
387
- }
388
-
389
- function streamOutputSince(path: string, offset: number): number {
390
- if (!existsSync(path)) return offset;
391
- const output = readFileSync(path);
392
- if (output.length <= offset) return output.length;
393
- process.stdout.write(output.subarray(offset));
394
- return output.length;
395
- }
396
-
397
- type WaitForWorkflowOutcomeArgs = {
398
- session: string;
399
- pid: number;
400
- };
400
+ type RunIntegrationResult = "completed" | "paused";
401
401
 
402
402
  type WorkflowOutcome = {
403
403
  status: "completed" | "paused" | "failed" | "exited";
@@ -405,70 +405,68 @@ type WorkflowOutcome = {
405
405
  phase?: "setup" | "workflow";
406
406
  };
407
407
 
408
- function clearSignalIfExists(path: string): void {
409
- if (!existsSync(path)) return;
410
- try {
411
- unlinkSync(path);
412
- } catch {
413
- // Ignore cleanup failures; next checks still validate actual state.
414
- }
415
- }
416
-
417
- async function waitForWorkflowOutcome(
418
- args: WaitForWorkflowOutcomeArgs,
419
- ): Promise<WorkflowOutcome> {
420
- const signalPaths = getPauseSignalPaths(args.session);
421
- if (args.pid <= 0) {
422
- return { status: "exited" };
423
- }
424
- let outputOffset = 0;
408
+ type Deferred<T> = {
409
+ promise: Promise<T>;
410
+ resolve(value: T): void;
411
+ };
425
412
 
426
- while (true) {
427
- outputOffset = streamOutputSince(
428
- signalPaths.outputSignalPath,
429
- outputOffset,
430
- );
413
+ function createDeferred<T>(): Deferred<T> {
414
+ let resolve!: (value: T) => void;
415
+ const promise = new Promise<T>((resolvePromise) => {
416
+ resolve = resolvePromise;
417
+ });
418
+ return { promise, resolve };
419
+ }
431
420
 
432
- if (existsSync(signalPaths.failedSignalPath)) {
433
- outputOffset = streamOutputSince(
434
- signalPaths.outputSignalPath,
435
- outputOffset,
436
- );
437
- const failureDetails = await waitForFailureDetails(
438
- signalPaths.failedSignalPath,
439
- );
440
- return {
421
+ function createWorkflowHandlers(
422
+ settleOutcome: (outcome: WorkflowOutcome) => void,
423
+ ): DaemonToCliApi {
424
+ return {
425
+ workflowOutput: (event) => {
426
+ const stream =
427
+ event.stream === "stdout" ? process.stdout : process.stderr;
428
+ stream.write(event.text);
429
+ },
430
+ workflowPaused: () => {
431
+ settleOutcome({ status: "paused" });
432
+ },
433
+ workflowFinished: (event) => {
434
+ if (event.result === "completed") {
435
+ settleOutcome({ status: "completed" });
436
+ return;
437
+ }
438
+ settleOutcome({
441
439
  status: "failed",
442
- message: failureDetails?.message,
443
- phase: failureDetails?.phase,
444
- };
445
- }
440
+ message: event.message,
441
+ phase: event.phase,
442
+ });
443
+ },
444
+ };
445
+ }
446
446
 
447
- if (existsSync(signalPaths.completedSignalPath)) {
448
- outputOffset = streamOutputSince(
449
- signalPaths.outputSignalPath,
450
- outputOffset,
451
- );
452
- return { status: "completed" };
453
- }
447
+ async function waitForWorkflowOutcome(
448
+ pid: number,
449
+ outcomePromise: Promise<WorkflowOutcome>,
450
+ ): Promise<WorkflowOutcome> {
451
+ let processExitInterval: ReturnType<typeof setInterval> | undefined;
454
452
 
455
- if (existsSync(signalPaths.pausedSignalPath)) {
456
- outputOffset = streamOutputSince(
457
- signalPaths.outputSignalPath,
458
- outputOffset,
459
- );
460
- return { status: "paused" };
453
+ const processExitPromise = new Promise<WorkflowOutcome>((resolve) => {
454
+ if (pid <= 0 || !isProcessRunning(pid)) {
455
+ resolve({ status: "exited" });
456
+ return;
461
457
  }
462
458
 
463
- if (!isProcessRunning(args.pid)) {
464
- outputOffset = streamOutputSince(
465
- signalPaths.outputSignalPath,
466
- outputOffset,
467
- );
468
- return { status: "exited" };
469
- }
459
+ processExitInterval = setInterval(() => {
460
+ if (!isProcessRunning(pid)) {
461
+ resolve({ status: "exited" });
462
+ }
463
+ }, 250);
464
+ });
470
465
 
471
- await new Promise((resolveWait) => setTimeout(resolveWait, 250));
466
+ try {
467
+ return await Promise.race([outcomePromise, processExitPromise]);
468
+ } finally {
469
+ if (processExitInterval) clearInterval(processExitInterval);
472
470
  }
473
471
  }
474
472
 
@@ -477,56 +475,63 @@ async function runResume(
477
475
  logger: LoggerApi,
478
476
  sessionState: SessionState,
479
477
  ): Promise<void> {
480
- const {
481
- pausedSignalPath,
482
- resumeSignalPath,
483
- completedSignalPath,
484
- failedSignalPath,
485
- outputSignalPath,
486
- } = getPauseSignalPaths(session);
487
-
488
- if (!existsSync(pausedSignalPath)) {
478
+ if (sessionState.pid == null || !isProcessRunning(sessionState.pid)) {
489
479
  throw new Error(
490
- `Session "${session}" is not paused. Run "libretto run ... --session ${session}" and call pause("${session}") first.`,
480
+ `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid ?? "unknown"} is not running).`,
491
481
  );
492
482
  }
493
483
 
494
- if (sessionState.pid == null || !isProcessRunning(sessionState.pid)) {
484
+ if (!sessionState.daemonSocketPath) {
495
485
  throw new Error(
496
- `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid ?? "unknown"} is not running).`,
486
+ `No active paused workflow found for session "${session}" (daemon socket is missing).`,
497
487
  );
498
488
  }
499
489
 
500
- // Clear stale pause/output markers before signaling resume so we always wait
501
- // for the next pause/completion and only stream post-resume logs.
502
- clearSignalIfExists(pausedSignalPath);
503
- clearSignalIfExists(outputSignalPath);
504
- clearSignalIfExists(completedSignalPath);
505
- clearSignalIfExists(failedSignalPath);
506
- setSessionStatus(session, "active", logger);
490
+ const workflowOutcome = createDeferred<WorkflowOutcome>();
491
+ const handlers = createWorkflowHandlers(workflowOutcome.resolve);
492
+ let client: DaemonClient;
493
+ try {
494
+ client = await DaemonClient.connect(
495
+ sessionState.daemonSocketPath,
496
+ handlers,
497
+ );
498
+ } catch {
499
+ throw new Error(
500
+ `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid} is not running).`,
501
+ );
502
+ }
507
503
 
508
- writeFileSync(
509
- resumeSignalPath,
510
- JSON.stringify(
511
- {
512
- resumedAt: new Date().toISOString(),
513
- sourcePid: process.pid,
514
- },
515
- null,
516
- 2,
517
- ),
518
- "utf8",
519
- );
520
- console.log(`Resume signal sent for session "${session}".`);
504
+ let outcome: WorkflowOutcome;
505
+ try {
506
+ const status = await client.getWorkflowStatus();
507
+ if (status.state !== "paused") {
508
+ throw new Error(
509
+ `Session "${session}" is not paused. Run "${librettoCommand(`run ... --session ${session}`)}" and call pause("${session}") first.`,
510
+ );
511
+ }
521
512
 
522
- const outcome = await waitForWorkflowOutcome({
523
- session,
524
- pid: sessionState.pid!,
525
- });
513
+ await client.resumeWorkflow();
514
+ setSessionStatus(session, "active", logger);
515
+ console.log(`Resume requested for session "${session}".`);
516
+
517
+ outcome = await waitForWorkflowOutcome(
518
+ sessionState.pid!,
519
+ workflowOutcome.promise,
520
+ );
521
+ } finally {
522
+ client.destroy();
523
+ }
526
524
 
527
525
  if (outcome.status === "completed") {
528
526
  setSessionStatus(session, "completed", logger);
529
527
  console.log("Integration completed.");
528
+ if (sessionState.stayOpenOnSuccess) {
529
+ console.log(
530
+ `Browser is still open for session "${session}". Close it with: libretto close --session ${session}`,
531
+ );
532
+ } else {
533
+ await runClose(session, logger);
534
+ }
530
535
  return;
531
536
  }
532
537
  if (outcome.status === "failed") {
@@ -540,7 +545,8 @@ async function runResume(
540
545
  if (outcome.status === "exited") {
541
546
  setSessionStatus(session, "exited", logger);
542
547
  throw new Error(
543
- `Workflow process for session "${session}" exited before reporting completion or pause.`,
548
+ outcome.message ??
549
+ `Workflow process for session "${session}" exited before reporting completion or pause.`,
544
550
  );
545
551
  }
546
552
  setSessionStatus(session, "paused", logger);
@@ -550,53 +556,95 @@ async function runResume(
550
556
  async function runIntegrationFromFile(
551
557
  args: RunIntegrationCommandRequest,
552
558
  logger: LoggerApi,
553
- ): Promise<void> {
559
+ ): Promise<RunIntegrationResult> {
554
560
  await stopExistingFailedRunSession(args.session, logger);
555
- const signalPaths = getPauseSignalPaths(args.session);
556
- clearSignalIfExists(signalPaths.pausedSignalPath);
557
- clearSignalIfExists(signalPaths.resumeSignalPath);
558
- clearSignalIfExists(signalPaths.completedSignalPath);
559
- clearSignalIfExists(signalPaths.failedSignalPath);
560
- clearSignalIfExists(signalPaths.outputSignalPath);
561
-
562
- const workerEntryPath = fileURLToPath(
563
- new URL("../workers/run-integration-worker.js", import.meta.url),
561
+
562
+ const absoluteIntegrationPath = getAbsoluteIntegrationPath(
563
+ args.integrationPath,
564
564
  );
565
- const payload = JSON.stringify({
566
- integrationPath: args.integrationPath,
567
- session: args.session,
568
- params: args.params,
569
- headless: args.headless,
570
- visualize: args.visualize,
571
- authProfileDomain: args.authProfileDomain,
572
- viewport: args.viewport,
573
- accessMode: args.accessMode,
574
- cdpEndpoint: args.cdpEndpoint,
575
- provider: args.provider,
576
- } satisfies RunIntegrationWorkerRequest);
577
- const worker = spawn(
578
- process.execPath,
579
- [
580
- tsxCliPath,
581
- ...(args.tsconfigPath ? ["--tsconfig", args.tsconfigPath] : []),
582
- workerEntryPath,
583
- payload,
584
- ],
565
+ if (args.authProfileDomain) {
566
+ const normalizedDomain = normalizeDomain(normalizeUrl(args.authProfileDomain));
567
+ if (!hasProfile(normalizedDomain)) {
568
+ const profilePath = getProfilePath(normalizedDomain);
569
+ throw new Error(
570
+ [
571
+ `Local auth profile not found for domain "${normalizedDomain}".`,
572
+ `Expected profile file: ${profilePath}`,
573
+ "To create it:",
574
+ ` 1. ${librettoCommand(`open https://${normalizedDomain} --headed --session ${args.session}`)}`,
575
+ " 2. Log in manually in the browser window.",
576
+ ` 3. ${librettoCommand(`save ${normalizedDomain} --session ${args.session}`)}`,
577
+ ].join("\n"),
578
+ );
579
+ }
580
+ }
581
+
582
+ const runLogPath = logFileForSession(args.session);
583
+ const workflowOutcome = createDeferred<WorkflowOutcome>();
584
+ const handlers = createWorkflowHandlers(workflowOutcome.resolve);
585
+ const {
586
+ pid,
587
+ socketPath: daemonSocketPath,
588
+ provider,
589
+ client,
590
+ } = await DaemonClient.spawn({
591
+ config: {
592
+ session: args.session,
593
+ experiments: args.experiments,
594
+ browser: args.providerName
595
+ ? { kind: "provider", providerName: args.providerName }
596
+ : {
597
+ kind: "launch",
598
+ headed: !args.headless,
599
+ viewport: args.viewport ?? { width: 1366, height: 768 },
600
+ },
601
+ workflow: {
602
+ integrationPath: absoluteIntegrationPath,
603
+ params: args.params,
604
+ visualize: args.visualize,
605
+ stayOpenOnSuccess: args.stayOpenOnSuccess,
606
+ tsconfigPath: args.tsconfigPath,
607
+ authProfileDomain: args.authProfileDomain,
608
+ },
609
+ },
610
+ logger,
611
+ logPath: runLogPath,
612
+ startupTimeoutMs: 60_000,
613
+ handlers,
614
+ });
615
+
616
+ writeSessionState(
585
617
  {
586
- detached: true,
587
- stdio: "ignore",
588
- env: process.env,
618
+ port: 0,
619
+ pid,
620
+ cdpEndpoint: provider?.cdpEndpoint,
621
+ session: args.session,
622
+ startedAt: new Date().toISOString(),
623
+ status: "active",
624
+ mode: args.accessMode,
625
+ viewport: args.viewport,
626
+ stayOpenOnSuccess: args.stayOpenOnSuccess,
627
+ daemonSocketPath,
628
+ provider: provider
629
+ ? { name: provider.name, sessionId: provider.sessionId }
630
+ : undefined,
589
631
  },
632
+ logger,
590
633
  );
591
- worker.unref();
592
- const outcome = await waitForWorkflowOutcome({
593
- session: args.session,
594
- pid: worker.pid ?? 0,
595
- });
634
+ if (provider?.liveViewUrl) {
635
+ console.log(`View live session: ${provider.liveViewUrl}`);
636
+ }
637
+
638
+ let outcome: WorkflowOutcome;
639
+ try {
640
+ outcome = await waitForWorkflowOutcome(pid, workflowOutcome.promise);
641
+ } finally {
642
+ client.destroy();
643
+ }
596
644
  if (outcome.status === "paused") {
597
645
  setSessionStatus(args.session, "paused", logger);
598
646
  console.log("Workflow paused.");
599
- return;
647
+ return "paused";
600
648
  }
601
649
  if (outcome.status === "failed") {
602
650
  setSessionStatus(args.session, "failed", logger);
@@ -610,11 +658,20 @@ async function runIntegrationFromFile(
610
658
  if (outcome.status === "exited") {
611
659
  setSessionStatus(args.session, "exited", logger);
612
660
  throw new Error(
613
- "Workflow process exited before reporting completion or pause during run.",
661
+ outcome.message ??
662
+ "Workflow process exited before reporting completion or pause during run.",
614
663
  );
615
664
  }
616
665
  setSessionStatus(args.session, "completed", logger);
617
666
  console.log("Integration completed.");
667
+ if (args.stayOpenOnSuccess) {
668
+ console.log(
669
+ `Browser is still open for session "${args.session}". Close it with: libretto close --session ${args.session}`,
670
+ );
671
+ } else {
672
+ await runClose(args.session, logger);
673
+ }
674
+ return "completed";
618
675
  }
619
676
 
620
677
  function readStdinSync(): string | null {
@@ -642,7 +699,7 @@ export const execInput = SimpleCLI.input({
642
699
  },
643
700
  }).refine(
644
701
  (input) => input.code !== undefined,
645
- `Usage: libretto exec <code|-> [--session <name>] [--visualize]\n echo '<code>' | libretto exec - [--session <name>] [--visualize]`,
702
+ `Usage: ${librettoCommand("exec <code|-> [--session <name>] [--visualize]")}\n echo '<code>' | ${librettoCommand("exec - [--session <name>] [--visualize]")}`,
646
703
  );
647
704
 
648
705
  export const execCommand = SimpleCLI.command({
@@ -683,7 +740,7 @@ export const readonlyExecInput = SimpleCLI.input({
683
740
  },
684
741
  }).refine(
685
742
  (input) => input.code !== undefined,
686
- `Usage: libretto readonly-exec <code|-> [--session <name>] [--page <id>]\n echo '<code>' | libretto readonly-exec - [--session <name>] [--page <id>]`,
743
+ `Usage: ${librettoCommand("readonly-exec <code|-> [--session <name>] [--page <id>]")}\n echo '<code>' | ${librettoCommand("readonly-exec - [--session <name>] [--page <id>]")}`,
687
744
  );
688
745
 
689
746
  export const readonlyExecCommand = SimpleCLI.command({
@@ -705,7 +762,7 @@ export const readonlyExecCommand = SimpleCLI.command({
705
762
  });
706
763
  });
707
764
 
708
- const runUsage = `Usage: libretto run <integrationFile> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--read-only|--write-access] [--no-visualize] [--viewport WxH]`;
765
+ 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]")}`;
709
766
 
710
767
  export const runInput = SimpleCLI.input({
711
768
  positionals: [
@@ -739,6 +796,10 @@ export const runInput = SimpleCLI.input({
739
796
  name: "no-visualize",
740
797
  help: "Disable ghost cursor + highlight visualization in headed mode",
741
798
  }),
799
+ stayOpenOnSuccess: SimpleCLI.flag({
800
+ name: "stay-open-on-success",
801
+ help: "Keep the browser session open after the workflow completes successfully",
802
+ }),
742
803
  authProfile: SimpleCLI.option(z.string().optional(), {
743
804
  name: "auth-profile",
744
805
  help: "Domain for local auth profile (e.g. apps.example.com)",
@@ -795,6 +856,7 @@ export const runCommand = SimpleCLI.command({
795
856
  })
796
857
  .input(runInput)
797
858
  .use(withAutoSession())
859
+ .use(withExperiments())
798
860
  .handle(async ({ input, ctx }) => {
799
861
  warnIfInstalledSkillOutOfDate();
800
862
  await stopExistingFailedRunSession(ctx.session, ctx.logger);
@@ -813,64 +875,34 @@ export const runCommand = SimpleCLI.command({
813
875
  );
814
876
 
815
877
  const providerName = resolveProviderName(input.provider);
816
- let cdpEndpoint: string | undefined;
817
- let providerInfo: { name: string; sessionId: string } | undefined;
818
- let provider: ReturnType<typeof getCloudProviderApi> | undefined;
819
- if (providerName !== "local") {
820
- provider = getCloudProviderApi(providerName);
878
+ const daemonProviderName = providerName === "local" ? undefined : providerName;
879
+ if (daemonProviderName) {
821
880
  console.log(
822
881
  `Creating ${providerName} browser session (session: ${ctx.session})...`,
823
882
  );
824
- const providerSession = await provider.createSession();
825
- ctx.logger.info("run-provider-session-created", {
883
+ ctx.logger.info("run-provider-session-requested", {
826
884
  provider: providerName,
827
- sessionId: providerSession.sessionId,
828
- cdpEndpoint: providerSession.cdpEndpoint,
829
- liveViewUrl: providerSession.liveViewUrl,
830
885
  });
831
- if (providerSession.liveViewUrl) {
832
- console.log(`View live session: ${providerSession.liveViewUrl}`);
833
- }
834
886
  console.log(`Connecting to ${providerName} browser...`);
835
- cdpEndpoint = providerSession.cdpEndpoint;
836
- providerInfo = {
837
- name: providerName,
838
- sessionId: providerSession.sessionId,
839
- };
840
887
  }
841
888
 
842
- try {
843
- await runIntegrationFromFile(
844
- {
845
- integrationPath: input.integrationFile!,
846
- session: ctx.session,
847
- params,
848
- tsconfigPath: input.tsconfig,
849
- headless: cdpEndpoint ? true : (headlessMode ?? false),
850
- visualize,
851
- authProfileDomain: input.authProfile,
852
- viewport,
853
- accessMode: input.readOnly ? "read-only" : input.writeAccess ? "write-access" : (readLibrettoConfig().sessionMode ?? "write-access"),
854
- cdpEndpoint,
855
- provider: providerInfo,
856
- },
857
- ctx.logger,
858
- );
859
- } finally {
860
- if (provider && providerInfo) {
861
- try {
862
- const result = await provider.closeSession(providerInfo.sessionId);
863
- if (result.replayUrl) {
864
- console.log(`View recording: ${result.replayUrl}`);
865
- }
866
- } catch (cleanupErr) {
867
- console.error(
868
- `Failed to clean up ${providerInfo.name} session ${providerInfo.sessionId}:`,
869
- cleanupErr instanceof Error ? cleanupErr.message : cleanupErr,
870
- );
871
- }
872
- }
873
- }
889
+ await runIntegrationFromFile(
890
+ {
891
+ integrationPath: input.integrationFile!,
892
+ session: ctx.session,
893
+ params,
894
+ tsconfigPath: input.tsconfig,
895
+ headless: daemonProviderName ? true : (headlessMode ?? false),
896
+ visualize,
897
+ authProfileDomain: input.authProfile,
898
+ viewport,
899
+ accessMode: input.readOnly ? "read-only" : input.writeAccess ? "write-access" : (readLibrettoConfig().sessionMode ?? "write-access"),
900
+ providerName: daemonProviderName,
901
+ stayOpenOnSuccess: input.stayOpenOnSuccess,
902
+ experiments: ctx.experiments,
903
+ },
904
+ ctx.logger,
905
+ );
874
906
  });
875
907
 
876
908
  export const resumeInput = SimpleCLI.input({