alvin-bot 4.8.8 → 4.9.0

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.
@@ -1,34 +1,194 @@
1
1
  /**
2
- * Multi-Strategy Browser Manager
2
+ * Multi-Strategy Browser Manager — with automatic fallback chain.
3
3
  *
4
- * Auto-selects between three browser strategies:
5
- * - CLI: Headless Playwright, one-shot (screenshots, text extraction, PDF)
6
- * - Gateway: Persistent HTTP browser server (interactive browsing, form-filling)
7
- * - CDP: Attach to user's live Chrome via DevTools Protocol
4
+ * Strategy priority:
5
+ * 1. Gateway (browse-server.cjs HTTP server) if script exists and is running
6
+ * 2. CDP (Chrome DevTools Protocol) via hub browser.sh cdp, persistent cookies
7
+ * 3. Hub Stealth (Playwright + stealth plugin) via hub browser.sh stealth
8
+ * 4. Raw CLI (bare Playwright) — last resort, easily blocked
9
+ *
10
+ * If a strategy is unavailable, we automatically cascade to the next one
11
+ * and log a warning so failures are visible, not silent.
8
12
  */
9
- import { spawn } from "child_process";
13
+ import { execSync, spawn } from "child_process";
10
14
  import http from "http";
11
15
  import fs from "fs";
12
16
  import { config } from "../config.js";
13
- import { BROWSE_SERVER_SCRIPT } from "../paths.js";
17
+ import { BROWSE_SERVER_SCRIPT, HUB_BROWSER_SH } from "../paths.js";
14
18
  import { screenshotUrl, extractText, generatePdf } from "./browser.js";
15
- /** Auto-select the best browser strategy for a task */
19
+ import { webfetchNavigate, WebfetchFailed } from "./browser-webfetch.js";
20
+ const CDP_PORT = 9222;
21
+ const EXEC_TIMEOUT = 60_000; // 60s for page loads via shell
22
+ // ── Logging ──────────────────────────────────────────────────────────
23
+ function log(msg) {
24
+ console.warn(`[browser-manager] ${msg}`);
25
+ }
26
+ // ── Availability Checks ──────────────────────────────────────────────
27
+ function isGatewayScriptPresent() {
28
+ return fs.existsSync(BROWSE_SERVER_SCRIPT);
29
+ }
30
+ async function isGatewayRunning() {
31
+ try {
32
+ const health = await gatewayRequest("/health");
33
+ return !!health?.ok;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ function isHubBrowserAvailable() {
40
+ return fs.existsSync(HUB_BROWSER_SH);
41
+ }
42
+ async function isCDPAvailable() {
43
+ return new Promise((resolve) => {
44
+ const req = http.get(`http://127.0.0.1:${CDP_PORT}/json/version`, (res) => {
45
+ let data = "";
46
+ res.on("data", (chunk) => (data += chunk));
47
+ res.on("end", () => resolve(res.statusCode === 200));
48
+ });
49
+ req.on("error", () => resolve(false));
50
+ req.setTimeout(3000, () => {
51
+ req.destroy();
52
+ resolve(false);
53
+ });
54
+ });
55
+ }
56
+ // ── Strategy Selection with Fallback ─────────────────────────────────
57
+ /** Pick the preferred strategy based on task type.
58
+ *
59
+ * Default for a one-shot read is `webfetch` — the cheapest tier. It
60
+ * only fails on JS-heavy or bot-guarded pages, and the cascade in
61
+ * resolveStrategy() handles the upgrade path automatically.
62
+ */
16
63
  export function selectStrategy(task = {}) {
17
64
  if (task.useUserBrowser || config.cdpUrl)
18
65
  return "cdp";
19
66
  if (task.interactive || task.multiStep)
20
67
  return "gateway";
68
+ return "webfetch";
69
+ }
70
+ /**
71
+ * Resolve the preferred strategy to one that's actually available.
72
+ *
73
+ * Cascade order:
74
+ * webfetch → hub-stealth → cdp → gateway → cli
75
+ *
76
+ * Rationale:
77
+ * - `webfetch` is a plain HTTP GET — instant, zero footprint.
78
+ * - `hub-stealth` (playwright+stealth) handles JS-rendered pages
79
+ * without a persistent browser process.
80
+ * - `cdp` brings cookies/auth for login-walled sites.
81
+ * - `gateway` exposes the multi-step HTTP API (ref-based ops, long
82
+ * sessions) when the browse-server.cjs helper is available.
83
+ * - `cli` (raw Playwright) is the last-resort fallback.
84
+ */
85
+ export async function resolveStrategy(preferred) {
86
+ const chain = [];
87
+ // Build fallback chain starting from preferred. webfetch and
88
+ // hub-stealth are always available (no external state check), so
89
+ // they're included as floor entries. CDP/gateway only get in if the
90
+ // caller asked for them explicitly, since they need running daemons.
91
+ switch (preferred) {
92
+ case "webfetch":
93
+ chain.push("webfetch", "hub-stealth", "cli");
94
+ break;
95
+ case "gateway":
96
+ chain.push("gateway", "cdp", "hub-stealth", "webfetch", "cli");
97
+ break;
98
+ case "cdp":
99
+ chain.push("cdp", "hub-stealth", "webfetch", "cli");
100
+ break;
101
+ case "hub-stealth":
102
+ chain.push("hub-stealth", "webfetch", "cli");
103
+ break;
104
+ case "cli":
105
+ chain.push("cli");
106
+ break;
107
+ }
108
+ for (const strategy of chain) {
109
+ switch (strategy) {
110
+ case "webfetch":
111
+ // Native fetch is always present on Node ≥ 18 — no availability
112
+ // probe needed. Each call is self-contained, so we return the
113
+ // strategy tag and let navigate() handle per-call errors.
114
+ return "webfetch";
115
+ case "gateway":
116
+ if (isGatewayScriptPresent() && (await isGatewayRunning()))
117
+ return "gateway";
118
+ if (!isGatewayScriptPresent()) {
119
+ log("Gateway unavailable: browse-server.cjs not found. Falling back.");
120
+ }
121
+ else {
122
+ log("Gateway not running. Falling back.");
123
+ }
124
+ break;
125
+ case "cdp":
126
+ if (await isCDPAvailable())
127
+ return "cdp";
128
+ // Try starting CDP via hub script
129
+ if (isHubBrowserAvailable()) {
130
+ try {
131
+ log("CDP Chrome not running — attempting to start via hub browser.sh...");
132
+ execSync(`"${HUB_BROWSER_SH}" cdp start headless`, {
133
+ stdio: "pipe",
134
+ timeout: 15_000,
135
+ });
136
+ // Give it a moment to spin up
137
+ await new Promise((r) => setTimeout(r, 3000));
138
+ if (await isCDPAvailable()) {
139
+ log("CDP Chrome started successfully.");
140
+ return "cdp";
141
+ }
142
+ }
143
+ catch (err) {
144
+ log(`Failed to start CDP Chrome: ${err.message}`);
145
+ }
146
+ }
147
+ log("CDP unavailable. Falling back.");
148
+ break;
149
+ case "hub-stealth":
150
+ if (isHubBrowserAvailable())
151
+ return "hub-stealth";
152
+ log("Hub browser.sh not found. Falling back to raw Playwright.");
153
+ break;
154
+ case "cli":
155
+ return "cli"; // Always available as last resort
156
+ }
157
+ }
21
158
  return "cli";
22
159
  }
23
- // ── Gateway Management ────────────────────────────────────────────────
160
+ function execHub(args) {
161
+ try {
162
+ const result = execSync(`"${HUB_BROWSER_SH}" ${args}`, {
163
+ stdio: "pipe",
164
+ timeout: EXEC_TIMEOUT,
165
+ env: { ...process.env, PATH: process.env.PATH },
166
+ });
167
+ const stdout = result.toString().trim();
168
+ // Try to parse as JSON (stealth outputs JSON)
169
+ try {
170
+ return JSON.parse(stdout);
171
+ }
172
+ catch {
173
+ // Not JSON — return as raw text
174
+ return { title: "", url: "", raw: stdout };
175
+ }
176
+ }
177
+ catch (err) {
178
+ const error = err;
179
+ log(`Hub script failed: ${error.stderr?.toString()?.trim() || error.message}`);
180
+ return null;
181
+ }
182
+ }
183
+ // ── Gateway Management ───────────────────────────────────────────────
24
184
  let gatewayProcess = null;
25
- async function gatewayRequest(path, params = {}) {
185
+ async function gatewayRequest(urlPath, params = {}, timeoutMs = 15_000) {
26
186
  const query = new URLSearchParams(params).toString();
27
- const url = `http://127.0.0.1:${config.browseServerPort}${path}${query ? "?" + query : ""}`;
187
+ const url = `http://127.0.0.1:${config.browseServerPort}${urlPath}${query ? "?" + query : ""}`;
28
188
  return new Promise((resolve, reject) => {
29
- http.get(url, (res) => {
189
+ const req = http.get(url, (res) => {
30
190
  let data = "";
31
- res.on("data", chunk => data += chunk);
191
+ res.on("data", (chunk) => (data += chunk));
32
192
  res.on("end", () => {
33
193
  try {
34
194
  resolve(JSON.parse(data));
@@ -37,107 +197,314 @@ async function gatewayRequest(path, params = {}) {
37
197
  reject(new Error(`Invalid JSON from gateway: ${data.slice(0, 200)}`));
38
198
  }
39
199
  });
40
- }).on("error", reject);
200
+ });
201
+ req.on("error", reject);
202
+ req.setTimeout(timeoutMs, () => {
203
+ req.destroy(new Error(`Gateway request timed out after ${timeoutMs}ms: ${urlPath}`));
204
+ });
41
205
  });
42
206
  }
43
207
  async function ensureGateway() {
44
208
  // Check if already running
45
- try {
46
- const health = await gatewayRequest("/health");
47
- if (health.ok)
48
- return true;
49
- }
50
- catch { /* not running */ }
51
- // Start it
52
- if (!fs.existsSync(BROWSE_SERVER_SCRIPT))
209
+ if (await isGatewayRunning())
210
+ return true;
211
+ // Try to start it
212
+ if (!isGatewayScriptPresent()) {
213
+ log("Cannot start gateway: browse-server.cjs not found.");
53
214
  return false;
215
+ }
54
216
  gatewayProcess = spawn("node", [BROWSE_SERVER_SCRIPT, String(config.browseServerPort)], {
55
217
  stdio: "pipe",
56
218
  detached: false,
57
219
  });
58
- gatewayProcess.on("exit", () => { gatewayProcess = null; });
220
+ gatewayProcess.on("exit", () => {
221
+ gatewayProcess = null;
222
+ });
59
223
  // Wait for startup (max 10s)
60
224
  for (let i = 0; i < 20; i++) {
61
- await new Promise(r => setTimeout(r, 500));
62
- try {
63
- const health = await gatewayRequest("/health");
64
- if (health.ok)
65
- return true;
66
- }
67
- catch { /* still starting */ }
225
+ await new Promise((r) => setTimeout(r, 500));
226
+ if (await isGatewayRunning())
227
+ return true;
68
228
  }
229
+ log("Gateway failed to start within 10s.");
69
230
  return false;
70
231
  }
71
- // ── Unified Operations ────────────────────────────────────────────────
72
- /** Navigate to URL using best strategy */
232
+ // ── Unified Operations ───────────────────────────────────────────────
233
+ /** Navigate to URL using best available strategy.
234
+ *
235
+ * Error-based cascade: if the chosen tier throws, we walk DOWN the
236
+ * priority chain until one succeeds or we exhaust the list. This lets
237
+ * a 403 from webfetch transparently upgrade to hub-stealth without
238
+ * callers having to know about the fallback graph.
239
+ */
73
240
  export async function navigate(url, task = {}) {
74
- const strategy = selectStrategy(task);
75
- if (strategy === "gateway") {
76
- await ensureGateway();
77
- return gatewayRequest("/navigate", { url });
78
- }
79
- if (strategy === "cdp") {
80
- // CDP: use playwright connectOverCDP
81
- const { chromium } = await import("playwright");
82
- const browser = await chromium.connectOverCDP(config.cdpUrl);
83
- const contexts = browser.contexts();
84
- const page = contexts[0]?.pages()[0] || await contexts[0]?.newPage() || await browser.newPage();
85
- await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
86
- const title = await page.title();
87
- return { title, url: page.url() };
88
- }
89
- // CLI: simple text extraction
90
- const text = await extractText(url);
91
- return { title: url, url, tree: [text.slice(0, 500)] };
241
+ const primary = await resolveStrategy(selectStrategy(task));
242
+ log(`navigate(${url}) using strategy: ${primary}`);
243
+ // Try primary, then hub-stealth as a universal fallback. We keep the
244
+ // fallback list short here to avoid cascading timeouts — the full
245
+ // cascade is only for resolveStrategy's availability check.
246
+ const attempt = async (strategy) => {
247
+ return navigateOne(strategy, url);
248
+ };
249
+ try {
250
+ return await attempt(primary);
251
+ }
252
+ catch (err) {
253
+ log(`navigate(${url}) ${primary} failed: ${err.message}`);
254
+ if (primary === "webfetch") {
255
+ // Webfetch is the most common tier and the most common to hit a
256
+ // bot guard cascade to hub-stealth explicitly, then cli.
257
+ try {
258
+ return await attempt("hub-stealth");
259
+ }
260
+ catch (err2) {
261
+ log(`navigate(${url}) hub-stealth fallback failed: ${err2.message}`);
262
+ return await attempt("cli");
263
+ }
264
+ }
265
+ throw err;
266
+ }
267
+ }
268
+ /** Single-strategy navigate — no fallback logic, just do the thing. */
269
+ async function navigateOne(strategy, url) {
270
+ switch (strategy) {
271
+ case "webfetch": {
272
+ try {
273
+ const r = await webfetchNavigate(url);
274
+ return { title: r.title, url: r.url };
275
+ }
276
+ catch (err) {
277
+ if (err instanceof WebfetchFailed)
278
+ throw err;
279
+ throw new WebfetchFailed(url, err.message, { cause: err });
280
+ }
281
+ }
282
+ case "gateway": {
283
+ await ensureGateway();
284
+ return gatewayRequest("/navigate", { url });
285
+ }
286
+ case "cdp": {
287
+ // Try hub CDP first
288
+ if (isHubBrowserAvailable()) {
289
+ const result = execHub(`cdp goto "${url}"`);
290
+ if (result && !result.error) {
291
+ return { title: result.title || "", url: result.url || url };
292
+ }
293
+ }
294
+ // Fallback: direct Playwright CDP
295
+ try {
296
+ const { chromium } = await import("playwright");
297
+ const browser = await chromium.connectOverCDP(config.cdpUrl || `http://127.0.0.1:${CDP_PORT}`);
298
+ const contexts = browser.contexts();
299
+ const page = contexts[0]?.pages()[0] || (await contexts[0]?.newPage()) || (await browser.newPage());
300
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
301
+ const title = await page.title();
302
+ return { title, url: page.url() };
303
+ }
304
+ catch (err) {
305
+ log(`Direct CDP failed: ${err.message}`);
306
+ // Last resort: try stealth
307
+ if (isHubBrowserAvailable()) {
308
+ const stealthResult = execHub(`stealth "${url}"`);
309
+ if (stealthResult) {
310
+ return { title: stealthResult.title || "", url: stealthResult.url || url };
311
+ }
312
+ }
313
+ throw err;
314
+ }
315
+ }
316
+ case "hub-stealth": {
317
+ const result = execHub(`stealth "${url}"`);
318
+ if (result && !result.error) {
319
+ return { title: result.title || "", url: result.url || url };
320
+ }
321
+ // Fallback to raw CLI
322
+ log("Hub stealth failed, falling back to raw Playwright.");
323
+ const text = await extractText(url);
324
+ return { title: url, url, tree: [text.slice(0, 500)] };
325
+ }
326
+ case "cli":
327
+ default: {
328
+ const text = await extractText(url);
329
+ return { title: url, url, tree: [text.slice(0, 500)] };
330
+ }
331
+ }
92
332
  }
93
333
  /** Take a screenshot */
94
334
  export async function screenshot(url, options = {}) {
95
- const strategy = selectStrategy();
96
- if (strategy === "gateway") {
97
- await ensureGateway();
98
- if (url)
99
- await gatewayRequest("/navigate", { url });
100
- const result = await gatewayRequest("/screenshot", options.fullPage ? { full: "true" } : {});
101
- return result.path;
102
- }
103
- // CLI fallback
104
- return screenshotUrl(url, { fullPage: options.fullPage });
105
- }
106
- /** Get accessibility tree (gateway only) */
335
+ const strategy = await resolveStrategy(selectStrategy());
336
+ log(`screenshot(${url}) using strategy: ${strategy}`);
337
+ switch (strategy) {
338
+ case "gateway": {
339
+ await ensureGateway();
340
+ if (url)
341
+ await gatewayRequest("/navigate", { url });
342
+ const result = await gatewayRequest("/screenshot", options.fullPage ? { full: "true" } : {});
343
+ return result.path;
344
+ }
345
+ case "cdp": {
346
+ if (isHubBrowserAvailable()) {
347
+ const tmpName = `shot_${Date.now()}.png`;
348
+ const result = execHub(`cdp shot "${url}" ${tmpName}`);
349
+ if (result?.screenshot)
350
+ return result.screenshot;
351
+ }
352
+ // Fallback to raw Playwright
353
+ return screenshotUrl(url, { fullPage: options.fullPage });
354
+ }
355
+ case "hub-stealth": {
356
+ const tmpName = `shot_${Date.now()}.png`;
357
+ const result = execHub(`stealth "${url}" --screenshot=${tmpName}`);
358
+ if (result?.screenshot)
359
+ return result.screenshot;
360
+ // Fallback
361
+ return screenshotUrl(url, { fullPage: options.fullPage });
362
+ }
363
+ case "cli":
364
+ default:
365
+ return screenshotUrl(url, { fullPage: options.fullPage });
366
+ }
367
+ }
368
+ // ── CDP Direct-Playwright Helper ─────────────────────────────────────
369
+ // Used as fallback when the gateway isn't running but CDP Chrome is.
370
+ // Each call opens a short-lived CDP connection, operates on the newest
371
+ // existing page in the current context (keeps Chrome itself alive), and
372
+ // disconnects. Safe for sub-agents that need a single op at a time.
373
+ async function withCdpPage(fn) {
374
+ const { chromium } = await import("playwright");
375
+ const browser = await chromium.connectOverCDP(config.cdpUrl || `http://127.0.0.1:${CDP_PORT}`);
376
+ try {
377
+ const context = browser.contexts()[0];
378
+ if (!context)
379
+ throw new Error("No CDP contexts available — is Chrome CDP running?");
380
+ const pages = context.pages();
381
+ const page = pages[pages.length - 1] || (await context.newPage());
382
+ return await fn(page);
383
+ }
384
+ finally {
385
+ await browser.close(); // Closes CDP connection, not Chrome itself
386
+ }
387
+ }
388
+ const NEEDS_INTERACTIVE_HINT = "Start CDP Chrome: ~/.claude/hub/SCRIPTS/browser.sh cdp start headless";
389
+ /**
390
+ * Get accessibility tree (gateway preferred, CDP fallback returns outerHTML).
391
+ * The @eN ref model only exists in the gateway; under CDP we return a
392
+ * best-effort DOM snippet instead so callers can still see what's there.
393
+ */
107
394
  export async function getTree(limit = 100) {
108
- await ensureGateway();
109
- return gatewayRequest("/tree", { limit: String(limit) });
110
- }
111
- /** Click element by ref (gateway only) */
112
- export async function click(ref) {
113
- await ensureGateway();
114
- return gatewayRequest("/click", { ref });
115
- }
116
- /** Fill input (gateway only) */
117
- export async function fill(ref, value) {
118
- await ensureGateway();
119
- await gatewayRequest("/fill", { ref, value });
120
- }
121
- /** Type text (gateway only) */
122
- export async function type(ref, text) {
123
- await ensureGateway();
124
- await gatewayRequest("/type", { ref, text });
125
- }
126
- /** Press key (gateway only) */
127
- export async function press(key, ref) {
128
- await ensureGateway();
129
- await gatewayRequest("/press", ref ? { key, ref } : { key });
130
- }
131
- /** Scroll page (gateway only) */
395
+ if (await isGatewayRunning()) {
396
+ return gatewayRequest("/tree", { limit: String(limit) });
397
+ }
398
+ if (await isCDPAvailable()) {
399
+ return withCdpPage(async (page) => {
400
+ const elements = await page.$$eval("a, button, input, select, textarea, [role=button], [role=link]", (els, max) => els.slice(0, max).map((el, i) => {
401
+ const tag = el.tagName.toLowerCase();
402
+ const text = (el.textContent || "").trim().slice(0, 60);
403
+ const id = el.id ? `#${el.id}` : "";
404
+ const name = el.name
405
+ ? `[name=${el.name}]`
406
+ : "";
407
+ return `@e${i + 1} <${tag}${id}${name}> "${text}"`;
408
+ }), limit);
409
+ return { tree: elements, total: elements.length };
410
+ });
411
+ }
412
+ throw new Error(`[browser-manager] getTree requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
413
+ }
414
+ /**
415
+ * Click an element. Accepts a gateway ref (@eN → "eN") when gateway is
416
+ * running, or a CSS selector when only CDP is available.
417
+ */
418
+ export async function click(refOrSelector) {
419
+ if (await isGatewayRunning()) {
420
+ return gatewayRequest("/click", { ref: refOrSelector });
421
+ }
422
+ if (await isCDPAvailable()) {
423
+ return withCdpPage(async (page) => {
424
+ await page.click(refOrSelector, { timeout: 10_000 });
425
+ return { title: await page.title(), url: page.url() };
426
+ });
427
+ }
428
+ throw new Error(`[browser-manager] click() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
429
+ }
430
+ /** Fill an input. refOrSelector semantics match click(). */
431
+ export async function fill(refOrSelector, value) {
432
+ if (await isGatewayRunning()) {
433
+ await gatewayRequest("/fill", { ref: refOrSelector, value });
434
+ return;
435
+ }
436
+ if (await isCDPAvailable()) {
437
+ await withCdpPage(async (page) => {
438
+ await page.fill(refOrSelector, value, { timeout: 10_000 });
439
+ });
440
+ return;
441
+ }
442
+ throw new Error(`[browser-manager] fill() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
443
+ }
444
+ /** Type text character-by-character (for inputs that reject page.fill). */
445
+ export async function type(refOrSelector, text) {
446
+ if (await isGatewayRunning()) {
447
+ await gatewayRequest("/type", { ref: refOrSelector, text });
448
+ return;
449
+ }
450
+ if (await isCDPAvailable()) {
451
+ await withCdpPage(async (page) => {
452
+ await page.type(refOrSelector, text, { timeout: 10_000 });
453
+ });
454
+ return;
455
+ }
456
+ throw new Error(`[browser-manager] type() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
457
+ }
458
+ /** Press a keyboard key (page-level if no ref, element-level with ref). */
459
+ export async function press(key, refOrSelector) {
460
+ if (await isGatewayRunning()) {
461
+ await gatewayRequest("/press", refOrSelector ? { key, ref: refOrSelector } : { key });
462
+ return;
463
+ }
464
+ if (await isCDPAvailable()) {
465
+ await withCdpPage(async (page) => {
466
+ if (refOrSelector) {
467
+ await page.locator(refOrSelector).press(key, { timeout: 10_000 });
468
+ }
469
+ else {
470
+ await page.keyboard.press(key);
471
+ }
472
+ });
473
+ return;
474
+ }
475
+ throw new Error(`[browser-manager] press() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
476
+ }
477
+ /** Scroll page. CDP fallback uses window.scrollBy. */
132
478
  export async function scroll(direction, amount = 600) {
133
- await ensureGateway();
134
- return gatewayRequest("/scroll", { direction, amount: String(amount) });
479
+ if (await isGatewayRunning()) {
480
+ return gatewayRequest("/scroll", { direction, amount: String(amount) });
481
+ }
482
+ if (await isCDPAvailable()) {
483
+ return withCdpPage(async (page) => {
484
+ const delta = direction === "up" ? -amount :
485
+ direction === "top" ? -1e9 :
486
+ direction === "bottom" ? 1e9 :
487
+ amount;
488
+ await page.evaluate((d) => window.scrollBy(0, d), delta);
489
+ return { title: await page.title(), url: page.url() };
490
+ });
491
+ }
492
+ throw new Error(`[browser-manager] scroll() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
135
493
  }
136
- /** Evaluate JS (gateway only) */
494
+ /** Evaluate JS in the page context. */
137
495
  export async function evaluate(js) {
138
- await ensureGateway();
139
- const result = await gatewayRequest("/eval", { js });
140
- return result.result;
496
+ if (await isGatewayRunning()) {
497
+ const result = await gatewayRequest("/eval", { js });
498
+ return result.result;
499
+ }
500
+ if (await isCDPAvailable()) {
501
+ return withCdpPage(async (page) => {
502
+ // `page.evaluate(fn)` wraps a function — we need eval of a raw
503
+ // expression string, so wrap in an IIFE.
504
+ return page.evaluate(new Function(`return (${js})`));
505
+ });
506
+ }
507
+ throw new Error(`[browser-manager] evaluate() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
141
508
  }
142
509
  /** Generate PDF from URL */
143
510
  export async function pdf(url) {
@@ -154,8 +521,16 @@ export async function close() {
154
521
  gatewayProcess = null;
155
522
  }
156
523
  }
157
- /** Get current page info (gateway) */
524
+ /** Get current page info (gateway preferred, CDP fallback reads newest page). */
158
525
  export async function info() {
159
- await ensureGateway();
160
- return gatewayRequest("/info");
526
+ if (await isGatewayRunning()) {
527
+ return gatewayRequest("/info");
528
+ }
529
+ if (await isCDPAvailable()) {
530
+ return withCdpPage(async (page) => ({
531
+ title: await page.title(),
532
+ url: page.url(),
533
+ }));
534
+ }
535
+ throw new Error(`[browser-manager] info() requires gateway or CDP. ${NEEDS_INTERACTIVE_HINT}`);
161
536
  }