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,7 +1,13 @@
1
1
  import type { Page } from "playwright";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync } from "node:fs";
3
3
  import type { LoggerApi } from "../../../shared/logger/index.js";
4
4
  import { getSessionSnapshotRunDir } from "../context.js";
5
+ import {
6
+ snapshot,
7
+ type Snapshot,
8
+ } from "../../../shared/snapshot/capture-snapshot.js";
9
+ import { waitForPageStable } from "../../../shared/snapshot/wait-for-page-stable.js";
10
+ import { librettoCommand } from "../../../shared/package-manager.js";
5
11
  import {
6
12
  resolveSnapshotViewport,
7
13
  readSnapshotViewportMetrics,
@@ -12,18 +18,75 @@ import {
12
18
 
13
19
  const RENDER_SETTLE_TIMEOUT_MS = 10_000;
14
20
 
15
- export async function handleSnapshot(
21
+ type SnapshotScreenshot = {
22
+ pngPath: string;
23
+ snapshotRunId: string;
24
+ pageUrl: string;
25
+ title: string;
26
+ };
27
+
28
+ export async function handleCompactSnapshot(
16
29
  targetPage: Page,
17
30
  session: string,
18
31
  logger: LoggerApi,
19
- pageId?: string,
32
+ options: {
33
+ pageId?: string;
34
+ cachedSnapshot?: Snapshot | null;
35
+ useCachedSnapshot?: boolean;
36
+ } = {},
20
37
  ): Promise<{
38
+ mode: "compact";
21
39
  pngPath: string;
22
- htmlPath: string;
23
- snapshotRunId: string;
24
- pageUrl: string;
25
- title: string;
40
+ snapshot: Snapshot;
26
41
  }> {
42
+ if (options.useCachedSnapshot) {
43
+ if (!options.cachedSnapshot) {
44
+ throw new Error(
45
+ `No compact snapshot is cached for session "${session}". Run ${librettoCommand(`snapshot --session ${session}`)} first.`,
46
+ );
47
+ }
48
+ const screenshot = await captureSnapshotScreenshot(
49
+ targetPage,
50
+ session,
51
+ logger,
52
+ options.pageId,
53
+ );
54
+ return {
55
+ mode: "compact",
56
+ pngPath: screenshot.pngPath,
57
+ snapshot: options.cachedSnapshot,
58
+ };
59
+ }
60
+
61
+ const waitResult = await waitForPageStable(targetPage);
62
+ if (!waitResult.ok) {
63
+ logger.warn("compact-snapshot-stability-wait-incomplete", {
64
+ session,
65
+ pageId: options.pageId,
66
+ diagnostics: waitResult.diagnostics,
67
+ });
68
+ }
69
+
70
+ const screenshot = await captureSnapshotScreenshot(
71
+ targetPage,
72
+ session,
73
+ logger,
74
+ options.pageId,
75
+ );
76
+
77
+ return {
78
+ mode: "compact",
79
+ pngPath: screenshot.pngPath,
80
+ snapshot: await snapshot(targetPage),
81
+ };
82
+ }
83
+
84
+ async function captureSnapshotScreenshot(
85
+ targetPage: Page,
86
+ session: string,
87
+ logger: LoggerApi,
88
+ pageId?: string,
89
+ ): Promise<SnapshotScreenshot> {
27
90
  const snapshotRunId = `snapshot-${Date.now()}`;
28
91
  const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
29
92
  mkdirSync(snapshotRunDir, { recursive: true });
@@ -45,7 +108,6 @@ export async function handleSnapshot(
45
108
  }
46
109
 
47
110
  const pngPath = `${snapshotRunDir}/page.png`;
48
- const htmlPath = `${snapshotRunDir}/page.html`;
49
111
 
50
112
  // Wait for network to settle before capturing.
51
113
  await Promise.race([
@@ -91,22 +153,16 @@ export async function handleSnapshot(
91
153
  await targetPage.screenshot({ path: pngPath });
92
154
  }
93
155
 
94
- // Capture HTML content.
95
- const htmlContent = await targetPage.content();
96
- writeFileSync(htmlPath, htmlContent);
97
-
98
- logger.info("screenshot-success", {
156
+ logger.info("screenshot-captured", {
99
157
  session,
100
158
  pageUrl,
101
159
  title,
102
160
  pngPath,
103
- htmlPath,
104
161
  snapshotRunId,
105
162
  });
106
163
 
107
164
  return {
108
165
  pngPath,
109
- htmlPath,
110
166
  snapshotRunId,
111
167
  pageUrl: pageUrl ?? "",
112
168
  title: title ?? "",
@@ -0,0 +1,56 @@
1
+ import {
2
+ readLibrettoConfig,
3
+ type LibrettoConfig,
4
+ writeLibrettoConfig,
5
+ } from "./config.js";
6
+
7
+ export type ExperimentMetadata = {
8
+ title: string;
9
+ oneSentenceDescription: string;
10
+ docs?: string;
11
+ defaultValue: boolean;
12
+ };
13
+
14
+ export const EXPERIMENTS: Readonly<Record<string, ExperimentMetadata>> = {};
15
+
16
+ export type ExperimentName = string;
17
+ export type Experiments = Record<ExperimentName, boolean>;
18
+
19
+ export function isExperimentName(name: string): name is ExperimentName {
20
+ return Object.hasOwn(EXPERIMENTS, name);
21
+ }
22
+
23
+ export function resolveExperiments(
24
+ config: LibrettoConfig = readLibrettoConfig(),
25
+ ): Experiments {
26
+ return Object.fromEntries(
27
+ Object.entries(EXPERIMENTS).map(([name, metadata]) => [
28
+ name,
29
+ config.experiments?.[name] ?? metadata.defaultValue,
30
+ ]),
31
+ ) as Experiments;
32
+ }
33
+
34
+ export function setExperimentEnabled(
35
+ name: string,
36
+ enabled: boolean,
37
+ configPath?: string,
38
+ ): Experiments {
39
+ if (!isExperimentName(name)) {
40
+ throw new Error(`Unknown experiment "${name}".`);
41
+ }
42
+
43
+ const config = readLibrettoConfig(configPath);
44
+ const writtenConfig = writeLibrettoConfig(
45
+ {
46
+ ...config,
47
+ experiments: {
48
+ ...config.experiments,
49
+ [name]: enabled,
50
+ },
51
+ },
52
+ configPath,
53
+ );
54
+
55
+ return resolveExperiments(writtenConfig);
56
+ }
@@ -105,6 +105,7 @@ async function getProviderModel(
105
105
  if (!apiKey) {
106
106
  throw new Error(missingProviderCredentialsMessage(provider));
107
107
  }
108
+ // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
108
109
  const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
109
110
  const google = createGoogleGenerativeAI({ apiKey });
110
111
  return google(modelId);
@@ -114,6 +115,7 @@ async function getProviderModel(
114
115
  if (!project) {
115
116
  throw new Error(missingProviderCredentialsMessage(provider));
116
117
  }
118
+ // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
117
119
  const { createVertex } = await import("@ai-sdk/google-vertex");
118
120
  const vertex = createVertex({
119
121
  project,
@@ -126,6 +128,7 @@ async function getProviderModel(
126
128
  if (!apiKey) {
127
129
  throw new Error(missingProviderCredentialsMessage(provider));
128
130
  }
131
+ // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
129
132
  const { createAnthropic } = await import("@ai-sdk/anthropic");
130
133
  const anthropic = createAnthropic({ apiKey });
131
134
  return anthropic(modelId);
@@ -135,6 +138,7 @@ async function getProviderModel(
135
138
  if (!apiKey) {
136
139
  throw new Error(missingProviderCredentialsMessage(provider));
137
140
  }
141
+ // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
138
142
  const { createOpenAI } = await import("@ai-sdk/openai");
139
143
  const openai = createOpenAI({ apiKey });
140
144
  return openai(modelId);
@@ -144,6 +148,7 @@ async function getProviderModel(
144
148
  if (!apiKey) {
145
149
  throw new Error(missingProviderCredentialsMessage(provider));
146
150
  }
151
+ // @lintc-ignore Human-approved: we don't want to import unless the user is using that subagent.
147
152
  const { createOpenAI } = await import("@ai-sdk/openai");
148
153
  const openrouter = createOpenAI({
149
154
  apiKey,
@@ -22,6 +22,7 @@ import {
22
22
  type SessionStatus,
23
23
  type SessionState,
24
24
  } from "../../shared/state/index.js";
25
+ import { librettoCommand } from "../../shared/package-manager.js";
25
26
 
26
27
  const SESSION_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
27
28
 
@@ -154,7 +155,7 @@ function throwSessionNotFoundError(session: string): never {
154
155
  }
155
156
  lines.push("");
156
157
  lines.push("Start one with:");
157
- lines.push(` libretto open <url> --session ${session}`);
158
+ lines.push(` ${librettoCommand(`open <url> --session ${session}`)}`);
158
159
  throw new Error(lines.join("\n"));
159
160
  }
160
161
 
@@ -230,7 +231,7 @@ export function assertSessionAllowsCommand(
230
231
 
231
232
  const supportedModes = [...allowedModes].join(", ");
232
233
  throw new Error(
233
- `Command "${commandName}" is blocked for session "${state.session}" because it is in ${mode} mode. Allowed modes for this command: ${supportedModes}. Run \`libretto session-mode write-access --session ${state.session}\` to unlock the session.`,
234
+ `Command "${commandName}" is blocked for session "${state.session}" because it is in ${mode} mode. Allowed modes for this command: ${supportedModes}. Run \`${librettoCommand(`session-mode write-access --session ${state.session}`)}\` to unlock the session.`,
234
235
  );
235
236
  }
236
237
 
@@ -281,7 +282,7 @@ export function assertSessionAvailableForStart(
281
282
  // if they have a provider field with a cdpEndpoint.
282
283
  if (existingState.provider && existingState.cdpEndpoint) {
283
284
  throw new Error(
284
- `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: libretto close --session ${session}`,
285
+ `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: ${librettoCommand(`close --session ${session}`)}`,
285
286
  );
286
287
  }
287
288
 
@@ -291,6 +292,6 @@ export function assertSessionAvailableForStart(
291
292
  }
292
293
  const endpoint = `http://127.0.0.1:${existingState.port}`;
293
294
  throw new Error(
294
- `Session "${session}" is already open and connected to ${endpoint} (pid ${existingState.pid}). Create a new session or close the current one with: libretto close --session ${session}`,
295
+ `Session "${session}" is already open and connected to ${endpoint} (pid ${existingState.pid}). Create a new session or close the current one with: ${librettoCommand(`close --session ${session}`)}`,
295
296
  );
296
297
  }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { REPO_ROOT } from "./context.js";
5
+ import { librettoCommand } from "../../shared/package-manager.js";
5
6
 
6
7
  type PackageManifest = {
7
8
  version?: string;
@@ -85,7 +86,7 @@ export function warnIfInstalledSkillOutOfDate(): void {
85
86
  }
86
87
 
87
88
  console.error(
88
- `Warning: Your agent skill (${mismatch.installedVersion}) is out of date with your Libretto CLI (${mismatch.cliVersion}). Please run \`npx libretto setup\` to update your skills to the correct version.`,
89
+ `Warning: Your agent skill (${mismatch.installedVersion}) is out of date with your Libretto CLI (${mismatch.cliVersion}). Please run \`${librettoCommand("setup")}\` to update your skills to the correct version.`,
89
90
  );
90
91
  } catch {
91
92
  // Never block command execution on a best-effort skill version check.
@@ -0,0 +1,237 @@
1
+ import type { BrowserContext, Page } from "playwright";
2
+ import type { LoggerApi } from "../../../shared/logger/index.js";
3
+ import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
4
+ import type {
5
+ ExportedLibrettoWorkflow,
6
+ LibrettoWorkflowContext,
7
+ } from "../../../shared/workflow/workflow.js";
8
+ import {
9
+ getAbsoluteIntegrationPath,
10
+ installHeadedWorkflowVisualization,
11
+ loadDefaultWorkflow,
12
+ } from "../workflow-runtime.js";
13
+
14
+ type WorkflowPausedState = {
15
+ state: "paused";
16
+ session: string;
17
+ pausedAt: string;
18
+ url?: string;
19
+ };
20
+
21
+ export type WorkflowFinishedArgs =
22
+ | { result: "completed"; completedAt: string }
23
+ | { result: "failed"; message: string; phase: "setup" | "workflow" };
24
+
25
+ type WorkflowFinishedState = {
26
+ state: "finished";
27
+ } & WorkflowFinishedArgs;
28
+
29
+ export type WorkflowOutcome = WorkflowPausedState | WorkflowFinishedState;
30
+
31
+ export type WorkflowStatus =
32
+ | { state: "idle" }
33
+ | { state: "running" }
34
+ | WorkflowOutcome;
35
+
36
+ type WorkflowLogEvent = {
37
+ stream: "stdout" | "stderr";
38
+ text: string;
39
+ };
40
+
41
+ export type WorkflowControllerConfig = {
42
+ session: string;
43
+ headed: boolean;
44
+ page: Page;
45
+ context: BrowserContext;
46
+ logger: LoggerApi;
47
+ onLog?: (event: WorkflowLogEvent) => void;
48
+ onOutcome?: (outcome: WorkflowOutcome) => void;
49
+ };
50
+
51
+ export type WorkflowStartConfig = {
52
+ integrationPath: string;
53
+ params?: unknown;
54
+ visualize?: boolean;
55
+ loadedWorkflow?: ExportedLibrettoWorkflow;
56
+ };
57
+
58
+ type PendingPause = {
59
+ resolve(): void;
60
+ };
61
+
62
+ type WritableStreamWithWrite = NodeJS.WriteStream & {
63
+ write: NodeJS.WriteStream["write"];
64
+ };
65
+
66
+ export class WorkflowController {
67
+ private status: WorkflowStatus = { state: "idle" };
68
+ private pendingPause: PendingPause | undefined;
69
+ private started = false;
70
+
71
+ constructor(private readonly config: WorkflowControllerConfig) {}
72
+
73
+ start(workflowConfig: WorkflowStartConfig): void {
74
+ if (this.started) {
75
+ throw new Error("Workflow controller has already started.");
76
+ }
77
+
78
+ this.started = true;
79
+ this.status = { state: "running" };
80
+ void this.run(workflowConfig);
81
+ }
82
+
83
+ pause(args: {
84
+ session: string;
85
+ pausedAt: string;
86
+ url?: string;
87
+ }): Promise<void> {
88
+ if (this.pendingPause) {
89
+ throw new Error("Workflow is already paused.");
90
+ }
91
+
92
+ return new Promise<void>((resolve) => {
93
+ this.pendingPause = { resolve };
94
+ this.status = { state: "paused", ...args };
95
+ this.config.onOutcome?.(this.status);
96
+ });
97
+ }
98
+
99
+ resume(): void {
100
+ if (!this.pendingPause) {
101
+ throw new Error("Workflow is not paused.");
102
+ }
103
+
104
+ const pendingPause = this.pendingPause;
105
+ this.pendingPause = undefined;
106
+ this.status = { state: "running" };
107
+ pendingPause.resolve();
108
+ }
109
+
110
+ getStatus(): WorkflowStatus {
111
+ return this.status;
112
+ }
113
+
114
+ private async run(workflowConfig: WorkflowStartConfig): Promise<void> {
115
+ const restoreOutput = this.captureProcessOutput();
116
+ try {
117
+ const absolutePath = getAbsoluteIntegrationPath(
118
+ workflowConfig.integrationPath,
119
+ );
120
+ const workflow =
121
+ workflowConfig.loadedWorkflow ??
122
+ (await loadDefaultWorkflow(absolutePath));
123
+ const workflowLogger = this.config.logger.withScope("integration-run", {
124
+ integrationPath: absolutePath,
125
+ workflowName: workflow.name,
126
+ session: this.config.session,
127
+ });
128
+
129
+ console.log(
130
+ `Running workflow "${workflow.name}" from ${absolutePath} (${this.config.headed ? "headed" : "headless"})...`,
131
+ );
132
+
133
+ if (this.config.headed && workflowConfig.visualize !== false) {
134
+ await installHeadedWorkflowVisualization({
135
+ context: this.config.context,
136
+ logger: workflowLogger,
137
+ });
138
+ }
139
+
140
+ // tsx/esbuild can inject __name() wrappers when keepNames is true.
141
+ // Playwright serializes callbacks via Function#toString() into the browser
142
+ // context, which lacks __name, causing ReferenceError without this polyfill.
143
+ await this.config.context.addInitScript(() => {
144
+ (globalThis as Record<string, unknown>).__name = (
145
+ target: unknown,
146
+ value: string,
147
+ ) =>
148
+ Object.defineProperty(target as object, "name", {
149
+ value,
150
+ configurable: true,
151
+ });
152
+ });
153
+
154
+ const workflowContext: LibrettoWorkflowContext = {
155
+ session: this.config.session,
156
+ page: this.config.page,
157
+ };
158
+
159
+ const uninstallPauseHandler = installPauseHandler((pauseArgs) =>
160
+ this.pause({
161
+ ...pauseArgs,
162
+ url: this.config.page.isClosed() ? undefined : this.config.page.url(),
163
+ }),
164
+ );
165
+ try {
166
+ await workflow.run(workflowContext, workflowConfig.params ?? {});
167
+ } catch (error) {
168
+ this.emitOutcome({
169
+ state: "finished",
170
+ result: "failed",
171
+ message: error instanceof Error ? error.message : String(error),
172
+ phase: "workflow",
173
+ });
174
+ return;
175
+ } finally {
176
+ uninstallPauseHandler();
177
+ }
178
+
179
+ this.emitOutcome({
180
+ state: "finished",
181
+ result: "completed",
182
+ completedAt: new Date().toISOString(),
183
+ });
184
+ } catch (error) {
185
+ this.emitOutcome({
186
+ state: "finished",
187
+ result: "failed",
188
+ message: error instanceof Error ? error.message : String(error),
189
+ phase: "setup",
190
+ });
191
+ } finally {
192
+ restoreOutput();
193
+ }
194
+ }
195
+
196
+ private emitOutcome(outcome: WorkflowOutcome): void {
197
+ this.resolvePendingPause();
198
+ this.status = outcome;
199
+ this.config.onOutcome?.(outcome);
200
+ }
201
+
202
+ private resolvePendingPause(): void {
203
+ const pendingPause = this.pendingPause;
204
+ if (!pendingPause) return;
205
+
206
+ this.pendingPause = undefined;
207
+ pendingPause.resolve();
208
+ }
209
+
210
+ private captureProcessOutput(): () => void {
211
+ const stdout = process.stdout as WritableStreamWithWrite;
212
+ const stderr = process.stderr as WritableStreamWithWrite;
213
+ const originalStdoutWrite = stdout.write;
214
+ const originalStderrWrite = stderr.write;
215
+
216
+ stdout.write = ((...writeArgs: Parameters<typeof stdout.write>) => {
217
+ const [chunk] = writeArgs;
218
+ this.config.onLog?.({ stream: "stdout", text: chunkToString(chunk) });
219
+ return Reflect.apply(originalStdoutWrite, stdout, writeArgs) as boolean;
220
+ }) as typeof stdout.write;
221
+
222
+ stderr.write = ((...writeArgs: Parameters<typeof stderr.write>) => {
223
+ const [chunk] = writeArgs;
224
+ this.config.onLog?.({ stream: "stderr", text: chunkToString(chunk) });
225
+ return Reflect.apply(originalStderrWrite, stderr, writeArgs) as boolean;
226
+ }) as typeof stderr.write;
227
+
228
+ return () => {
229
+ stdout.write = originalStdoutWrite;
230
+ stderr.write = originalStderrWrite;
231
+ };
232
+ }
233
+ }
234
+
235
+ function chunkToString(chunk: unknown): string {
236
+ return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
237
+ }
@@ -0,0 +1,86 @@
1
+ import type { BrowserContext } from "playwright";
2
+ import { existsSync } from "node:fs";
3
+ import { cwd } from "node:process";
4
+ import { isAbsolute, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { instrumentContext } from "../../index.js";
7
+ import {
8
+ getDefaultWorkflowFromModuleExports,
9
+ getWorkflowsFromModuleExports,
10
+ type ExportedLibrettoWorkflow,
11
+ } from "../../shared/workflow/workflow.js";
12
+ import type { LoggerApi } from "../../shared/logger/index.js";
13
+
14
+ const TSCONFIG_HINT =
15
+ "TypeScript compilation failed. Pass --tsconfig <path> to run against a specific tsconfig.";
16
+
17
+ function isTsxCompileError(error: unknown): error is Error {
18
+ return (
19
+ error instanceof Error &&
20
+ (error.name === "TransformError" ||
21
+ error.message.startsWith("Cannot resolve tsconfig at path:"))
22
+ );
23
+ }
24
+
25
+ export function getAbsoluteIntegrationPath(integrationPath: string): string {
26
+ const absolutePath = isAbsolute(integrationPath)
27
+ ? integrationPath
28
+ : resolve(cwd(), integrationPath);
29
+ if (!existsSync(absolutePath)) {
30
+ throw new Error(`Integration file does not exist: ${absolutePath}`);
31
+ }
32
+ return absolutePath;
33
+ }
34
+
35
+ export async function loadDefaultWorkflow(
36
+ absolutePath: string,
37
+ ): Promise<ExportedLibrettoWorkflow> {
38
+ let loadedModule: Record<string, unknown>;
39
+ try {
40
+ // @lintc-ignore Human-approved: user workflow files must be loaded dynamically from the CLI argument.
41
+ loadedModule = (await import(pathToFileURL(absolutePath).href)) as Record<
42
+ string,
43
+ unknown
44
+ >;
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ const compileHint = isTsxCompileError(error)
48
+ ? `\n${TSCONFIG_HINT}`
49
+ : absolutePath.endsWith(".ts") || absolutePath.endsWith(".tsx")
50
+ ? `\n${TSCONFIG_HINT}`
51
+ : "";
52
+ throw new Error(
53
+ `Failed to import integration module at ${absolutePath}: ${message}${compileHint}`,
54
+ );
55
+ }
56
+
57
+ const defaultWorkflow = getDefaultWorkflowFromModuleExports(loadedModule);
58
+ if (defaultWorkflow) {
59
+ return defaultWorkflow;
60
+ }
61
+
62
+ const availableWorkflowNames = getWorkflowsFromModuleExports(loadedModule).map(
63
+ (candidate) => candidate.name,
64
+ );
65
+
66
+ if (availableWorkflowNames.length === 0) {
67
+ throw new Error(
68
+ `No default-exported workflow found in ${absolutePath}. Export the workflow with \`export default workflow("name", handler)\`.`,
69
+ );
70
+ }
71
+
72
+ throw new Error(
73
+ `No default-exported workflow found in ${absolutePath}. libretto run only uses the file's default export. Available named workflows: ${availableWorkflowNames.join(", ")}`,
74
+ );
75
+ }
76
+
77
+ export async function installHeadedWorkflowVisualization(args: {
78
+ context: BrowserContext;
79
+ logger: LoggerApi;
80
+ instrument?: typeof instrumentContext;
81
+ }): Promise<void> {
82
+ await (args.instrument ?? instrumentContext)(args.context, {
83
+ visualize: true,
84
+ logger: args.logger,
85
+ });
86
+ }
package/src/cli/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runLibrettoCLI } from "./cli.js";
3
3
 
4
- export { runClose } from "./commands/browser.js";
5
4
  export { runLibrettoCLI };
6
5
 
7
6
  void runLibrettoCLI();
package/src/cli/router.ts CHANGED
@@ -1,19 +1,20 @@
1
- import { aiCommands } from "./commands/ai.js";
2
1
  import { authCommands } from "./commands/auth.js";
3
2
  import { billingCommands } from "./commands/billing.js";
4
3
  import { browserCommands } from "./commands/browser.js";
5
4
  import { deployCommand } from "./commands/deploy.js";
6
5
  import { executionCommands } from "./commands/execution.js";
6
+ import { experimentsCommand } from "./commands/experiments.js";
7
7
  import { setupCommand } from "./commands/setup.js";
8
8
  import { statusCommand } from "./commands/status.js";
9
9
  import { snapshotCommand } from "./commands/snapshot.js";
10
+ import { librettoCommand } from "../shared/package-manager.js";
10
11
  import { SimpleCLI } from "./framework/simple-cli.js";
11
12
 
12
13
  export const cliRoutes = {
13
14
  ...browserCommands,
14
15
  deploy: deployCommand,
16
+ experiments: experimentsCommand,
15
17
  ...executionCommands,
16
- ai: aiCommands,
17
18
  auth: authCommands,
18
19
  billing: billingCommands,
19
20
  setup: setupCommand,
@@ -22,5 +23,5 @@ export const cliRoutes = {
22
23
  };
23
24
 
24
25
  export function createCLIApp() {
25
- return SimpleCLI.define("libretto", cliRoutes);
26
+ return SimpleCLI.define(librettoCommand(), cliRoutes);
26
27
  }
@@ -0,0 +1,20 @@
1
+ export type ActivePauseHandler = (args: {
2
+ session: string;
3
+ pausedAt: string;
4
+ url?: string;
5
+ }) => Promise<void>;
6
+
7
+ let activePauseHandler: ActivePauseHandler | undefined;
8
+
9
+ export function installPauseHandler(handler: ActivePauseHandler): () => void {
10
+ const previousHandler = activePauseHandler;
11
+ activePauseHandler = handler;
12
+
13
+ return () => {
14
+ activePauseHandler = previousHandler;
15
+ };
16
+ }
17
+
18
+ export function getActivePauseHandler(): ActivePauseHandler | undefined {
19
+ return activePauseHandler;
20
+ }