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,21 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createServer, connect as netConnect } from "node:net";
3
- import { unlink } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { openSync, closeSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createIpcPeer } from "../../../shared/ipc/ipc.js";
7
+ import { connectToIpcSocket } from "../../../shared/ipc/socket-transport.js";
4
8
  import { REPO_ROOT } from "../context.js";
9
+ function createNoopDaemonToCliHandlers() {
10
+ return {
11
+ workflowOutput: () => {
12
+ },
13
+ workflowPaused: () => {
14
+ },
15
+ workflowFinished: () => {
16
+ }
17
+ };
18
+ }
5
19
  class DaemonClientError extends Error {
6
20
  constructor(message, output) {
7
21
  super(message);
@@ -9,163 +23,190 @@ class DaemonClientError extends Error {
9
23
  this.name = "DaemonClientError";
10
24
  }
11
25
  }
26
+ function isDaemonReadyMessage(message) {
27
+ if (typeof message !== "object" || message === null) return false;
28
+ const candidate = message;
29
+ return candidate.type === "ready" && typeof candidate.socketPath === "string";
30
+ }
31
+ function isDaemonStartupErrorMessage(message) {
32
+ if (typeof message !== "object" || message === null) return false;
33
+ const candidate = message;
34
+ return candidate.type === "startup-error" && typeof candidate.message === "string";
35
+ }
12
36
  function getDaemonSocketPath(session) {
13
37
  const hash = createHash("sha256").update(`${REPO_ROOT}:${session}`).digest("hex").slice(0, 12);
14
38
  return `/tmp/libretto-${process.getuid()}-${hash}.sock`;
15
39
  }
16
- class DaemonServer {
17
- constructor(socketPath, handler) {
18
- this.socketPath = socketPath;
19
- this.handler = handler;
40
+ class DaemonClient {
41
+ constructor(ipc) {
42
+ this.ipc = ipc;
20
43
  }
21
- server = null;
22
- async listen() {
23
- try {
24
- await unlink(this.socketPath);
25
- } catch (err) {
26
- if (err.code !== "ENOENT") throw err;
27
- }
28
- const server = createServer((socket) => {
29
- let buffer = "";
30
- socket.on("data", (chunk) => {
31
- buffer += chunk.toString();
32
- const newlineIndex = buffer.indexOf("\n");
33
- if (newlineIndex === -1) return;
34
- const line = buffer.slice(0, newlineIndex);
35
- buffer = buffer.slice(newlineIndex + 1);
36
- void (async () => {
37
- let response;
38
- try {
39
- const request = JSON.parse(line);
40
- const data = await this.handler(request);
41
- response = { id: request.id, type: "result", data };
42
- } catch (err) {
43
- const id = (() => {
44
- try {
45
- return JSON.parse(line).id ?? "unknown";
46
- } catch {
47
- return "unknown";
48
- }
49
- })();
50
- response = {
51
- id,
52
- type: "error",
53
- message: err instanceof Error ? err.message : String(err),
54
- output: err instanceof Error ? err.output : void 0
55
- };
56
- }
57
- socket.end(JSON.stringify(response) + "\n");
58
- })();
59
- });
60
- });
61
- this.server = server;
62
- await new Promise((resolve, reject) => {
63
- server.on("error", reject);
64
- server.listen(this.socketPath, () => resolve());
65
- });
44
+ static async connect(socketPath, handlers = createNoopDaemonToCliHandlers()) {
45
+ const transport = await connectToIpcSocket(socketPath);
46
+ return new DaemonClient(
47
+ createIpcPeer(transport, handlers)
48
+ );
66
49
  }
67
- async close() {
68
- const server = this.server;
69
- if (!server) return;
70
- this.server = null;
71
- await new Promise((resolve, reject) => {
72
- server.close((err) => err ? reject(err) : resolve());
50
+ static async spawn(options) {
51
+ const { config, logger, logPath, startupTimeoutMs, handlers, onFailure } = options;
52
+ const { session } = config;
53
+ const daemonEntryPath = fileURLToPath(
54
+ new URL("./daemon.js", import.meta.url)
55
+ );
56
+ const require2 = createRequire(import.meta.url);
57
+ const tsxCliPath = require2.resolve("tsx/cli");
58
+ const childStderrFd = openSync(logPath, "a");
59
+ const child = spawn(
60
+ process.execPath,
61
+ [
62
+ tsxCliPath,
63
+ ...config.workflow?.tsconfigPath ? ["--tsconfig", config.workflow.tsconfigPath] : [],
64
+ daemonEntryPath,
65
+ JSON.stringify(config)
66
+ ],
67
+ {
68
+ detached: true,
69
+ stdio: ["ignore", "ignore", childStderrFd, "ipc"]
70
+ }
71
+ );
72
+ closeSync(childStderrFd);
73
+ const pid = child.pid;
74
+ logger.info("daemon-spawned", { pid, session });
75
+ const readyMessage = await DaemonClient.waitForReadyMessage({
76
+ child,
77
+ timeoutMs: startupTimeoutMs,
78
+ formatTimeoutError: () => new Error(
79
+ `Daemon failed to start within ${Math.ceil(startupTimeoutMs / 1e3)}s. Check logs: ${logPath}`
80
+ ),
81
+ formatSpawnError: (error) => {
82
+ const errWithCode = error;
83
+ const hint = errWithCode.code === "ENOENT" ? " Ensure Node.js is available in PATH for child processes." : "";
84
+ return new Error(
85
+ `Failed to spawn daemon: ${error.message}.${hint} Check logs: ${logPath}`
86
+ );
87
+ },
88
+ formatExitError: (code, signal) => {
89
+ const status = code ?? signal ?? "unknown";
90
+ return new Error(
91
+ `Daemon exited before startup (status: ${status}). Check logs: ${logPath}`
92
+ );
93
+ },
94
+ onReady: (message) => {
95
+ logger.info("daemon-ready", {
96
+ session,
97
+ socketPath: message.socketPath,
98
+ pid
99
+ });
100
+ child.disconnect();
101
+ child.unref();
102
+ },
103
+ onSpawnError: (error) => {
104
+ logger.error("daemon-spawn-error", { error, session });
105
+ },
106
+ onExit: (code, signal, ready) => {
107
+ logger.warn("daemon-exit", { code, signal, session, pid, ready });
108
+ }
109
+ }).catch(async (error) => {
110
+ try {
111
+ process.kill(pid, "SIGTERM");
112
+ } catch {
113
+ }
114
+ await onFailure?.();
115
+ throw error;
73
116
  });
74
- try {
75
- await unlink(this.socketPath);
76
- } catch (err) {
77
- if (err.code !== "ENOENT") throw err;
78
- }
79
- }
80
- }
81
- class DaemonClient {
82
- constructor(socketPath) {
83
- this.socketPath = socketPath;
117
+ const client = await DaemonClient.connect(
118
+ readyMessage.socketPath,
119
+ handlers
120
+ );
121
+ const socketPath = readyMessage.socketPath;
122
+ logger.info("daemon-ipc-ready", { session, socketPath });
123
+ return { pid, socketPath, provider: readyMessage.provider, client };
84
124
  }
85
- async send(request) {
125
+ static async waitForReadyMessage(args) {
126
+ const {
127
+ child,
128
+ timeoutMs,
129
+ formatTimeoutError,
130
+ formatSpawnError,
131
+ formatExitError,
132
+ onReady,
133
+ onSpawnError,
134
+ onExit
135
+ } = args;
86
136
  return new Promise((resolve, reject) => {
87
- const socket = netConnect(this.socketPath);
88
- let buffer = "";
89
- socket.on("connect", () => {
90
- socket.write(JSON.stringify(request) + "\n");
91
- });
92
- socket.on("data", (chunk) => {
93
- buffer += chunk.toString();
94
- });
95
- socket.on("end", () => {
96
- try {
97
- const response = JSON.parse(buffer.trim());
98
- resolve(response);
99
- } catch (err) {
100
- reject(
101
- new Error(
102
- `Failed to parse daemon response: ${err instanceof Error ? err.message : String(err)}`
103
- )
104
- );
137
+ let ready = false;
138
+ let timeout;
139
+ const cleanup = () => {
140
+ clearTimeout(timeout);
141
+ child.off("message", onMessage);
142
+ child.off("error", onError);
143
+ child.off("exit", onChildExit);
144
+ };
145
+ const fail = (error) => {
146
+ cleanup();
147
+ reject(error);
148
+ };
149
+ timeout = setTimeout(() => fail(formatTimeoutError()), timeoutMs);
150
+ const onMessage = (message) => {
151
+ if (isDaemonStartupErrorMessage(message)) {
152
+ fail(new Error(message.message));
153
+ return;
105
154
  }
106
- });
107
- socket.on("error", (err) => {
108
- reject(err);
109
- });
110
- });
111
- }
112
- generateId() {
113
- return Math.random().toString(36).slice(2, 10);
114
- }
115
- async sendOrThrow(request) {
116
- const response = await this.send(request);
117
- if (response.type === "error") {
118
- throw new DaemonClientError(response.message, response.output);
119
- }
120
- return response.data;
121
- }
122
- async sendResult(request) {
123
- const response = await this.send(request);
124
- if (response.type === "error") {
125
- return {
126
- ok: false,
127
- message: response.message,
128
- output: response.output
155
+ if (!isDaemonReadyMessage(message)) return;
156
+ ready = true;
157
+ cleanup();
158
+ onReady?.(message);
159
+ resolve(message);
129
160
  };
130
- }
131
- return { ok: true, data: response.data };
161
+ const onError = (error) => {
162
+ onSpawnError?.(error);
163
+ fail(formatSpawnError(error));
164
+ };
165
+ const onChildExit = (code, signal) => {
166
+ onExit?.(code, signal, ready);
167
+ if (ready) return;
168
+ fail(formatExitError(code, signal));
169
+ };
170
+ child.on("message", onMessage);
171
+ child.on("error", onError);
172
+ child.on("exit", onChildExit);
173
+ });
132
174
  }
133
175
  async ping() {
134
176
  try {
135
- await this.sendOrThrow({ id: this.generateId(), command: "ping" });
177
+ await this.ipc.call.ping();
136
178
  return true;
137
179
  } catch {
138
180
  return false;
139
181
  }
140
182
  }
141
183
  async pages() {
142
- return this.sendOrThrow({ id: this.generateId(), command: "pages" });
184
+ return this.ipc.call.pages();
143
185
  }
144
186
  async exec(args) {
145
- return this.sendResult({
146
- id: this.generateId(),
147
- command: "exec",
148
- ...args
149
- });
187
+ return this.ipc.call.exec(args);
150
188
  }
151
189
  async readonlyExec(args) {
152
- return this.sendResult({
153
- id: this.generateId(),
154
- command: "readonly-exec",
155
- ...args
156
- });
190
+ return this.ipc.call.readonlyExec(args);
157
191
  }
158
192
  async snapshot(args = {}) {
159
- return this.sendOrThrow({
160
- id: this.generateId(),
161
- command: "snapshot",
162
- ...args
163
- });
193
+ return this.ipc.call.snapshot(args);
194
+ }
195
+ async getWorkflowStatus() {
196
+ return this.ipc.call.getWorkflowStatus();
197
+ }
198
+ async resumeWorkflow() {
199
+ await this.ipc.call.resumeWorkflow();
200
+ }
201
+ async close() {
202
+ return this.ipc.call.close();
203
+ }
204
+ destroy() {
205
+ this.ipc.destroy();
164
206
  }
165
207
  }
166
208
  export {
167
209
  DaemonClient,
168
210
  DaemonClientError,
169
- DaemonServer,
170
211
  getDaemonSocketPath
171
212
  };
@@ -1,5 +1,10 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync } from "node:fs";
2
2
  import { getSessionSnapshotRunDir } from "../context.js";
3
+ import {
4
+ snapshot
5
+ } from "../../../shared/snapshot/capture-snapshot.js";
6
+ import { waitForPageStable } from "../../../shared/snapshot/wait-for-page-stable.js";
7
+ import { librettoCommand } from "../../../shared/package-manager.js";
3
8
  import {
4
9
  resolveSnapshotViewport,
5
10
  readSnapshotViewportMetrics,
@@ -8,7 +13,46 @@ import {
8
13
  forceSnapshotViewport
9
14
  } from "../../commands/snapshot.js";
10
15
  const RENDER_SETTLE_TIMEOUT_MS = 1e4;
11
- async function handleSnapshot(targetPage, session, logger, pageId) {
16
+ async function handleCompactSnapshot(targetPage, session, logger, options = {}) {
17
+ if (options.useCachedSnapshot) {
18
+ if (!options.cachedSnapshot) {
19
+ throw new Error(
20
+ `No compact snapshot is cached for session "${session}". Run ${librettoCommand(`snapshot --session ${session}`)} first.`
21
+ );
22
+ }
23
+ const screenshot2 = await captureSnapshotScreenshot(
24
+ targetPage,
25
+ session,
26
+ logger,
27
+ options.pageId
28
+ );
29
+ return {
30
+ mode: "compact",
31
+ pngPath: screenshot2.pngPath,
32
+ snapshot: options.cachedSnapshot
33
+ };
34
+ }
35
+ const waitResult = await waitForPageStable(targetPage);
36
+ if (!waitResult.ok) {
37
+ logger.warn("compact-snapshot-stability-wait-incomplete", {
38
+ session,
39
+ pageId: options.pageId,
40
+ diagnostics: waitResult.diagnostics
41
+ });
42
+ }
43
+ const screenshot = await captureSnapshotScreenshot(
44
+ targetPage,
45
+ session,
46
+ logger,
47
+ options.pageId
48
+ );
49
+ return {
50
+ mode: "compact",
51
+ pngPath: screenshot.pngPath,
52
+ snapshot: await snapshot(targetPage)
53
+ };
54
+ }
55
+ async function captureSnapshotScreenshot(targetPage, session, logger, pageId) {
12
56
  const snapshotRunId = `snapshot-${Date.now()}`;
13
57
  const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
14
58
  mkdirSync(snapshotRunDir, { recursive: true });
@@ -25,7 +69,6 @@ async function handleSnapshot(targetPage, session, logger, pageId) {
25
69
  logger.warn("screenshot-url-read-failed", { session, pageId, error });
26
70
  }
27
71
  const pngPath = `${snapshotRunDir}/page.png`;
28
- const htmlPath = `${snapshotRunDir}/page.html`;
29
72
  await Promise.race([
30
73
  targetPage.waitForLoadState("networkidle").catch(() => {
31
74
  }),
@@ -63,24 +106,20 @@ async function handleSnapshot(targetPage, session, logger, pageId) {
63
106
  );
64
107
  await targetPage.screenshot({ path: pngPath });
65
108
  }
66
- const htmlContent = await targetPage.content();
67
- writeFileSync(htmlPath, htmlContent);
68
- logger.info("screenshot-success", {
109
+ logger.info("screenshot-captured", {
69
110
  session,
70
111
  pageUrl,
71
112
  title,
72
113
  pngPath,
73
- htmlPath,
74
114
  snapshotRunId
75
115
  });
76
116
  return {
77
117
  pngPath,
78
- htmlPath,
79
118
  snapshotRunId,
80
119
  pageUrl: pageUrl ?? "",
81
120
  title: title ?? ""
82
121
  };
83
122
  }
84
123
  export {
85
- handleSnapshot
124
+ handleCompactSnapshot
86
125
  };
@@ -0,0 +1,39 @@
1
+ import {
2
+ readLibrettoConfig,
3
+ writeLibrettoConfig
4
+ } from "./config.js";
5
+ const EXPERIMENTS = {};
6
+ function isExperimentName(name) {
7
+ return Object.hasOwn(EXPERIMENTS, name);
8
+ }
9
+ function resolveExperiments(config = readLibrettoConfig()) {
10
+ return Object.fromEntries(
11
+ Object.entries(EXPERIMENTS).map(([name, metadata]) => [
12
+ name,
13
+ config.experiments?.[name] ?? metadata.defaultValue
14
+ ])
15
+ );
16
+ }
17
+ function setExperimentEnabled(name, enabled, configPath) {
18
+ if (!isExperimentName(name)) {
19
+ throw new Error(`Unknown experiment "${name}".`);
20
+ }
21
+ const config = readLibrettoConfig(configPath);
22
+ const writtenConfig = writeLibrettoConfig(
23
+ {
24
+ ...config,
25
+ experiments: {
26
+ ...config.experiments,
27
+ [name]: enabled
28
+ }
29
+ },
30
+ configPath
31
+ );
32
+ return resolveExperiments(writtenConfig);
33
+ }
34
+ export {
35
+ EXPERIMENTS,
36
+ isExperimentName,
37
+ resolveExperiments,
38
+ setExperimentEnabled
39
+ };
@@ -18,6 +18,7 @@ import {
18
18
  parseSessionStateContent,
19
19
  serializeSessionState
20
20
  } from "../../shared/state/index.js";
21
+ import { librettoCommand } from "../../shared/package-manager.js";
21
22
  const SESSION_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
22
23
  const SESSION_DEV_SERVER = "dev-server";
23
24
  const SESSION_BROWSER_AGENT = "browser-agent";
@@ -119,7 +120,7 @@ function throwSessionNotFoundError(session) {
119
120
  }
120
121
  lines.push("");
121
122
  lines.push("Start one with:");
122
- lines.push(` libretto open <url> --session ${session}`);
123
+ lines.push(` ${librettoCommand(`open <url> --session ${session}`)}`);
123
124
  throw new Error(lines.join("\n"));
124
125
  }
125
126
  function assertSessionStateExistsOrThrow(session) {
@@ -176,7 +177,7 @@ function assertSessionAllowsCommand(state, commandName, allowedModes) {
176
177
  }
177
178
  const supportedModes = [...allowedModes].join(", ");
178
179
  throw new Error(
179
- `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.`
180
+ `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.`
180
181
  );
181
182
  }
182
183
  function clearSessionState(session, logger) {
@@ -213,7 +214,7 @@ function assertSessionAvailableForStart(session, logger) {
213
214
  if (!existingState) return;
214
215
  if (existingState.provider && existingState.cdpEndpoint) {
215
216
  throw new Error(
216
- `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: libretto close --session ${session}`
217
+ `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: ${librettoCommand(`close --session ${session}`)}`
217
218
  );
218
219
  }
219
220
  if (existingState.pid == null || !isPidRunning(existingState.pid)) {
@@ -222,7 +223,7 @@ function assertSessionAvailableForStart(session, logger) {
222
223
  }
223
224
  const endpoint = `http://127.0.0.1:${existingState.port}`;
224
225
  throw new Error(
225
- `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}`
226
+ `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}`)}`
226
227
  );
227
228
  }
228
229
  export {
@@ -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
  const INSTALLED_SKILL_PATHS = [
6
7
  [".agents", "skills", "libretto", "SKILL.md"],
7
8
  [".claude", "skills", "libretto", "SKILL.md"]
@@ -63,7 +64,7 @@ function warnIfInstalledSkillOutOfDate() {
63
64
  return;
64
65
  }
65
66
  console.error(
66
- `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.`
67
+ `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.`
67
68
  );
68
69
  } catch {
69
70
  }
@@ -0,0 +1,147 @@
1
+ import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
2
+ import {
3
+ getAbsoluteIntegrationPath,
4
+ installHeadedWorkflowVisualization,
5
+ loadDefaultWorkflow
6
+ } from "../workflow-runtime.js";
7
+ class WorkflowController {
8
+ constructor(config) {
9
+ this.config = config;
10
+ }
11
+ status = { state: "idle" };
12
+ pendingPause;
13
+ started = false;
14
+ start(workflowConfig) {
15
+ if (this.started) {
16
+ throw new Error("Workflow controller has already started.");
17
+ }
18
+ this.started = true;
19
+ this.status = { state: "running" };
20
+ void this.run(workflowConfig);
21
+ }
22
+ pause(args) {
23
+ if (this.pendingPause) {
24
+ throw new Error("Workflow is already paused.");
25
+ }
26
+ return new Promise((resolve) => {
27
+ this.pendingPause = { resolve };
28
+ this.status = { state: "paused", ...args };
29
+ this.config.onOutcome?.(this.status);
30
+ });
31
+ }
32
+ resume() {
33
+ if (!this.pendingPause) {
34
+ throw new Error("Workflow is not paused.");
35
+ }
36
+ const pendingPause = this.pendingPause;
37
+ this.pendingPause = void 0;
38
+ this.status = { state: "running" };
39
+ pendingPause.resolve();
40
+ }
41
+ getStatus() {
42
+ return this.status;
43
+ }
44
+ async run(workflowConfig) {
45
+ const restoreOutput = this.captureProcessOutput();
46
+ try {
47
+ const absolutePath = getAbsoluteIntegrationPath(
48
+ workflowConfig.integrationPath
49
+ );
50
+ const workflow = workflowConfig.loadedWorkflow ?? await loadDefaultWorkflow(absolutePath);
51
+ const workflowLogger = this.config.logger.withScope("integration-run", {
52
+ integrationPath: absolutePath,
53
+ workflowName: workflow.name,
54
+ session: this.config.session
55
+ });
56
+ console.log(
57
+ `Running workflow "${workflow.name}" from ${absolutePath} (${this.config.headed ? "headed" : "headless"})...`
58
+ );
59
+ if (this.config.headed && workflowConfig.visualize !== false) {
60
+ await installHeadedWorkflowVisualization({
61
+ context: this.config.context,
62
+ logger: workflowLogger
63
+ });
64
+ }
65
+ await this.config.context.addInitScript(() => {
66
+ globalThis.__name = (target, value) => Object.defineProperty(target, "name", {
67
+ value,
68
+ configurable: true
69
+ });
70
+ });
71
+ const workflowContext = {
72
+ session: this.config.session,
73
+ page: this.config.page
74
+ };
75
+ const uninstallPauseHandler = installPauseHandler(
76
+ (pauseArgs) => this.pause({
77
+ ...pauseArgs,
78
+ url: this.config.page.isClosed() ? void 0 : this.config.page.url()
79
+ })
80
+ );
81
+ try {
82
+ await workflow.run(workflowContext, workflowConfig.params ?? {});
83
+ } catch (error) {
84
+ this.emitOutcome({
85
+ state: "finished",
86
+ result: "failed",
87
+ message: error instanceof Error ? error.message : String(error),
88
+ phase: "workflow"
89
+ });
90
+ return;
91
+ } finally {
92
+ uninstallPauseHandler();
93
+ }
94
+ this.emitOutcome({
95
+ state: "finished",
96
+ result: "completed",
97
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
98
+ });
99
+ } catch (error) {
100
+ this.emitOutcome({
101
+ state: "finished",
102
+ result: "failed",
103
+ message: error instanceof Error ? error.message : String(error),
104
+ phase: "setup"
105
+ });
106
+ } finally {
107
+ restoreOutput();
108
+ }
109
+ }
110
+ emitOutcome(outcome) {
111
+ this.resolvePendingPause();
112
+ this.status = outcome;
113
+ this.config.onOutcome?.(outcome);
114
+ }
115
+ resolvePendingPause() {
116
+ const pendingPause = this.pendingPause;
117
+ if (!pendingPause) return;
118
+ this.pendingPause = void 0;
119
+ pendingPause.resolve();
120
+ }
121
+ captureProcessOutput() {
122
+ const stdout = process.stdout;
123
+ const stderr = process.stderr;
124
+ const originalStdoutWrite = stdout.write;
125
+ const originalStderrWrite = stderr.write;
126
+ stdout.write = ((...writeArgs) => {
127
+ const [chunk] = writeArgs;
128
+ this.config.onLog?.({ stream: "stdout", text: chunkToString(chunk) });
129
+ return Reflect.apply(originalStdoutWrite, stdout, writeArgs);
130
+ });
131
+ stderr.write = ((...writeArgs) => {
132
+ const [chunk] = writeArgs;
133
+ this.config.onLog?.({ stream: "stderr", text: chunkToString(chunk) });
134
+ return Reflect.apply(originalStderrWrite, stderr, writeArgs);
135
+ });
136
+ return () => {
137
+ stdout.write = originalStdoutWrite;
138
+ stderr.write = originalStderrWrite;
139
+ };
140
+ }
141
+ }
142
+ function chunkToString(chunk) {
143
+ return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
144
+ }
145
+ export {
146
+ WorkflowController
147
+ };