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
@@ -25,30 +25,69 @@ import {
25
25
  type Page,
26
26
  } from "playwright";
27
27
  import { mkdir } from "node:fs/promises";
28
- import { appendFileSync } from "node:fs";
28
+ import { appendFileSync, existsSync, writeFileSync } from "node:fs";
29
29
  import { installSessionTelemetry } from "../session-telemetry.js";
30
+ import {
31
+ createIpcPeer,
32
+ type IpcPeer,
33
+ type IpcPeerHandlers,
34
+ } from "../../../shared/ipc/ipc.js";
35
+ import {
36
+ createIpcSocketServer,
37
+ listenOnIpcSocket,
38
+ } from "../../../shared/ipc/socket-transport.js";
30
39
  import {
31
40
  createLoggerForSession,
32
41
  getSessionDir,
33
42
  getSessionNetworkLogPath,
34
43
  getSessionActionsLogPath,
44
+ getSessionProviderClosePath,
45
+ getSessionStatePath,
35
46
  } from "../context.js";
36
47
  import type { LoggerApi } from "../../../shared/logger/index.js";
48
+ import type { ExportedLibrettoWorkflow } from "../../../shared/workflow/workflow.js";
37
49
  import {
38
- DaemonServer,
39
50
  getDaemonSocketPath,
40
- type DaemonRequest,
51
+ type CliToDaemonApi,
52
+ type DaemonCloseResult,
53
+ type DaemonExecOutput,
54
+ type DaemonExecResult,
55
+ type DaemonToCliApi,
41
56
  } from "./ipc.js";
42
57
  import { wrapPageForActionLogging } from "../telemetry.js";
58
+ import {
59
+ getProfilePath,
60
+ hasProfile,
61
+ normalizeDomain,
62
+ normalizeUrl,
63
+ } from "../browser.js";
43
64
  import { handlePages } from "./pages.js";
44
65
  import { handleExec, handleReadonlyExec } from "./exec.js";
45
- import { handleSnapshot } from "./snapshot.js";
66
+ import { handleCompactSnapshot } from "./snapshot.js";
67
+ import { librettoCommand } from "../../../shared/package-manager.js";
68
+ import type { Snapshot } from "../../../shared/snapshot/types.js";
69
+ import { snapshot } from "../../../shared/snapshot/capture-snapshot.js";
70
+ import { diffSnapshots } from "../../../shared/snapshot/diff-snapshots.js";
71
+ import {
72
+ installPageStabilityWaiter,
73
+ preparePageStabilityWait,
74
+ waitForPageStable,
75
+ } from "../../../shared/snapshot/wait-for-page-stable.js";
46
76
  import {
47
- isConnectConfig,
48
77
  type DaemonConfig,
49
- type DaemonLaunchConfig,
50
- type DaemonConnectConfig,
78
+ type DaemonBrowserLaunchConfig,
79
+ type DaemonBrowserConnectConfig,
80
+ type DaemonBrowserProviderConfig,
81
+ type DaemonWorkflowConfig,
51
82
  } from "./config.js";
83
+ import type { Experiments } from "../experiments.js";
84
+ import { getCloudProviderApi } from "../providers/index.js";
85
+ import type { ProviderApi } from "../providers/types.js";
86
+ import {
87
+ getAbsoluteIntegrationPath,
88
+ loadDefaultWorkflow,
89
+ } from "../workflow-runtime.js";
90
+ import { WorkflowController } from "../workflow-runner/runner.js";
52
91
 
53
92
  function isOperationalPage(page: Page): boolean {
54
93
  const url = page.url();
@@ -56,6 +95,63 @@ function isOperationalPage(page: Page): boolean {
56
95
  }
57
96
 
58
97
  type TelemetryEntry = Record<string, unknown>;
98
+ type ErrorWithOutput = Error & { output?: DaemonExecOutput };
99
+ type ShutdownOptions = { keepIpcClientsAlive?: boolean };
100
+ type ShutdownHandler = (options: ShutdownOptions) => Promise<void> | void;
101
+
102
+ async function waitForSessionState(session: string): Promise<void> {
103
+ const deadline = Date.now() + 2_000;
104
+ while (Date.now() < deadline) {
105
+ if (existsSync(getSessionStatePath(session))) return;
106
+ await new Promise((resolve) => setTimeout(resolve, 25));
107
+ }
108
+ throw new Error(
109
+ `Session state was not written before workflow start for "${session}".`,
110
+ );
111
+ }
112
+
113
+ class UserFacingStartupError extends Error {
114
+ constructor(message: string) {
115
+ super(message);
116
+ this.name = "UserFacingStartupError";
117
+ }
118
+ }
119
+
120
+ function getMissingLocalAuthProfileError(args: {
121
+ normalizedDomain: string;
122
+ profilePath: string;
123
+ session: string;
124
+ }): string {
125
+ return [
126
+ `Local auth profile not found for domain "${args.normalizedDomain}".`,
127
+ `Expected profile file: ${args.profilePath}`,
128
+ "To create it:",
129
+ ` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
130
+ " 2. Log in manually in the browser window.",
131
+ ` 3. libretto save ${args.normalizedDomain} --session ${args.session}`,
132
+ ].join("\n");
133
+ }
134
+
135
+ function resolveAuthProfileStorageStatePath(args: {
136
+ authProfileDomain?: string;
137
+ session: string;
138
+ }): string | undefined {
139
+ if (!args.authProfileDomain) return undefined;
140
+ const normalizedDomain = normalizeDomain(
141
+ normalizeUrl(args.authProfileDomain),
142
+ );
143
+ const profilePath = getProfilePath(normalizedDomain);
144
+ if (!hasProfile(normalizedDomain)) {
145
+ throw new UserFacingStartupError(
146
+ getMissingLocalAuthProfileError({
147
+ normalizedDomain,
148
+ profilePath,
149
+ session: args.session,
150
+ }),
151
+ );
152
+ }
153
+ return profilePath;
154
+ }
59
155
 
60
156
  // ── BrowserDaemon ──────────────────────────────────────────────────────
61
157
 
@@ -66,21 +162,29 @@ class BrowserDaemon {
66
162
  readonly logger: LoggerApi;
67
163
  private readonly execState: Record<string, unknown> = {};
68
164
  private readonly pageById = new Map<string, Page>();
165
+ private readonly shutdownHandlers: ShutdownHandler[] = [];
166
+ private readonly connectedClis = new Set<IpcPeer<DaemonToCliApi>>();
167
+ private workflowController: WorkflowController | undefined;
168
+ private shutdownPromise: Promise<DaemonCloseResult> | undefined;
169
+ private readonly latestCompactSnapshotByPage = new WeakMap<Page, Snapshot>();
69
170
 
70
171
  private constructor(
71
172
  private readonly session: string,
173
+ private readonly experiments: Experiments,
72
174
  private readonly externallyManaged: boolean,
73
175
  private readonly browser: Browser,
74
176
  private readonly context: BrowserContext,
75
177
  private readonly page: Page,
76
- private readonly ipcServer: DaemonServer,
77
178
  logger: LoggerApi,
179
+ private readonly providerSession?: {
180
+ provider: ProviderApi;
181
+ name: string;
182
+ sessionId: string;
183
+ },
78
184
  ) {
79
185
  this.logger = logger.withScope("child");
80
186
  }
81
187
 
82
- private shuttingDown = false;
83
-
84
188
  private trackPage(page: Page): string {
85
189
  const id = `page-${Math.random().toString(36).slice(2, 5)}`;
86
190
  this.pageById.set(id, page);
@@ -88,6 +192,16 @@ class BrowserDaemon {
88
192
  return id;
89
193
  }
90
194
 
195
+ private async installCompactSnapshotWaiter(page: Page): Promise<void> {
196
+ const result = await preparePageStabilityWait(page, { timeoutMs: 1_000 });
197
+ if (!result.ok) {
198
+ this.logger.warn("compact-snapshot-waiter-install-incomplete", {
199
+ session: this.session,
200
+ diagnostics: result.diagnostics,
201
+ });
202
+ }
203
+ }
204
+
91
205
  // ── Shared initialization ──────────────────────────────────────────
92
206
 
93
207
  /**
@@ -97,6 +211,7 @@ class BrowserDaemon {
97
211
  */
98
212
  private static async initialize(args: {
99
213
  session: string;
214
+ experiments: Experiments;
100
215
  externallyManaged: boolean;
101
216
  browser: Browser;
102
217
  context: BrowserContext;
@@ -104,15 +219,29 @@ class BrowserDaemon {
104
219
  initialPages: Page[];
105
220
  /** If set, navigate to this URL after telemetry but before starting IPC. */
106
221
  navigateUrl?: string;
222
+ readyProvider?: {
223
+ name: string;
224
+ sessionId: string;
225
+ cdpEndpoint: string;
226
+ liveViewUrl?: string;
227
+ };
228
+ providerSession?: {
229
+ provider: ProviderApi;
230
+ name: string;
231
+ sessionId: string;
232
+ };
107
233
  }): Promise<BrowserDaemon> {
108
234
  const {
109
235
  session,
236
+ experiments,
110
237
  externallyManaged,
111
238
  browser,
112
239
  context,
113
240
  page,
114
241
  initialPages,
115
242
  navigateUrl,
243
+ readyProvider,
244
+ providerSession,
116
245
  } = args;
117
246
 
118
247
  await mkdir(getSessionDir(session), { recursive: true });
@@ -142,21 +271,20 @@ class BrowserDaemon {
142
271
  });
143
272
  }
144
273
 
145
- // IPC server — handler is wired after construction to avoid a
146
- // circular type inference issue (daemon references itself).
274
+ await context.addInitScript(installPageStabilityWaiter);
275
+
276
+ // IPC server — typed handlers are attached per client connection so one
277
+ // daemon lifetime can serve multiple CLI invocations.
147
278
  const socketPath = getDaemonSocketPath(session);
148
- let handler: (request: DaemonRequest) => Promise<unknown>;
149
- const ipcServer = new DaemonServer(socketPath, (request) =>
150
- handler(request),
151
- );
152
279
  const daemon = new BrowserDaemon(
153
280
  session,
281
+ experiments,
154
282
  externallyManaged,
155
283
  browser,
156
284
  context,
157
285
  page,
158
- ipcServer,
159
286
  logger,
287
+ providerSession,
160
288
  );
161
289
 
162
290
  // Action logging and page tracking must be registered before optional
@@ -165,9 +293,15 @@ class BrowserDaemon {
165
293
  wrapPageForActionLogging(p, session);
166
294
  daemon.trackPage(p);
167
295
  }
296
+ await Promise.all(
297
+ initialPages.map((initialPage) =>
298
+ daemon.installCompactSnapshotWaiter(initialPage),
299
+ ),
300
+ );
168
301
  context.on("page", (newPage) => {
169
302
  wrapPageForActionLogging(newPage, session);
170
303
  daemon.trackPage(newPage);
304
+ void daemon.installCompactSnapshotWaiter(newPage);
171
305
  });
172
306
 
173
307
  // Navigate after telemetry is installed (so we capture the initial
@@ -177,8 +311,42 @@ class BrowserDaemon {
177
311
  await page.goto(navigateUrl);
178
312
  }
179
313
 
180
- handler = (request) => daemon.handleRequest(request);
181
- await ipcServer.listen();
314
+ const ipcServer = createIpcSocketServer((transport) => {
315
+ const cli = createIpcPeer<DaemonToCliApi, CliToDaemonApi>(
316
+ transport,
317
+ daemon.createIpcHandlers(),
318
+ );
319
+ const stopTracking = transport.onClose?.(() => {
320
+ daemon.connectedClis.delete(cli);
321
+ stopTracking?.();
322
+ });
323
+ daemon.connectedClis.add(cli);
324
+ });
325
+ daemon.registerShutdownHandler(async (options) => {
326
+ if (!options.keepIpcClientsAlive) {
327
+ for (const cli of daemon.connectedClis) {
328
+ cli.destroy();
329
+ }
330
+ daemon.connectedClis.clear();
331
+ }
332
+ if (options.keepIpcClientsAlive) {
333
+ ipcServer.close((error) => {
334
+ if (error) {
335
+ daemon.logger.warn("ipc-server-close-failed", {
336
+ session,
337
+ error,
338
+ });
339
+ }
340
+ });
341
+ return;
342
+ }
343
+ await new Promise<void>((resolve, reject) => {
344
+ ipcServer.close((error) => (error ? reject(error) : resolve()));
345
+ });
346
+ });
347
+
348
+ await listenOnIpcSocket(ipcServer, socketPath);
349
+ process.send?.({ type: "ready", socketPath, provider: readyProvider });
182
350
  daemon.logger.info("ipc-server-listening", { socketPath });
183
351
 
184
352
  browser.on("disconnected", () => {
@@ -190,7 +358,13 @@ class BrowserDaemon {
190
358
 
191
359
  // ── Launch mode ────────────────────────────────────────────────────
192
360
 
193
- static async launchBrowser(config: DaemonLaunchConfig): Promise<BrowserDaemon> {
361
+ static async launchBrowser(args: {
362
+ session: string;
363
+ experiments: Experiments;
364
+ browser: DaemonBrowserLaunchConfig;
365
+ workflow?: DaemonWorkflowConfig;
366
+ }): Promise<BrowserDaemon> {
367
+ const { session, browser: config } = args;
194
368
  const windowPositionArg = config.windowPosition
195
369
  ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
196
370
  : undefined;
@@ -199,17 +373,24 @@ class BrowserDaemon {
199
373
  headless: !config.headed,
200
374
  args: [
201
375
  "--disable-blink-features=AutomationControlled",
202
- `--remote-debugging-port=${config.port}`,
376
+ ...(config.remoteDebuggingPort
377
+ ? [`--remote-debugging-port=${config.remoteDebuggingPort}`]
378
+ : []),
203
379
  "--remote-debugging-address=127.0.0.1",
204
380
  "--no-focus-on-check",
205
381
  ...(windowPositionArg ? [windowPositionArg] : []),
206
382
  ],
207
383
  });
208
384
 
385
+ const storageStatePath =
386
+ config.storageStatePath ??
387
+ resolveAuthProfileStorageStatePath({
388
+ authProfileDomain: args.workflow?.authProfileDomain,
389
+ session,
390
+ });
391
+
209
392
  const context = await browser.newContext({
210
- ...(config.storageStatePath
211
- ? { storageState: config.storageStatePath }
212
- : {}),
393
+ ...(storageStatePath ? { storageState: storageStatePath } : {}),
213
394
  viewport: {
214
395
  width: config.viewport.width,
215
396
  height: config.viewport.height,
@@ -223,19 +404,20 @@ class BrowserDaemon {
223
404
  page.setDefaultNavigationTimeout(45000);
224
405
 
225
406
  const daemon = await BrowserDaemon.initialize({
226
- session: config.session,
407
+ session,
408
+ experiments: args.experiments,
227
409
  externallyManaged: false,
228
410
  browser,
229
411
  context,
230
412
  page,
231
413
  initialPages: [page],
232
- navigateUrl: config.url,
414
+ navigateUrl: config.initialUrl,
233
415
  });
234
416
 
235
417
  daemon.logger.info("child-launched", {
236
- port: config.port,
418
+ port: config.remoteDebuggingPort,
237
419
  pid: process.pid,
238
- session: config.session,
420
+ session,
239
421
  });
240
422
 
241
423
  return daemon;
@@ -243,9 +425,12 @@ class BrowserDaemon {
243
425
 
244
426
  // ── Connect mode ───────────────────────────────────────────────────
245
427
 
246
- static async connectToEndpoint(
247
- config: DaemonConnectConfig,
248
- ): Promise<BrowserDaemon> {
428
+ static async connectToEndpoint(args: {
429
+ session: string;
430
+ experiments: Experiments;
431
+ browser: DaemonBrowserConnectConfig;
432
+ }): Promise<BrowserDaemon> {
433
+ const { session, browser: config } = args;
249
434
  const browser = await chromium.connectOverCDP(config.cdpEndpoint);
250
435
 
251
436
  // Discover existing contexts and pages.
@@ -259,47 +444,168 @@ class BrowserDaemon {
259
444
  : await context.newPage();
260
445
 
261
446
  const daemon = await BrowserDaemon.initialize({
262
- session: config.session,
447
+ session,
448
+ experiments: args.experiments,
263
449
  externallyManaged: true,
264
450
  browser,
265
451
  context,
266
452
  page,
267
- initialPages:
268
- operationalPages.length > 0 ? operationalPages : [page],
269
- navigateUrl: config.url,
453
+ initialPages: operationalPages.length > 0 ? operationalPages : [page],
454
+ navigateUrl: config.initialUrl,
270
455
  });
271
456
 
272
457
  daemon.logger.info("child-connected", {
273
458
  cdpEndpoint: config.cdpEndpoint,
274
- url: config.url,
459
+ url: config.initialUrl,
275
460
  pid: process.pid,
276
- session: config.session,
461
+ session,
277
462
  });
278
463
 
279
464
  return daemon;
280
465
  }
281
466
 
467
+ static async connectToProvider(args: {
468
+ session: string;
469
+ experiments: Experiments;
470
+ browser: DaemonBrowserProviderConfig;
471
+ }): Promise<BrowserDaemon> {
472
+ const { session, browser: config } = args;
473
+ const provider = getCloudProviderApi(config.providerName);
474
+ const providerSession = await provider.createSession();
475
+ try {
476
+ const browser = await chromium.connectOverCDP(
477
+ providerSession.cdpEndpoint,
478
+ );
479
+
480
+ const contexts = browser.contexts();
481
+ const context =
482
+ contexts.length > 0 ? contexts[0] : await browser.newContext();
483
+ const operationalPages = context.pages().filter(isOperationalPage);
484
+ const page =
485
+ operationalPages.length > 0
486
+ ? operationalPages[operationalPages.length - 1]
487
+ : await context.newPage();
488
+
489
+ const daemon = await BrowserDaemon.initialize({
490
+ session,
491
+ experiments: args.experiments,
492
+ externallyManaged: true,
493
+ browser,
494
+ context,
495
+ page,
496
+ initialPages: operationalPages.length > 0 ? operationalPages : [page],
497
+ navigateUrl: config.initialUrl,
498
+ readyProvider: {
499
+ name: config.providerName,
500
+ sessionId: providerSession.sessionId,
501
+ cdpEndpoint: providerSession.cdpEndpoint,
502
+ liveViewUrl: providerSession.liveViewUrl,
503
+ },
504
+ providerSession: {
505
+ provider,
506
+ name: config.providerName,
507
+ sessionId: providerSession.sessionId,
508
+ },
509
+ });
510
+
511
+ daemon.logger.info("child-provider-connected", {
512
+ provider: config.providerName,
513
+ sessionId: providerSession.sessionId,
514
+ url: config.initialUrl,
515
+ pid: process.pid,
516
+ session,
517
+ });
518
+
519
+ return daemon;
520
+ } catch (error) {
521
+ await provider.closeSession(providerSession.sessionId);
522
+ throw error;
523
+ }
524
+ }
525
+
282
526
  // ── Lifecycle ──────────────────────────────────────────────────────
283
527
 
284
- async shutdown(reason: string, closeBrowser: boolean): Promise<void> {
285
- if (this.shuttingDown) return;
286
- this.shuttingDown = true;
287
- this.logger.info(reason, { session: this.session });
288
- await this.ipcServer.close();
289
- if (!closeBrowser) return;
290
- if (this.externallyManaged) {
291
- // Drop the CDP pipe without killing the external browser.
292
- try {
293
- (
294
- this.browser as unknown as {
295
- _connection?: { close(): void };
528
+ registerShutdownHandler(handler: ShutdownHandler): void {
529
+ this.shutdownHandlers.push(handler);
530
+ }
531
+
532
+ shutdown(
533
+ reason: string,
534
+ closeBrowser: boolean,
535
+ options: ShutdownOptions = {},
536
+ ): Promise<DaemonCloseResult> {
537
+ if (this.shutdownPromise) return this.shutdownPromise;
538
+ this.shutdownPromise = this.performShutdown(reason, closeBrowser, options);
539
+ return this.shutdownPromise;
540
+ }
541
+
542
+ private async performShutdown(
543
+ reason: string,
544
+ closeBrowser: boolean,
545
+ options: ShutdownOptions,
546
+ ): Promise<DaemonCloseResult> {
547
+ let replayUrl: string | undefined;
548
+ try {
549
+ this.logger.info(reason, { session: this.session });
550
+ for (const handler of this.shutdownHandlers) {
551
+ await handler(options);
552
+ }
553
+ if (closeBrowser) {
554
+ if (this.externallyManaged) {
555
+ // Playwright does not expose a public "detach from this CDP browser"
556
+ // API. Closing the private connection lets the daemon's event loop
557
+ // drain without asking Playwright to close the externally managed
558
+ // browser/provider session itself.
559
+ try {
560
+ (
561
+ this.browser as unknown as {
562
+ _connection?: { close(): void };
563
+ }
564
+ )._connection?.close();
565
+ } catch {
566
+ // Connection may already be closed.
296
567
  }
297
- )._connection?.close();
298
- } catch {
299
- // Connection may already be closed.
568
+ } else {
569
+ await this.browser.close();
570
+ }
571
+ }
572
+ if (this.providerSession) {
573
+ const result = await this.providerSession.provider.closeSession(
574
+ this.providerSession.sessionId,
575
+ );
576
+ replayUrl = result.replayUrl;
577
+ if (result.replayUrl) {
578
+ this.logger.info("provider-recording", {
579
+ session: this.session,
580
+ provider: this.providerSession.name,
581
+ sessionId: this.providerSession.sessionId,
582
+ replayUrl: result.replayUrl,
583
+ });
584
+ }
585
+ writeFileSync(
586
+ getSessionProviderClosePath(this.session),
587
+ JSON.stringify(
588
+ {
589
+ provider: this.providerSession.name,
590
+ sessionId: this.providerSession.sessionId,
591
+ replayUrl: result.replayUrl,
592
+ },
593
+ null,
594
+ 2,
595
+ ),
596
+ "utf8",
597
+ );
598
+ }
599
+ return replayUrl ? { replayUrl } : {};
600
+ } finally {
601
+ if (options.keepIpcClientsAlive) {
602
+ setImmediate(() => {
603
+ for (const cli of this.connectedClis) {
604
+ cli.destroy();
605
+ }
606
+ this.connectedClis.clear();
607
+ });
300
608
  }
301
- } else {
302
- await this.browser.close();
303
609
  }
304
610
  }
305
611
 
@@ -307,82 +613,245 @@ class BrowserDaemon {
307
613
 
308
614
  private resolveTargetPage(pageId?: string): Page {
309
615
  if (!pageId) {
310
- if (this.pageById.size > 1) {
616
+ if (this.page.isClosed()) {
617
+ const openPages = Array.from(this.pageById.values());
618
+ if (openPages.length === 1) return openPages[0];
311
619
  throw new Error(
312
- `Multiple pages are open in session "${this.session}". Pass --page <id> to target a page (run "libretto pages --session ${this.session}" to list ids).`,
620
+ `The primary page for session "${this.session}" is closed. Run "${librettoCommand(`pages --session ${this.session}`)}" to choose a page id.`,
313
621
  );
314
622
  }
315
- // Return the single tracked page rather than `this.page` — the
316
- // initial page may have been closed and replaced by a new one.
317
- if (this.pageById.size === 1) {
318
- return this.pageById.values().next().value!;
319
- }
320
623
  return this.page;
321
624
  }
322
625
  const page = this.pageById.get(pageId);
323
626
  if (!page) {
324
627
  throw new Error(
325
- `Page "${pageId}" was not found in session "${this.session}". Run "libretto pages --session ${this.session}" to list ids.`,
628
+ `Page "${pageId}" was not found in session "${this.session}". Run "${librettoCommand(`pages --session ${this.session}`)}" to list ids.`,
326
629
  );
327
630
  }
328
631
  return page;
329
632
  }
330
633
 
331
- // ── IPC handler ────────────────────────────────────────────────────
634
+ // ── IPC handlers ───────────────────────────────────────────────────
635
+
636
+ private createIpcHandlers(): IpcPeerHandlers<CliToDaemonApi> {
637
+ return {
638
+ ping: () => ({ protocolVersion: PROTOCOL_VERSION }),
639
+ pages: () =>
640
+ this.withRequestTimeout(() => handlePages(this.pageById, this.page)),
641
+ exec: (args) => this.runExec(args),
642
+ readonlyExec: (args) => this.runReadonlyExec(args),
643
+ snapshot: (args) => this.runSnapshot(args),
644
+ getWorkflowStatus: () => this.getWorkflowStatus(),
645
+ resumeWorkflow: () => this.resumeWorkflow(),
646
+ close: () =>
647
+ this.shutdown("ipc-close", true, {
648
+ keepIpcClientsAlive: true,
649
+ }),
650
+ };
651
+ }
332
652
 
333
- private async handleRequest(request: DaemonRequest): Promise<unknown> {
334
- if (request.command === "ping") {
335
- return { protocolVersion: PROTOCOL_VERSION };
653
+ private async runSnapshot(
654
+ args: Parameters<CliToDaemonApi["snapshot"]>[0],
655
+ ): Promise<ReturnType<CliToDaemonApi["snapshot"]>> {
656
+ const targetPage = this.resolveTargetPage(args.pageId);
657
+ const result = await this.withRequestTimeout(() =>
658
+ handleCompactSnapshot(
659
+ targetPage,
660
+ this.session,
661
+ this.logger,
662
+ {
663
+ pageId: args.pageId,
664
+ cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
665
+ useCachedSnapshot: args.useCachedSnapshot,
666
+ },
667
+ ),
668
+ );
669
+ if (!args.useCachedSnapshot) {
670
+ this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
336
671
  }
672
+ return result;
673
+ }
337
674
 
675
+ private async withRequestTimeout<T>(
676
+ operation: () => Promise<T> | T,
677
+ ): Promise<T> {
338
678
  // All non-ping commands get a timeout guard. The timer is cleared
339
679
  // when the command settles to avoid orphaned timers that would
340
680
  // keep the event loop alive after shutdown.
341
- let timerId: ReturnType<typeof setTimeout>;
342
- return Promise.race([
343
- this.dispatchCommand(request).finally(() => clearTimeout(timerId)),
344
- new Promise<never>((_resolve, reject) => {
345
- timerId = setTimeout(
346
- () =>
347
- reject(
348
- new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`),
349
- ),
350
- REQUEST_TIMEOUT_MS,
351
- );
352
- }),
353
- ]);
681
+ let timerId: ReturnType<typeof setTimeout> | undefined;
682
+ const timeout = new Promise<never>((_resolve, reject) => {
683
+ timerId = setTimeout(
684
+ () =>
685
+ reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)),
686
+ REQUEST_TIMEOUT_MS,
687
+ );
688
+ });
689
+
690
+ try {
691
+ return await Promise.race([operation(), timeout]);
692
+ } finally {
693
+ if (timerId) clearTimeout(timerId);
694
+ }
354
695
  }
355
696
 
356
- private async dispatchCommand(request: DaemonRequest): Promise<unknown> {
357
- switch (request.command) {
358
- case "pages":
359
- return handlePages(this.pageById, this.page);
360
- case "exec":
361
- return handleExec(
362
- this.resolveTargetPage(request.pageId),
363
- request.code,
697
+ private async runExec(
698
+ args: Parameters<CliToDaemonApi["exec"]>[0],
699
+ ): Promise<DaemonExecResult> {
700
+ return this.runCompactExec(args);
701
+ }
702
+
703
+ private async runCompactExec(
704
+ args: Parameters<CliToDaemonApi["exec"]>[0],
705
+ ): Promise<DaemonExecResult> {
706
+ let targetPage: Page | undefined;
707
+ try {
708
+ targetPage = this.resolveTargetPage(args.pageId);
709
+ const page = targetPage;
710
+ const data = await this.withRequestTimeout(async () => {
711
+ const before =
712
+ this.latestCompactSnapshotByPage.get(page) ?? (await snapshot(page));
713
+ const result = await handleExec(
714
+ page,
715
+ args.code,
364
716
  this.context,
365
717
  this.browser,
366
718
  this.execState,
367
719
  this.session,
368
- request.visualize,
720
+ args.visualize,
369
721
  );
370
- case "readonly-exec":
371
- return handleReadonlyExec(
372
- this.resolveTargetPage(request.pageId),
373
- request.code,
374
- );
375
- case "snapshot":
376
- return handleSnapshot(
377
- this.resolveTargetPage(request.pageId),
378
- this.session,
379
- this.logger,
380
- request.pageId,
381
- );
382
- default:
383
- throw new Error(
384
- `Unknown command: ${(request as { command: string }).command}`,
722
+
723
+ try {
724
+ const waitResult = await waitForPageStable(page);
725
+ if (!waitResult.ok) {
726
+ this.logger.warn("compact-exec-stability-wait-incomplete", {
727
+ session: this.session,
728
+ pageId: args.pageId,
729
+ diagnostics: waitResult.diagnostics,
730
+ });
731
+ }
732
+
733
+ const after = await snapshot(page);
734
+ const snapshotDiff = diffSnapshots(before, after);
735
+ this.latestCompactSnapshotByPage.set(page, after);
736
+ return { ...result, snapshotDiff };
737
+ } catch (error) {
738
+ this.latestCompactSnapshotByPage.delete(page);
739
+ this.logger.warn("compact-exec-diff-failed", {
740
+ session: this.session,
741
+ pageId: args.pageId,
742
+ error: error instanceof Error ? error.message : String(error),
743
+ });
744
+ return result;
745
+ }
746
+ });
747
+ return { ok: true, data };
748
+ } catch (error) {
749
+ if (targetPage) this.latestCompactSnapshotByPage.delete(targetPage);
750
+ return this.createExecErrorResult(error);
751
+ }
752
+ }
753
+
754
+ private async runReadonlyExec(
755
+ args: Parameters<CliToDaemonApi["readonlyExec"]>[0],
756
+ ): Promise<DaemonExecResult> {
757
+ try {
758
+ const data = await this.withRequestTimeout(() =>
759
+ handleReadonlyExec(this.resolveTargetPage(args.pageId), args.code),
760
+ );
761
+ return { ok: true, data };
762
+ } catch (error) {
763
+ return this.createExecErrorResult(error);
764
+ }
765
+ }
766
+
767
+ private createExecErrorResult(error: unknown): DaemonExecResult {
768
+ return {
769
+ ok: false,
770
+ message: error instanceof Error ? error.message : String(error),
771
+ output:
772
+ error instanceof Error ? (error as ErrorWithOutput).output : undefined,
773
+ };
774
+ }
775
+
776
+ startWorkflow(args: {
777
+ workflow: DaemonWorkflowConfig;
778
+ headed: boolean;
779
+ loadedWorkflow?: ExportedLibrettoWorkflow;
780
+ }): void {
781
+ if (this.workflowController) {
782
+ throw new Error("Workflow controller has already started.");
783
+ }
784
+
785
+ this.workflowController = new WorkflowController({
786
+ session: this.session,
787
+ headed: args.headed,
788
+ page: this.page,
789
+ context: this.context,
790
+ logger: this.logger,
791
+ onLog: (event) => {
792
+ void this.broadcast("workflowOutput", event);
793
+ },
794
+ onOutcome: (outcome) => {
795
+ if (outcome.state === "paused") {
796
+ void this.broadcast("workflowPaused", {
797
+ pausedAt: outcome.pausedAt,
798
+ url: outcome.url,
799
+ });
800
+ return;
801
+ }
802
+ void this.broadcast(
803
+ "workflowFinished",
804
+ outcome.result === "completed"
805
+ ? { result: "completed", completedAt: outcome.completedAt }
806
+ : {
807
+ result: "failed",
808
+ message: outcome.message,
809
+ phase: outcome.phase,
810
+ },
385
811
  );
812
+ },
813
+ });
814
+ this.workflowController.start({
815
+ integrationPath: args.workflow.integrationPath,
816
+ params: args.workflow.params,
817
+ visualize: args.workflow.visualize,
818
+ loadedWorkflow: args.loadedWorkflow,
819
+ });
820
+ }
821
+
822
+ getWorkflowStatus(): ReturnType<WorkflowController["getStatus"]> {
823
+ return this.workflowController?.getStatus() ?? { state: "idle" };
824
+ }
825
+
826
+ resumeWorkflow(): void {
827
+ if (!this.workflowController) {
828
+ throw new Error("Workflow is not paused.");
829
+ }
830
+ this.workflowController.resume();
831
+ }
832
+
833
+ async broadcast<Name extends keyof DaemonToCliApi>(
834
+ name: Name,
835
+ message: Parameters<DaemonToCliApi[Name]>[0],
836
+ ): Promise<void> {
837
+ const results = await Promise.allSettled(
838
+ Array.from(this.connectedClis, (cli) => {
839
+ const call = cli.call[name] as (
840
+ message: Parameters<DaemonToCliApi[Name]>[0],
841
+ ) => Promise<void>;
842
+ return call(message);
843
+ }),
844
+ );
845
+ for (const result of results) {
846
+ if (result.status === "rejected") {
847
+ this.logger.warn("workflow-event-failed", {
848
+ event: name,
849
+ error:
850
+ result.reason instanceof Error
851
+ ? result.reason.message
852
+ : String(result.reason),
853
+ });
854
+ }
386
855
  }
387
856
  }
388
857
  }
@@ -391,10 +860,65 @@ class BrowserDaemon {
391
860
 
392
861
  async function main(): Promise<void> {
393
862
  const config = JSON.parse(process.argv[2]) as DaemonConfig;
863
+ const headed =
864
+ config.browser.kind === "launch" ? config.browser.headed : false;
394
865
 
395
- const daemon = isConnectConfig(config)
396
- ? await BrowserDaemon.connectToEndpoint(config)
397
- : await BrowserDaemon.launchBrowser(config);
866
+ let loadedWorkflow: ExportedLibrettoWorkflow | undefined;
867
+ if (config.workflow) {
868
+ try {
869
+ loadedWorkflow = await loadDefaultWorkflow(
870
+ getAbsoluteIntegrationPath(config.workflow.integrationPath),
871
+ );
872
+ } catch (error) {
873
+ throw new UserFacingStartupError(
874
+ error instanceof Error ? error.message : String(error),
875
+ );
876
+ }
877
+ }
878
+
879
+ const daemon =
880
+ config.browser.kind === "provider"
881
+ ? await BrowserDaemon.connectToProvider({
882
+ session: config.session,
883
+ experiments: config.experiments,
884
+ browser: config.browser,
885
+ })
886
+ : config.browser.kind === "connect"
887
+ ? await BrowserDaemon.connectToEndpoint({
888
+ session: config.session,
889
+ experiments: config.experiments,
890
+ browser: config.browser,
891
+ })
892
+ : await BrowserDaemon.launchBrowser({
893
+ session: config.session,
894
+ experiments: config.experiments,
895
+ browser: config.browser,
896
+ workflow: config.workflow,
897
+ });
898
+
899
+ if (config.workflow) {
900
+ void waitForSessionState(config.session)
901
+ .then(() =>
902
+ daemon.startWorkflow({
903
+ workflow: config.workflow!,
904
+ headed,
905
+ loadedWorkflow,
906
+ }),
907
+ )
908
+ .catch((error) => {
909
+ const message = error instanceof Error ? error.message : String(error);
910
+ daemon.logger.error("workflow-failed", {
911
+ error: message,
912
+ });
913
+ return daemon
914
+ .broadcast("workflowFinished", {
915
+ result: "failed" as const,
916
+ message,
917
+ phase: "setup" as const,
918
+ })
919
+ .finally(() => daemon.shutdown("workflow-start-failed", true));
920
+ });
921
+ }
398
922
 
399
923
  process.on("SIGTERM", () => {
400
924
  void daemon.shutdown("child-sigterm", true);
@@ -426,4 +950,18 @@ async function main(): Promise<void> {
426
950
  // letting the process exit naturally.
427
951
  }
428
952
 
429
- await main();
953
+ function reportStartupError(error: unknown): never {
954
+ if (error instanceof UserFacingStartupError) {
955
+ process.send?.({
956
+ type: "startup-error",
957
+ message: error.message,
958
+ });
959
+ }
960
+ process.exit(1);
961
+ }
962
+
963
+ try {
964
+ await main();
965
+ } catch (error) {
966
+ reportStartupError(error);
967
+ }