jev-cdp 0.1.4 → 0.1.6

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 (3) hide show
  1. package/README.md +7 -5
  2. package/dist/cli.js +720 -140
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "jev-cdp",
6
- version: "0.1.4",
6
+ version: "0.1.6",
7
7
  description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
8
8
  type: "module",
9
9
  license: "MIT",
@@ -38,9 +38,9 @@ var package_default = {
38
38
 
39
39
  // src/browser.ts
40
40
  import { createHash } from "crypto";
41
- import { mkdir, mkdtemp, rm } from "fs/promises";
42
- import { tmpdir } from "os";
43
- import { dirname, join, resolve } from "path";
41
+ import { mkdir, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
42
+ import { tmpdir as tmpdir2 } from "os";
43
+ import { dirname, join as join2, resolve } from "path";
44
44
 
45
45
  // src/cdp.ts
46
46
  async function listChromeTargets(cdpUrl) {
@@ -143,6 +143,88 @@ class CdpClient {
143
143
  }
144
144
  }
145
145
 
146
+ // src/ffmpeg.ts
147
+ import { mkdtemp, rm } from "fs/promises";
148
+ import { tmpdir } from "os";
149
+ import { join } from "path";
150
+ function selectFrameSyncOption(help) {
151
+ if (/^\s*-fps_mode(?:\[:<stream_spec>\])?\s/m.test(help))
152
+ return "-fps_mode";
153
+ if (/^\s*-vsync\s/m.test(help))
154
+ return "-vsync";
155
+ throw new Error("FFmpeg supports neither -fps_mode nor -vsync");
156
+ }
157
+ async function run(command) {
158
+ const process2 = Bun.spawn(command, { stdout: "ignore", stderr: "pipe" });
159
+ const stderr = await new Response(process2.stderr).text();
160
+ return { code: await process2.exited, stderr };
161
+ }
162
+ async function recordingEncoder(ffmpeg = Bun.which("ffmpeg")) {
163
+ if (!ffmpeg)
164
+ throw new Error("FFmpeg not found on PATH");
165
+ const process2 = Bun.spawn([ffmpeg, "-hide_banner", "-h", "full"], { stdout: "pipe", stderr: "pipe" });
166
+ const [stdout, stderr, code] = await Promise.all([
167
+ new Response(process2.stdout).text(),
168
+ new Response(process2.stderr).text(),
169
+ process2.exited
170
+ ]);
171
+ if (code !== 0)
172
+ throw new Error(`Could not inspect FFmpeg options: ${stderr.slice(-500)}`);
173
+ return { path: ffmpeg, sync: selectFrameSyncOption(stdout + stderr) };
174
+ }
175
+ function recordingCommand(encoder, manifest, output) {
176
+ return [
177
+ encoder.path,
178
+ "-y",
179
+ "-f",
180
+ "concat",
181
+ "-safe",
182
+ "0",
183
+ "-i",
184
+ manifest,
185
+ encoder.sync,
186
+ "vfr",
187
+ "-c:v",
188
+ "libx264",
189
+ "-pix_fmt",
190
+ "yuv420p",
191
+ "-movflags",
192
+ "+faststart",
193
+ output
194
+ ];
195
+ }
196
+ async function renderRecording(encoder, manifest, output) {
197
+ const result = await run(recordingCommand(encoder, manifest, output));
198
+ if (result.code !== 0)
199
+ throw new Error(`Could not render recording: ${result.stderr.slice(-800)}`);
200
+ }
201
+ async function checkRecordingEncoder() {
202
+ const encoder = await recordingEncoder();
203
+ const directory = await mkdtemp(join(tmpdir(), "jev-cdp-ffmpeg-check-"));
204
+ try {
205
+ const frame = join(directory, "frame.ppm");
206
+ const manifest = join(directory, "frames.ffconcat");
207
+ const output = join(directory, "check.mp4");
208
+ await Bun.write(frame, Buffer.concat([Buffer.from(`P6
209
+ 16 16
210
+ 255
211
+ `), Buffer.alloc(16 * 16 * 3, 90)]));
212
+ const quoted = frame.replaceAll("'", "'\\''");
213
+ await Bun.write(manifest, `ffconcat version 1.0
214
+ file '${quoted}'
215
+ duration 0.12
216
+ file '${quoted}'
217
+ `);
218
+ await renderRecording(encoder, manifest, output);
219
+ const decoded = await run([encoder.path, "-v", "error", "-i", output, "-f", "null", "-"]);
220
+ if (decoded.code !== 0)
221
+ throw new Error(`Could not decode recording: ${decoded.stderr.slice(-500)}`);
222
+ return `FFmpeg at ${encoder.path}; ${encoder.sync} vfr and libx264 encode/decode passed`;
223
+ } finally {
224
+ await rm(directory, { recursive: true, force: true });
225
+ }
226
+ }
227
+
146
228
  // src/snapshot.js
147
229
  var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1ad2 under the MIT License.
148
230
  (() => {
@@ -207,8 +289,15 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
207
289
  const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
208
290
  if (!rname || r.width<=0 || r.height<=0 || x<0 || y<0 || x>=innerWidth || y>=innerHeight) continue;
209
291
  if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
292
+ const hit=document.elementFromPoint(x,y);
293
+ const coveredBy=hit && !e.contains(hit) && !hit.contains(e) ?
294
+ {tag:hit.tagName.toLowerCase(),text:(hit.innerText||hit.getAttribute('aria-label')||'').trim().slice(0,160),role:hit.getAttribute('role')} : null;
295
+ const region=e.closest('form,dialog,[role="dialog"],article,section,li,tr,[role="row"],main,nav,header,footer');
296
+ const nearbyText=(region?.innerText||e.parentElement?.innerText||'').trim().replace(/\\s+/g,' ').slice(0,240);
210
297
  const base={node:identity(e),role:rname,label:name(e)||rname,...(sensitive(e)?{sensitive:true}:{}),
211
- rect:{x:r.x,y:r.y,w:r.width,h:r.height}};
298
+ rect:{x:r.x,y:r.y,w:r.width,h:r.height},frameUrl:location.href,
299
+ nearbyText,region:region?.getAttribute('aria-label')||region?.getAttribute('role')||region?.tagName.toLowerCase()||'',
300
+ clickable:!coveredBy,coveredBy};
212
301
  for (const key of ['checked','selected','expanded','pressed']) {
213
302
  const value=e.getAttribute('aria-'+key);
214
303
  if (value!==null) base[key]=value;
@@ -228,14 +317,20 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
228
317
  if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
229
318
  }
230
319
  }
231
- const words=[], walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);
232
- const range=document.createRange(); let node,length=0;
233
- while ((node=walker.nextNode()) && length<6000) {
234
- const value=node.textContent.trim(), parent=node.parentElement;
235
- if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
236
- range.selectNodeContents(node); const r=range.getBoundingClientRect();
237
- if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
238
- words.push(value); length+=value.length;
320
+ const roots=[document.body], shadowRoots=[];
321
+ for (const root of roots) for (const e of root.querySelectorAll('*')) {
322
+ if (e.shadowRoot) { roots.push(e.shadowRoot); shadowRoots.push(e.shadowRoot); }
323
+ }
324
+ const words=[], range=document.createRange(); let length=0;
325
+ for (const root of [...shadowRoots,document.body]) {
326
+ const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let node;
327
+ while ((node=walker.nextNode()) && length<6000) {
328
+ const value=node.textContent.trim(), parent=node.parentElement;
329
+ if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
330
+ range.selectNodeContents(node); const r=range.getBoundingClientRect();
331
+ if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
332
+ words.push(value); length+=value.length;
333
+ }
239
334
  }
240
335
  }
241
336
  const text=words.join('\\n').slice(0,6000), height=document.documentElement.scrollHeight;
@@ -249,7 +344,6 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
249
344
  actions.forEach((a,i)=>a.id='e'+(i+1));
250
345
  if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
251
346
  if (scrollY>0) actions.push({id:'scroll_up',kind:'scroll',label:'Scroll up',delta:-560});
252
- actions.push({id:'wait',kind:'wait',label:'Wait for the page to update'});
253
347
  return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
254
348
  scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
255
349
  })()
@@ -278,6 +372,18 @@ var RECORDING_CURSOR_INIT = `(() => {
278
372
 
279
373
  class StalePageError extends Error {
280
374
  }
375
+
376
+ class WaitTimeoutError extends Error {
377
+ elapsedMs;
378
+ pendingCondition;
379
+ state;
380
+ constructor(elapsedMs, pendingCondition, state) {
381
+ super(`Wait timed out after ${elapsedMs}ms: ${pendingCondition}`);
382
+ this.elapsedMs = elapsedMs;
383
+ this.pendingCondition = pendingCondition;
384
+ this.state = state;
385
+ }
386
+ }
281
387
  function stableValue(value) {
282
388
  if (Array.isArray(value))
283
389
  return value.map(stableValue);
@@ -323,6 +429,17 @@ class Browser {
323
429
  #openedTabs = [];
324
430
  #cdpUrl;
325
431
  #switchOnPopup = false;
432
+ #loadingFrames = new Set;
433
+ #waitBudgetMs;
434
+ #waitSpentMs = 0;
435
+ #transitions = [];
436
+ #origin = null;
437
+ #changeListeners = new Set;
438
+ #targetUrls = new Map;
439
+ #newTargets = new Set;
440
+ #popupListeners = new Set;
441
+ #consoleErrors = [];
442
+ #watchedConsoleSessions = new Set;
326
443
  constructor(cdp, sessionId, targetId, ownsTarget, browserContextId, options) {
327
444
  this.#cdp = cdp;
328
445
  this.#cdpUrl = options.cdpUrl;
@@ -333,6 +450,7 @@ class Browser {
333
450
  this.#keepOpen = options.keepOpen ?? false;
334
451
  this.#screenshots = options.screenshots ?? false;
335
452
  this.#interactionPauses = options.interactionPauses ?? 0;
453
+ this.#waitBudgetMs = options.waitBudgetMs ?? 15000;
336
454
  this.#recordingPath = options.recordingPath ? resolve(options.recordingPath) : undefined;
337
455
  this.#screenshotPath = options.screenshotPath ? resolve(options.screenshotPath) : undefined;
338
456
  }
@@ -385,10 +503,30 @@ class Browser {
385
503
  const browser = new Browser(cdp, attached.sessionId, targetId, ownsTarget, browserContextId, options);
386
504
  browser.#knownTargets = new Set((await listChromeTargets(options.cdpUrl)).map((target) => target.id));
387
505
  try {
506
+ const recordTargetUrl = (params) => {
507
+ const info = params.targetInfo;
508
+ if (info?.type === "page" && info.targetId && !browser.#knownTargets.has(info.targetId)) {
509
+ browser.#newTargets.add(info.targetId);
510
+ for (const listener of browser.#popupListeners)
511
+ listener();
512
+ }
513
+ if (!info?.targetId || !info.url || info.url === "about:blank")
514
+ return;
515
+ const urls = browser.#targetUrls.get(info.targetId) ?? [];
516
+ if (urls.at(-1) !== info.url)
517
+ urls.push(info.url);
518
+ browser.#targetUrls.set(info.targetId, urls);
519
+ };
520
+ browser.#stopPageEvents.push(cdp.on("Target.targetCreated", "", recordTargetUrl), cdp.on("Target.targetInfoChanged", "", recordTargetUrl));
521
+ await cdp.command("Target.setDiscoverTargets", { discover: true });
388
522
  browser.#stopPageEvents.push(cdp.on("Target.attachedToTarget", browser.#sessionId, (params) => {
389
523
  const info = params.targetInfo;
390
- if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string")
524
+ if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string") {
391
525
  browser.#frameSessions.set(info.targetId, params.sessionId);
526
+ browser.watchConsole(params.sessionId, info.targetId).catch(() => {
527
+ return;
528
+ });
529
+ }
392
530
  }));
393
531
  browser.#stopPageEvents.push(cdp.on("Page.frameNavigated", browser.#sessionId, (params) => {
394
532
  const frame = params.frame;
@@ -396,6 +534,7 @@ class Browser {
396
534
  browser.#frameContexts.delete(frame.id);
397
535
  }));
398
536
  await browser.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
537
+ await browser.watchConsole();
399
538
  await browser.call("Page.enable");
400
539
  await browser.call("Emulation.setDeviceMetricsOverride", {
401
540
  width: 1120,
@@ -412,7 +551,9 @@ class Browser {
412
551
  await browser.startRecording();
413
552
  return browser;
414
553
  } catch (error) {
415
- await browser.close();
554
+ await browser.close().catch(() => {
555
+ return;
556
+ });
416
557
  throw error;
417
558
  }
418
559
  }
@@ -422,13 +563,65 @@ class Browser {
422
563
  get targetId() {
423
564
  return this.#targetId;
424
565
  }
425
- async watchPageLoads() {
426
- if (!this.#interactionPauses)
566
+ takeConsoleErrors() {
567
+ return this.#consoleErrors.splice(0);
568
+ }
569
+ pendingConsoleErrors() {
570
+ return [...this.#consoleErrors];
571
+ }
572
+ async watchConsole(sessionId = this.#sessionId, targetId = this.#targetId) {
573
+ if (this.#watchedConsoleSessions.has(sessionId))
427
574
  return;
575
+ this.#watchedConsoleSessions.add(sessionId);
576
+ const record = (source, message, url, timestamp) => {
577
+ if (!message.trim())
578
+ return;
579
+ this.#consoleErrors.push({
580
+ source,
581
+ message: message.slice(0, 4000),
582
+ ...url ? { url } : {},
583
+ ...timestamp !== undefined ? { timestamp } : {},
584
+ targetId
585
+ });
586
+ };
587
+ this.#stopPageEvents.push(this.#cdp.on("Runtime.consoleAPICalled", sessionId, (params) => {
588
+ if (params.type !== "error")
589
+ return;
590
+ const args = params.args;
591
+ const message = args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? "";
592
+ const frame = params.stackTrace?.callFrames?.[0];
593
+ record("console", message, frame?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
594
+ }), this.#cdp.on("Runtime.exceptionThrown", sessionId, (params) => {
595
+ const details = params.exceptionDetails;
596
+ record("exception", details?.exception?.description ?? details?.text ?? "Uncaught exception", details?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
597
+ }), this.#cdp.on("Log.entryAdded", sessionId, (params) => {
598
+ const entry = params.entry;
599
+ if (entry?.level === "error")
600
+ record("log", entry.text ?? "", entry.url, entry.timestamp);
601
+ }));
602
+ await this.#cdp.command("Runtime.enable", {}, sessionId);
603
+ await this.#cdp.command("Log.enable", {}, sessionId);
604
+ }
605
+ async watchPageLoads() {
428
606
  await this.call("Page.enable");
429
607
  const tree = await this.call("Page.getFrameTree");
430
608
  this.#mainFrameId = tree.frameTree.frame.id;
609
+ await this.call("Runtime.addBinding", { name: "__jevDomChanged" });
610
+ const observeDom = `(() => {
611
+ if (window.__jevDomWatching) return;
612
+ window.__jevDomWatching = true;
613
+ const start = () => new MutationObserver(() => window.__jevDomChanged?.('change'))
614
+ .observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
615
+ if (document.documentElement) start(); else document.addEventListener('DOMContentLoaded', start, {once:true});
616
+ })()`;
617
+ await this.call("Page.addScriptToEvaluateOnNewDocument", { source: observeDom });
618
+ await this.evaluate(observeDom).catch(() => {
619
+ return;
620
+ });
431
621
  const loading = (params) => {
622
+ if (typeof params.frameId === "string")
623
+ this.#loadingFrames.add(params.frameId);
624
+ this.signalChange();
432
625
  if (params.frameId !== this.#mainFrameId || this.#pageLoadPromise)
433
626
  return;
434
627
  this.#pageLoadPromise = new Promise((resolve2) => {
@@ -436,6 +629,9 @@ class Browser {
436
629
  });
437
630
  };
438
631
  const loaded = () => {
632
+ if (this.#mainFrameId)
633
+ this.#loadingFrames.delete(this.#mainFrameId);
634
+ this.signalChange();
439
635
  if (!this.#pageLoadPromise)
440
636
  return;
441
637
  this.#pauseUntil = performance.now() + this.#interactionPauses;
@@ -445,34 +641,69 @@ class Browser {
445
641
  };
446
642
  this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
447
643
  const frame = params.frame;
644
+ this.signalChange();
448
645
  if (frame && frame.id === this.#mainFrameId && !frame.parentId)
449
646
  loading({ frameId: frame.id });
450
647
  }), this.#cdp.on("Page.loadEventFired", this.#sessionId, loaded), this.#cdp.on("Page.frameStoppedLoading", this.#sessionId, (params) => {
648
+ if (typeof params.frameId === "string")
649
+ this.#loadingFrames.delete(params.frameId);
650
+ this.signalChange();
451
651
  if (params.frameId === this.#mainFrameId)
452
652
  loaded();
453
653
  }), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
654
+ this.signalChange();
454
655
  if (params.frameId === this.#mainFrameId) {
455
656
  this.#pauseUntil = performance.now() + this.#interactionPauses;
456
657
  this.resetRecordingCursor().catch(() => {
457
658
  return;
458
659
  });
459
660
  }
460
- }));
661
+ }), this.#cdp.on("Page.frameAttached", this.#sessionId, () => this.signalChange()), this.#cdp.on("Page.frameDetached", this.#sessionId, () => this.signalChange()), this.#cdp.on("Runtime.bindingCalled", this.#sessionId, () => this.signalChange()));
662
+ }
663
+ signalChange() {
664
+ for (const listener of this.#changeListeners)
665
+ listener();
666
+ }
667
+ async waitForChange(ms) {
668
+ await new Promise((resolve2) => {
669
+ const done = () => {
670
+ clearTimeout(timer);
671
+ this.#changeListeners.delete(done);
672
+ resolve2();
673
+ };
674
+ const timer = setTimeout(done, Math.min(ms, 150));
675
+ this.#changeListeners.add(done);
676
+ });
677
+ }
678
+ async waitForNewTarget(ms) {
679
+ if (this.#newTargets.size)
680
+ return;
681
+ await new Promise((resolve2) => {
682
+ const done = () => {
683
+ clearTimeout(timer);
684
+ this.#popupListeners.delete(done);
685
+ resolve2();
686
+ };
687
+ const timer = setTimeout(done, ms);
688
+ this.#popupListeners.add(done);
689
+ });
461
690
  }
462
691
  async waitForInteractionPause() {
463
692
  if (!this.#interactionPauses)
464
693
  return;
694
+ const waitStarted = performance.now();
465
695
  while (true) {
466
696
  const pageLoad = this.#pageLoadPromise;
467
697
  if (pageLoad) {
468
- await Promise.race([
469
- pageLoad,
470
- Bun.sleep(15000).then(() => {
471
- throw new Error("Page did not finish loading within 15 seconds");
472
- })
473
- ]);
698
+ const remainingBudget = this.#waitBudgetMs - this.#waitSpentMs - (performance.now() - waitStarted);
699
+ if (remainingBudget <= 0)
700
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
701
+ await Promise.race([pageLoad, Bun.sleep(remainingBudget).then(() => {
702
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
703
+ })]);
474
704
  continue;
475
705
  }
706
+ this.#waitSpentMs += performance.now() - waitStarted;
476
707
  const remaining = this.#pauseUntil - performance.now();
477
708
  if (remaining <= 0)
478
709
  return;
@@ -482,11 +713,10 @@ class Browser {
482
713
  async startRecording() {
483
714
  if (!this.#recordingPath)
484
715
  return;
485
- if (!Bun.which("ffmpeg"))
486
- throw new Error("--recording requires ffmpeg on PATH");
716
+ await recordingEncoder();
487
717
  const firstSegment = !this.#recordingDirectory;
488
718
  if (firstSegment)
489
- this.#recordingDirectory = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
719
+ this.#recordingDirectory = await mkdtemp2(join2(tmpdir2(), "jev-cdp-recording-"));
490
720
  await this.call("Page.enable");
491
721
  await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
492
722
  const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
@@ -494,7 +724,7 @@ class Browser {
494
724
  throw new Error("Could not read the recording viewport");
495
725
  await this.animateCursor(viewport.width / 2, viewport.height / 2);
496
726
  const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
497
- const initialPath = join(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
727
+ const initialPath = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
498
728
  await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
499
729
  if (firstSegment)
500
730
  this.#recordingStartedAt = performance.now();
@@ -505,7 +735,7 @@ class Browser {
505
735
  return;
506
736
  });
507
737
  const sequence = this.#recordingSequence++;
508
- const path = join(this.#recordingDirectory, `${String(sequence).padStart(6, "0")}.jpg`);
738
+ const path = join2(this.#recordingDirectory, `${String(sequence).padStart(6, "0")}.jpg`);
509
739
  const elapsedMs = sequence ? performance.now() - this.#recordingStartedAt : 0;
510
740
  this.#recordingWrites = this.#recordingWrites.then(async () => {
511
741
  await Bun.write(path, Buffer.from(frame.data, "base64"));
@@ -543,33 +773,12 @@ class Browser {
543
773
  lines.push(`file '${quoted(frame.path)}'`, `duration ${duration.toFixed(4)}`);
544
774
  }
545
775
  lines.push(`file '${quoted(this.#recordingFrames.at(-1).path)}'`);
546
- const manifest = join(this.#recordingDirectory, "frames.ffconcat");
776
+ const manifest = join2(this.#recordingDirectory, "frames.ffconcat");
547
777
  await Bun.write(manifest, `${lines.join(`
548
778
  `)}
549
779
  `);
550
- const process2 = Bun.spawn([
551
- Bun.which("ffmpeg"),
552
- "-y",
553
- "-f",
554
- "concat",
555
- "-safe",
556
- "0",
557
- "-i",
558
- manifest,
559
- "-vsync",
560
- "vfr",
561
- "-c:v",
562
- "libx264",
563
- "-pix_fmt",
564
- "yuv420p",
565
- "-movflags",
566
- "+faststart",
567
- this.#recordingPath
568
- ], { stdout: "ignore", stderr: "pipe" });
569
- const stderr = await new Response(process2.stderr).text();
570
- if (await process2.exited !== 0)
571
- throw new Error(`Could not render recording: ${stderr.slice(-800)}`);
572
- await rm(this.#recordingDirectory, { recursive: true, force: true });
780
+ await renderRecording(await recordingEncoder(), manifest, this.#recordingPath);
781
+ await rm2(this.#recordingDirectory, { recursive: true, force: true });
573
782
  this.#recordingDirectory = undefined;
574
783
  }
575
784
  async saveFinalScreenshot() {
@@ -612,18 +821,52 @@ class Browser {
612
821
  })()`);
613
822
  }
614
823
  async waitForReady() {
615
- const deadline = Date.now() + 15000;
616
- while (Date.now() < deadline) {
824
+ const started = performance.now();
825
+ const deadline = started + this.#waitBudgetMs - this.#waitSpentMs;
826
+ while (performance.now() < deadline) {
617
827
  try {
618
- if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete")
828
+ if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete") {
829
+ this.#waitSpentMs += performance.now() - started;
619
830
  return;
831
+ }
832
+ } catch (error) {
833
+ if (!(error instanceof StalePageError))
834
+ throw error;
835
+ }
836
+ await this.waitForChange(Math.max(1, deadline - performance.now()));
837
+ }
838
+ this.#waitSpentMs += performance.now() - started;
839
+ const state = await this.observe(false).catch(() => null);
840
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "main document loading", state);
841
+ }
842
+ async waitForSemanticReady(initial, screenshot = this.#screenshots) {
843
+ const started = performance.now();
844
+ const spentAtStart = this.#waitSpentMs;
845
+ const deadline = started + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
846
+ const accountWait = () => {
847
+ this.#waitSpentMs += Math.max(0, performance.now() - started - (this.#waitSpentMs - spentAtStart));
848
+ };
849
+ let state = initial;
850
+ while (true) {
851
+ const pending = state.frames.find((frame) => frame.loading || frame.readyState !== "complete");
852
+ const loadingText = state.frames.length > 1 && !state.actions.some((action) => action.frameId) && /(?:^|\n)loading(?:\.{0,3}|\s)/i.test(state.text);
853
+ if (!pending && !loadingText) {
854
+ accountWait();
855
+ return state;
856
+ }
857
+ const condition = pending ? `frame ${pending.url || pending.id} loading` : "embedded content loading";
858
+ if (performance.now() >= deadline) {
859
+ accountWait();
860
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs), condition, state);
861
+ }
862
+ await this.waitForChange(Math.max(1, deadline - performance.now()));
863
+ try {
864
+ state = await this.observe(screenshot);
620
865
  } catch (error) {
621
866
  if (!(error instanceof StalePageError))
622
867
  throw error;
623
868
  }
624
- await Bun.sleep(20);
625
869
  }
626
- throw new Error("Page did not finish loading within 15 seconds");
627
870
  }
628
871
  async evaluate(expression, awaitPromise = false) {
629
872
  const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, awaitPromise ? 15000 : undefined);
@@ -644,14 +887,48 @@ class Browser {
644
887
  throw new StalePageError("Frame changed during evaluation");
645
888
  return response.result?.value;
646
889
  }
647
- async frameOffset(frameId) {
648
- const owner = await this.call("DOM.getFrameOwner", { frameId });
649
- const box = await this.call("DOM.getBoxModel", { backendNodeId: owner.backendNodeId });
890
+ async frameOffset(frameId, parentId = null) {
891
+ const session = parentId ? this.#frameSessions.get(parentId) ?? this.#sessionId : this.#sessionId;
892
+ const owner = await this.#cdp.command("DOM.getFrameOwner", { frameId }, session);
893
+ const box = await this.#cdp.command("DOM.getBoxModel", { backendNodeId: owner.backendNodeId }, session);
650
894
  return { x: box.model.content[0], y: box.model.content[1] };
651
895
  }
652
- async childFrames() {
896
+ async frameScreenOffset(frameId, frames) {
897
+ const states = frames ?? await this.frameStates();
898
+ let id = frameId;
899
+ let x = 0, y = 0;
900
+ while (id) {
901
+ const frame = states.find((item) => item.id === id);
902
+ if (!frame?.parentId)
903
+ break;
904
+ const offset = await this.frameOffset(id, frame.parentId);
905
+ x += offset.x;
906
+ y += offset.y;
907
+ id = frame.parentId;
908
+ }
909
+ return { x, y };
910
+ }
911
+ async frameStates() {
653
912
  const tree = await this.call("Page.getFrameTree");
654
- return (tree.frameTree.childFrames ?? []).map((child) => child.frame.id);
913
+ const frames = [];
914
+ const visit = async (node, parentId) => {
915
+ const { id, url = "" } = node.frame;
916
+ let readyState = null;
917
+ try {
918
+ readyState = id === this.#mainFrameId ? await this.evaluate("document.readyState") ?? null : await this.evaluateFrame(id, "document.readyState") ?? null;
919
+ } catch {}
920
+ frames.push({
921
+ id,
922
+ parentId,
923
+ url,
924
+ readyState,
925
+ loading: this.#loadingFrames.has(id) || readyState !== "complete"
926
+ });
927
+ for (const child of node.childFrames ?? [])
928
+ await visit(child, id);
929
+ };
930
+ await visit(tree.frameTree, null);
931
+ return frames;
655
932
  }
656
933
  async discoverTabs() {
657
934
  if (!this.#switchOnPopup)
@@ -660,6 +937,7 @@ class Browser {
660
937
  if (target.type !== "page" || this.#knownTargets.has(target.id))
661
938
  continue;
662
939
  this.#knownTargets.add(target.id);
940
+ this.#newTargets.delete(target.id);
663
941
  const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
664
942
  try {
665
943
  await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
@@ -680,6 +958,9 @@ class Browser {
680
958
  }
681
959
  this.#sessionId = attached.sessionId;
682
960
  this.#targetId = target.id;
961
+ this.#loadingFrames.clear();
962
+ await this.watchConsole();
963
+ await this.watchPageLoads();
683
964
  this.#switchOnPopup = false;
684
965
  await this.startRecording();
685
966
  } else {
@@ -687,7 +968,30 @@ class Browser {
687
968
  }
688
969
  }
689
970
  this.#openedTabs.push({ id: target.id, url: target.url, title: target.title });
971
+ if (this.#origin)
972
+ this.#transitions.push({
973
+ kind: "new_tab",
974
+ control: this.#origin.control,
975
+ fromTargetId: this.#origin.targetId,
976
+ targetId: target.id,
977
+ fromUrl: this.#origin.url,
978
+ destinationUrl: target.url,
979
+ settled: false
980
+ });
981
+ const urls = this.#targetUrls.get(target.id) ?? [];
982
+ const requested = this.#origin?.href ?? urls[0];
983
+ if (this.#origin && requested && requested !== target.url)
984
+ this.#transitions.push({
985
+ kind: "redirect",
986
+ control: this.#origin.control,
987
+ fromTargetId: this.#origin.targetId,
988
+ targetId: target.id,
989
+ fromUrl: requested,
990
+ destinationUrl: target.url,
991
+ settled: false
992
+ });
690
993
  }
994
+ this.#switchOnPopup = false;
691
995
  }
692
996
  async settleAfterInput() {
693
997
  const action = this.#afterInput;
@@ -726,32 +1030,42 @@ class Browser {
726
1030
  await this.settleAfterInput();
727
1031
  await this.discoverTabs();
728
1032
  let info;
729
- for (let attempt = 0;attempt < 10; attempt++) {
1033
+ const waitStarted = performance.now();
1034
+ const deadline = waitStarted + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
1035
+ let waited = false;
1036
+ while (true) {
730
1037
  try {
731
1038
  info = await this.evaluate(snapshot_default);
732
1039
  if (info)
733
1040
  break;
734
1041
  } catch (error) {
735
- if (!(error instanceof StalePageError) || attempt === 9)
1042
+ if (!(error instanceof StalePageError))
736
1043
  throw error;
737
1044
  }
738
- await Bun.sleep(20);
1045
+ if (performance.now() >= deadline) {
1046
+ this.#waitSpentMs += performance.now() - waitStarted;
1047
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "document navigating", null);
1048
+ }
1049
+ waited = true;
1050
+ await this.waitForChange(Math.max(1, deadline - performance.now()));
739
1051
  }
740
- if (!info)
741
- throw new StalePageError("Document is navigating");
1052
+ if (waited)
1053
+ this.#waitSpentMs += performance.now() - waitStarted;
742
1054
  if (this.#openedTabs.length) {
743
- const targets = await listChromeTargets(this.#cdpUrl);
1055
+ const targets2 = await listChromeTargets(this.#cdpUrl);
744
1056
  this.#openedTabs = this.#openedTabs.map((tab) => {
745
- const current = targets.find((target) => target.id === tab.id);
1057
+ const current = targets2.find((target) => target.id === tab.id);
746
1058
  return current ? { ...tab, url: current.url, title: current.title } : tab;
747
1059
  });
748
1060
  info.text = `${info.text}
749
1061
  ${this.#openedTabs.map((tab) => `Opened new tab: ${tab.title} ${tab.url} (target ${tab.id})`).join(`
750
1062
  `)}`;
751
1063
  }
752
- for (const frameId of await this.childFrames()) {
1064
+ const frames = await this.frameStates();
1065
+ for (const frame of frames.filter((frame2) => frame2.parentId !== null)) {
1066
+ const frameId = frame.id;
753
1067
  try {
754
- const offset = await this.frameOffset(frameId);
1068
+ const offset = await this.frameScreenOffset(frameId, frames);
755
1069
  const child = await this.evaluateFrame(frameId, snapshot_default);
756
1070
  if (!child)
757
1071
  continue;
@@ -763,13 +1077,67 @@ ${child.text}`.slice(0, 6000);
763
1077
  const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
764
1078
  if (rect.x < 0 || rect.y < 0 || rect.x >= info.w || rect.y >= info.h)
765
1079
  continue;
766
- info.actions.push({ ...action, frameId, rect, id: `e${info.actions.length + 1}` });
1080
+ const covering = await this.evaluate(`(() => {
1081
+ const e=document.elementFromPoint(${rect.x + rect.w / 2},${rect.y + rect.h / 2});
1082
+ if (!e || e.tagName==='IFRAME') return null;
1083
+ return {tag:e.tagName.toLowerCase(),text:(e.innerText||e.getAttribute('aria-label')||'').trim().slice(0,160),role:e.getAttribute('role')};
1084
+ })()`);
1085
+ info.actions.push({
1086
+ ...action,
1087
+ frameId,
1088
+ rect,
1089
+ id: `e${info.actions.length + 1}`,
1090
+ clickable: action.clickable && !covering,
1091
+ coveredBy: covering ?? action.coveredBy
1092
+ });
767
1093
  info.guards[`${frameId}:${action.node}`] = child.guards[String(action.node)];
768
1094
  }
769
1095
  info.marker = [info.marker, frameId, child.marker];
770
1096
  } catch {}
771
1097
  }
772
- const page = { ...info, fingerprint: fingerprint(info) };
1098
+ if (this.#origin && this.#targetId === this.#origin.targetId && info.url !== this.#origin.url && !this.#transitions.some((transition) => transition.control === this.#origin.control && transition.fromUrl === this.#origin.url && transition.targetId === this.#targetId)) {
1099
+ this.#transitions.push({
1100
+ kind: "navigation",
1101
+ control: this.#origin.control,
1102
+ fromTargetId: this.#origin.targetId,
1103
+ targetId: this.#targetId,
1104
+ fromUrl: this.#origin.url,
1105
+ destinationUrl: info.url,
1106
+ settled: false
1107
+ });
1108
+ if (this.#origin.href && this.#origin.href !== info.url)
1109
+ this.#transitions.push({
1110
+ kind: "redirect",
1111
+ control: this.#origin.control,
1112
+ fromTargetId: this.#origin.targetId,
1113
+ targetId: this.#targetId,
1114
+ fromUrl: this.#origin.href,
1115
+ destinationUrl: info.url,
1116
+ settled: false
1117
+ });
1118
+ }
1119
+ const targets = this.#transitions.length ? await listChromeTargets(this.#cdpUrl) : [];
1120
+ for (const transition of this.#transitions) {
1121
+ const target = targets.find((item) => item.id === transition.targetId);
1122
+ if (target)
1123
+ transition.destinationUrl = target.url;
1124
+ transition.settled = frames.every((frame) => !frame.loading);
1125
+ }
1126
+ if (this.#origin?.href)
1127
+ for (const transition of this.#transitions.filter((item) => item.kind === "new_tab" && item.control === this.#origin.control)) {
1128
+ if (transition.destinationUrl !== this.#origin.href && !this.#transitions.some((item) => item.kind === "redirect" && item.targetId === transition.targetId && item.fromUrl === this.#origin.href)) {
1129
+ this.#transitions.push({
1130
+ kind: "redirect",
1131
+ control: transition.control,
1132
+ fromTargetId: transition.fromTargetId,
1133
+ targetId: transition.targetId,
1134
+ fromUrl: this.#origin.href,
1135
+ destinationUrl: transition.destinationUrl,
1136
+ settled: transition.settled
1137
+ });
1138
+ }
1139
+ }
1140
+ const page = { ...info, frames, transitions: [...this.#transitions], fingerprint: fingerprint(info) };
773
1141
  if (screenshot) {
774
1142
  const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
775
1143
  page.screenshot = capture.data;
@@ -792,15 +1160,49 @@ ${child.text}`.slice(0, 6000);
792
1160
  })()`);
793
1161
  return stableStringify(current) === stableStringify([page.page_key, page.guards[String(action.node)]]);
794
1162
  }
795
- const marker = await this.evaluate(MARKER);
1163
+ let marker = await this.evaluate(MARKER);
1164
+ for (const frame of (await this.frameStates()).filter((item) => item.parentId !== null)) {
1165
+ try {
1166
+ const childMarker = await this.evaluateFrame(frame.id, MARKER);
1167
+ if (childMarker !== undefined)
1168
+ marker = [marker ?? null, frame.id, childMarker];
1169
+ } catch {
1170
+ return false;
1171
+ }
1172
+ }
796
1173
  return stableStringify(marker) === stableStringify(page.marker);
797
1174
  }
1175
+ async describeElement(action) {
1176
+ const expression = `(node => {
1177
+ const e = window.__jevFast?.nodes.get(node);
1178
+ if (!e?.isConnected) return null;
1179
+ const path = [];
1180
+ for (let current = e; current; current = current.parentElement) {
1181
+ if (current.id && document.querySelectorAll('#' + CSS.escape(current.id)).length === 1) {
1182
+ path.unshift('#' + CSS.escape(current.id));
1183
+ break;
1184
+ }
1185
+ let position = 1;
1186
+ for (let sibling = current.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
1187
+ if (sibling.tagName === current.tagName) position++;
1188
+ }
1189
+ path.unshift(current.tagName.toLowerCase() + ':nth-of-type(' + position + ')');
1190
+ }
1191
+ return {css:path.join(' > '),tag:e.tagName.toLowerCase(),
1192
+ href:typeof e.href === 'string' ? e.href : e.getAttribute('href'),
1193
+ inputType:e.getAttribute('type'),frameUrl:location.href,target:e.getAttribute('target')};
1194
+ })(${action.node})`;
1195
+ const details = action.frameId ? await this.evaluateFrame(action.frameId, expression) : await this.evaluate(expression);
1196
+ if (!details)
1197
+ throw new StalePageError("Target changed before replay details were captured");
1198
+ return details;
1199
+ }
798
1200
  async act(action, page, text) {
799
1201
  if (!await this.fresh(page, action))
800
1202
  throw new StalePageError("Page changed since this decision");
801
1203
  if (action.kind === "wait") {
802
1204
  await Bun.sleep(100);
803
- return;
1205
+ return { element: null, performedAt: performance.now() };
804
1206
  }
805
1207
  if (action.kind === "scroll") {
806
1208
  await this.animateCursor(550, 650);
@@ -811,14 +1213,24 @@ ${child.text}`.slice(0, 6000);
811
1213
  deltaX: 0,
812
1214
  deltaY: action.delta ?? 0
813
1215
  });
814
- return;
1216
+ return { element: null, performedAt: performance.now() };
815
1217
  }
816
1218
  if (typeof action.node !== "number")
817
1219
  throw new Error("Invalid observed node");
1220
+ const details = await this.describeElement(action);
1221
+ if (action.kind === "click")
1222
+ this.#origin = {
1223
+ control: action.label,
1224
+ targetId: this.#targetId,
1225
+ url: page.url,
1226
+ href: details.href
1227
+ };
818
1228
  const target = await (action.frameId ? this.evaluateFrame(action.frameId, `(action => {
819
1229
  const e=window.__jevFast?.nodes.get(action.node);
820
1230
  if (!e?.isConnected || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
821
- const r=e.getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2};
1231
+ const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2;
1232
+ if (!e.contains(document.elementFromPoint(x,y))) return null;
1233
+ return {x,y};
822
1234
  })(${JSON.stringify(action)})`) : this.evaluate(`(action => {
823
1235
  const e=window.__jevFast?.nodes.get(action.node);
824
1236
  if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
@@ -841,11 +1253,37 @@ ${child.text}`.slice(0, 6000);
841
1253
  throw new Error("Dropdown execution was not confirmed");
842
1254
  throw new StalePageError("Target changed or is covered");
843
1255
  }
1256
+ let performedAt = action.kind === "select" ? performance.now() : 0;
844
1257
  if (action.frameId) {
845
- const offset = await this.frameOffset(action.frameId);
1258
+ const offset = await this.frameScreenOffset(action.frameId);
846
1259
  target.x += offset.x;
847
1260
  target.y += offset.y;
1261
+ const covered = await this.evaluate(`(() => {
1262
+ const e=document.elementFromPoint(${target.x},${target.y});
1263
+ return !e || e.tagName!=='IFRAME';
1264
+ })()`);
1265
+ if (covered)
1266
+ throw new StalePageError("Target is covered in the parent page");
1267
+ }
1268
+ let frame = null;
1269
+ if (action.frameId) {
1270
+ const frames = await this.frameStates();
1271
+ const parentId = frames.find((item) => item.id === action.frameId)?.parentId ?? null;
1272
+ const index = frames.filter((item) => item.parentId === parentId).findIndex((item) => item.id === action.frameId);
1273
+ if (index < 0)
1274
+ throw new StalePageError("Target frame changed before input");
1275
+ frame = { id: action.frameId, parentId, url: details.frameUrl, index };
848
1276
  }
1277
+ const element = {
1278
+ css: details.css,
1279
+ role: action.role ?? null,
1280
+ name: action.label,
1281
+ tag: details.tag,
1282
+ href: details.href,
1283
+ inputType: details.inputType,
1284
+ point: { x: target.x, y: target.y },
1285
+ frame
1286
+ };
849
1287
  await this.animateCursor(target.x, target.y);
850
1288
  if (action.kind !== "select") {
851
1289
  if (action.kind === "click" && this.#interactionPauses > 0) {
@@ -867,6 +1305,7 @@ ${child.text}`.slice(0, 6000);
867
1305
  if (type === "mousePressed")
868
1306
  await this.animateCursor(target.x, target.y, true);
869
1307
  }
1308
+ performedAt = performance.now();
870
1309
  if (action.kind === "fill") {
871
1310
  const modifiers = process.platform === "darwin" ? 4 : 2;
872
1311
  await this.call("Input.dispatchKeyEvent", {
@@ -883,13 +1322,15 @@ ${child.text}`.slice(0, 6000);
883
1322
  modifiers
884
1323
  });
885
1324
  await this.call("Input.insertText", { text: text ?? "" });
1325
+ performedAt = performance.now();
886
1326
  }
887
1327
  }
888
1328
  this.#afterInput = action;
889
- if (action.frameId && action.kind === "click") {
1329
+ if (action.kind === "click") {
890
1330
  this.#switchOnPopup = true;
891
- await Bun.sleep(650);
1331
+ await this.waitForNewTarget(details.target === "_blank" ? 650 : 100);
892
1332
  }
1333
+ return { element, performedAt };
893
1334
  }
894
1335
  async close() {
895
1336
  if (this.#closed)
@@ -926,9 +1367,9 @@ ${child.text}`.slice(0, 6000);
926
1367
  }
927
1368
 
928
1369
  // src/model.ts
929
- import { mkdtemp as mkdtemp2, readFile, rm as rm2 } from "fs/promises";
930
- import { tmpdir as tmpdir2 } from "os";
931
- import { join as join2 } from "path";
1370
+ import { mkdtemp as mkdtemp3, readFile, rm as rm3 } from "fs/promises";
1371
+ import { tmpdir as tmpdir3 } from "os";
1372
+ import { join as join3 } from "path";
932
1373
 
933
1374
  // src/questions.ts
934
1375
  var NEXT_ACTION = `Advance the user's entire goal from the CURRENT page using one operation.
@@ -938,9 +1379,9 @@ its matching autocomplete suggestion selected. For date pickers, CLICK the field
938
1379
  Set every requested filter/control; a matching result alone does not prove a requested filter was set.
939
1380
  Do not toggle a checkbox, switch, or radio already in the requested state.
940
1381
  Submit populated search fields before opening a result; a populated field alone is not an applied search.
941
- WAIT only when the needed control is absent/disabled, or submitted results are still loading.
942
1382
  If Search/Submit is visible and the required fields are ready, CLICK it immediately.
943
- Recent WAIT actions are not evidence of loading. Prefer a useful visible control over WAIT.
1383
+ Loading and frame readiness are handled by the browser adapter before this decision.
1384
+ Use frame URL, nearby text, bounds, and clickability to distinguish repeated labels.
944
1385
  DONE requires visible evidence that ALL requirements are satisfied. If asked to open a result,
945
1386
  a matching link is not enough. BLOCKED means no supported operation can make progress.`;
946
1387
  var TARGET = `Choose the best observed target if the next operation is the one specified in this question.
@@ -1012,13 +1453,21 @@ function actionSpace(actions) {
1012
1453
  }
1013
1454
  if (typeof action.node !== "number")
1014
1455
  continue;
1015
- if (!indices.has(action.node)) {
1456
+ const identity = `${action.frameId ?? "main"}:${action.node}`;
1457
+ if (!indices.has(identity)) {
1016
1458
  const index2 = String(elements.length + 1);
1017
- indices.set(action.node, index2);
1459
+ indices.set(identity, index2);
1018
1460
  const element2 = {
1019
1461
  index: index2,
1020
1462
  label: action.label.split(" \u2192 ")[0] ?? action.label,
1021
- operations: []
1463
+ operations: [],
1464
+ frameId: action.frameId ?? null,
1465
+ frameUrl: action.frameUrl ?? null,
1466
+ bounds: action.rect ?? null,
1467
+ nearbyText: action.nearbyText ?? "",
1468
+ region: action.region ?? "",
1469
+ clickable: action.clickable ?? true,
1470
+ coveredBy: action.coveredBy ?? null
1022
1471
  };
1023
1472
  for (const key of ["role", "value", "checked", "selected", "expanded", "pressed", "sensitive"]) {
1024
1473
  if (action[key] !== undefined)
@@ -1030,7 +1479,9 @@ function actionSpace(actions) {
1030
1479
  }
1031
1480
  elements.push(element2);
1032
1481
  }
1033
- const index = indices.get(action.node);
1482
+ const index = indices.get(identity);
1483
+ if (action.clickable === false)
1484
+ continue;
1034
1485
  const group = targets[operation] ??= {};
1035
1486
  const element = elements[Number(index) - 1];
1036
1487
  if (!element.operations.includes(operation))
@@ -1069,6 +1520,13 @@ async function choose(page, goal, history, providedFields = []) {
1069
1520
  type: "choice",
1070
1521
  criteria: Object.fromEntries(Object.entries(candidates).map(([index, action]) => [index, {
1071
1522
  element: `[${index}] ${action.label}`,
1523
+ frame_id: action.frameId ?? null,
1524
+ frame: action.frameUrl ?? page.url,
1525
+ region: action.region ?? "",
1526
+ nearby_text: action.nearbyText ?? "",
1527
+ bounds: action.rect ?? null,
1528
+ clickable: action.clickable ?? true,
1529
+ covered_by: action.coveredBy ?? null,
1072
1530
  current_value: action.current_value ?? action.value ?? "",
1073
1531
  ...Object.fromEntries(["role", "checked", "selected", "expanded", "pressed", "sensitive"].filter((name) => action[name] !== undefined).map((name) => [name, action[name]]))
1074
1532
  }])),
@@ -1151,9 +1609,9 @@ async function codexFieldText(context) {
1151
1609
  if (!["none", "low", "medium", "high", "xhigh", "max"].includes(reasoning)) {
1152
1610
  throw new Error("Unsupported Codex reasoning effort");
1153
1611
  }
1154
- const folder = await mkdtemp2(join2(tmpdir2(), "jev-codex-text-"));
1155
- const outputPath = join2(folder, "output.json");
1156
- const schemaPath = join2(folder, "schema.json");
1612
+ const folder = await mkdtemp3(join3(tmpdir3(), "jev-codex-text-"));
1613
+ const outputPath = join3(folder, "output.json");
1614
+ const schemaPath = join3(folder, "schema.json");
1157
1615
  await Bun.write(schemaPath, JSON.stringify(text_value_schema_default));
1158
1616
  const childEnvironment = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !["TYPESAFE_API_KEY", "TEXT_MODEL_API_KEY"].includes(name)).map(([name, value]) => [name, value]));
1159
1617
  const command = [
@@ -1216,7 +1674,7 @@ ${JSON.stringify(context)}`;
1216
1674
  usage: {}
1217
1675
  }];
1218
1676
  } finally {
1219
- await rm2(folder, { recursive: true, force: true });
1677
+ await rm3(folder, { recursive: true, force: true });
1220
1678
  }
1221
1679
  }
1222
1680
  async function apiFieldText(context) {
@@ -1248,12 +1706,23 @@ function fieldText(context) {
1248
1706
  }
1249
1707
 
1250
1708
  // src/agent.ts
1709
+ function redactConsoleErrors(errors, secrets) {
1710
+ const redact = (text) => secrets.reduce((value, secret) => value.replaceAll(secret, "[redacted]"), text);
1711
+ return errors.map((error) => ({
1712
+ ...error,
1713
+ message: redact(error.message),
1714
+ ...error.url ? { url: redact(error.url) } : {}
1715
+ }));
1716
+ }
1717
+
1251
1718
  class Agent {
1252
1719
  #browser;
1253
1720
  #goal;
1254
1721
  #maxSteps;
1255
1722
  #screenshots;
1256
1723
  #fieldValues;
1724
+ #sensitiveFieldLabels;
1725
+ #secretValues;
1257
1726
  #page;
1258
1727
  #decision = null;
1259
1728
  #history = [];
@@ -1262,6 +1731,8 @@ class Agent {
1262
1731
  #status = "ready";
1263
1732
  #startedAt = null;
1264
1733
  #pendingText = null;
1734
+ #waitTimeout;
1735
+ #initialConsoleErrors = [];
1265
1736
  constructor(browser, page, options) {
1266
1737
  this.#browser = browser;
1267
1738
  this.#page = page;
@@ -1269,6 +1740,9 @@ class Agent {
1269
1740
  this.#maxSteps = options.maxSteps;
1270
1741
  this.#screenshots = options.screenshots ?? false;
1271
1742
  this.#fieldValues = options.fieldValues ?? {};
1743
+ this.#sensitiveFieldLabels = new Set(options.sensitiveFieldLabels ?? []);
1744
+ this.#secretValues = Object.entries(this.#fieldValues).filter(([label, value]) => this.#sensitiveFieldLabels.has(label) && value.length > 0).map(([, value]) => value);
1745
+ this.#startedAt = performance.now();
1272
1746
  }
1273
1747
  static async create(options) {
1274
1748
  if (!options.goal.trim())
@@ -1286,12 +1760,24 @@ class Agent {
1286
1760
  recordingPath: options.recordingPath,
1287
1761
  screenshotPath: options.screenshotPath,
1288
1762
  freshContext: options.freshContext,
1289
- interactionPauses: options.interactionPauses
1763
+ interactionPauses: options.interactionPauses,
1764
+ waitBudgetMs: options.waitBudgetMs
1290
1765
  });
1291
1766
  try {
1292
- return new Agent(browser, await browser.observe(options.screenshots), options);
1767
+ const agent = new Agent(browser, await browser.observe(options.screenshots), options);
1768
+ try {
1769
+ await agent.waitForReadiness();
1770
+ } catch (error) {
1771
+ if (!(error instanceof WaitTimeoutError))
1772
+ throw error;
1773
+ agent.recordWaitTimeout(error);
1774
+ }
1775
+ agent.#initialConsoleErrors = redactConsoleErrors(browser.takeConsoleErrors(), agent.#secretValues);
1776
+ return agent;
1293
1777
  } catch (error) {
1294
- await browser.close();
1778
+ await browser.close().catch(() => {
1779
+ return;
1780
+ });
1295
1781
  throw error;
1296
1782
  }
1297
1783
  }
@@ -1306,7 +1792,14 @@ class Agent {
1306
1792
  status: this.#status,
1307
1793
  elapsedMs: this.elapsedMs(),
1308
1794
  maxSteps: this.#maxSteps,
1309
- elements: actionSpace(this.#page.actions).elements
1795
+ elements: actionSpace(this.#page.actions).elements,
1796
+ initialConsoleErrors: [...this.#initialConsoleErrors],
1797
+ consoleErrors: [
1798
+ ...this.#initialConsoleErrors,
1799
+ ...this.#history.flatMap((entry) => entry.consoleErrors),
1800
+ ...redactConsoleErrors(this.#browser.pendingConsoleErrors(), this.#secretValues)
1801
+ ],
1802
+ ...this.#waitTimeout ? { waitTimeout: this.#waitTimeout } : {}
1310
1803
  };
1311
1804
  }
1312
1805
  get targetId() {
@@ -1320,15 +1813,12 @@ class Agent {
1320
1813
  this.#startedAt = performance.now();
1321
1814
  if (!await this.#browser.fresh(this.#page)) {
1322
1815
  this.#page = await this.#browser.observe(this.#screenshots);
1816
+ await this.waitForReadiness();
1323
1817
  }
1324
1818
  this.#decision = null;
1325
- if (["done", "blocked", "budget_exhausted"].includes(this.#status)) {
1819
+ if (["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
1326
1820
  throw new Error("This run has stopped");
1327
1821
  }
1328
- if (this.#decisions.length >= this.#maxSteps * 2) {
1329
- this.#status = "budget_exhausted";
1330
- return;
1331
- }
1332
1822
  this.#decision = await choose(this.#page, this.#goal, this.#history, Object.keys(this.#fieldValues));
1333
1823
  this.#decisions.push(this.#decision);
1334
1824
  this.#status = "predicted";
@@ -1369,7 +1859,7 @@ class Agent {
1369
1859
  if (provided !== undefined) {
1370
1860
  text = provided;
1371
1861
  helper = { model: "provided-field-value", provider: "caller", latency_ms: 0, usage: {} };
1372
- this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive ? "[redacted]" : text });
1862
+ this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive || this.#sensitiveFieldLabels.has(action.label) ? "[redacted]" : text });
1373
1863
  } else if (this.#pendingText?.contextKey === contextKey) {
1374
1864
  ({ text, helper } = this.#pendingText);
1375
1865
  } else {
@@ -1379,7 +1869,8 @@ class Agent {
1379
1869
  }
1380
1870
  }
1381
1871
  await this.#browser.waitForInteractionPause();
1382
- await this.#browser.act(action, page, text ?? undefined);
1872
+ const fromTargetId = this.#browser.targetId;
1873
+ const { element, performedAt } = await this.#browser.act(action, page, text ?? undefined);
1383
1874
  this.#pendingText = null;
1384
1875
  const entry = {
1385
1876
  step: this.#history.length + 1,
@@ -1389,21 +1880,33 @@ class Agent {
1389
1880
  probability: decision.probabilities[selected] ?? 0,
1390
1881
  confidence: decision.confidence,
1391
1882
  latency_ms: decision.latency_ms,
1392
- text: action.sensitive && text !== null ? "[redacted]" : text,
1883
+ text: (action.sensitive || this.#sensitiveFieldLabels.has(action.label)) && text !== null ? "[redacted]" : text,
1393
1884
  text_helper: helper?.model ?? null,
1394
1885
  text_latency_ms: helper?.latency_ms ?? 0,
1395
1886
  operation: decision.operation,
1396
1887
  target: decision.target,
1397
1888
  page_changed: null,
1889
+ from_url: page.url,
1398
1890
  url: page.url,
1891
+ viewport: { width: page.w, height: page.h },
1892
+ from_target_id: fromTargetId,
1893
+ target_id: this.#browser.targetId,
1894
+ element,
1895
+ value: action.kind === "select" ? action.value ?? null : null,
1896
+ delta_y: action.kind === "scroll" ? action.delta ?? 0 : null,
1897
+ redacted: action.kind === "fill" && (Boolean(action.sensitive) || this.#sensitiveFieldLabels.has(action.label)),
1399
1898
  usage: decision.usage,
1400
- executed_ms: this.elapsedMs(),
1401
- elapsed_ms: this.elapsedMs()
1899
+ executed_ms: Math.round(performedAt - this.#startedAt),
1900
+ elapsed_ms: this.elapsedMs(),
1901
+ consoleErrors: []
1402
1902
  };
1403
1903
  this.#history.push(entry);
1404
1904
  this.#page = await this.#browser.observe(this.#screenshots);
1905
+ await this.waitForReadiness();
1906
+ entry.consoleErrors = redactConsoleErrors(this.#browser.takeConsoleErrors(), this.#secretValues);
1405
1907
  entry.page_changed = this.#page.fingerprint !== page.fingerprint;
1406
1908
  entry.url = this.#page.url;
1909
+ entry.target_id = this.#browser.targetId;
1407
1910
  entry.elapsed_ms = this.elapsedMs();
1408
1911
  this.#status = "ready";
1409
1912
  }
@@ -1412,21 +1915,41 @@ class Agent {
1412
1915
  await this.predict();
1413
1916
  await this.act();
1414
1917
  } catch (error) {
1918
+ if (error instanceof WaitTimeoutError) {
1919
+ this.recordWaitTimeout(error);
1920
+ return this.snapshot();
1921
+ }
1415
1922
  if (!(error instanceof StalePageError))
1416
1923
  throw error;
1417
1924
  this.#decision = null;
1418
1925
  this.#status = "ready";
1419
1926
  this.#page = await this.#browser.observe(this.#screenshots);
1927
+ try {
1928
+ await this.waitForReadiness();
1929
+ } catch (waitError) {
1930
+ if (!(waitError instanceof WaitTimeoutError))
1931
+ throw waitError;
1932
+ this.recordWaitTimeout(waitError);
1933
+ }
1420
1934
  }
1421
1935
  return this.snapshot();
1422
1936
  }
1423
1937
  async run(onStep) {
1424
- while (!["done", "blocked", "budget_exhausted"].includes(this.#status)) {
1938
+ while (!["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
1425
1939
  const state = await this.tick();
1426
1940
  onStep?.(state);
1427
1941
  }
1428
1942
  return this.snapshot();
1429
1943
  }
1944
+ async waitForReadiness() {
1945
+ this.#page = await this.#browser.waitForSemanticReady(this.#page, this.#screenshots);
1946
+ }
1947
+ recordWaitTimeout(error) {
1948
+ this.#status = "wait_timeout";
1949
+ this.#waitTimeout = { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition };
1950
+ if (error.state)
1951
+ this.#page = error.state;
1952
+ }
1430
1953
  close() {
1431
1954
  return this.#browser.close();
1432
1955
  }
@@ -1502,6 +2025,8 @@ Goal control:
1502
2025
  --goal <text> One bounded browser goal. Required.
1503
2026
  --max-steps <number> Maximum executed browser actions.
1504
2027
  [env: JEV_MAX_STEPS] [default: 12]
2028
+ --wait-budget-ms <number> Total wall-clock budget for page and frame readiness.
2029
+ [default: 15000]
1505
2030
 
1506
2031
  Browser behavior:
1507
2032
  --visible Activate the controlled tab.
@@ -1525,12 +2050,15 @@ Evidence and output:
1525
2050
  -h, --help Show this help and exit.
1526
2051
 
1527
2052
  Output:
1528
- The final result is one JSON object on stdout. Progress and diagnostics use stderr.
2053
+ Stdout is JSON Lines: one object per executed action, then one result object.
2054
+ Each action includes new console errors observed during that step. The result
2055
+ includes initial errors and all errors observed during the run.
2056
+ Errors and diagnostics use stderr.
1529
2057
 
1530
2058
  Exit codes:
1531
2059
  0 Jev reported the goal complete.
1532
2060
  1 Invalid configuration or runtime failure.
1533
- 2 Jev reported that it was blocked.
2061
+ 2 Jev was blocked or the wait budget timed out.
1534
2062
  3 The maximum browser-step budget was exhausted.
1535
2063
 
1536
2064
  Examples:
@@ -1613,6 +2141,7 @@ function addFieldValue(options, assignment, fromEnvironment) {
1613
2141
  if (!supplied)
1614
2142
  throw new CliError(`Environment variable is missing or empty: ${environmentName}`);
1615
2143
  options.fieldValues[label] = supplied;
2144
+ options.sensitiveFieldLabels.push(label);
1616
2145
  } else {
1617
2146
  options.fieldValues[label] = assignment.slice(separator + 1);
1618
2147
  }
@@ -1622,10 +2151,12 @@ function parseRunOptions(args) {
1622
2151
  cdpUrl: process.env.CHROME_CDP_URL ?? DEFAULT_CDP_URL,
1623
2152
  maxSteps: parsePositiveInteger(process.env.JEV_MAX_STEPS ?? "12", "JEV_MAX_STEPS"),
1624
2153
  interactionPauses: 0,
2154
+ waitBudgetMs: 15000,
1625
2155
  visible: enabled(process.env.JEV_BROWSER_VISIBLE),
1626
2156
  keepOpen: enabled(process.env.JEV_BROWSER_KEEP_OPEN),
1627
2157
  finalState: false,
1628
2158
  fieldValues: {},
2159
+ sensitiveFieldLabels: [],
1629
2160
  freshContext: enabled(process.env.JEV_BROWSER_FRESH_CONTEXT)
1630
2161
  };
1631
2162
  for (let index = 0;index < args.length; index++) {
@@ -1642,6 +2173,8 @@ function parseRunOptions(args) {
1642
2173
  options.maxSteps = parsePositiveInteger(nextValue(args, index++, argument), argument);
1643
2174
  else if (argument === "--interaction-pauses")
1644
2175
  options.interactionPauses = parseNonNegativeInteger(nextValue(args, index++, argument), argument);
2176
+ else if (argument === "--wait-budget-ms")
2177
+ options.waitBudgetMs = parsePositiveInteger(nextValue(args, index++, argument), argument);
1645
2178
  else if (argument === "--recording")
1646
2179
  options.recordingPath = nextValue(args, index++, argument);
1647
2180
  else if (argument === "--screenshot")
@@ -1689,17 +2222,45 @@ function parseCommonOptions(args, help) {
1689
2222
  }
1690
2223
  return options;
1691
2224
  }
1692
- function latencyStats(values) {
2225
+ function actionEvent(entry, maxSteps) {
2226
+ return {
2227
+ type: "action",
2228
+ status: "executed",
2229
+ step: entry.step,
2230
+ elapsedMs: entry.executed_ms,
2231
+ budget: { used: entry.step, max: maxSteps, remaining: maxSteps - entry.step },
2232
+ page: { before: entry.from_url, after: entry.url, changed: entry.page_changed, viewport: entry.viewport },
2233
+ tab: { before: entry.from_target_id, after: entry.target_id },
2234
+ consoleErrors: entry.consoleErrors,
2235
+ action: {
2236
+ kind: entry.kind,
2237
+ label: entry.action,
2238
+ element: entry.element,
2239
+ ...entry.kind === "fill" ? { text: entry.text, redacted: entry.redacted } : {},
2240
+ ...entry.kind === "select" ? { optionValue: entry.value } : {},
2241
+ ...entry.kind === "scroll" ? { deltaY: entry.delta_y, point: { x: 550, y: 650 } } : {},
2242
+ ...entry.kind === "wait" ? { durationMs: 100 } : {}
2243
+ }
2244
+ };
2245
+ }
2246
+ function semanticState(page, elements) {
2247
+ const frameTree = (parentId) => page.frames.filter((frame) => frame.parentId === parentId).map((frame) => ({ ...frame, children: frameTree(frame.id) }));
1693
2248
  return {
1694
- count: values.length,
1695
- totalMs: values.reduce((sum, value) => sum + value, 0),
1696
- averageMs: values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : null,
1697
- maxMs: values.length ? Math.max(...values) : null
2249
+ url: page.url,
2250
+ title: page.title,
2251
+ text: page.text,
2252
+ viewport: { width: page.w, height: page.h },
2253
+ scroll: page.scroll,
2254
+ elements,
2255
+ frameTree: frameTree(null),
2256
+ transitions: page.transitions,
2257
+ omittedActions: page.omitted_actions
1698
2258
  };
1699
2259
  }
1700
2260
  async function runGoal(args) {
1701
2261
  const options = parseRunOptions(args);
1702
2262
  let agent;
2263
+ let reportedActions = 0;
1703
2264
  try {
1704
2265
  agent = await Agent.create({
1705
2266
  url: options.url,
@@ -1708,50 +2269,65 @@ async function runGoal(args) {
1708
2269
  cdpUrl: options.cdpUrl,
1709
2270
  maxSteps: options.maxSteps,
1710
2271
  interactionPauses: options.interactionPauses,
2272
+ waitBudgetMs: options.waitBudgetMs,
1711
2273
  visible: options.visible,
1712
2274
  keepOpen: options.keepOpen,
1713
2275
  recordingPath: options.recordingPath,
1714
2276
  screenshotPath: options.screenshotPath,
1715
2277
  fieldValues: options.fieldValues,
2278
+ sensitiveFieldLabels: options.sensitiveFieldLabels,
1716
2279
  freshContext: options.freshContext
1717
2280
  });
1718
- let reportedActions = 0;
1719
- const result = await agent.run((state) => {
2281
+ await agent.run((state) => {
1720
2282
  const action = state.history.length > reportedActions ? state.history.at(-1) : undefined;
1721
- const decision = state.decisions.at(-1);
1722
2283
  reportedActions = state.history.length;
1723
- const operation = action?.operation ?? decision?.operation ?? "none";
1724
- const jevLatency = action?.latency_ms ?? decision?.latency_ms;
1725
- const helper = action?.text_helper ? ` text=${action.text_helper}:${action.text_latency_ms}ms` : "";
1726
- console.error(`elapsed=${state.elapsedMs}ms actions=${state.history.length}/${state.maxSteps} status=${state.status} operation=${operation} jev=${jevLatency ?? 0}ms${helper}`);
2284
+ if (action)
2285
+ console.log(JSON.stringify(actionEvent(action, state.maxSteps)));
1727
2286
  });
2287
+ await agent.close();
2288
+ const result = agent.snapshot();
1728
2289
  console.log(JSON.stringify({
2290
+ type: "result",
1729
2291
  status: result.status,
1730
2292
  targetId: agent.targetId,
1731
2293
  url: result.page.url,
1732
2294
  actions: result.history.length,
1733
2295
  maxSteps: result.maxSteps,
2296
+ budget: { used: result.history.length, max: result.maxSteps, remaining: result.maxSteps - result.history.length },
1734
2297
  elapsedMs: result.elapsedMs,
1735
2298
  textCalls: result.textCalls.length,
1736
- latency: {
1737
- jev: latencyStats(result.decisions.map((decision) => decision.latency_ms)),
1738
- textHelper: latencyStats(result.textCalls.map((call) => call.latency_ms))
1739
- },
2299
+ initialConsoleErrors: result.initialConsoleErrors,
2300
+ consoleErrors: result.consoleErrors,
2301
+ ...result.waitTimeout ? { waitTimeout: result.waitTimeout } : {},
1740
2302
  ...options.recordingPath ? { recording: options.recordingPath } : {},
1741
2303
  ...options.screenshotPath ? { screenshot: options.screenshotPath } : {},
1742
- ...options.finalState ? {
1743
- finalState: {
1744
- url: result.page.url,
1745
- title: result.page.title,
1746
- text: result.page.text,
1747
- viewport: { width: result.page.w, height: result.page.h },
1748
- scroll: result.page.scroll,
1749
- elements: result.elements,
1750
- omittedActions: result.page.omitted_actions
1751
- }
2304
+ ...options.finalState || result.status === "wait_timeout" ? {
2305
+ finalState: semanticState(result.page, result.elements)
1752
2306
  } : {}
1753
2307
  }));
1754
2308
  return result.status === "done" ? 0 : result.status === "budget_exhausted" ? 3 : 2;
2309
+ } catch (error) {
2310
+ const state = agent?.snapshot();
2311
+ for (const action of state?.history.slice(reportedActions) ?? []) {
2312
+ console.log(JSON.stringify(actionEvent(action, options.maxSteps)));
2313
+ }
2314
+ console.log(JSON.stringify({
2315
+ type: "result",
2316
+ status: error instanceof WaitTimeoutError ? "wait_timeout" : "error",
2317
+ targetId: agent?.targetId ?? null,
2318
+ url: state?.page.url ?? (error instanceof WaitTimeoutError ? error.state?.url ?? null : null),
2319
+ actions: state?.history.length ?? 0,
2320
+ maxSteps: options.maxSteps,
2321
+ budget: { used: state?.history.length ?? 0, max: options.maxSteps, remaining: options.maxSteps - (state?.history.length ?? 0) },
2322
+ elapsedMs: state?.elapsedMs ?? 0,
2323
+ initialConsoleErrors: state?.initialConsoleErrors ?? [],
2324
+ consoleErrors: state?.consoleErrors ?? [],
2325
+ ...error instanceof WaitTimeoutError ? { waitTimeout: { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition } } : {},
2326
+ ...state ? { finalState: semanticState(state.page, state.elements) } : error instanceof WaitTimeoutError && error.state ? { finalState: semanticState(error.state, actionSpace(error.state.actions).elements) } : {}
2327
+ }));
2328
+ if (error instanceof WaitTimeoutError)
2329
+ return 2;
2330
+ throw error;
1755
2331
  } finally {
1756
2332
  await agent?.close();
1757
2333
  }
@@ -1791,8 +2367,11 @@ async function doctor(args) {
1791
2367
  required: true
1792
2368
  });
1793
2369
  }
1794
- const ffmpeg = Bun.which("ffmpeg");
1795
- checks.push(ffmpeg ? { name: "recording", status: "ok", detail: `FFmpeg found at ${ffmpeg}`, required: false } : { name: "recording", status: "warning", detail: "FFmpeg not found; --recording will be unavailable", required: false });
2370
+ try {
2371
+ checks.push({ name: "recording", status: "ok", detail: await checkRecordingEncoder(), required: false });
2372
+ } catch (error) {
2373
+ checks.push({ name: "recording", status: "warning", detail: `${error instanceof Error ? error.message : String(error)}; --recording will be unavailable`, required: false });
2374
+ }
1796
2375
  if (process.env.TEXT_MODEL_PROVIDER === "api") {
1797
2376
  checks.push(process.env.TEXT_MODEL_API_KEY ? { name: "text-helper", status: "ok", detail: `API helper configured (${process.env.TEXT_MODEL ?? "deepseek-chat"})`, required: false } : { name: "text-helper", status: "warning", detail: "TEXT_MODEL_PROVIDER=api but TEXT_MODEL_API_KEY is not set", required: false });
1798
2377
  } else {
@@ -1859,5 +2438,6 @@ async function main(args = Bun.argv.slice(2)) {
1859
2438
  if (import.meta.main)
1860
2439
  process.exitCode = await main();
1861
2440
  export {
2441
+ actionEvent,
1862
2442
  main
1863
2443
  };