jev-cdp 0.1.3 → 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 +7 -3
  2. package/dist/cli.js +738 -91
  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,7 +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
+
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.
102
106
 
103
107
  ## Supply known field values without another LLM
104
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.3",
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);
@@ -317,8 +341,26 @@ class Browser {
317
341
  #pageLoadPromise = null;
318
342
  #resolvePageLoad;
319
343
  #pauseUntil = 0;
344
+ #frameSessions = new Map;
345
+ #frameContexts = new Map;
346
+ #knownTargets = new Set;
347
+ #openedTabs = [];
348
+ #cdpUrl;
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;
320
361
  constructor(cdp, sessionId, targetId, ownsTarget, browserContextId, options) {
321
362
  this.#cdp = cdp;
363
+ this.#cdpUrl = options.cdpUrl;
322
364
  this.#sessionId = sessionId;
323
365
  this.#targetId = targetId;
324
366
  this.#ownsTarget = ownsTarget;
@@ -326,6 +368,7 @@ class Browser {
326
368
  this.#keepOpen = options.keepOpen ?? false;
327
369
  this.#screenshots = options.screenshots ?? false;
328
370
  this.#interactionPauses = options.interactionPauses ?? 0;
371
+ this.#waitBudgetMs = options.waitBudgetMs ?? 15000;
329
372
  this.#recordingPath = options.recordingPath ? resolve(options.recordingPath) : undefined;
330
373
  this.#screenshotPath = options.screenshotPath ? resolve(options.screenshotPath) : undefined;
331
374
  }
@@ -376,7 +419,41 @@ class Browser {
376
419
  flatten: true
377
420
  });
378
421
  const browser = new Browser(cdp, attached.sessionId, targetId, ownsTarget, browserContextId, options);
422
+ browser.#knownTargets = new Set((await listChromeTargets(options.cdpUrl)).map((target) => target.id));
379
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 });
440
+ browser.#stopPageEvents.push(cdp.on("Target.attachedToTarget", browser.#sessionId, (params) => {
441
+ const info = params.targetInfo;
442
+ if (info?.type === "iframe" && info.targetId && typeof params.sessionId === "string") {
443
+ browser.#frameSessions.set(info.targetId, params.sessionId);
444
+ browser.watchConsole(params.sessionId, info.targetId).catch(() => {
445
+ return;
446
+ });
447
+ }
448
+ }));
449
+ browser.#stopPageEvents.push(cdp.on("Page.frameNavigated", browser.#sessionId, (params) => {
450
+ const frame = params.frame;
451
+ if (frame?.id)
452
+ browser.#frameContexts.delete(frame.id);
453
+ }));
454
+ await browser.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
455
+ await browser.watchConsole();
456
+ await browser.call("Page.enable");
380
457
  await browser.call("Emulation.setDeviceMetricsOverride", {
381
458
  width: 1120,
382
459
  height: 780,
@@ -392,7 +469,9 @@ class Browser {
392
469
  await browser.startRecording();
393
470
  return browser;
394
471
  } catch (error) {
395
- await browser.close();
472
+ await browser.close().catch(() => {
473
+ return;
474
+ });
396
475
  throw error;
397
476
  }
398
477
  }
@@ -402,13 +481,65 @@ class Browser {
402
481
  get targetId() {
403
482
  return this.#targetId;
404
483
  }
405
- async watchPageLoads() {
406
- 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))
407
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() {
408
524
  await this.call("Page.enable");
409
525
  const tree = await this.call("Page.getFrameTree");
410
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
+ });
411
539
  const loading = (params) => {
540
+ if (typeof params.frameId === "string")
541
+ this.#loadingFrames.add(params.frameId);
542
+ this.signalChange();
412
543
  if (params.frameId !== this.#mainFrameId || this.#pageLoadPromise)
413
544
  return;
414
545
  this.#pageLoadPromise = new Promise((resolve2) => {
@@ -416,6 +547,9 @@ class Browser {
416
547
  });
417
548
  };
418
549
  const loaded = () => {
550
+ if (this.#mainFrameId)
551
+ this.#loadingFrames.delete(this.#mainFrameId);
552
+ this.signalChange();
419
553
  if (!this.#pageLoadPromise)
420
554
  return;
421
555
  this.#pauseUntil = performance.now() + this.#interactionPauses;
@@ -425,34 +559,69 @@ class Browser {
425
559
  };
426
560
  this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
427
561
  const frame = params.frame;
562
+ this.signalChange();
428
563
  if (frame && frame.id === this.#mainFrameId && !frame.parentId)
429
564
  loading({ frameId: frame.id });
430
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();
431
569
  if (params.frameId === this.#mainFrameId)
432
570
  loaded();
433
571
  }), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
572
+ this.signalChange();
434
573
  if (params.frameId === this.#mainFrameId) {
435
574
  this.#pauseUntil = performance.now() + this.#interactionPauses;
436
575
  this.resetRecordingCursor().catch(() => {
437
576
  return;
438
577
  });
439
578
  }
440
- }));
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
+ });
441
608
  }
442
609
  async waitForInteractionPause() {
443
610
  if (!this.#interactionPauses)
444
611
  return;
612
+ const waitStarted = performance.now();
445
613
  while (true) {
446
614
  const pageLoad = this.#pageLoadPromise;
447
615
  if (pageLoad) {
448
- await Promise.race([
449
- pageLoad,
450
- Bun.sleep(15000).then(() => {
451
- throw new Error("Page did not finish loading within 15 seconds");
452
- })
453
- ]);
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
+ })]);
454
622
  continue;
455
623
  }
624
+ this.#waitSpentMs += performance.now() - waitStarted;
456
625
  const remaining = this.#pauseUntil - performance.now();
457
626
  if (remaining <= 0)
458
627
  return;
@@ -464,7 +633,9 @@ class Browser {
464
633
  return;
465
634
  if (!Bun.which("ffmpeg"))
466
635
  throw new Error("--recording requires ffmpeg on PATH");
467
- this.#recordingDirectory = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
636
+ const firstSegment = !this.#recordingDirectory;
637
+ if (firstSegment)
638
+ this.#recordingDirectory = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
468
639
  await this.call("Page.enable");
469
640
  await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
470
641
  const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
@@ -472,11 +643,11 @@ class Browser {
472
643
  throw new Error("Could not read the recording viewport");
473
644
  await this.animateCursor(viewport.width / 2, viewport.height / 2);
474
645
  const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
475
- const initialPath = join(this.#recordingDirectory, "000000.jpg");
646
+ const initialPath = join(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
476
647
  await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
477
- this.#recordingFrames.push({ path: initialPath, elapsedMs: 0 });
478
- this.#recordingSequence = 1;
479
- this.#recordingStartedAt = performance.now();
648
+ if (firstSegment)
649
+ this.#recordingStartedAt = performance.now();
650
+ this.#recordingFrames.push({ path: initialPath, elapsedMs: firstSegment ? 0 : performance.now() - this.#recordingStartedAt });
480
651
  this.#stopRecordingEvents = this.#cdp.on("Page.screencastFrame", this.#sessionId, (params) => {
481
652
  const frame = params;
482
653
  this.call("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {
@@ -590,18 +761,52 @@ class Browser {
590
761
  })()`);
591
762
  }
592
763
  async waitForReady() {
593
- const deadline = Date.now() + 15000;
594
- while (Date.now() < deadline) {
764
+ const started = performance.now();
765
+ const deadline = started + this.#waitBudgetMs - this.#waitSpentMs;
766
+ while (performance.now() < deadline) {
595
767
  try {
596
- 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;
597
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);
598
805
  } catch (error) {
599
806
  if (!(error instanceof StalePageError))
600
807
  throw error;
601
808
  }
602
- await Bun.sleep(20);
603
809
  }
604
- throw new Error("Page did not finish loading within 15 seconds");
605
810
  }
606
811
  async evaluate(expression, awaitPromise = false) {
607
812
  const response = await this.call("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, awaitPromise ? 15000 : undefined);
@@ -609,6 +814,125 @@ class Browser {
609
814
  throw new StalePageError("Document changed during evaluation");
610
815
  return response.result?.value;
611
816
  }
817
+ async evaluateFrame(frameId, expression) {
818
+ const session = this.#frameSessions.get(frameId);
819
+ let contextId = this.#frameContexts.get(frameId);
820
+ if (!session && !contextId) {
821
+ const context = await this.call("Page.createIsolatedWorld", { frameId });
822
+ contextId = context.executionContextId;
823
+ this.#frameContexts.set(frameId, contextId);
824
+ }
825
+ const response = await this.#cdp.command("Runtime.evaluate", { expression, returnByValue: true, ...contextId ? { contextId } : {} }, session ?? this.#sessionId);
826
+ if (response.exceptionDetails)
827
+ throw new StalePageError("Frame changed during evaluation");
828
+ return response.result?.value;
829
+ }
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);
834
+ return { x: box.model.content[0], y: box.model.content[1] };
835
+ }
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() {
852
+ const tree = await this.call("Page.getFrameTree");
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;
872
+ }
873
+ async discoverTabs() {
874
+ if (!this.#switchOnPopup)
875
+ return;
876
+ for (const target of await listChromeTargets(this.#cdpUrl)) {
877
+ if (target.type !== "page" || this.#knownTargets.has(target.id))
878
+ continue;
879
+ this.#knownTargets.add(target.id);
880
+ this.#newTargets.delete(target.id);
881
+ const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
882
+ try {
883
+ await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
884
+ width: 1120,
885
+ height: 780,
886
+ deviceScaleFactor: 1,
887
+ mobile: false
888
+ }, attached.sessionId);
889
+ } finally {
890
+ if (this.#switchOnPopup) {
891
+ if (this.#recordingPath) {
892
+ await this.call("Page.stopScreencast").catch(() => {
893
+ return;
894
+ });
895
+ this.#stopRecordingEvents?.();
896
+ this.#stopRecordingEvents = undefined;
897
+ await this.#recordingWrites;
898
+ }
899
+ this.#sessionId = attached.sessionId;
900
+ this.#targetId = target.id;
901
+ this.#loadingFrames.clear();
902
+ await this.watchConsole();
903
+ await this.watchPageLoads();
904
+ this.#switchOnPopup = false;
905
+ await this.startRecording();
906
+ } else {
907
+ await this.#cdp.command("Target.detachFromTarget", { sessionId: attached.sessionId });
908
+ }
909
+ }
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
+ });
933
+ }
934
+ this.#switchOnPopup = false;
935
+ }
612
936
  async settleAfterInput() {
613
937
  const action = this.#afterInput;
614
938
  this.#afterInput = null;
@@ -644,21 +968,116 @@ class Browser {
644
968
  }
645
969
  async observe(screenshot = this.#screenshots) {
646
970
  await this.settleAfterInput();
971
+ await this.discoverTabs();
647
972
  let info;
648
- 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) {
649
977
  try {
650
978
  info = await this.evaluate(snapshot_default);
651
979
  if (info)
652
980
  break;
653
981
  } catch (error) {
654
- if (!(error instanceof StalePageError) || attempt === 9)
982
+ if (!(error instanceof StalePageError))
655
983
  throw error;
656
984
  }
657
- 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()));
991
+ }
992
+ if (waited)
993
+ this.#waitSpentMs += performance.now() - waitStarted;
994
+ if (this.#openedTabs.length) {
995
+ const targets2 = await listChromeTargets(this.#cdpUrl);
996
+ this.#openedTabs = this.#openedTabs.map((tab) => {
997
+ const current = targets2.find((target) => target.id === tab.id);
998
+ return current ? { ...tab, url: current.url, title: current.title } : tab;
999
+ });
1000
+ info.text = `${info.text}
1001
+ ${this.#openedTabs.map((tab) => `Opened new tab: ${tab.title} ${tab.url} (target ${tab.id})`).join(`
1002
+ `)}`;
1003
+ }
1004
+ const frames = await this.frameStates();
1005
+ for (const frame of frames.filter((frame2) => frame2.parentId !== null)) {
1006
+ const frameId = frame.id;
1007
+ try {
1008
+ const offset = await this.frameScreenOffset(frameId, frames);
1009
+ const child = await this.evaluateFrame(frameId, snapshot_default);
1010
+ if (!child)
1011
+ continue;
1012
+ info.text = `${info.text}
1013
+ ${child.text}`.slice(0, 6000);
1014
+ for (const action of child.actions) {
1015
+ if (!action.node || !action.rect)
1016
+ continue;
1017
+ const rect = { ...action.rect, x: action.rect.x + offset.x, y: action.rect.y + offset.y };
1018
+ if (rect.x < 0 || rect.y < 0 || rect.x >= info.w || rect.y >= info.h)
1019
+ continue;
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
+ });
1033
+ info.guards[`${frameId}:${action.node}`] = child.guards[String(action.node)];
1034
+ }
1035
+ info.marker = [info.marker, frameId, child.marker];
1036
+ } catch {}
658
1037
  }
659
- if (!info)
660
- throw new StalePageError("Document is navigating");
661
- 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) };
662
1081
  if (screenshot) {
663
1082
  const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
664
1083
  page.screenshot = capture.data;
@@ -666,6 +1085,12 @@ class Browser {
666
1085
  return page;
667
1086
  }
668
1087
  async fresh(page, action) {
1088
+ if (action?.frameId && typeof action.node === "number") {
1089
+ const current = await this.evaluateFrame(action.frameId, `(() => {
1090
+ const c=window.__jevFast; return c ? c.guard(c.nodes.get(${action.node})) : null;
1091
+ })()`);
1092
+ return stableStringify(current) === stableStringify(page.guards[`${action.frameId}:${action.node}`]);
1093
+ }
669
1094
  if (action && (action.kind === "click" || action.kind === "select")) {
670
1095
  if (typeof action.node !== "number")
671
1096
  return false;
@@ -675,15 +1100,49 @@ class Browser {
675
1100
  })()`);
676
1101
  return stableStringify(current) === stableStringify([page.page_key, page.guards[String(action.node)]]);
677
1102
  }
678
- 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
+ }
679
1113
  return stableStringify(marker) === stableStringify(page.marker);
680
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
+ }
681
1140
  async act(action, page, text) {
682
1141
  if (!await this.fresh(page, action))
683
1142
  throw new StalePageError("Page changed since this decision");
684
1143
  if (action.kind === "wait") {
685
1144
  await Bun.sleep(100);
686
- return;
1145
+ return { element: null, performedAt: performance.now() };
687
1146
  }
688
1147
  if (action.kind === "scroll") {
689
1148
  await this.animateCursor(550, 650);
@@ -694,11 +1153,25 @@ class Browser {
694
1153
  deltaX: 0,
695
1154
  deltaY: action.delta ?? 0
696
1155
  });
697
- return;
1156
+ return { element: null, performedAt: performance.now() };
698
1157
  }
699
1158
  if (typeof action.node !== "number")
700
1159
  throw new Error("Invalid observed node");
701
- const target = await this.evaluate(`(action => {
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
+ };
1168
+ const target = await (action.frameId ? this.evaluateFrame(action.frameId, `(action => {
1169
+ const e=window.__jevFast?.nodes.get(action.node);
1170
+ if (!e?.isConnected || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
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};
1174
+ })(${JSON.stringify(action)})`) : this.evaluate(`(action => {
702
1175
  const e=window.__jevFast?.nodes.get(action.node);
703
1176
  if (!e?.isConnected || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]') ||
704
1177
  !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return null;
@@ -714,12 +1187,43 @@ class Browser {
714
1187
  e.dispatchEvent(new Event('change',{bubbles:true}));
715
1188
  }
716
1189
  return {x,y};
717
- })(${JSON.stringify(action)})`);
1190
+ })(${JSON.stringify(action)})`));
718
1191
  if (!target) {
719
1192
  if (action.kind === "select")
720
1193
  throw new Error("Dropdown execution was not confirmed");
721
1194
  throw new StalePageError("Target changed or is covered");
722
1195
  }
1196
+ let performedAt = action.kind === "select" ? performance.now() : 0;
1197
+ if (action.frameId) {
1198
+ const offset = await this.frameScreenOffset(action.frameId);
1199
+ target.x += offset.x;
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");
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
+ };
723
1227
  await this.animateCursor(target.x, target.y);
724
1228
  if (action.kind !== "select") {
725
1229
  if (action.kind === "click" && this.#interactionPauses > 0) {
@@ -741,6 +1245,7 @@ class Browser {
741
1245
  if (type === "mousePressed")
742
1246
  await this.animateCursor(target.x, target.y, true);
743
1247
  }
1248
+ performedAt = performance.now();
744
1249
  if (action.kind === "fill") {
745
1250
  const modifiers = process.platform === "darwin" ? 4 : 2;
746
1251
  await this.call("Input.dispatchKeyEvent", {
@@ -757,9 +1262,15 @@ class Browser {
757
1262
  modifiers
758
1263
  });
759
1264
  await this.call("Input.insertText", { text: text ?? "" });
1265
+ performedAt = performance.now();
760
1266
  }
761
1267
  }
762
1268
  this.#afterInput = action;
1269
+ if (action.kind === "click") {
1270
+ this.#switchOnPopup = true;
1271
+ await this.waitForNewTarget(details.target === "_blank" ? 650 : 100);
1272
+ }
1273
+ return { element, performedAt };
763
1274
  }
764
1275
  async close() {
765
1276
  if (this.#closed)
@@ -808,9 +1319,9 @@ its matching autocomplete suggestion selected. For date pickers, CLICK the field
808
1319
  Set every requested filter/control; a matching result alone does not prove a requested filter was set.
809
1320
  Do not toggle a checkbox, switch, or radio already in the requested state.
810
1321
  Submit populated search fields before opening a result; a populated field alone is not an applied search.
811
- WAIT only when the needed control is absent/disabled, or submitted results are still loading.
812
1322
  If Search/Submit is visible and the required fields are ready, CLICK it immediately.
813
- 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.
814
1325
  DONE requires visible evidence that ALL requirements are satisfied. If asked to open a result,
815
1326
  a matching link is not enough. BLOCKED means no supported operation can make progress.`;
816
1327
  var TARGET = `Choose the best observed target if the next operation is the one specified in this question.
@@ -882,13 +1393,21 @@ function actionSpace(actions) {
882
1393
  }
883
1394
  if (typeof action.node !== "number")
884
1395
  continue;
885
- if (!indices.has(action.node)) {
1396
+ const identity = `${action.frameId ?? "main"}:${action.node}`;
1397
+ if (!indices.has(identity)) {
886
1398
  const index2 = String(elements.length + 1);
887
- indices.set(action.node, index2);
1399
+ indices.set(identity, index2);
888
1400
  const element2 = {
889
1401
  index: index2,
890
1402
  label: action.label.split(" \u2192 ")[0] ?? action.label,
891
- 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
892
1411
  };
893
1412
  for (const key of ["role", "value", "checked", "selected", "expanded", "pressed", "sensitive"]) {
894
1413
  if (action[key] !== undefined)
@@ -900,7 +1419,9 @@ function actionSpace(actions) {
900
1419
  }
901
1420
  elements.push(element2);
902
1421
  }
903
- const index = indices.get(action.node);
1422
+ const index = indices.get(identity);
1423
+ if (action.clickable === false)
1424
+ continue;
904
1425
  const group = targets[operation] ??= {};
905
1426
  const element = elements[Number(index) - 1];
906
1427
  if (!element.operations.includes(operation))
@@ -939,6 +1460,13 @@ async function choose(page, goal, history, providedFields = []) {
939
1460
  type: "choice",
940
1461
  criteria: Object.fromEntries(Object.entries(candidates).map(([index, action]) => [index, {
941
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,
942
1470
  current_value: action.current_value ?? action.value ?? "",
943
1471
  ...Object.fromEntries(["role", "checked", "selected", "expanded", "pressed", "sensitive"].filter((name) => action[name] !== undefined).map((name) => [name, action[name]]))
944
1472
  }])),
@@ -1118,12 +1646,23 @@ function fieldText(context) {
1118
1646
  }
1119
1647
 
1120
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
+
1121
1658
  class Agent {
1122
1659
  #browser;
1123
1660
  #goal;
1124
1661
  #maxSteps;
1125
1662
  #screenshots;
1126
1663
  #fieldValues;
1664
+ #sensitiveFieldLabels;
1665
+ #secretValues;
1127
1666
  #page;
1128
1667
  #decision = null;
1129
1668
  #history = [];
@@ -1132,6 +1671,8 @@ class Agent {
1132
1671
  #status = "ready";
1133
1672
  #startedAt = null;
1134
1673
  #pendingText = null;
1674
+ #waitTimeout;
1675
+ #initialConsoleErrors = [];
1135
1676
  constructor(browser, page, options) {
1136
1677
  this.#browser = browser;
1137
1678
  this.#page = page;
@@ -1139,6 +1680,9 @@ class Agent {
1139
1680
  this.#maxSteps = options.maxSteps;
1140
1681
  this.#screenshots = options.screenshots ?? false;
1141
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();
1142
1686
  }
1143
1687
  static async create(options) {
1144
1688
  if (!options.goal.trim())
@@ -1156,12 +1700,24 @@ class Agent {
1156
1700
  recordingPath: options.recordingPath,
1157
1701
  screenshotPath: options.screenshotPath,
1158
1702
  freshContext: options.freshContext,
1159
- interactionPauses: options.interactionPauses
1703
+ interactionPauses: options.interactionPauses,
1704
+ waitBudgetMs: options.waitBudgetMs
1160
1705
  });
1161
1706
  try {
1162
- 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;
1163
1717
  } catch (error) {
1164
- await browser.close();
1718
+ await browser.close().catch(() => {
1719
+ return;
1720
+ });
1165
1721
  throw error;
1166
1722
  }
1167
1723
  }
@@ -1176,7 +1732,14 @@ class Agent {
1176
1732
  status: this.#status,
1177
1733
  elapsedMs: this.elapsedMs(),
1178
1734
  maxSteps: this.#maxSteps,
1179
- 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 } : {}
1180
1743
  };
1181
1744
  }
1182
1745
  get targetId() {
@@ -1190,15 +1753,12 @@ class Agent {
1190
1753
  this.#startedAt = performance.now();
1191
1754
  if (!await this.#browser.fresh(this.#page)) {
1192
1755
  this.#page = await this.#browser.observe(this.#screenshots);
1756
+ await this.waitForReadiness();
1193
1757
  }
1194
1758
  this.#decision = null;
1195
- if (["done", "blocked", "budget_exhausted"].includes(this.#status)) {
1759
+ if (["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
1196
1760
  throw new Error("This run has stopped");
1197
1761
  }
1198
- if (this.#decisions.length >= this.#maxSteps * 2) {
1199
- this.#status = "budget_exhausted";
1200
- return;
1201
- }
1202
1762
  this.#decision = await choose(this.#page, this.#goal, this.#history, Object.keys(this.#fieldValues));
1203
1763
  this.#decisions.push(this.#decision);
1204
1764
  this.#status = "predicted";
@@ -1239,7 +1799,7 @@ class Agent {
1239
1799
  if (provided !== undefined) {
1240
1800
  text = provided;
1241
1801
  helper = { model: "provided-field-value", provider: "caller", latency_ms: 0, usage: {} };
1242
- 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 });
1243
1803
  } else if (this.#pendingText?.contextKey === contextKey) {
1244
1804
  ({ text, helper } = this.#pendingText);
1245
1805
  } else {
@@ -1249,7 +1809,8 @@ class Agent {
1249
1809
  }
1250
1810
  }
1251
1811
  await this.#browser.waitForInteractionPause();
1252
- 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);
1253
1814
  this.#pendingText = null;
1254
1815
  const entry = {
1255
1816
  step: this.#history.length + 1,
@@ -1259,21 +1820,33 @@ class Agent {
1259
1820
  probability: decision.probabilities[selected] ?? 0,
1260
1821
  confidence: decision.confidence,
1261
1822
  latency_ms: decision.latency_ms,
1262
- text: action.sensitive && text !== null ? "[redacted]" : text,
1823
+ text: (action.sensitive || this.#sensitiveFieldLabels.has(action.label)) && text !== null ? "[redacted]" : text,
1263
1824
  text_helper: helper?.model ?? null,
1264
1825
  text_latency_ms: helper?.latency_ms ?? 0,
1265
1826
  operation: decision.operation,
1266
1827
  target: decision.target,
1267
1828
  page_changed: null,
1829
+ from_url: page.url,
1268
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)),
1269
1838
  usage: decision.usage,
1270
- executed_ms: this.elapsedMs(),
1271
- elapsed_ms: this.elapsedMs()
1839
+ executed_ms: Math.round(performedAt - this.#startedAt),
1840
+ elapsed_ms: this.elapsedMs(),
1841
+ consoleErrors: []
1272
1842
  };
1273
1843
  this.#history.push(entry);
1274
1844
  this.#page = await this.#browser.observe(this.#screenshots);
1845
+ await this.waitForReadiness();
1846
+ entry.consoleErrors = redactConsoleErrors(this.#browser.takeConsoleErrors(), this.#secretValues);
1275
1847
  entry.page_changed = this.#page.fingerprint !== page.fingerprint;
1276
1848
  entry.url = this.#page.url;
1849
+ entry.target_id = this.#browser.targetId;
1277
1850
  entry.elapsed_ms = this.elapsedMs();
1278
1851
  this.#status = "ready";
1279
1852
  }
@@ -1282,21 +1855,41 @@ class Agent {
1282
1855
  await this.predict();
1283
1856
  await this.act();
1284
1857
  } catch (error) {
1858
+ if (error instanceof WaitTimeoutError) {
1859
+ this.recordWaitTimeout(error);
1860
+ return this.snapshot();
1861
+ }
1285
1862
  if (!(error instanceof StalePageError))
1286
1863
  throw error;
1287
1864
  this.#decision = null;
1288
1865
  this.#status = "ready";
1289
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
+ }
1290
1874
  }
1291
1875
  return this.snapshot();
1292
1876
  }
1293
1877
  async run(onStep) {
1294
- while (!["done", "blocked", "budget_exhausted"].includes(this.#status)) {
1878
+ while (!["done", "blocked", "budget_exhausted", "wait_timeout"].includes(this.#status)) {
1295
1879
  const state = await this.tick();
1296
1880
  onStep?.(state);
1297
1881
  }
1298
1882
  return this.snapshot();
1299
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
+ }
1300
1893
  close() {
1301
1894
  return this.#browser.close();
1302
1895
  }
@@ -1372,6 +1965,8 @@ Goal control:
1372
1965
  --goal <text> One bounded browser goal. Required.
1373
1966
  --max-steps <number> Maximum executed browser actions.
1374
1967
  [env: JEV_MAX_STEPS] [default: 12]
1968
+ --wait-budget-ms <number> Total wall-clock budget for page and frame readiness.
1969
+ [default: 15000]
1375
1970
 
1376
1971
  Browser behavior:
1377
1972
  --visible Activate the controlled tab.
@@ -1395,12 +1990,15 @@ Evidence and output:
1395
1990
  -h, --help Show this help and exit.
1396
1991
 
1397
1992
  Output:
1398
- 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.
1399
1997
 
1400
1998
  Exit codes:
1401
1999
  0 Jev reported the goal complete.
1402
2000
  1 Invalid configuration or runtime failure.
1403
- 2 Jev reported that it was blocked.
2001
+ 2 Jev was blocked or the wait budget timed out.
1404
2002
  3 The maximum browser-step budget was exhausted.
1405
2003
 
1406
2004
  Examples:
@@ -1483,6 +2081,7 @@ function addFieldValue(options, assignment, fromEnvironment) {
1483
2081
  if (!supplied)
1484
2082
  throw new CliError(`Environment variable is missing or empty: ${environmentName}`);
1485
2083
  options.fieldValues[label] = supplied;
2084
+ options.sensitiveFieldLabels.push(label);
1486
2085
  } else {
1487
2086
  options.fieldValues[label] = assignment.slice(separator + 1);
1488
2087
  }
@@ -1492,10 +2091,12 @@ function parseRunOptions(args) {
1492
2091
  cdpUrl: process.env.CHROME_CDP_URL ?? DEFAULT_CDP_URL,
1493
2092
  maxSteps: parsePositiveInteger(process.env.JEV_MAX_STEPS ?? "12", "JEV_MAX_STEPS"),
1494
2093
  interactionPauses: 0,
2094
+ waitBudgetMs: 15000,
1495
2095
  visible: enabled(process.env.JEV_BROWSER_VISIBLE),
1496
2096
  keepOpen: enabled(process.env.JEV_BROWSER_KEEP_OPEN),
1497
2097
  finalState: false,
1498
2098
  fieldValues: {},
2099
+ sensitiveFieldLabels: [],
1499
2100
  freshContext: enabled(process.env.JEV_BROWSER_FRESH_CONTEXT)
1500
2101
  };
1501
2102
  for (let index = 0;index < args.length; index++) {
@@ -1512,6 +2113,8 @@ function parseRunOptions(args) {
1512
2113
  options.maxSteps = parsePositiveInteger(nextValue(args, index++, argument), argument);
1513
2114
  else if (argument === "--interaction-pauses")
1514
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);
1515
2118
  else if (argument === "--recording")
1516
2119
  options.recordingPath = nextValue(args, index++, argument);
1517
2120
  else if (argument === "--screenshot")
@@ -1559,17 +2162,45 @@ function parseCommonOptions(args, help) {
1559
2162
  }
1560
2163
  return options;
1561
2164
  }
1562
- 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) }));
1563
2188
  return {
1564
- count: values.length,
1565
- totalMs: values.reduce((sum, value) => sum + value, 0),
1566
- averageMs: values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : null,
1567
- 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
1568
2198
  };
1569
2199
  }
1570
2200
  async function runGoal(args) {
1571
2201
  const options = parseRunOptions(args);
1572
2202
  let agent;
2203
+ let reportedActions = 0;
1573
2204
  try {
1574
2205
  agent = await Agent.create({
1575
2206
  url: options.url,
@@ -1578,50 +2209,65 @@ async function runGoal(args) {
1578
2209
  cdpUrl: options.cdpUrl,
1579
2210
  maxSteps: options.maxSteps,
1580
2211
  interactionPauses: options.interactionPauses,
2212
+ waitBudgetMs: options.waitBudgetMs,
1581
2213
  visible: options.visible,
1582
2214
  keepOpen: options.keepOpen,
1583
2215
  recordingPath: options.recordingPath,
1584
2216
  screenshotPath: options.screenshotPath,
1585
2217
  fieldValues: options.fieldValues,
2218
+ sensitiveFieldLabels: options.sensitiveFieldLabels,
1586
2219
  freshContext: options.freshContext
1587
2220
  });
1588
- let reportedActions = 0;
1589
- const result = await agent.run((state) => {
2221
+ await agent.run((state) => {
1590
2222
  const action = state.history.length > reportedActions ? state.history.at(-1) : undefined;
1591
- const decision = state.decisions.at(-1);
1592
2223
  reportedActions = state.history.length;
1593
- const operation = action?.operation ?? decision?.operation ?? "none";
1594
- const jevLatency = action?.latency_ms ?? decision?.latency_ms;
1595
- const helper = action?.text_helper ? ` text=${action.text_helper}:${action.text_latency_ms}ms` : "";
1596
- 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)));
1597
2226
  });
2227
+ await agent.close();
2228
+ const result = agent.snapshot();
1598
2229
  console.log(JSON.stringify({
2230
+ type: "result",
1599
2231
  status: result.status,
1600
2232
  targetId: agent.targetId,
1601
2233
  url: result.page.url,
1602
2234
  actions: result.history.length,
1603
2235
  maxSteps: result.maxSteps,
2236
+ budget: { used: result.history.length, max: result.maxSteps, remaining: result.maxSteps - result.history.length },
1604
2237
  elapsedMs: result.elapsedMs,
1605
2238
  textCalls: result.textCalls.length,
1606
- latency: {
1607
- jev: latencyStats(result.decisions.map((decision) => decision.latency_ms)),
1608
- textHelper: latencyStats(result.textCalls.map((call) => call.latency_ms))
1609
- },
2239
+ initialConsoleErrors: result.initialConsoleErrors,
2240
+ consoleErrors: result.consoleErrors,
2241
+ ...result.waitTimeout ? { waitTimeout: result.waitTimeout } : {},
1610
2242
  ...options.recordingPath ? { recording: options.recordingPath } : {},
1611
2243
  ...options.screenshotPath ? { screenshot: options.screenshotPath } : {},
1612
- ...options.finalState ? {
1613
- finalState: {
1614
- url: result.page.url,
1615
- title: result.page.title,
1616
- text: result.page.text,
1617
- viewport: { width: result.page.w, height: result.page.h },
1618
- scroll: result.page.scroll,
1619
- elements: result.elements,
1620
- omittedActions: result.page.omitted_actions
1621
- }
2244
+ ...options.finalState || result.status === "wait_timeout" ? {
2245
+ finalState: semanticState(result.page, result.elements)
1622
2246
  } : {}
1623
2247
  }));
1624
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;
1625
2271
  } finally {
1626
2272
  await agent?.close();
1627
2273
  }
@@ -1729,5 +2375,6 @@ async function main(args = Bun.argv.slice(2)) {
1729
2375
  if (import.meta.main)
1730
2376
  process.exitCode = await main();
1731
2377
  export {
2378
+ actionEvent,
1732
2379
  main
1733
2380
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-cdp",
3
- "version": "0.1.3",
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",