jev-cdp 0.1.4 → 0.1.5

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 +6 -4
  2. package/dist/cli.js +616 -99
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,8 +27,8 @@ This is an early experimental port. See [NOTICE.md](NOTICE.md) for source attrib
27
27
  Run the published CLI without adding it to a project. Bun must be installed for either command:
28
28
 
29
29
  ```bash
30
- bunx jev-cdp@0.1.3 help run
31
- npx -y jev-cdp@0.1.3 help run
30
+ bunx jev-cdp@0.1.5 help run
31
+ npx -y jev-cdp@0.1.5 help run
32
32
  ```
33
33
 
34
34
  Chrome with a CDP endpoint and `TYPESAFE_API_KEY` are required for browser runs. FFmpeg is required for `--recording`.
@@ -98,9 +98,11 @@ bun run run -- run \
98
98
 
99
99
  `--recording` captures Chrome's compositor screencast stream for the full goal and renders an H.264 MP4. Because Chrome's native pointer is not part of that stream, the adapter draws a high-contrast cursor that starts at the viewport center, glides to each target, and pulses on clicks. `--interaction-pauses` adds a deterministic delay after opening or loading a page, switching to an attached tab, or changing the URL within a page. Jev chooses during that delay, and the adapter waits only for any time left before acting. The same flag also pauses after moving to a click target and before pressing the mouse. Jev does not choose or observe these delays. `--screenshot` saves the final viewport after the goal stops; when recording is also enabled, it reuses the final screencast frame.
100
100
 
101
- `--final-state` adds an AI-oriented semantic snapshot to the final JSON on standard output. It includes the final URL, title, visible text, viewport, scroll state, actionable elements, accessible labels, and control state such as `pressed`, `checked`, `selected`, and `expanded`. Progress remains on standard error, so a coding agent can parse standard output as one JSON object and choose the next bounded goal without another browser observation.
101
+ `jev-cdp run` writes JSON Lines to standard output: one `type:"action"` object per executed action, followed by one `type:"result"` object with the final status and action budget. Action objects include elapsed execution time, the operation, page and tab URLs, the viewport, and a CSS selector, role, name, frame, and coordinates for the element. Fill actions include the entered text; password fields and `--field-value-env` values are redacted and must be supplied separately for replay. Select and scroll actions include their option value or wheel delta. The CSS selector and page URL can be used as Playwright replay targets, with the coordinates as a fallback at the recorded viewport size. `--final-state` adds the final semantic page snapshot: a frame tree with URLs and loading state, actionable elements with frame identity, screen bounds, nearby text, region, clickability, and any element covering the click point, plus new-tab and redirect transitions. Automatic navigation and embedded-content waits use `--wait-budget-ms` (15 seconds by default) and do not consume `--max-steps`; a timeout returns `wait_timeout`, its pending condition and elapsed wait time, and the latest semantic state. Runtime failures emit a `type:"result"` object with `status:"error"`; diagnostic text uses standard error.
102
102
 
103
- The snapshot includes visible controls in direct child iframes, including cross-origin frames. Clicking a link there uses its frame coordinates and checks that the observed control is still current. If that click opens a new tab, Jev switches to it, returns its target ID for the next goal, and keeps the recording and 1120×780 viewport consistent across the switch.
103
+ Each action object includes `consoleErrors` observed during that step. The result object includes `initialConsoleErrors` already present when attaching to the tab and `consoleErrors` for the full run. These fields include browser console errors, uncaught exceptions, and error-level DevTools log entries from the page and its frames. Review them alongside the semantic state; a console error does not by itself establish that the goal failed.
104
+
105
+ The snapshot includes visible controls in nested iframes, including cross-origin frames. Clicking a link there uses its frame coordinates and checks that the observed control is still current. If that click opens a new tab, Jev switches to it, returns its target ID for the next goal, and keeps the recording and 1120×780 viewport consistent across the switch.
104
106
 
105
107
  ## Supply known field values without another LLM
106
108
 
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.5",
7
7
  description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
8
8
  type: "module",
9
9
  license: "MIT",
@@ -207,8 +207,15 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
207
207
  const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
208
208
  if (!rname || r.width<=0 || r.height<=0 || x<0 || y<0 || x>=innerWidth || y>=innerHeight) continue;
209
209
  if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
210
+ const hit=document.elementFromPoint(x,y);
211
+ const coveredBy=hit && !e.contains(hit) && !hit.contains(e) ?
212
+ {tag:hit.tagName.toLowerCase(),text:(hit.innerText||hit.getAttribute('aria-label')||'').trim().slice(0,160),role:hit.getAttribute('role')} : null;
213
+ const region=e.closest('form,dialog,[role="dialog"],article,section,li,tr,[role="row"],main,nav,header,footer');
214
+ const nearbyText=(region?.innerText||e.parentElement?.innerText||'').trim().replace(/\\s+/g,' ').slice(0,240);
210
215
  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}};
216
+ rect:{x:r.x,y:r.y,w:r.width,h:r.height},frameUrl:location.href,
217
+ nearbyText,region:region?.getAttribute('aria-label')||region?.getAttribute('role')||region?.tagName.toLowerCase()||'',
218
+ clickable:!coveredBy,coveredBy};
212
219
  for (const key of ['checked','selected','expanded','pressed']) {
213
220
  const value=e.getAttribute('aria-'+key);
214
221
  if (value!==null) base[key]=value;
@@ -228,14 +235,20 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
228
235
  if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
229
236
  }
230
237
  }
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;
238
+ const roots=[document.body], shadowRoots=[];
239
+ for (const root of roots) for (const e of root.querySelectorAll('*')) {
240
+ if (e.shadowRoot) { roots.push(e.shadowRoot); shadowRoots.push(e.shadowRoot); }
241
+ }
242
+ const words=[], range=document.createRange(); let length=0;
243
+ for (const root of [...shadowRoots,document.body]) {
244
+ const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let node;
245
+ while ((node=walker.nextNode()) && length<6000) {
246
+ const value=node.textContent.trim(), parent=node.parentElement;
247
+ if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
248
+ range.selectNodeContents(node); const r=range.getBoundingClientRect();
249
+ if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
250
+ words.push(value); length+=value.length;
251
+ }
239
252
  }
240
253
  }
241
254
  const text=words.join('\\n').slice(0,6000), height=document.documentElement.scrollHeight;
@@ -249,7 +262,6 @@ var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1
249
262
  actions.forEach((a,i)=>a.id='e'+(i+1));
250
263
  if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
251
264
  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
265
  return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
254
266
  scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
255
267
  })()
@@ -278,6 +290,18 @@ var RECORDING_CURSOR_INIT = `(() => {
278
290
 
279
291
  class StalePageError extends Error {
280
292
  }
293
+
294
+ class WaitTimeoutError extends Error {
295
+ elapsedMs;
296
+ pendingCondition;
297
+ state;
298
+ constructor(elapsedMs, pendingCondition, state) {
299
+ super(`Wait timed out after ${elapsedMs}ms: ${pendingCondition}`);
300
+ this.elapsedMs = elapsedMs;
301
+ this.pendingCondition = pendingCondition;
302
+ this.state = state;
303
+ }
304
+ }
281
305
  function stableValue(value) {
282
306
  if (Array.isArray(value))
283
307
  return value.map(stableValue);
@@ -323,6 +347,17 @@ class Browser {
323
347
  #openedTabs = [];
324
348
  #cdpUrl;
325
349
  #switchOnPopup = false;
350
+ #loadingFrames = new Set;
351
+ #waitBudgetMs;
352
+ #waitSpentMs = 0;
353
+ #transitions = [];
354
+ #origin = null;
355
+ #changeListeners = new Set;
356
+ #targetUrls = new Map;
357
+ #newTargets = new Set;
358
+ #popupListeners = new Set;
359
+ #consoleErrors = [];
360
+ #watchedConsoleSessions = new Set;
326
361
  constructor(cdp, sessionId, targetId, ownsTarget, browserContextId, options) {
327
362
  this.#cdp = cdp;
328
363
  this.#cdpUrl = options.cdpUrl;
@@ -333,6 +368,7 @@ class Browser {
333
368
  this.#keepOpen = options.keepOpen ?? false;
334
369
  this.#screenshots = options.screenshots ?? false;
335
370
  this.#interactionPauses = options.interactionPauses ?? 0;
371
+ this.#waitBudgetMs = options.waitBudgetMs ?? 15000;
336
372
  this.#recordingPath = options.recordingPath ? resolve(options.recordingPath) : undefined;
337
373
  this.#screenshotPath = options.screenshotPath ? resolve(options.screenshotPath) : undefined;
338
374
  }
@@ -385,10 +421,30 @@ class Browser {
385
421
  const browser = new Browser(cdp, attached.sessionId, targetId, ownsTarget, browserContextId, options);
386
422
  browser.#knownTargets = new Set((await listChromeTargets(options.cdpUrl)).map((target) => target.id));
387
423
  try {
424
+ const recordTargetUrl = (params) => {
425
+ const info = params.targetInfo;
426
+ if (info?.type === "page" && info.targetId && !browser.#knownTargets.has(info.targetId)) {
427
+ browser.#newTargets.add(info.targetId);
428
+ for (const listener of browser.#popupListeners)
429
+ listener();
430
+ }
431
+ if (!info?.targetId || !info.url || info.url === "about:blank")
432
+ return;
433
+ const urls = browser.#targetUrls.get(info.targetId) ?? [];
434
+ if (urls.at(-1) !== info.url)
435
+ urls.push(info.url);
436
+ browser.#targetUrls.set(info.targetId, urls);
437
+ };
438
+ browser.#stopPageEvents.push(cdp.on("Target.targetCreated", "", recordTargetUrl), cdp.on("Target.targetInfoChanged", "", recordTargetUrl));
439
+ await cdp.command("Target.setDiscoverTargets", { discover: true });
388
440
  browser.#stopPageEvents.push(cdp.on("Target.attachedToTarget", browser.#sessionId, (params) => {
389
441
  const info = params.targetInfo;
390
- if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string")
442
+ if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string") {
391
443
  browser.#frameSessions.set(info.targetId, params.sessionId);
444
+ browser.watchConsole(params.sessionId, info.targetId).catch(() => {
445
+ return;
446
+ });
447
+ }
392
448
  }));
393
449
  browser.#stopPageEvents.push(cdp.on("Page.frameNavigated", browser.#sessionId, (params) => {
394
450
  const frame = params.frame;
@@ -396,6 +452,7 @@ class Browser {
396
452
  browser.#frameContexts.delete(frame.id);
397
453
  }));
398
454
  await browser.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
455
+ await browser.watchConsole();
399
456
  await browser.call("Page.enable");
400
457
  await browser.call("Emulation.setDeviceMetricsOverride", {
401
458
  width: 1120,
@@ -412,7 +469,9 @@ class Browser {
412
469
  await browser.startRecording();
413
470
  return browser;
414
471
  } catch (error) {
415
- await browser.close();
472
+ await browser.close().catch(() => {
473
+ return;
474
+ });
416
475
  throw error;
417
476
  }
418
477
  }
@@ -422,13 +481,65 @@ class Browser {
422
481
  get targetId() {
423
482
  return this.#targetId;
424
483
  }
425
- async watchPageLoads() {
426
- if (!this.#interactionPauses)
484
+ takeConsoleErrors() {
485
+ return this.#consoleErrors.splice(0);
486
+ }
487
+ pendingConsoleErrors() {
488
+ return [...this.#consoleErrors];
489
+ }
490
+ async watchConsole(sessionId = this.#sessionId, targetId = this.#targetId) {
491
+ if (this.#watchedConsoleSessions.has(sessionId))
427
492
  return;
493
+ this.#watchedConsoleSessions.add(sessionId);
494
+ const record = (source, message, url, timestamp) => {
495
+ if (!message.trim())
496
+ return;
497
+ this.#consoleErrors.push({
498
+ source,
499
+ message: message.slice(0, 4000),
500
+ ...url ? { url } : {},
501
+ ...timestamp !== undefined ? { timestamp } : {},
502
+ targetId
503
+ });
504
+ };
505
+ this.#stopPageEvents.push(this.#cdp.on("Runtime.consoleAPICalled", sessionId, (params) => {
506
+ if (params.type !== "error")
507
+ return;
508
+ const args = params.args;
509
+ const message = args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? "";
510
+ const frame = params.stackTrace?.callFrames?.[0];
511
+ record("console", message, frame?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
512
+ }), this.#cdp.on("Runtime.exceptionThrown", sessionId, (params) => {
513
+ const details = params.exceptionDetails;
514
+ record("exception", details?.exception?.description ?? details?.text ?? "Uncaught exception", details?.url, typeof params.timestamp === "number" ? params.timestamp : undefined);
515
+ }), this.#cdp.on("Log.entryAdded", sessionId, (params) => {
516
+ const entry = params.entry;
517
+ if (entry?.level === "error")
518
+ record("log", entry.text ?? "", entry.url, entry.timestamp);
519
+ }));
520
+ await this.#cdp.command("Runtime.enable", {}, sessionId);
521
+ await this.#cdp.command("Log.enable", {}, sessionId);
522
+ }
523
+ async watchPageLoads() {
428
524
  await this.call("Page.enable");
429
525
  const tree = await this.call("Page.getFrameTree");
430
526
  this.#mainFrameId = tree.frameTree.frame.id;
527
+ await this.call("Runtime.addBinding", { name: "__jevDomChanged" });
528
+ const observeDom = `(() => {
529
+ if (window.__jevDomWatching) return;
530
+ window.__jevDomWatching = true;
531
+ const start = () => new MutationObserver(() => window.__jevDomChanged?.('change'))
532
+ .observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
533
+ if (document.documentElement) start(); else document.addEventListener('DOMContentLoaded', start, {once:true});
534
+ })()`;
535
+ await this.call("Page.addScriptToEvaluateOnNewDocument", { source: observeDom });
536
+ await this.evaluate(observeDom).catch(() => {
537
+ return;
538
+ });
431
539
  const loading = (params) => {
540
+ if (typeof params.frameId === "string")
541
+ this.#loadingFrames.add(params.frameId);
542
+ this.signalChange();
432
543
  if (params.frameId !== this.#mainFrameId || this.#pageLoadPromise)
433
544
  return;
434
545
  this.#pageLoadPromise = new Promise((resolve2) => {
@@ -436,6 +547,9 @@ class Browser {
436
547
  });
437
548
  };
438
549
  const loaded = () => {
550
+ if (this.#mainFrameId)
551
+ this.#loadingFrames.delete(this.#mainFrameId);
552
+ this.signalChange();
439
553
  if (!this.#pageLoadPromise)
440
554
  return;
441
555
  this.#pauseUntil = performance.now() + this.#interactionPauses;
@@ -445,34 +559,69 @@ class Browser {
445
559
  };
446
560
  this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
447
561
  const frame = params.frame;
562
+ this.signalChange();
448
563
  if (frame && frame.id === this.#mainFrameId && !frame.parentId)
449
564
  loading({ frameId: frame.id });
450
565
  }), this.#cdp.on("Page.loadEventFired", this.#sessionId, loaded), this.#cdp.on("Page.frameStoppedLoading", this.#sessionId, (params) => {
566
+ if (typeof params.frameId === "string")
567
+ this.#loadingFrames.delete(params.frameId);
568
+ this.signalChange();
451
569
  if (params.frameId === this.#mainFrameId)
452
570
  loaded();
453
571
  }), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
572
+ this.signalChange();
454
573
  if (params.frameId === this.#mainFrameId) {
455
574
  this.#pauseUntil = performance.now() + this.#interactionPauses;
456
575
  this.resetRecordingCursor().catch(() => {
457
576
  return;
458
577
  });
459
578
  }
460
- }));
579
+ }), 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()));
580
+ }
581
+ signalChange() {
582
+ for (const listener of this.#changeListeners)
583
+ listener();
584
+ }
585
+ async waitForChange(ms) {
586
+ await new Promise((resolve2) => {
587
+ const done = () => {
588
+ clearTimeout(timer);
589
+ this.#changeListeners.delete(done);
590
+ resolve2();
591
+ };
592
+ const timer = setTimeout(done, Math.min(ms, 150));
593
+ this.#changeListeners.add(done);
594
+ });
595
+ }
596
+ async waitForNewTarget(ms) {
597
+ if (this.#newTargets.size)
598
+ return;
599
+ await new Promise((resolve2) => {
600
+ const done = () => {
601
+ clearTimeout(timer);
602
+ this.#popupListeners.delete(done);
603
+ resolve2();
604
+ };
605
+ const timer = setTimeout(done, ms);
606
+ this.#popupListeners.add(done);
607
+ });
461
608
  }
462
609
  async waitForInteractionPause() {
463
610
  if (!this.#interactionPauses)
464
611
  return;
612
+ const waitStarted = performance.now();
465
613
  while (true) {
466
614
  const pageLoad = this.#pageLoadPromise;
467
615
  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
- ]);
616
+ const remainingBudget = this.#waitBudgetMs - this.#waitSpentMs - (performance.now() - waitStarted);
617
+ if (remainingBudget <= 0)
618
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
619
+ await Promise.race([pageLoad, Bun.sleep(remainingBudget).then(() => {
620
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs + performance.now() - waitStarted), "main document loading", null);
621
+ })]);
474
622
  continue;
475
623
  }
624
+ this.#waitSpentMs += performance.now() - waitStarted;
476
625
  const remaining = this.#pauseUntil - performance.now();
477
626
  if (remaining <= 0)
478
627
  return;
@@ -612,18 +761,52 @@ class Browser {
612
761
  })()`);
613
762
  }
614
763
  async waitForReady() {
615
- const deadline = Date.now() + 15000;
616
- while (Date.now() < deadline) {
764
+ const started = performance.now();
765
+ const deadline = started + this.#waitBudgetMs - this.#waitSpentMs;
766
+ while (performance.now() < deadline) {
617
767
  try {
618
- if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete")
768
+ if (!this.#pageLoadPromise && await this.evaluate("document.readyState") === "complete") {
769
+ this.#waitSpentMs += performance.now() - started;
619
770
  return;
771
+ }
772
+ } catch (error) {
773
+ if (!(error instanceof StalePageError))
774
+ throw error;
775
+ }
776
+ await this.waitForChange(Math.max(1, deadline - performance.now()));
777
+ }
778
+ this.#waitSpentMs += performance.now() - started;
779
+ const state = await this.observe(false).catch(() => null);
780
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "main document loading", state);
781
+ }
782
+ async waitForSemanticReady(initial, screenshot = this.#screenshots) {
783
+ const started = performance.now();
784
+ const spentAtStart = this.#waitSpentMs;
785
+ const deadline = started + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
786
+ const accountWait = () => {
787
+ this.#waitSpentMs += Math.max(0, performance.now() - started - (this.#waitSpentMs - spentAtStart));
788
+ };
789
+ let state = initial;
790
+ while (true) {
791
+ const pending = state.frames.find((frame) => frame.loading || frame.readyState !== "complete");
792
+ const loadingText = state.frames.length > 1 && !state.actions.some((action) => action.frameId) && /(?:^|\n)loading(?:\.{0,3}|\s)/i.test(state.text);
793
+ if (!pending && !loadingText) {
794
+ accountWait();
795
+ return state;
796
+ }
797
+ const condition = pending ? `frame ${pending.url || pending.id} loading` : "embedded content loading";
798
+ if (performance.now() >= deadline) {
799
+ accountWait();
800
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs), condition, state);
801
+ }
802
+ await this.waitForChange(Math.max(1, deadline - performance.now()));
803
+ try {
804
+ state = await this.observe(screenshot);
620
805
  } catch (error) {
621
806
  if (!(error instanceof StalePageError))
622
807
  throw error;
623
808
  }
624
- await Bun.sleep(20);
625
809
  }
626
- throw new Error("Page did not finish loading within 15 seconds");
627
810
  }
628
811
  async evaluate(expression, awaitPromise = false) {
629
812
  const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, awaitPromise ? 15000 : undefined);
@@ -644,14 +827,48 @@ class Browser {
644
827
  throw new StalePageError("Frame changed during evaluation");
645
828
  return response.result?.value;
646
829
  }
647
- async frameOffset(frameId) {
648
- const owner = await this.call("DOM.getFrameOwner", { frameId });
649
- const box = await this.call("DOM.getBoxModel", { backendNodeId: owner.backendNodeId });
830
+ async frameOffset(frameId, parentId = null) {
831
+ const session = parentId ? this.#frameSessions.get(parentId) ?? this.#sessionId : this.#sessionId;
832
+ const owner = await this.#cdp.command("DOM.getFrameOwner", { frameId }, session);
833
+ const box = await this.#cdp.command("DOM.getBoxModel", { backendNodeId: owner.backendNodeId }, session);
650
834
  return { x: box.model.content[0], y: box.model.content[1] };
651
835
  }
652
- async childFrames() {
836
+ async frameScreenOffset(frameId, frames) {
837
+ const states = frames ?? await this.frameStates();
838
+ let id = frameId;
839
+ let x = 0, y = 0;
840
+ while (id) {
841
+ const frame = states.find((item) => item.id === id);
842
+ if (!frame?.parentId)
843
+ break;
844
+ const offset = await this.frameOffset(id, frame.parentId);
845
+ x += offset.x;
846
+ y += offset.y;
847
+ id = frame.parentId;
848
+ }
849
+ return { x, y };
850
+ }
851
+ async frameStates() {
653
852
  const tree = await this.call("Page.getFrameTree");
654
- return (tree.frameTree.childFrames ?? []).map((child) => child.frame.id);
853
+ const frames = [];
854
+ const visit = async (node, parentId) => {
855
+ const { id, url = "" } = node.frame;
856
+ let readyState = null;
857
+ try {
858
+ readyState = id === this.#mainFrameId ? await this.evaluate("document.readyState") ?? null : await this.evaluateFrame(id, "document.readyState") ?? null;
859
+ } catch {}
860
+ frames.push({
861
+ id,
862
+ parentId,
863
+ url,
864
+ readyState,
865
+ loading: this.#loadingFrames.has(id) || readyState !== "complete"
866
+ });
867
+ for (const child of node.childFrames ?? [])
868
+ await visit(child, id);
869
+ };
870
+ await visit(tree.frameTree, null);
871
+ return frames;
655
872
  }
656
873
  async discoverTabs() {
657
874
  if (!this.#switchOnPopup)
@@ -660,6 +877,7 @@ class Browser {
660
877
  if (target.type !== "page" || this.#knownTargets.has(target.id))
661
878
  continue;
662
879
  this.#knownTargets.add(target.id);
880
+ this.#newTargets.delete(target.id);
663
881
  const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
664
882
  try {
665
883
  await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
@@ -680,6 +898,9 @@ class Browser {
680
898
  }
681
899
  this.#sessionId = attached.sessionId;
682
900
  this.#targetId = target.id;
901
+ this.#loadingFrames.clear();
902
+ await this.watchConsole();
903
+ await this.watchPageLoads();
683
904
  this.#switchOnPopup = false;
684
905
  await this.startRecording();
685
906
  } else {
@@ -687,7 +908,30 @@ class Browser {
687
908
  }
688
909
  }
689
910
  this.#openedTabs.push({ id: target.id, url: target.url, title: target.title });
911
+ if (this.#origin)
912
+ this.#transitions.push({
913
+ kind: "new_tab",
914
+ control: this.#origin.control,
915
+ fromTargetId: this.#origin.targetId,
916
+ targetId: target.id,
917
+ fromUrl: this.#origin.url,
918
+ destinationUrl: target.url,
919
+ settled: false
920
+ });
921
+ const urls = this.#targetUrls.get(target.id) ?? [];
922
+ const requested = this.#origin?.href ?? urls[0];
923
+ if (this.#origin && requested && requested !== target.url)
924
+ this.#transitions.push({
925
+ kind: "redirect",
926
+ control: this.#origin.control,
927
+ fromTargetId: this.#origin.targetId,
928
+ targetId: target.id,
929
+ fromUrl: requested,
930
+ destinationUrl: target.url,
931
+ settled: false
932
+ });
690
933
  }
934
+ this.#switchOnPopup = false;
691
935
  }
692
936
  async settleAfterInput() {
693
937
  const action = this.#afterInput;
@@ -726,32 +970,42 @@ class Browser {
726
970
  await this.settleAfterInput();
727
971
  await this.discoverTabs();
728
972
  let info;
729
- for (let attempt = 0;attempt < 10; attempt++) {
973
+ const waitStarted = performance.now();
974
+ const deadline = waitStarted + Math.max(0, this.#waitBudgetMs - this.#waitSpentMs);
975
+ let waited = false;
976
+ while (true) {
730
977
  try {
731
978
  info = await this.evaluate(snapshot_default);
732
979
  if (info)
733
980
  break;
734
981
  } catch (error) {
735
- if (!(error instanceof StalePageError) || attempt === 9)
982
+ if (!(error instanceof StalePageError))
736
983
  throw error;
737
984
  }
738
- await Bun.sleep(20);
985
+ if (performance.now() >= deadline) {
986
+ this.#waitSpentMs += performance.now() - waitStarted;
987
+ throw new WaitTimeoutError(Math.round(this.#waitSpentMs), "document navigating", null);
988
+ }
989
+ waited = true;
990
+ await this.waitForChange(Math.max(1, deadline - performance.now()));
739
991
  }
740
- if (!info)
741
- throw new StalePageError("Document is navigating");
992
+ if (waited)
993
+ this.#waitSpentMs += performance.now() - waitStarted;
742
994
  if (this.#openedTabs.length) {
743
- const targets = await listChromeTargets(this.#cdpUrl);
995
+ const targets2 = await listChromeTargets(this.#cdpUrl);
744
996
  this.#openedTabs = this.#openedTabs.map((tab) => {
745
- const current = targets.find((target) => target.id === tab.id);
997
+ const current = targets2.find((target) => target.id === tab.id);
746
998
  return current ? { ...tab, url: current.url, title: current.title } : tab;
747
999
  });
748
1000
  info.text = `${info.text}
749
1001
  ${this.#openedTabs.map((tab) => `Opened new tab: ${tab.title} ${tab.url} (target ${tab.id})`).join(`
750
1002
  `)}`;
751
1003
  }
752
- for (const frameId of await this.childFrames()) {
1004
+ const frames = await this.frameStates();
1005
+ for (const frame of frames.filter((frame2) => frame2.parentId !== null)) {
1006
+ const frameId = frame.id;
753
1007
  try {
754
- const offset = await this.frameOffset(frameId);
1008
+ const offset = await this.frameScreenOffset(frameId, frames);
755
1009
  const child = await this.evaluateFrame(frameId, snapshot_default);
756
1010
  if (!child)
757
1011
  continue;
@@ -763,13 +1017,67 @@ ${child.text}`.slice(0, 6000);
763
1017
  const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
764
1018
  if (rect.x < 0 || rect.y < 0 || rect.x >= info.w || rect.y >= info.h)
765
1019
  continue;
766
- info.actions.push({ ...action, frameId, rect, id: `e${info.actions.length + 1}` });
1020
+ const covering = await this.evaluate(`(() => {
1021
+ const e=document.elementFromPoint(${rect.x + rect.w / 2},${rect.y + rect.h / 2});
1022
+ if (!e || e.tagName==='IFRAME') return null;
1023
+ return {tag:e.tagName.toLowerCase(),text:(e.innerText||e.getAttribute('aria-label')||'').trim().slice(0,160),role:e.getAttribute('role')};
1024
+ })()`);
1025
+ info.actions.push({
1026
+ ...action,
1027
+ frameId,
1028
+ rect,
1029
+ id: `e${info.actions.length + 1}`,
1030
+ clickable: action.clickable && !covering,
1031
+ coveredBy: covering ?? action.coveredBy
1032
+ });
767
1033
  info.guards[`${frameId}:${action.node}`] = child.guards[String(action.node)];
768
1034
  }
769
1035
  info.marker = [info.marker, frameId, child.marker];
770
1036
  } catch {}
771
1037
  }
772
- const page = { ...info, fingerprint: fingerprint(info) };
1038
+ 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)) {
1039
+ this.#transitions.push({
1040
+ kind: "navigation",
1041
+ control: this.#origin.control,
1042
+ fromTargetId: this.#origin.targetId,
1043
+ targetId: this.#targetId,
1044
+ fromUrl: this.#origin.url,
1045
+ destinationUrl: info.url,
1046
+ settled: false
1047
+ });
1048
+ if (this.#origin.href && this.#origin.href !== info.url)
1049
+ this.#transitions.push({
1050
+ kind: "redirect",
1051
+ control: this.#origin.control,
1052
+ fromTargetId: this.#origin.targetId,
1053
+ targetId: this.#targetId,
1054
+ fromUrl: this.#origin.href,
1055
+ destinationUrl: info.url,
1056
+ settled: false
1057
+ });
1058
+ }
1059
+ const targets = this.#transitions.length ? await listChromeTargets(this.#cdpUrl) : [];
1060
+ for (const transition of this.#transitions) {
1061
+ const target = targets.find((item) => item.id === transition.targetId);
1062
+ if (target)
1063
+ transition.destinationUrl = target.url;
1064
+ transition.settled = frames.every((frame) => !frame.loading);
1065
+ }
1066
+ if (this.#origin?.href)
1067
+ for (const transition of this.#transitions.filter((item) => item.kind === "new_tab" && item.control === this.#origin.control)) {
1068
+ if (transition.destinationUrl !== this.#origin.href && !this.#transitions.some((item) => item.kind === "redirect" && item.targetId === transition.targetId && item.fromUrl === this.#origin.href)) {
1069
+ this.#transitions.push({
1070
+ kind: "redirect",
1071
+ control: transition.control,
1072
+ fromTargetId: transition.fromTargetId,
1073
+ targetId: transition.targetId,
1074
+ fromUrl: this.#origin.href,
1075
+ destinationUrl: transition.destinationUrl,
1076
+ settled: transition.settled
1077
+ });
1078
+ }
1079
+ }
1080
+ const page = { ...info, frames, transitions: [...this.#transitions], fingerprint: fingerprint(info) };
773
1081
  if (screenshot) {
774
1082
  const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
775
1083
  page.screenshot = capture.data;
@@ -792,15 +1100,49 @@ ${child.text}`.slice(0, 6000);
792
1100
  })()`);
793
1101
  return stableStringify(current) === stableStringify([page.page_key, page.guards[String(action.node)]]);
794
1102
  }
795
- const marker = await this.evaluate(MARKER);
1103
+ let marker = await this.evaluate(MARKER);
1104
+ for (const frame of (await this.frameStates()).filter((item) => item.parentId !== null)) {
1105
+ try {
1106
+ const childMarker = await this.evaluateFrame(frame.id, MARKER);
1107
+ if (childMarker !== undefined)
1108
+ marker = [marker ?? null, frame.id, childMarker];
1109
+ } catch {
1110
+ return false;
1111
+ }
1112
+ }
796
1113
  return stableStringify(marker) === stableStringify(page.marker);
797
1114
  }
1115
+ async describeElement(action) {
1116
+ const expression = `(node => {
1117
+ const e = window.__jevFast?.nodes.get(node);
1118
+ if (!e?.isConnected) return null;
1119
+ const path = [];
1120
+ for (let current = e; current; current = current.parentElement) {
1121
+ if (current.id && document.querySelectorAll('#' + CSS.escape(current.id)).length === 1) {
1122
+ path.unshift('#' + CSS.escape(current.id));
1123
+ break;
1124
+ }
1125
+ let position = 1;
1126
+ for (let sibling = current.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
1127
+ if (sibling.tagName === current.tagName) position++;
1128
+ }
1129
+ path.unshift(current.tagName.toLowerCase() + ':nth-of-type(' + position + ')');
1130
+ }
1131
+ return {css:path.join(' > '),tag:e.tagName.toLowerCase(),
1132
+ href:typeof e.href === 'string' ? e.href : e.getAttribute('href'),
1133
+ inputType:e.getAttribute('type'),frameUrl:location.href,target:e.getAttribute('target')};
1134
+ })(${action.node})`;
1135
+ const details = action.frameId ? await this.evaluateFrame(action.frameId, expression) : await this.evaluate(expression);
1136
+ if (!details)
1137
+ throw new StalePageError("Target changed before replay details were captured");
1138
+ return details;
1139
+ }
798
1140
  async act(action, page, text) {
799
1141
  if (!await this.fresh(page, action))
800
1142
  throw new StalePageError("Page changed since this decision");
801
1143
  if (action.kind === "wait") {
802
1144
  await Bun.sleep(100);
803
- return;
1145
+ return { element: null, performedAt: performance.now() };
804
1146
  }
805
1147
  if (action.kind === "scroll") {
806
1148
  await this.animateCursor(550, 650);
@@ -811,14 +1153,24 @@ ${child.text}`.slice(0, 6000);
811
1153
  deltaX: 0,
812
1154
  deltaY: action.delta ?? 0
813
1155
  });
814
- return;
1156
+ return { element: null, performedAt: performance.now() };
815
1157
  }
816
1158
  if (typeof action.node !== "number")
817
1159
  throw new Error("Invalid observed node");
1160
+ const details = await this.describeElement(action);
1161
+ if (action.kind === "click")
1162
+ this.#origin = {
1163
+ control: action.label,
1164
+ targetId: this.#targetId,
1165
+ url: page.url,
1166
+ href: details.href
1167
+ };
818
1168
  const target = await (action.frameId ? this.evaluateFrame(action.frameId, `(action => {
819
1169
  const e=window.__jevFast?.nodes.get(action.node);
820
1170
  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};
1171
+ const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2;
1172
+ if (!e.contains(document.elementFromPoint(x,y))) return null;
1173
+ return {x,y};
822
1174
  })(${JSON.stringify(action)})`) : this.evaluate(`(action => {
823
1175
  const e=window.__jevFast?.nodes.get(action.node);
824
1176
  if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
@@ -841,11 +1193,37 @@ ${child.text}`.slice(0, 6000);
841
1193
  throw new Error("Dropdown execution was not confirmed");
842
1194
  throw new StalePageError("Target changed or is covered");
843
1195
  }
1196
+ let performedAt = action.kind === "select" ? performance.now() : 0;
844
1197
  if (action.frameId) {
845
- const offset = await this.frameOffset(action.frameId);
1198
+ const offset = await this.frameScreenOffset(action.frameId);
846
1199
  target.x += offset.x;
847
1200
  target.y += offset.y;
1201
+ const covered = await this.evaluate(`(() => {
1202
+ const e=document.elementFromPoint(${target.x},${target.y});
1203
+ return !e || e.tagName!=='IFRAME';
1204
+ })()`);
1205
+ if (covered)
1206
+ throw new StalePageError("Target is covered in the parent page");
848
1207
  }
1208
+ let frame = null;
1209
+ if (action.frameId) {
1210
+ const frames = await this.frameStates();
1211
+ const parentId = frames.find((item) => item.id === action.frameId)?.parentId ?? null;
1212
+ const index = frames.filter((item) => item.parentId === parentId).findIndex((item) => item.id === action.frameId);
1213
+ if (index < 0)
1214
+ throw new StalePageError("Target frame changed before input");
1215
+ frame = { id: action.frameId, parentId, url: details.frameUrl, index };
1216
+ }
1217
+ const element = {
1218
+ css: details.css,
1219
+ role: action.role ?? null,
1220
+ name: action.label,
1221
+ tag: details.tag,
1222
+ href: details.href,
1223
+ inputType: details.inputType,
1224
+ point: { x: target.x, y: target.y },
1225
+ frame
1226
+ };
849
1227
  await this.animateCursor(target.x, target.y);
850
1228
  if (action.kind !== "select") {
851
1229
  if (action.kind === "click" && this.#interactionPauses > 0) {
@@ -867,6 +1245,7 @@ ${child.text}`.slice(0, 6000);
867
1245
  if (type === "mousePressed")
868
1246
  await this.animateCursor(target.x, target.y, true);
869
1247
  }
1248
+ performedAt = performance.now();
870
1249
  if (action.kind === "fill") {
871
1250
  const modifiers = process.platform === "darwin" ? 4 : 2;
872
1251
  await this.call("Input.dispatchKeyEvent", {
@@ -883,13 +1262,15 @@ ${child.text}`.slice(0, 6000);
883
1262
  modifiers
884
1263
  });
885
1264
  await this.call("Input.insertText", { text: text ?? "" });
1265
+ performedAt = performance.now();
886
1266
  }
887
1267
  }
888
1268
  this.#afterInput = action;
889
- if (action.frameId && action.kind === "click") {
1269
+ if (action.kind === "click") {
890
1270
  this.#switchOnPopup = true;
891
- await Bun.sleep(650);
1271
+ await this.waitForNewTarget(details.target === "_blank" ? 650 : 100);
892
1272
  }
1273
+ return { element, performedAt };
893
1274
  }
894
1275
  async close() {
895
1276
  if (this.#closed)
@@ -938,9 +1319,9 @@ its matching autocomplete suggestion selected. For date pickers, CLICK the field
938
1319
  Set every requested filter/control; a matching result alone does not prove a requested filter was set.
939
1320
  Do not toggle a checkbox, switch, or radio already in the requested state.
940
1321
  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
1322
  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.
1323
+ Loading and frame readiness are handled by the browser adapter before this decision.
1324
+ Use frame URL, nearby text, bounds, and clickability to distinguish repeated labels.
944
1325
  DONE requires visible evidence that ALL requirements are satisfied. If asked to open a result,
945
1326
  a matching link is not enough. BLOCKED means no supported operation can make progress.`;
946
1327
  var TARGET = `Choose the best observed target if the next operation is the one specified in this question.
@@ -1012,13 +1393,21 @@ function actionSpace(actions) {
1012
1393
  }
1013
1394
  if (typeof action.node !== "number")
1014
1395
  continue;
1015
- if (!indices.has(action.node)) {
1396
+ const identity = `${action.frameId ?? "main"}:${action.node}`;
1397
+ if (!indices.has(identity)) {
1016
1398
  const index2 = String(elements.length + 1);
1017
- indices.set(action.node, index2);
1399
+ indices.set(identity, index2);
1018
1400
  const element2 = {
1019
1401
  index: index2,
1020
1402
  label: action.label.split(" \u2192 ")[0] ?? action.label,
1021
- operations: []
1403
+ operations: [],
1404
+ frameId: action.frameId ?? null,
1405
+ frameUrl: action.frameUrl ?? null,
1406
+ bounds: action.rect ?? null,
1407
+ nearbyText: action.nearbyText ?? "",
1408
+ region: action.region ?? "",
1409
+ clickable: action.clickable ?? true,
1410
+ coveredBy: action.coveredBy ?? null
1022
1411
  };
1023
1412
  for (const key of ["role", "value", "checked", "selected", "expanded", "pressed", "sensitive"]) {
1024
1413
  if (action[key] !== undefined)
@@ -1030,7 +1419,9 @@ function actionSpace(actions) {
1030
1419
  }
1031
1420
  elements.push(element2);
1032
1421
  }
1033
- const index = indices.get(action.node);
1422
+ const index = indices.get(identity);
1423
+ if (action.clickable === false)
1424
+ continue;
1034
1425
  const group = targets[operation] ??= {};
1035
1426
  const element = elements[Number(index) - 1];
1036
1427
  if (!element.operations.includes(operation))
@@ -1069,6 +1460,13 @@ async function choose(page, goal, history, providedFields = []) {
1069
1460
  type: "choice",
1070
1461
  criteria: Object.fromEntries(Object.entries(candidates).map(([index, action]) => [index, {
1071
1462
  element: `[${index}] ${action.label}`,
1463
+ frame_id: action.frameId ?? null,
1464
+ frame: action.frameUrl ?? page.url,
1465
+ region: action.region ?? "",
1466
+ nearby_text: action.nearbyText ?? "",
1467
+ bounds: action.rect ?? null,
1468
+ clickable: action.clickable ?? true,
1469
+ covered_by: action.coveredBy ?? null,
1072
1470
  current_value: action.current_value ?? action.value ?? "",
1073
1471
  ...Object.fromEntries(["role", "checked", "selected", "expanded", "pressed", "sensitive"].filter((name) => action[name] !== undefined).map((name) => [name, action[name]]))
1074
1472
  }])),
@@ -1248,12 +1646,23 @@ function fieldText(context) {
1248
1646
  }
1249
1647
 
1250
1648
  // src/agent.ts
1649
+ function redactConsoleErrors(errors, secrets) {
1650
+ const redact = (text) => secrets.reduce((value, secret) => value.replaceAll(secret, "[redacted]"), text);
1651
+ return errors.map((error) => ({
1652
+ ...error,
1653
+ message: redact(error.message),
1654
+ ...error.url ? { url: redact(error.url) } : {}
1655
+ }));
1656
+ }
1657
+
1251
1658
  class Agent {
1252
1659
  #browser;
1253
1660
  #goal;
1254
1661
  #maxSteps;
1255
1662
  #screenshots;
1256
1663
  #fieldValues;
1664
+ #sensitiveFieldLabels;
1665
+ #secretValues;
1257
1666
  #page;
1258
1667
  #decision = null;
1259
1668
  #history = [];
@@ -1262,6 +1671,8 @@ class Agent {
1262
1671
  #status = "ready";
1263
1672
  #startedAt = null;
1264
1673
  #pendingText = null;
1674
+ #waitTimeout;
1675
+ #initialConsoleErrors = [];
1265
1676
  constructor(browser, page, options) {
1266
1677
  this.#browser = browser;
1267
1678
  this.#page = page;
@@ -1269,6 +1680,9 @@ class Agent {
1269
1680
  this.#maxSteps = options.maxSteps;
1270
1681
  this.#screenshots = options.screenshots ?? false;
1271
1682
  this.#fieldValues = options.fieldValues ?? {};
1683
+ this.#sensitiveFieldLabels = new Set(options.sensitiveFieldLabels ?? []);
1684
+ this.#secretValues = Object.entries(this.#fieldValues).filter(([label, value]) => this.#sensitiveFieldLabels.has(label) && value.length > 0).map(([, value]) => value);
1685
+ this.#startedAt = performance.now();
1272
1686
  }
1273
1687
  static async create(options) {
1274
1688
  if (!options.goal.trim())
@@ -1286,12 +1700,24 @@ class Agent {
1286
1700
  recordingPath: options.recordingPath,
1287
1701
  screenshotPath: options.screenshotPath,
1288
1702
  freshContext: options.freshContext,
1289
- interactionPauses: options.interactionPauses
1703
+ interactionPauses: options.interactionPauses,
1704
+ waitBudgetMs: options.waitBudgetMs
1290
1705
  });
1291
1706
  try {
1292
- return new Agent(browser, await browser.observe(options.screenshots), options);
1707
+ const agent = new Agent(browser, await browser.observe(options.screenshots), options);
1708
+ try {
1709
+ await agent.waitForReadiness();
1710
+ } catch (error) {
1711
+ if (!(error instanceof WaitTimeoutError))
1712
+ throw error;
1713
+ agent.recordWaitTimeout(error);
1714
+ }
1715
+ agent.#initialConsoleErrors = redactConsoleErrors(browser.takeConsoleErrors(), agent.#secretValues);
1716
+ return agent;
1293
1717
  } catch (error) {
1294
- await browser.close();
1718
+ await browser.close().catch(() => {
1719
+ return;
1720
+ });
1295
1721
  throw error;
1296
1722
  }
1297
1723
  }
@@ -1306,7 +1732,14 @@ class Agent {
1306
1732
  status: this.#status,
1307
1733
  elapsedMs: this.elapsedMs(),
1308
1734
  maxSteps: this.#maxSteps,
1309
- elements: actionSpace(this.#page.actions).elements
1735
+ elements: actionSpace(this.#page.actions).elements,
1736
+ initialConsoleErrors: [...this.#initialConsoleErrors],
1737
+ consoleErrors: [
1738
+ ...this.#initialConsoleErrors,
1739
+ ...this.#history.flatMap((entry) => entry.consoleErrors),
1740
+ ...redactConsoleErrors(this.#browser.pendingConsoleErrors(), this.#secretValues)
1741
+ ],
1742
+ ...this.#waitTimeout ? { waitTimeout: this.#waitTimeout } : {}
1310
1743
  };
1311
1744
  }
1312
1745
  get targetId() {
@@ -1320,15 +1753,12 @@ class Agent {
1320
1753
  this.#startedAt = performance.now();
1321
1754
  if (!await this.#browser.fresh(this.#page)) {
1322
1755
  this.#page = await this.#browser.observe(this.#screenshots);
1756
+ await this.waitForReadiness();
1323
1757
  }
1324
1758
  this.#decision = null;
1325
- if (["done", "blocked", "budget_exhausted"].includes(this.#status)) {
1759
+ if (["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
1326
1760
  throw new Error("This run has stopped");
1327
1761
  }
1328
- if (this.#decisions.length >= this.#maxSteps * 2) {
1329
- this.#status = "budget_exhausted";
1330
- return;
1331
- }
1332
1762
  this.#decision = await choose(this.#page, this.#goal, this.#history, Object.keys(this.#fieldValues));
1333
1763
  this.#decisions.push(this.#decision);
1334
1764
  this.#status = "predicted";
@@ -1369,7 +1799,7 @@ class Agent {
1369
1799
  if (provided !== undefined) {
1370
1800
  text = provided;
1371
1801
  helper = { model: "provided-field-value", provider: "caller", latency_ms: 0, usage: {} };
1372
- this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive ? "[redacted]" : text });
1802
+ this.#textCalls.push({ ...helper, field: action.label, value: action.sensitive || this.#sensitiveFieldLabels.has(action.label) ? "[redacted]" : text });
1373
1803
  } else if (this.#pendingText?.contextKey === contextKey) {
1374
1804
  ({ text, helper } = this.#pendingText);
1375
1805
  } else {
@@ -1379,7 +1809,8 @@ class Agent {
1379
1809
  }
1380
1810
  }
1381
1811
  await this.#browser.waitForInteractionPause();
1382
- await this.#browser.act(action, page, text ?? undefined);
1812
+ const fromTargetId = this.#browser.targetId;
1813
+ const { element, performedAt } = await this.#browser.act(action, page, text ?? undefined);
1383
1814
  this.#pendingText = null;
1384
1815
  const entry = {
1385
1816
  step: this.#history.length + 1,
@@ -1389,21 +1820,33 @@ class Agent {
1389
1820
  probability: decision.probabilities[selected] ?? 0,
1390
1821
  confidence: decision.confidence,
1391
1822
  latency_ms: decision.latency_ms,
1392
- text: action.sensitive && text !== null ? "[redacted]" : text,
1823
+ text: (action.sensitive || this.#sensitiveFieldLabels.has(action.label)) && text !== null ? "[redacted]" : text,
1393
1824
  text_helper: helper?.model ?? null,
1394
1825
  text_latency_ms: helper?.latency_ms ?? 0,
1395
1826
  operation: decision.operation,
1396
1827
  target: decision.target,
1397
1828
  page_changed: null,
1829
+ from_url: page.url,
1398
1830
  url: page.url,
1831
+ viewport: { width: page.w, height: page.h },
1832
+ from_target_id: fromTargetId,
1833
+ target_id: this.#browser.targetId,
1834
+ element,
1835
+ value: action.kind === "select" ? action.value ?? null : null,
1836
+ delta_y: action.kind === "scroll" ? action.delta ?? 0 : null,
1837
+ redacted: action.kind === "fill" && (Boolean(action.sensitive) || this.#sensitiveFieldLabels.has(action.label)),
1399
1838
  usage: decision.usage,
1400
- executed_ms: this.elapsedMs(),
1401
- elapsed_ms: this.elapsedMs()
1839
+ executed_ms: Math.round(performedAt - this.#startedAt),
1840
+ elapsed_ms: this.elapsedMs(),
1841
+ consoleErrors: []
1402
1842
  };
1403
1843
  this.#history.push(entry);
1404
1844
  this.#page = await this.#browser.observe(this.#screenshots);
1845
+ await this.waitForReadiness();
1846
+ entry.consoleErrors = redactConsoleErrors(this.#browser.takeConsoleErrors(), this.#secretValues);
1405
1847
  entry.page_changed = this.#page.fingerprint !== page.fingerprint;
1406
1848
  entry.url = this.#page.url;
1849
+ entry.target_id = this.#browser.targetId;
1407
1850
  entry.elapsed_ms = this.elapsedMs();
1408
1851
  this.#status = "ready";
1409
1852
  }
@@ -1412,21 +1855,41 @@ class Agent {
1412
1855
  await this.predict();
1413
1856
  await this.act();
1414
1857
  } catch (error) {
1858
+ if (error instanceof WaitTimeoutError) {
1859
+ this.recordWaitTimeout(error);
1860
+ return this.snapshot();
1861
+ }
1415
1862
  if (!(error instanceof StalePageError))
1416
1863
  throw error;
1417
1864
  this.#decision = null;
1418
1865
  this.#status = "ready";
1419
1866
  this.#page = await this.#browser.observe(this.#screenshots);
1867
+ try {
1868
+ await this.waitForReadiness();
1869
+ } catch (waitError) {
1870
+ if (!(waitError instanceof WaitTimeoutError))
1871
+ throw waitError;
1872
+ this.recordWaitTimeout(waitError);
1873
+ }
1420
1874
  }
1421
1875
  return this.snapshot();
1422
1876
  }
1423
1877
  async run(onStep) {
1424
- while (!["done", "blocked", "budget_exhausted"].includes(this.#status)) {
1878
+ while (!["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
1425
1879
  const state = await this.tick();
1426
1880
  onStep?.(state);
1427
1881
  }
1428
1882
  return this.snapshot();
1429
1883
  }
1884
+ async waitForReadiness() {
1885
+ this.#page = await this.#browser.waitForSemanticReady(this.#page, this.#screenshots);
1886
+ }
1887
+ recordWaitTimeout(error) {
1888
+ this.#status = "wait_timeout";
1889
+ this.#waitTimeout = { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition };
1890
+ if (error.state)
1891
+ this.#page = error.state;
1892
+ }
1430
1893
  close() {
1431
1894
  return this.#browser.close();
1432
1895
  }
@@ -1502,6 +1965,8 @@ Goal control:
1502
1965
  --goal <text> One bounded browser goal. Required.
1503
1966
  --max-steps <number> Maximum executed browser actions.
1504
1967
  [env: JEV_MAX_STEPS] [default: 12]
1968
+ --wait-budget-ms <number> Total wall-clock budget for page and frame readiness.
1969
+ [default: 15000]
1505
1970
 
1506
1971
  Browser behavior:
1507
1972
  --visible Activate the controlled tab.
@@ -1525,12 +1990,15 @@ Evidence and output:
1525
1990
  -h, --help Show this help and exit.
1526
1991
 
1527
1992
  Output:
1528
- The final result is one JSON object on stdout. Progress and diagnostics use stderr.
1993
+ Stdout is JSON Lines: one object per executed action, then one result object.
1994
+ Each action includes new console errors observed during that step. The result
1995
+ includes initial errors and all errors observed during the run.
1996
+ Errors and diagnostics use stderr.
1529
1997
 
1530
1998
  Exit codes:
1531
1999
  0 Jev reported the goal complete.
1532
2000
  1 Invalid configuration or runtime failure.
1533
- 2 Jev reported that it was blocked.
2001
+ 2 Jev was blocked or the wait budget timed out.
1534
2002
  3 The maximum browser-step budget was exhausted.
1535
2003
 
1536
2004
  Examples:
@@ -1613,6 +2081,7 @@ function addFieldValue(options, assignment, fromEnvironment) {
1613
2081
  if (!supplied)
1614
2082
  throw new CliError(`Environment variable is missing or empty: ${environmentName}`);
1615
2083
  options.fieldValues[label] = supplied;
2084
+ options.sensitiveFieldLabels.push(label);
1616
2085
  } else {
1617
2086
  options.fieldValues[label] = assignment.slice(separator + 1);
1618
2087
  }
@@ -1622,10 +2091,12 @@ function parseRunOptions(args) {
1622
2091
  cdpUrl: process.env.CHROME_CDP_URL ?? DEFAULT_CDP_URL,
1623
2092
  maxSteps: parsePositiveInteger(process.env.JEV_MAX_STEPS ?? "12", "JEV_MAX_STEPS"),
1624
2093
  interactionPauses: 0,
2094
+ waitBudgetMs: 15000,
1625
2095
  visible: enabled(process.env.JEV_BROWSER_VISIBLE),
1626
2096
  keepOpen: enabled(process.env.JEV_BROWSER_KEEP_OPEN),
1627
2097
  finalState: false,
1628
2098
  fieldValues: {},
2099
+ sensitiveFieldLabels: [],
1629
2100
  freshContext: enabled(process.env.JEV_BROWSER_FRESH_CONTEXT)
1630
2101
  };
1631
2102
  for (let index = 0;index < args.length; index++) {
@@ -1642,6 +2113,8 @@ function parseRunOptions(args) {
1642
2113
  options.maxSteps = parsePositiveInteger(nextValue(args, index++, argument), argument);
1643
2114
  else if (argument === "--interaction-pauses")
1644
2115
  options.interactionPauses = parseNonNegativeInteger(nextValue(args, index++, argument), argument);
2116
+ else if (argument === "--wait-budget-ms")
2117
+ options.waitBudgetMs = parsePositiveInteger(nextValue(args, index++, argument), argument);
1645
2118
  else if (argument === "--recording")
1646
2119
  options.recordingPath = nextValue(args, index++, argument);
1647
2120
  else if (argument === "--screenshot")
@@ -1689,17 +2162,45 @@ function parseCommonOptions(args, help) {
1689
2162
  }
1690
2163
  return options;
1691
2164
  }
1692
- function latencyStats(values) {
2165
+ function actionEvent(entry, maxSteps) {
2166
+ return {
2167
+ type: "action",
2168
+ status: "executed",
2169
+ step: entry.step,
2170
+ elapsedMs: entry.executed_ms,
2171
+ budget: { used: entry.step, max: maxSteps, remaining: maxSteps - entry.step },
2172
+ page: { before: entry.from_url, after: entry.url, changed: entry.page_changed, viewport: entry.viewport },
2173
+ tab: { before: entry.from_target_id, after: entry.target_id },
2174
+ consoleErrors: entry.consoleErrors,
2175
+ action: {
2176
+ kind: entry.kind,
2177
+ label: entry.action,
2178
+ element: entry.element,
2179
+ ...entry.kind === "fill" ? { text: entry.text, redacted: entry.redacted } : {},
2180
+ ...entry.kind === "select" ? { optionValue: entry.value } : {},
2181
+ ...entry.kind === "scroll" ? { deltaY: entry.delta_y, point: { x: 550, y: 650 } } : {},
2182
+ ...entry.kind === "wait" ? { durationMs: 100 } : {}
2183
+ }
2184
+ };
2185
+ }
2186
+ function semanticState(page, elements) {
2187
+ const frameTree = (parentId) => page.frames.filter((frame) => frame.parentId === parentId).map((frame) => ({ ...frame, children: frameTree(frame.id) }));
1693
2188
  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
2189
+ url: page.url,
2190
+ title: page.title,
2191
+ text: page.text,
2192
+ viewport: { width: page.w, height: page.h },
2193
+ scroll: page.scroll,
2194
+ elements,
2195
+ frameTree: frameTree(null),
2196
+ transitions: page.transitions,
2197
+ omittedActions: page.omitted_actions
1698
2198
  };
1699
2199
  }
1700
2200
  async function runGoal(args) {
1701
2201
  const options = parseRunOptions(args);
1702
2202
  let agent;
2203
+ let reportedActions = 0;
1703
2204
  try {
1704
2205
  agent = await Agent.create({
1705
2206
  url: options.url,
@@ -1708,50 +2209,65 @@ async function runGoal(args) {
1708
2209
  cdpUrl: options.cdpUrl,
1709
2210
  maxSteps: options.maxSteps,
1710
2211
  interactionPauses: options.interactionPauses,
2212
+ waitBudgetMs: options.waitBudgetMs,
1711
2213
  visible: options.visible,
1712
2214
  keepOpen: options.keepOpen,
1713
2215
  recordingPath: options.recordingPath,
1714
2216
  screenshotPath: options.screenshotPath,
1715
2217
  fieldValues: options.fieldValues,
2218
+ sensitiveFieldLabels: options.sensitiveFieldLabels,
1716
2219
  freshContext: options.freshContext
1717
2220
  });
1718
- let reportedActions = 0;
1719
- const result = await agent.run((state) => {
2221
+ await agent.run((state) => {
1720
2222
  const action = state.history.length > reportedActions ? state.history.at(-1) : undefined;
1721
- const decision = state.decisions.at(-1);
1722
2223
  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}`);
2224
+ if (action)
2225
+ console.log(JSON.stringify(actionEvent(action, state.maxSteps)));
1727
2226
  });
2227
+ await agent.close();
2228
+ const result = agent.snapshot();
1728
2229
  console.log(JSON.stringify({
2230
+ type: "result",
1729
2231
  status: result.status,
1730
2232
  targetId: agent.targetId,
1731
2233
  url: result.page.url,
1732
2234
  actions: result.history.length,
1733
2235
  maxSteps: result.maxSteps,
2236
+ budget: { used: result.history.length, max: result.maxSteps, remaining: result.maxSteps - result.history.length },
1734
2237
  elapsedMs: result.elapsedMs,
1735
2238
  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
- },
2239
+ initialConsoleErrors: result.initialConsoleErrors,
2240
+ consoleErrors: result.consoleErrors,
2241
+ ...result.waitTimeout ? { waitTimeout: result.waitTimeout } : {},
1740
2242
  ...options.recordingPath ? { recording: options.recordingPath } : {},
1741
2243
  ...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
- }
2244
+ ...options.finalState || result.status === "wait_timeout" ? {
2245
+ finalState: semanticState(result.page, result.elements)
1752
2246
  } : {}
1753
2247
  }));
1754
2248
  return result.status === "done" ? 0 : result.status === "budget_exhausted" ? 3 : 2;
2249
+ } catch (error) {
2250
+ const state = agent?.snapshot();
2251
+ for (const action of state?.history.slice(reportedActions) ?? []) {
2252
+ console.log(JSON.stringify(actionEvent(action, options.maxSteps)));
2253
+ }
2254
+ console.log(JSON.stringify({
2255
+ type: "result",
2256
+ status: error instanceof WaitTimeoutError ? "wait_timeout" : "error",
2257
+ targetId: agent?.targetId ?? null,
2258
+ url: state?.page.url ?? (error instanceof WaitTimeoutError ? error.state?.url ?? null : null),
2259
+ actions: state?.history.length ?? 0,
2260
+ maxSteps: options.maxSteps,
2261
+ budget: { used: state?.history.length ?? 0, max: options.maxSteps, remaining: options.maxSteps - (state?.history.length ?? 0) },
2262
+ elapsedMs: state?.elapsedMs ?? 0,
2263
+ initialConsoleErrors: state?.initialConsoleErrors ?? [],
2264
+ consoleErrors: state?.consoleErrors ?? [],
2265
+ ...error instanceof WaitTimeoutError ? { waitTimeout: { elapsedMs: error.elapsedMs, pendingCondition: error.pendingCondition } } : {},
2266
+ ...state ? { finalState: semanticState(state.page, state.elements) } : error instanceof WaitTimeoutError && error.state ? { finalState: semanticState(error.state, actionSpace(error.state.actions).elements) } : {}
2267
+ }));
2268
+ if (error instanceof WaitTimeoutError)
2269
+ return 2;
2270
+ throw error;
1755
2271
  } finally {
1756
2272
  await agent?.close();
1757
2273
  }
@@ -1859,5 +2375,6 @@ async function main(args = Bun.argv.slice(2)) {
1859
2375
  if (import.meta.main)
1860
2376
  process.exitCode = await main();
1861
2377
  export {
2378
+ actionEvent,
1862
2379
  main
1863
2380
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-cdp",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
5
5
  "type": "module",
6
6
  "license": "MIT",