jev-cdp 0.1.5 → 0.1.7

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 -7
  2. package/dist/cli.js +330 -64
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,11 +27,11 @@ 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.5 help run
31
- npx -y jev-cdp@0.1.5 help run
30
+ bunx jev-cdp@0.1.7 help run
31
+ npx -y jev-cdp@0.1.7 help run
32
32
  ```
33
33
 
34
- Chrome with a CDP endpoint and `TYPESAFE_API_KEY` are required for browser runs. FFmpeg is required for `--recording`.
34
+ Chrome with a CDP endpoint and `TYPESAFE_API_KEY` are required for browser runs. FFmpeg with H.264 encoding (`libx264`) is required for `--recording`. Jev selects `-fps_mode vfr` when FFmpeg supports it and falls back to `-vsync vfr` for older builds. Run `jev-cdp doctor` to check the installed encoder with a short MP4 encode and decode.
35
35
 
36
36
  For source development:
37
37
 
@@ -96,13 +96,13 @@ bun run run -- run \
96
96
  --final-state
97
97
  ```
98
98
 
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.
99
+ `--recording` captures Chrome's compositor screencast stream for the full goal and renders a 1280×720 H.264 MP4. A 40-pixel browser bar shows the active tab's URL above the page. Chrome uses a 1506×800 viewport, scaled to fit the video beneath the bar, so controls remain at their observed coordinates while more of the page fits on screen. Because Chrome's native pointer is not part of the 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, changing the URL within a page, or moving to a click target before pressing the mouse. Jev chooses during page pauses, and the adapter waits only for any time left before acting. Jev does not choose or observe those delays. When an action opens a new tab, the recording shows a "New tab opened" notice over the previous tab for the full configured pause, then switches to the new tab. With zero pause, the notice appears for one frame without delaying Jev. `--screenshot` saves a 1280×720 image with the same browser bar, even when recording is off. If a new tab opened, the final screenshot also includes the notice.
100
100
 
101
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
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
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.
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 1506×800 viewport consistent across the switch.
106
106
 
107
107
  ## Supply known field values without another LLM
108
108
 
@@ -184,9 +184,9 @@ The context is disposed after evidence capture by default. Combine it with `--ke
184
184
  | `--cdp URL` | `CHROME_CDP_URL` | `http://127.0.0.1:9222` | Chrome DevTools HTTP endpoint |
185
185
  | `--tab TARGET_ID` | — | create a new tab | Attach to one exact existing Chrome page target |
186
186
  | `tabs` | — | — | Print target IDs, titles, and URLs for open page tabs |
187
- | `--recording PATH.mp4` | — | off | Record the complete goal with an animated cursor |
187
+ | `--recording PATH.mp4` | — | off | Record a 1280×720 video with a URL bar, animated cursor, and tab notices |
188
188
  | `--interaction-pauses MS` | — | `0` | Pause after page loads and before clicks, overlapping page pauses with Jev decisions |
189
- | `--screenshot PATH.jpg` | — | off | Save the final browser viewport |
189
+ | `--screenshot PATH.jpg` | — | off | Save a 1280×720 image with the URL bar and any new-tab notice |
190
190
  | `--final-state` | — | off | Include the final semantic page state in stdout JSON |
191
191
  | `--field-value LABEL=VALUE` | — | Luna fallback | Type caller-provided test data into the exactly labeled field |
192
192
  | `--field-value-env LABEL=NAME` | — | off | Read a sensitive field value from an environment variable |
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.5",
6
+ version: "0.1.7",
7
7
  description: "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
8
8
  type: "module",
9
9
  license: "MIT",
@@ -38,9 +38,9 @@ var package_default = {
38
38
 
39
39
  // src/browser.ts
40
40
  import { createHash } from "crypto";
41
- import { mkdir, mkdtemp, rm } from "fs/promises";
42
- import { tmpdir } from "os";
43
- import { dirname, join, resolve } from "path";
41
+ import { mkdir, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
42
+ import { tmpdir as tmpdir2 } from "os";
43
+ import { dirname, join as join2, resolve } from "path";
44
44
 
45
45
  // src/cdp.ts
46
46
  async function listChromeTargets(cdpUrl) {
@@ -143,6 +143,164 @@ class CdpClient {
143
143
  }
144
144
  }
145
145
 
146
+ // src/ffmpeg.ts
147
+ import { mkdtemp, rm } from "fs/promises";
148
+ import { tmpdir } from "os";
149
+ import { join } from "path";
150
+ function selectFrameSyncOption(help) {
151
+ if (/^\s*-fps_mode(?:\[:<stream_spec>\])?\s/m.test(help))
152
+ return "-fps_mode";
153
+ if (/^\s*-vsync\s/m.test(help))
154
+ return "-vsync";
155
+ throw new Error("FFmpeg supports neither -fps_mode nor -vsync");
156
+ }
157
+ async function run(command) {
158
+ const process2 = Bun.spawn(command, { stdout: "ignore", stderr: "pipe" });
159
+ const stderr = await new Response(process2.stderr).text();
160
+ return { code: await process2.exited, stderr };
161
+ }
162
+ async function recordingEncoder(ffmpeg = Bun.which("ffmpeg")) {
163
+ if (!ffmpeg)
164
+ throw new Error("FFmpeg not found on PATH");
165
+ const process2 = Bun.spawn([ffmpeg, "-hide_banner", "-h", "full"], { stdout: "pipe", stderr: "pipe" });
166
+ const [stdout, stderr, code] = await Promise.all([
167
+ new Response(process2.stdout).text(),
168
+ new Response(process2.stderr).text(),
169
+ process2.exited
170
+ ]);
171
+ if (code !== 0)
172
+ throw new Error(`Could not inspect FFmpeg options: ${stderr.slice(-500)}`);
173
+ return { path: ffmpeg, sync: selectFrameSyncOption(stdout + stderr) };
174
+ }
175
+ function recordingCommand(encoder, manifest, output) {
176
+ return [
177
+ encoder.path,
178
+ "-y",
179
+ "-f",
180
+ "concat",
181
+ "-safe",
182
+ "0",
183
+ "-i",
184
+ manifest,
185
+ encoder.sync,
186
+ "vfr",
187
+ "-c:v",
188
+ "libx264",
189
+ "-pix_fmt",
190
+ "yuv420p",
191
+ "-movflags",
192
+ "+faststart",
193
+ output
194
+ ];
195
+ }
196
+ async function renderRecording(encoder, manifest, output) {
197
+ const result = await run(recordingCommand(encoder, manifest, output));
198
+ if (result.code !== 0)
199
+ throw new Error(`Could not render recording: ${result.stderr.slice(-800)}`);
200
+ }
201
+ async function checkRecordingEncoder() {
202
+ const encoder = await recordingEncoder();
203
+ const directory = await mkdtemp(join(tmpdir(), "jev-cdp-ffmpeg-check-"));
204
+ try {
205
+ const frame = join(directory, "frame.ppm");
206
+ const manifest = join(directory, "frames.ffconcat");
207
+ const output = join(directory, "check.mp4");
208
+ await Bun.write(frame, Buffer.concat([Buffer.from(`P6
209
+ 16 16
210
+ 255
211
+ `), Buffer.alloc(16 * 16 * 3, 90)]));
212
+ const quoted = frame.replaceAll("'", "'\\''");
213
+ await Bun.write(manifest, `ffconcat version 1.0
214
+ file '${quoted}'
215
+ duration 0.12
216
+ file '${quoted}'
217
+ `);
218
+ await renderRecording(encoder, manifest, output);
219
+ const decoded = await run([encoder.path, "-v", "error", "-i", output, "-f", "null", "-"]);
220
+ if (decoded.code !== 0)
221
+ throw new Error(`Could not decode recording: ${decoded.stderr.slice(-500)}`);
222
+ return `FFmpeg at ${encoder.path}; ${encoder.sync} vfr and libx264 encode/decode passed`;
223
+ } finally {
224
+ await rm(directory, { recursive: true, force: true });
225
+ }
226
+ }
227
+
228
+ // src/recording-visual.ts
229
+ var VIDEO_WIDTH = 1280;
230
+ var VIDEO_HEIGHT = 720;
231
+ var BAR_HEIGHT = 40;
232
+ var PAGE_WIDTH = 1506;
233
+ var PAGE_HEIGHT = 800;
234
+ function composeFrameExpression(jpeg, url, newTabUrl) {
235
+ return `(async () => {
236
+ const bytes = Uint8Array.from(atob(${JSON.stringify(jpeg)}), character => character.charCodeAt(0));
237
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
238
+ const canvas = document.createElement('canvas');
239
+ canvas.width = ${VIDEO_WIDTH}; canvas.height = ${VIDEO_HEIGHT};
240
+ const context = canvas.getContext('2d');
241
+ if (!context) throw new Error('Could not create recording canvas');
242
+ context.fillStyle = '#f5f7fa';
243
+ context.fillRect(0, 0, canvas.width, ${BAR_HEIGHT});
244
+ context.fillStyle = '#ffffff';
245
+ context.beginPath();
246
+ context.roundRect(12, 6, canvas.width - 24, 28, 7);
247
+ context.fill();
248
+ context.strokeStyle = '#d7dce4';
249
+ context.lineWidth = 1;
250
+ context.stroke();
251
+ context.fillStyle = '#617185';
252
+ context.beginPath(); context.arc(28, 20, 4, 0, Math.PI * 2); context.fill();
253
+ const fit = (value, maxWidth) => {
254
+ if (context.measureText(value).width <= maxWidth) return value;
255
+ while (value.length && context.measureText(value + '\u2026').width > maxWidth) value = value.slice(0, -1);
256
+ return value + '\u2026';
257
+ };
258
+ const url = ${JSON.stringify(url)};
259
+ context.font = '14px system-ui, sans-serif';
260
+ context.fillStyle = '#263343';
261
+ context.textBaseline = 'middle';
262
+ context.fillText(fit(url, canvas.width - 72), 42, 20);
263
+ const chromeBorder = '#61738a';
264
+ context.fillStyle = chromeBorder;
265
+ context.fillRect(0, ${BAR_HEIGHT - 2}, canvas.width, 2);
266
+ context.drawImage(bitmap, 0, ${BAR_HEIGHT}, canvas.width, canvas.height - ${BAR_HEIGHT});
267
+ bitmap.close();
268
+ const newTabUrl = ${JSON.stringify(newTabUrl)};
269
+ if (newTabUrl) {
270
+ const cardWidth = 620, cardHeight = 104, radius = 12, x = (canvas.width - cardWidth) / 2;
271
+ const cardPath = () => {
272
+ context.beginPath();
273
+ context.moveTo(x, 0);
274
+ context.lineTo(x + cardWidth, 0);
275
+ context.lineTo(x + cardWidth, cardHeight - radius);
276
+ context.quadraticCurveTo(x + cardWidth, cardHeight, x + cardWidth - radius, cardHeight);
277
+ context.lineTo(x + radius, cardHeight);
278
+ context.quadraticCurveTo(x, cardHeight, x, cardHeight - radius);
279
+ context.closePath();
280
+ };
281
+ context.save();
282
+ context.shadowColor = 'rgba(15, 23, 42, .24)';
283
+ context.shadowBlur = 14;
284
+ context.shadowOffsetY = 5;
285
+ context.fillStyle = '#ffffff';
286
+ cardPath(); context.fill();
287
+ context.restore();
288
+ context.strokeStyle = chromeBorder;
289
+ context.lineWidth = 1.5;
290
+ cardPath(); context.stroke();
291
+ context.fillStyle = '#2563eb';
292
+ context.beginPath(); context.arc(x + 28, 35, 7, 0, Math.PI * 2); context.fill();
293
+ context.fillStyle = '#172033';
294
+ context.font = '600 19px system-ui, sans-serif';
295
+ context.fillText('New tab opened', x + 48, 35);
296
+ context.fillStyle = '#536277';
297
+ context.font = '14px system-ui, sans-serif';
298
+ context.fillText(fit(newTabUrl, cardWidth - 52), x + 24, 72);
299
+ }
300
+ return canvas.toDataURL('image/jpeg', .88).split(',')[1];
301
+ })()`;
302
+ }
303
+
146
304
  // src/snapshot.js
147
305
  var snapshot_default = `// Ported from browser-use/jev-ultrafast at commit 452c1ad2 under the MIT License.
148
306
  (() => {
@@ -335,6 +493,12 @@ class Browser {
335
493
  #recordingStartedAt = 0;
336
494
  #recordingSequence = 0;
337
495
  #recordingWrites = Promise.resolve();
496
+ #activeUrl = "about:blank";
497
+ #recordingUrlEvents = [];
498
+ #newTabNoticeUntil = 0;
499
+ #newTabNoticeUrl = null;
500
+ #newTabNoticeWindows = [];
501
+ #hadNewTab = false;
338
502
  #stopRecordingEvents;
339
503
  #stopPageEvents = [];
340
504
  #mainFrameId;
@@ -430,6 +594,8 @@ class Browser {
430
594
  }
431
595
  if (!info?.targetId || !info.url || info.url === "about:blank")
432
596
  return;
597
+ if (info.targetId === browser.#targetId)
598
+ browser.setActiveUrl(info.url);
433
599
  const urls = browser.#targetUrls.get(info.targetId) ?? [];
434
600
  if (urls.at(-1) !== info.url)
435
601
  urls.push(info.url);
@@ -455,8 +621,8 @@ class Browser {
455
621
  await browser.watchConsole();
456
622
  await browser.call("Page.enable");
457
623
  await browser.call("Emulation.setDeviceMetricsOverride", {
458
- width: 1120,
459
- height: 780,
624
+ width: PAGE_WIDTH,
625
+ height: PAGE_HEIGHT,
460
626
  deviceScaleFactor: 1,
461
627
  mobile: false
462
628
  });
@@ -465,6 +631,7 @@ class Browser {
465
631
  if (options.url)
466
632
  await browser.call("Page.navigate", { url: options.url });
467
633
  await browser.waitForReady();
634
+ browser.#activeUrl = await browser.evaluate("location.href") ?? browser.#activeUrl;
468
635
  browser.#pauseUntil = performance.now() + browser.#interactionPauses;
469
636
  await browser.startRecording();
470
637
  return browser;
@@ -481,6 +648,18 @@ class Browser {
481
648
  get targetId() {
482
649
  return this.#targetId;
483
650
  }
651
+ setActiveUrl(url) {
652
+ if (!url || url === this.#activeUrl)
653
+ return;
654
+ this.#activeUrl = url;
655
+ if (this.#recordingDirectory && this.#recordingFrames.length) {
656
+ this.#recordingUrlEvents.push({
657
+ elapsedMs: performance.now() - this.#recordingStartedAt,
658
+ targetId: this.#targetId,
659
+ url
660
+ });
661
+ }
662
+ }
484
663
  takeConsoleErrors() {
485
664
  return this.#consoleErrors.splice(0);
486
665
  }
@@ -524,6 +703,8 @@ class Browser {
524
703
  await this.call("Page.enable");
525
704
  const tree = await this.call("Page.getFrameTree");
526
705
  this.#mainFrameId = tree.frameTree.frame.id;
706
+ if (tree.frameTree.frame.url)
707
+ this.setActiveUrl(tree.frameTree.frame.url);
527
708
  await this.call("Runtime.addBinding", { name: "__jevDomChanged" });
528
709
  const observeDom = `(() => {
529
710
  if (window.__jevDomWatching) return;
@@ -560,8 +741,11 @@ class Browser {
560
741
  this.#stopPageEvents.push(this.#cdp.on("Page.frameStartedLoading", this.#sessionId, loading), this.#cdp.on("Page.frameNavigated", this.#sessionId, (params) => {
561
742
  const frame = params.frame;
562
743
  this.signalChange();
563
- if (frame && frame.id === this.#mainFrameId && !frame.parentId)
744
+ if (frame && frame.id === this.#mainFrameId && !frame.parentId) {
745
+ if (frame.url)
746
+ this.setActiveUrl(frame.url);
564
747
  loading({ frameId: frame.id });
748
+ }
565
749
  }), this.#cdp.on("Page.loadEventFired", this.#sessionId, loaded), this.#cdp.on("Page.frameStoppedLoading", this.#sessionId, (params) => {
566
750
  if (typeof params.frameId === "string")
567
751
  this.#loadingFrames.delete(params.frameId);
@@ -571,6 +755,8 @@ class Browser {
571
755
  }), this.#cdp.on("Page.navigatedWithinDocument", this.#sessionId, (params) => {
572
756
  this.signalChange();
573
757
  if (params.frameId === this.#mainFrameId) {
758
+ if (typeof params.url === "string")
759
+ this.setActiveUrl(params.url);
574
760
  this.#pauseUntil = performance.now() + this.#interactionPauses;
575
761
  this.resetRecordingCursor().catch(() => {
576
762
  return;
@@ -631,11 +817,10 @@ class Browser {
631
817
  async startRecording() {
632
818
  if (!this.#recordingPath)
633
819
  return;
634
- if (!Bun.which("ffmpeg"))
635
- throw new Error("--recording requires ffmpeg on PATH");
820
+ await recordingEncoder();
636
821
  const firstSegment = !this.#recordingDirectory;
637
822
  if (firstSegment)
638
- this.#recordingDirectory = await mkdtemp(join(tmpdir(), "jev-cdp-recording-"));
823
+ this.#recordingDirectory = await mkdtemp2(join2(tmpdir2(), "jev-cdp-recording-"));
639
824
  await this.call("Page.enable");
640
825
  await this.call("Page.addScriptToEvaluateOnNewDocument", { source: RECORDING_CURSOR_INIT });
641
826
  const viewport = await this.evaluate("({width: innerWidth, height: innerHeight})");
@@ -643,32 +828,68 @@ class Browser {
643
828
  throw new Error("Could not read the recording viewport");
644
829
  await this.animateCursor(viewport.width / 2, viewport.height / 2);
645
830
  const initial = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
646
- const initialPath = join(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
831
+ const initialPath = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
647
832
  await Bun.write(initialPath, Buffer.from(initial.data, "base64"));
648
833
  if (firstSegment)
649
834
  this.#recordingStartedAt = performance.now();
650
- this.#recordingFrames.push({ path: initialPath, elapsedMs: firstSegment ? 0 : performance.now() - this.#recordingStartedAt });
835
+ const initialElapsedMs = firstSegment ? 0 : performance.now() - this.#recordingStartedAt;
836
+ this.#recordingFrames.push({
837
+ path: initialPath,
838
+ elapsedMs: initialElapsedMs,
839
+ targetId: this.#targetId,
840
+ url: this.#activeUrl,
841
+ newTab: false
842
+ });
651
843
  this.#stopRecordingEvents = this.#cdp.on("Page.screencastFrame", this.#sessionId, (params) => {
652
844
  const frame = params;
653
845
  this.call("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {
654
846
  return;
655
847
  });
656
848
  const sequence = this.#recordingSequence++;
657
- const path = join(this.#recordingDirectory, `${String(sequence).padStart(6, "0")}.jpg`);
658
- const elapsedMs = sequence ? performance.now() - this.#recordingStartedAt : 0;
849
+ const path = join2(this.#recordingDirectory, `${String(sequence).padStart(6, "0")}.jpg`);
850
+ const capturedAt = performance.now();
851
+ const elapsedMs = sequence ? capturedAt - this.#recordingStartedAt : 0;
852
+ const url = this.#activeUrl;
853
+ const targetId = this.#targetId;
854
+ const notice = capturedAt < this.#newTabNoticeUntil;
855
+ const noticeUrl = notice ? this.#newTabNoticeUrl ?? undefined : undefined;
659
856
  this.#recordingWrites = this.#recordingWrites.then(async () => {
660
857
  await Bun.write(path, Buffer.from(frame.data, "base64"));
661
- this.#recordingFrames.push({ path, elapsedMs });
858
+ this.#recordingFrames.push({
859
+ path,
860
+ elapsedMs,
861
+ targetId,
862
+ url,
863
+ newTab: notice,
864
+ ...noticeUrl ? { noticeUrl } : {}
865
+ });
662
866
  });
663
867
  });
664
868
  await this.call("Page.startScreencast", {
665
869
  format: "jpeg",
666
870
  quality: 70,
667
- maxWidth: 1120,
668
- maxHeight: 780,
871
+ maxWidth: PAGE_WIDTH,
872
+ maxHeight: PAGE_HEIGHT,
669
873
  everyNthFrame: 3
670
874
  });
671
875
  }
876
+ async recordNewTabNotice(url) {
877
+ if (!this.#recordingPath || !this.#recordingDirectory)
878
+ return;
879
+ const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
880
+ const elapsedMs = performance.now() - this.#recordingStartedAt;
881
+ const path = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
882
+ await Bun.write(path, Buffer.from(capture.data, "base64"));
883
+ this.#recordingFrames.push({
884
+ path,
885
+ elapsedMs,
886
+ targetId: this.#targetId,
887
+ url: this.#activeUrl,
888
+ newTab: true,
889
+ noticeUrl: url
890
+ });
891
+ return elapsedMs;
892
+ }
672
893
  async finishRecording() {
673
894
  if (!this.#recordingPath || !this.#recordingDirectory)
674
895
  return;
@@ -681,8 +902,53 @@ class Browser {
681
902
  await this.#recordingWrites;
682
903
  if (!this.#recordingFrames.length)
683
904
  throw new Error("Recording produced no browser frames");
905
+ const stoppedAt = performance.now() - this.#recordingStartedAt;
906
+ this.#recordingFrames.sort((a, b) => a.elapsedMs - b.elapsedMs);
907
+ for (const event of this.#recordingUrlEvents) {
908
+ let index = this.#recordingFrames.length - 1;
909
+ while (index >= 0 && (this.#recordingFrames[index].elapsedMs >= event.elapsedMs || this.#recordingFrames[index].targetId !== event.targetId))
910
+ index--;
911
+ const before = this.#recordingFrames[index];
912
+ if (!before || before.url === event.url)
913
+ continue;
914
+ const path = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
915
+ await Bun.write(path, Bun.file(before.path));
916
+ const frame = {
917
+ path,
918
+ elapsedMs: event.elapsedMs,
919
+ targetId: event.targetId,
920
+ url: event.url,
921
+ newTab: this.#newTabNoticeWindows.some((window) => event.elapsedMs >= window.startMs && event.elapsedMs < window.endMs),
922
+ noticeUrl: before.noticeUrl
923
+ };
924
+ this.#recordingFrames.push(frame);
925
+ this.#recordingFrames.sort((a, b) => a.elapsedMs - b.elapsedMs);
926
+ }
927
+ for (const { endMs } of this.#newTabNoticeWindows) {
928
+ if (this.#interactionPauses > 0)
929
+ continue;
930
+ let index = this.#recordingFrames.length - 1;
931
+ while (index >= 0 && this.#recordingFrames[index].elapsedMs >= endMs)
932
+ index--;
933
+ const before = this.#recordingFrames[index];
934
+ if (!before?.newTab || this.#recordingFrames[index + 1]?.elapsedMs === endMs)
935
+ continue;
936
+ const path = join2(this.#recordingDirectory, `${String(this.#recordingSequence++).padStart(6, "0")}.jpg`);
937
+ await Bun.write(path, Bun.file(before.path));
938
+ this.#recordingFrames.splice(index + 1, 0, {
939
+ path,
940
+ elapsedMs: endMs,
941
+ targetId: before.targetId,
942
+ url: before.url,
943
+ newTab: false
944
+ });
945
+ }
946
+ for (const frame of this.#recordingFrames) {
947
+ const jpeg = Buffer.from(await Bun.file(frame.path).arrayBuffer()).toString("base64");
948
+ await Bun.write(frame.path, Buffer.from(await this.composeFrame(jpeg, frame.url, frame.newTab ? frame.noticeUrl ?? frame.url : null), "base64"));
949
+ }
684
950
  await mkdir(dirname(this.#recordingPath), { recursive: true });
685
- const finishedAt = performance.now() - this.#recordingStartedAt;
951
+ const finishedAt = Math.max(stoppedAt, this.#recordingFrames.at(-1).elapsedMs + 40);
686
952
  const quoted = (path) => path.replaceAll("'", "'\\''");
687
953
  const lines = ["ffconcat version 1.0"];
688
954
  for (let index = 0;index < this.#recordingFrames.length; index++) {
@@ -692,49 +958,27 @@ class Browser {
692
958
  lines.push(`file '${quoted(frame.path)}'`, `duration ${duration.toFixed(4)}`);
693
959
  }
694
960
  lines.push(`file '${quoted(this.#recordingFrames.at(-1).path)}'`);
695
- const manifest = join(this.#recordingDirectory, "frames.ffconcat");
961
+ const manifest = join2(this.#recordingDirectory, "frames.ffconcat");
696
962
  await Bun.write(manifest, `${lines.join(`
697
963
  `)}
698
964
  `);
699
- const process2 = Bun.spawn([
700
- Bun.which("ffmpeg"),
701
- "-y",
702
- "-f",
703
- "concat",
704
- "-safe",
705
- "0",
706
- "-i",
707
- manifest,
708
- "-vsync",
709
- "vfr",
710
- "-c:v",
711
- "libx264",
712
- "-pix_fmt",
713
- "yuv420p",
714
- "-movflags",
715
- "+faststart",
716
- this.#recordingPath
717
- ], { stdout: "ignore", stderr: "pipe" });
718
- const stderr = await new Response(process2.stderr).text();
719
- if (await process2.exited !== 0)
720
- throw new Error(`Could not render recording: ${stderr.slice(-800)}`);
721
- await rm(this.#recordingDirectory, { recursive: true, force: true });
965
+ await renderRecording(await recordingEncoder(), manifest, this.#recordingPath);
966
+ await rm2(this.#recordingDirectory, { recursive: true, force: true });
722
967
  this.#recordingDirectory = undefined;
723
968
  }
724
969
  async saveFinalScreenshot() {
725
970
  if (!this.#screenshotPath)
726
971
  return;
727
972
  await mkdir(dirname(this.#screenshotPath), { recursive: true });
728
- if (this.#recordingDirectory) {
729
- await this.#recordingWrites;
730
- const finalFrame = this.#recordingFrames.at(-1);
731
- if (finalFrame) {
732
- await Bun.write(this.#screenshotPath, Bun.file(finalFrame.path));
733
- return;
734
- }
735
- }
736
973
  const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 90 });
737
- await Bun.write(this.#screenshotPath, Buffer.from(capture.data, "base64"));
974
+ const url = await this.evaluate("location.href") ?? this.#activeUrl;
975
+ await Bun.write(this.#screenshotPath, Buffer.from(await this.composeFrame(capture.data, url, this.#hadNewTab ? url : null), "base64"));
976
+ }
977
+ async composeFrame(jpeg, url, newTabUrl) {
978
+ const composed = await this.evaluate(composeFrameExpression(jpeg, url, newTabUrl), true);
979
+ if (!composed)
980
+ throw new Error("Could not compose browser recording frame");
981
+ return composed;
738
982
  }
739
983
  async animateCursor(x, y, click = false) {
740
984
  if (!this.#recordingPath)
@@ -881,13 +1125,25 @@ class Browser {
881
1125
  const attached = await this.#cdp.command("Target.attachToTarget", { targetId: target.id, flatten: true });
882
1126
  try {
883
1127
  await this.#cdp.command("Emulation.setDeviceMetricsOverride", {
884
- width: 1120,
885
- height: 780,
1128
+ width: PAGE_WIDTH,
1129
+ height: PAGE_HEIGHT,
886
1130
  deviceScaleFactor: 1,
887
1131
  mobile: false
888
1132
  }, attached.sessionId);
889
1133
  } finally {
890
1134
  if (this.#switchOnPopup) {
1135
+ const noticeStart = await this.recordNewTabNotice(target.url);
1136
+ const noticeStartAt = noticeStart === undefined ? performance.now() : this.#recordingStartedAt + noticeStart;
1137
+ this.#newTabNoticeUrl = target.url;
1138
+ this.#newTabNoticeUntil = this.#interactionPauses > 0 ? Infinity : 0;
1139
+ const noticeWindow = noticeStart === undefined ? undefined : {
1140
+ startMs: noticeStart,
1141
+ endMs: noticeStart + Math.max(40, this.#interactionPauses)
1142
+ };
1143
+ if (noticeWindow)
1144
+ this.#newTabNoticeWindows.push(noticeWindow);
1145
+ if (this.#interactionPauses > 0)
1146
+ await Bun.sleep(Math.max(0, noticeStartAt + this.#interactionPauses - performance.now()));
891
1147
  if (this.#recordingPath) {
892
1148
  await this.call("Page.stopScreencast").catch(() => {
893
1149
  return;
@@ -896,13 +1152,20 @@ class Browser {
896
1152
  this.#stopRecordingEvents = undefined;
897
1153
  await this.#recordingWrites;
898
1154
  }
1155
+ if (noticeWindow && this.#interactionPauses > 0)
1156
+ noticeWindow.endMs = performance.now() - this.#recordingStartedAt;
1157
+ this.#newTabNoticeUrl = null;
1158
+ this.#newTabNoticeUntil = 0;
899
1159
  this.#sessionId = attached.sessionId;
900
1160
  this.#targetId = target.id;
1161
+ this.#activeUrl = target.url;
901
1162
  this.#loadingFrames.clear();
902
1163
  await this.watchConsole();
903
1164
  await this.watchPageLoads();
904
1165
  this.#switchOnPopup = false;
1166
+ this.#hadNewTab = true;
905
1167
  await this.startRecording();
1168
+ this.#pauseUntil = performance.now();
906
1169
  } else {
907
1170
  await this.#cdp.command("Target.detachFromTarget", { sessionId: attached.sessionId });
908
1171
  }
@@ -1080,7 +1343,7 @@ ${child.text}`.slice(0, 6000);
1080
1343
  const page = { ...info, frames, transitions: [...this.#transitions], fingerprint: fingerprint(info) };
1081
1344
  if (screenshot) {
1082
1345
  const capture = await this.call("Page.captureScreenshot", { format: "jpeg", quality: 72 });
1083
- page.screenshot = capture.data;
1346
+ page.screenshot = await this.composeFrame(capture.data, info.url, performance.now() < this.#newTabNoticeUntil ? this.#newTabNoticeUrl : null);
1084
1347
  }
1085
1348
  return page;
1086
1349
  }
@@ -1307,9 +1570,9 @@ ${child.text}`.slice(0, 6000);
1307
1570
  }
1308
1571
 
1309
1572
  // src/model.ts
1310
- import { mkdtemp as mkdtemp2, readFile, rm as rm2 } from "fs/promises";
1311
- import { tmpdir as tmpdir2 } from "os";
1312
- import { join as join2 } from "path";
1573
+ import { mkdtemp as mkdtemp3, readFile, rm as rm3 } from "fs/promises";
1574
+ import { tmpdir as tmpdir3 } from "os";
1575
+ import { join as join3 } from "path";
1313
1576
 
1314
1577
  // src/questions.ts
1315
1578
  var NEXT_ACTION = `Advance the user's entire goal from the CURRENT page using one operation.
@@ -1549,9 +1812,9 @@ async function codexFieldText(context) {
1549
1812
  if (!["none", "low", "medium", "high", "xhigh", "max"].includes(reasoning)) {
1550
1813
  throw new Error("Unsupported Codex reasoning effort");
1551
1814
  }
1552
- const folder = await mkdtemp2(join2(tmpdir2(), "jev-codex-text-"));
1553
- const outputPath = join2(folder, "output.json");
1554
- const schemaPath = join2(folder, "schema.json");
1815
+ const folder = await mkdtemp3(join3(tmpdir3(), "jev-codex-text-"));
1816
+ const outputPath = join3(folder, "output.json");
1817
+ const schemaPath = join3(folder, "schema.json");
1555
1818
  await Bun.write(schemaPath, JSON.stringify(text_value_schema_default));
1556
1819
  const childEnvironment = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !["TYPESAFE_API_KEY", "TEXT_MODEL_API_KEY"].includes(name)).map(([name, value]) => [name, value]));
1557
1820
  const command = [
@@ -1614,7 +1877,7 @@ ${JSON.stringify(context)}`;
1614
1877
  usage: {}
1615
1878
  }];
1616
1879
  } finally {
1617
- await rm2(folder, { recursive: true, force: true });
1880
+ await rm3(folder, { recursive: true, force: true });
1618
1881
  }
1619
1882
  }
1620
1883
  async function apiFieldText(context) {
@@ -1985,7 +2248,7 @@ Known field values:
1985
2248
  Evidence and output:
1986
2249
  --recording <file.mp4> Record the complete goal with an animated cursor.
1987
2250
  Requires FFmpeg.
1988
- --screenshot <file.jpg> Save the final browser viewport.
2251
+ --screenshot <file.jpg> Save a 1280x720 image with the URL bar.
1989
2252
  --final-state Add the final semantic page state to stdout JSON.
1990
2253
  -h, --help Show this help and exit.
1991
2254
 
@@ -2307,8 +2570,11 @@ async function doctor(args) {
2307
2570
  required: true
2308
2571
  });
2309
2572
  }
2310
- const ffmpeg = Bun.which("ffmpeg");
2311
- checks.push(ffmpeg ? { name: "recording", status: "ok", detail: `FFmpeg found at ${ffmpeg}`, required: false } : { name: "recording", status: "warning", detail: "FFmpeg not found; --recording will be unavailable", required: false });
2573
+ try {
2574
+ checks.push({ name: "recording", status: "ok", detail: await checkRecordingEncoder(), required: false });
2575
+ } catch (error) {
2576
+ checks.push({ name: "recording", status: "warning", detail: `${error instanceof Error ? error.message : String(error)}; --recording will be unavailable`, required: false });
2577
+ }
2312
2578
  if (process.env.TEXT_MODEL_PROVIDER === "api") {
2313
2579
  checks.push(process.env.TEXT_MODEL_API_KEY ? { name: "text-helper", status: "ok", detail: `API helper configured (${process.env.TEXT_MODEL ?? "deepseek-chat"})`, required: false } : { name: "text-helper", status: "warning", detail: "TEXT_MODEL_PROVIDER=api but TEXT_MODEL_API_KEY is not set", required: false });
2314
2580
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-cdp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A small Jev-powered bridge to Chrome through the Chrome DevTools Protocol.",
5
5
  "type": "module",
6
6
  "license": "MIT",