teamshare-bridge 0.21.23 → 0.21.26

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.
@@ -119,6 +119,7 @@ const SYSTEM_PROMPT = `You are a browser automation agent. A screenshot and a gr
119
119
  Your task: identify unanswered questions and answer them by clicking the correct elements.
120
120
 
121
121
  RULES (STRICT):
122
+ 0. BE FAST AND DECISIVE. Answer in a single pass with minimal reasoning — do NOT deliberate at length, re-check repeatedly, or restate the page. Emit the final JSON array as your primary output. Speed matters more than exhaustive deliberation.
122
123
  1. NEVER click Next, Submit, Back, Save, Continue, or any navigation. ONLY answer questions on the current screen.
123
124
  2. NEVER ask for help. If uncertain, pick the BEST GUESS. Always decide — never wait.
124
125
  3. Handle MULTIPLE unanswered questions in a SINGLE response.
@@ -257,6 +258,133 @@ RESPONSE FORMAT RULES (CRITICAL — follow exactly):
257
258
  - RIGHT: {"text":"say \\"hello\\""}
258
259
  - NEVER put raw newlines or unescaped quotes inside the text string.
259
260
  - If you need to explain your reasoning, use the "reason" field, NOT the "text" field.`;
261
+ /**
262
+ * Enable the Page domain and install the per-tab instrumentation the loop
263
+ * relies on: the stealth console wrapper, the Page.frameNavigated listener and
264
+ * the debounced MutationObserver. Extracted so every (re)attach - including a
265
+ * tab switch/close - re-runs it on the NEW tab. All of it must be idempotent
266
+ * (the injected scripts already early-return when their Symbol key exists).
267
+ */
268
+ async function instrumentPage(page) {
269
+ try {
270
+ await cdpSend(page, 'Page.enable', {});
271
+ console.log('[browse] Page domain enabled');
272
+ }
273
+ catch (err) {
274
+ console.error(`[browse] Page.enable failed: ${(0, config_1.msg)(err)}`);
275
+ }
276
+ // Stealth: install stack trace sanitization to hide CDP frames (global, once)
277
+ installStackTraceStealth();
278
+ // Stealth: suppress console serialization leak caused by CDP's Runtime.enable.
279
+ // Anti-bot probe: Object.defineProperty(obj,'x',{get(){window.__detected=1}});
280
+ // console.debug(obj); if(window.__detected) → CDP is serializing args
281
+ // Countermeasure: wrap console methods to prevent deep serialization.
282
+ try {
283
+ await cdpEval(page, `
284
+ (() => {
285
+ const CK = Symbol('__ts_cp');
286
+ if (window[CK]) return;
287
+ const wrap = (fn) => function(...args) {
288
+ return fn.apply(console, args.map(a => {
289
+ if (a && typeof a === 'object' && typeof a !== 'function') {
290
+ try { return Object.assign(Array.isArray(a) ? [] : {}, a); } catch { return a; }
291
+ }
292
+ return a;
293
+ }));
294
+ };
295
+ console.debug = wrap(console.debug);
296
+ console.log = wrap(console.log);
297
+ console.warn = wrap(console.warn);
298
+ console.error = wrap(console.error);
299
+ window[CK] = true;
300
+ })()
301
+ `);
302
+ }
303
+ catch { /* non-critical */ }
304
+ // Register CDP event: Page.frameNavigated (URL/navigation changes)
305
+ onCdpEvent(page, 'Page.frameNavigated', () => {
306
+ page._cdpNavigated = Date.now();
307
+ });
308
+ // Inject MutationObserver: watches DOM for significant content changes
309
+ // Uses same fingerprint comparison as Node.js side to prevent false positives.
310
+ // Filters out: timers, animations, scripts, styles, notifications, toasts.
311
+ // Debounces mutations (500ms) to batch rapid changes.
312
+ // Stealth: uses Symbol-keyed property (invisible to Object.keys / anti-bot scans).
313
+ try {
314
+ await cdpEval(page, `
315
+ (() => {
316
+ const KEY = Symbol('__ts_obs');
317
+ if (window[KEY]) return 'already attached';
318
+ const IGNORE = /timer|countdown|elapsed|clock|animation|animate|transition|spinner|progress|loading|toast|notification|alert|banner|snackbar|cookie|consent|modal|overlay|popup|tooltip|badge|tag|avatar|icon|svg|img|video|audio|canvas|font|stylesheet|script|meta|link|head/i;
319
+ let timer = null;
320
+ let lastText = '';
321
+ function getText() {
322
+ const els = document.querySelectorAll('input[type="radio"],input[type="checkbox"],input[type="text"],input[type="email"],input[type="password"],textarea,label,button');
323
+ return [...els].map(e => {
324
+ const t = (e.innerText || e.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
325
+ return e.type + '|' + t + '|' + (e.checked || false);
326
+ }).join('\\n');
327
+ }
328
+ function check() {
329
+ try {
330
+ const curr = getText();
331
+ if (curr === lastText) return;
332
+ const prev = lastText;
333
+ lastText = curr;
334
+ const prevGroups = prev ? prev.split('\\n').filter(Boolean) : [];
335
+ const currGroups = curr.split('\\n').filter(Boolean);
336
+ const prevSet = new Set(prevGroups);
337
+ const currSet = new Set(currGroups);
338
+ let removed = 0, changed = 0;
339
+ for (const g of prevGroups) {
340
+ if (!currSet.has(g)) {
341
+ if (prevGroups.indexOf(g) < currGroups.length) changed++;
342
+ else removed++;
343
+ }
344
+ }
345
+ const added = currGroups.filter(g => !prevSet.has(g)).length;
346
+ const score = added * 10 + removed * 10 + changed * 8;
347
+ if (score >= 15) {
348
+ window[KEY].changed = Date.now();
349
+ }
350
+ } catch {}
351
+ }
352
+ const obs = new MutationObserver((mutations) => {
353
+ let hasRelevant = false;
354
+ for (const m of mutations) {
355
+ if (m.target instanceof Element) {
356
+ const tag = m.target.tagName;
357
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'HEAD') continue;
358
+ const cls = m.target.className || '';
359
+ if (typeof cls === 'string' && IGNORE.test(cls)) continue;
360
+ const id = m.target.id || '';
361
+ if (IGNORE.test(id)) continue;
362
+ if (m.type === 'characterData' && m.target.parentElement) {
363
+ const ptag = m.target.parentElement.tagName;
364
+ if (ptag === 'SCRIPT' || ptag === 'STYLE') continue;
365
+ }
366
+ }
367
+ hasRelevant = true;
368
+ }
369
+ if (hasRelevant) {
370
+ clearTimeout(timer);
371
+ timer = setTimeout(check, 500);
372
+ }
373
+ });
374
+ obs.observe(document.body || document.documentElement, {
375
+ childList: true, subtree: true, characterData: true
376
+ });
377
+ window[KEY] = { observer: obs, changed: 0 };
378
+ lastText = getText();
379
+ return 'ok';
380
+ })()
381
+ `);
382
+ console.log('[browse] MutationObserver injected (stealth, debounced, filtered)');
383
+ }
384
+ catch (err) {
385
+ console.error(`[browse] MutationObserver injection failed: ${(0, config_1.msg)(err)}`);
386
+ }
387
+ }
260
388
  async function cmdBrowse(flags) {
261
389
  const autoAnswer = flags.get('auto-answer') === 'true';
262
390
  if (!autoAnswer) {
@@ -316,9 +444,18 @@ async function cmdBrowse(flags) {
316
444
  const maxActions = continuous ? Number.MAX_SAFE_INTEGER : parseInt(flags.get('max-actions') ?? '50', 10);
317
445
  const intervalMs = parseInt(flags.get('interval') ?? '2000', 10);
318
446
  const modelSpec = flags.get('model') ?? models_1.DEFAULT_VISION_MODEL;
319
- const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '300', 10), 30), 600);
447
+ // Target: a decisive answer in well under a minute. 60s is the abandon
448
+ // threshold (overridable); the fast abort + short retry below makes giving up
449
+ // cheap, so we never wait 5 minutes for a stale decision.
450
+ const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '60', 10), 20), 600);
320
451
  const timeoutMs = timeoutSec * 1000;
321
- const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '78', 10), 50), 100);
452
+ const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '70', 10), 50), 100);
453
+ /**
454
+ * Downscale the screenshot when the viewport is bigger than this many px on
455
+ * its longest side (fewer image tokens => faster vision response on big
456
+ * pages; a 1440px page produced 100KB+ images that blew the timeout).
457
+ */
458
+ const SCREENSHOT_MAX_EDGE = 1024;
322
459
  const maxConsecutiveFailures = parseInt(flags.get('max-consecutive-failures') ?? '0', 10); // 0 = unlimited
323
460
  const charDelayMs = Math.min(Math.max(parseInt(flags.get('char-delay') ?? '150', 10), 5), 200);
324
461
  const optimize = flags.has('optimize');
@@ -400,125 +537,9 @@ async function cmdBrowse(flags) {
400
537
  process.exitCode = 1;
401
538
  return;
402
539
  }
403
- // Enable Page domain (required for captureScreenshot)
404
- try {
405
- await cdpSend(page, 'Page.enable', {});
406
- console.log('[browse] Page domain enabled');
407
- }
408
- catch (err) {
409
- console.error(`[browse] Page.enable failed: ${(0, config_1.msg)(err)}`);
410
- }
411
- // Stealth: install stack trace sanitization to hide CDP frames
412
- installStackTraceStealth();
413
- // Stealth: suppress console serialization leak caused by CDP's Runtime.enable.
414
- // Anti-bot probe: Object.defineProperty(obj,'x',{get(){window.__detected=1}});
415
- // console.debug(obj); if(window.__detected) → CDP is serializing args
416
- // Countermeasure: wrap console methods to prevent deep serialization.
417
- try {
418
- await cdpEval(page, `
419
- (() => {
420
- const CK = Symbol('__ts_cp');
421
- if (window[CK]) return;
422
- const wrap = (fn) => function(...args) {
423
- return fn.apply(console, args.map(a => {
424
- if (a && typeof a === 'object' && typeof a !== 'function') {
425
- try { return Object.assign(Array.isArray(a) ? [] : {}, a); } catch { return a; }
426
- }
427
- return a;
428
- }));
429
- };
430
- console.debug = wrap(console.debug);
431
- console.log = wrap(console.log);
432
- console.warn = wrap(console.warn);
433
- console.error = wrap(console.error);
434
- window[CK] = true;
435
- })()
436
- `);
437
- }
438
- catch { /* non-critical */ }
439
- // Register CDP event: Page.frameNavigated (URL/navigation changes)
440
- onCdpEvent(page, 'Page.frameNavigated', () => {
441
- page._cdpNavigated = Date.now();
442
- });
443
- // Inject MutationObserver: watches DOM for significant content changes
444
- // Uses same fingerprint comparison as Node.js side to prevent false positives.
445
- // Filters out: timers, animations, scripts, styles, notifications, toasts.
446
- // Debounces mutations (500ms) to batch rapid changes.
447
- // Stealth: uses Symbol-keyed property (invisible to Object.keys / anti-bot scans).
448
- try {
449
- await cdpEval(page, `
450
- (() => {
451
- const KEY = Symbol('__ts_obs');
452
- if (window[KEY]) return 'already attached';
453
- const IGNORE = /timer|countdown|elapsed|clock|animation|animate|transition|spinner|progress|loading|toast|notification|alert|banner|snackbar|cookie|consent|modal|overlay|popup|tooltip|badge|tag|avatar|icon|svg|img|video|audio|canvas|font|stylesheet|script|meta|link|head/i;
454
- let timer = null;
455
- let lastText = '';
456
- function getText() {
457
- const els = document.querySelectorAll('input[type="radio"],input[type="checkbox"],input[type="text"],input[type="email"],input[type="password"],textarea,label,button');
458
- return [...els].map(e => {
459
- const t = (e.innerText || e.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
460
- return e.type + '|' + t + '|' + (e.checked || false);
461
- }).join('\\n');
462
- }
463
- function check() {
464
- try {
465
- const curr = getText();
466
- if (curr === lastText) return;
467
- const prev = lastText;
468
- lastText = curr;
469
- const prevGroups = prev ? prev.split('\\n').filter(Boolean) : [];
470
- const currGroups = curr.split('\\n').filter(Boolean);
471
- const prevSet = new Set(prevGroups);
472
- const currSet = new Set(currGroups);
473
- let removed = 0, changed = 0;
474
- for (const g of prevGroups) {
475
- if (!currSet.has(g)) {
476
- if (prevGroups.indexOf(g) < currGroups.length) changed++;
477
- else removed++;
478
- }
479
- }
480
- const added = currGroups.filter(g => !prevSet.has(g)).length;
481
- const score = added * 10 + removed * 10 + changed * 8;
482
- if (score >= 15) {
483
- window[KEY].changed = Date.now();
484
- }
485
- } catch {}
486
- }
487
- const obs = new MutationObserver((mutations) => {
488
- let hasRelevant = false;
489
- for (const m of mutations) {
490
- if (m.target instanceof Element) {
491
- const tag = m.target.tagName;
492
- if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'HEAD') continue;
493
- const cls = m.target.className || '';
494
- if (typeof cls === 'string' && IGNORE.test(cls)) continue;
495
- const id = m.target.id || '';
496
- if (IGNORE.test(id)) continue;
497
- if (m.type === 'characterData' && m.target.parentElement) {
498
- const ptag = m.target.parentElement.tagName;
499
- if (ptag === 'SCRIPT' || ptag === 'STYLE') continue;
500
- }
501
- }
502
- hasRelevant = true;
503
- }
504
- if (hasRelevant) {
505
- clearTimeout(timer);
506
- timer = setTimeout(check, 500);
507
- }
508
- });
509
- obs.observe(document.body || document.documentElement, {
510
- childList: true, subtree: true, characterData: true
511
- });
512
- window[KEY] = { observer: obs, changed: 0 };
513
- lastText = getText();
514
- return 'ok';
515
- })()
516
- `);
517
- console.log('[browse] MutationObserver injected (stealth, debounced, filtered)');
518
- }
519
- catch (err) {
520
- console.error(`[browse] MutationObserver injection failed: ${(0, config_1.msg)(err)}`);
521
- }
540
+ // Enable Page + install per-tab instrumentation. Re-run on every (re)attach
541
+ // so a tab switch/close leaves the loop fully instrumented.
542
+ await instrumentPage(page);
522
543
  // Agent loop
523
544
  let actionCount = 0;
524
545
  let llmCallCount = 0;
@@ -527,7 +548,84 @@ async function cmdBrowse(flags) {
527
548
  let lastScreenshot = null;
528
549
  let lastFingerprint = null;
529
550
  let codeConfirmedOptimal = false;
551
+ /** A 401/403 from the model API: terminal, retrying cannot succeed. */
552
+ let fatalAuthError = false;
530
553
  const maxRetries = 5;
554
+ /** How often to re-check which tab is active (ms). */
555
+ const TAB_CHECK_MS = 2000;
556
+ let lastTabCheck = 0;
557
+ let consecutiveScreenshotFailures = 0;
558
+ let reattachFailures = 0;
559
+ // Latency observability: rolling average of vision-call latency plus
560
+ // abort/timeout counts, so the operator can see how slow the model is.
561
+ const llmStats = { calls: 0, ok: 0, screenAborts: 0, timeouts: 0, totalMs: 0 };
562
+ const logLlmStats = () => {
563
+ if (llmStats.calls === 0 || llmStats.calls % 5 !== 0)
564
+ return;
565
+ const avg = Math.round(llmStats.totalMs / llmStats.calls / 1000);
566
+ console.log(`[browse] LLM latency: avg ${avg}s over ${llmStats.calls} call(s) — ${llmStats.ok} ok, ${llmStats.screenAborts} screen-abort, ${llmStats.timeouts} timeout`);
567
+ };
568
+ /**
569
+ * (Re)attach to a tab. A closed or switched tab used to leave the loop
570
+ * hammering a dead target ("CDP timeout: Page.captureScreenshot") forever;
571
+ * now we detect it and follow the tab the human is actually on.
572
+ */
573
+ async function reattach(reason, preferTargetId) {
574
+ console.log(`[browse] ${reason} - re-attaching...`);
575
+ try {
576
+ page.ws.close();
577
+ }
578
+ catch {
579
+ /* already closed */
580
+ }
581
+ page = await connectToPage(port, preferTargetId);
582
+ await instrumentPage(page);
583
+ // A different tab means different content: reset change trackers.
584
+ lastFingerprint = null;
585
+ lastScreenshot = null;
586
+ codeConfirmedOptimal = false;
587
+ consecutiveFailures = 0;
588
+ consecutiveScreenshotFailures = 0;
589
+ console.log(`[browse] Re-attached to tab: ${page.title} (${page.targetId.slice(0, 8)})`);
590
+ }
591
+ /**
592
+ * Throttled liveness check: is the bound tab still alive and the active one?
593
+ * Follows the human's active tab (and recovers after it is closed). `force`
594
+ * skips the throttle (used from the screenshot-failure path).
595
+ */
596
+ async function refreshTabIfNeeded(force = false) {
597
+ if (!force && Date.now() - lastTabCheck < TAB_CHECK_MS)
598
+ return false;
599
+ lastTabCheck = Date.now();
600
+ try {
601
+ if (page.disconnected) {
602
+ // The bound tab is gone: move straight to whatever tab is active now.
603
+ const active = await (0, cdp_1.pickActiveTab)(port).catch(() => null);
604
+ await reattach('Bound tab closed', active?.id);
605
+ reattachFailures = 0;
606
+ return true;
607
+ }
608
+ const active = await (0, cdp_1.pickActiveTab)(port, page.targetId);
609
+ if (!active)
610
+ return false;
611
+ if (active.id !== page.targetId) {
612
+ await reattach(`Active tab changed to "${active.title}"`, active.id);
613
+ reattachFailures = 0;
614
+ return true;
615
+ }
616
+ return false;
617
+ }
618
+ catch (err) {
619
+ reattachFailures++;
620
+ console.error(`[browse] Re-attach attempt ${reattachFailures} failed: ${(0, config_1.msg)(err)}`);
621
+ if (reattachFailures >= 6) {
622
+ console.error('[browse] Could not re-attach to any tab - stopping.');
623
+ stopped = true;
624
+ process.exitCode = 1;
625
+ }
626
+ return false;
627
+ }
628
+ }
531
629
  process.on('SIGINT', () => {
532
630
  console.log('\n[browse] Stopped by user');
533
631
  stopped = true;
@@ -536,17 +634,26 @@ async function cmdBrowse(flags) {
536
634
  console.log(`[browse] Agent started. ${continuous ? 'Running until Ctrl+C.' : `Max ${maxActions} actions.`} Press Ctrl+C to stop.\n`);
537
635
  while (!stopped && actionCount < maxActions) {
538
636
  try {
637
+ // Follow the human's active tab: re-attach if the bound tab was closed or
638
+ // the user switched to another one.
639
+ await refreshTabIfNeeded();
539
640
  // Take screenshot and extract elements in parallel
540
641
  const [screenshot, elements, pageContext] = await Promise.all([
541
- captureScreenshot(page, quality),
642
+ captureScreenshot(page, quality, SCREENSHOT_MAX_EDGE),
542
643
  extractElements(page),
543
644
  extractPageContext(page),
544
645
  ]);
545
646
  if (!screenshot) {
546
- console.error('[browse] Failed to capture screenshot, retrying...');
647
+ consecutiveScreenshotFailures++;
648
+ console.error(`[browse] Failed to capture screenshot (${consecutiveScreenshotFailures}), retrying...`);
649
+ // Two failures in a row generally means the target is gone.
650
+ if (consecutiveScreenshotFailures >= 2) {
651
+ await refreshTabIfNeeded(true);
652
+ }
547
653
  await sleep(intervalMs);
548
654
  continue;
549
655
  }
656
+ consecutiveScreenshotFailures = 0;
550
657
  // Detect screen change via question fingerprint comparison
551
658
  // Threshold 15: requires ≥1 new/removed question (10) + text change (8) = 18,
552
659
  // or 2 text changes (16). Score of 5 (single option change) is too sensitive.
@@ -566,28 +673,61 @@ async function cmdBrowse(flags) {
566
673
  const actionLabel = continuous ? `${actionCount + 1}/∞` : `${actionCount + 1}/${maxActions}`;
567
674
  console.log(`[browse] [${actionLabel}] Analyzing screenshot...`);
568
675
  let result = null;
676
+ /** The page changed under the LLM: re-analyze immediately, no backoff. */
677
+ let screenChangedDuringLlm = false;
569
678
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
570
679
  result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, attempt, maxRetries, page, lastFingerprint);
571
680
  llmCallCount++;
681
+ llmStats.calls++;
682
+ llmStats.totalMs += result.durationMs;
572
683
  if (llmCallCount > 50 && llmCallCount % 25 === 0) {
573
684
  console.log(`[browse] Warning: ${llmCallCount} LLM calls made — may hit rate limits`);
574
685
  }
575
- if (result.actions.length > 0)
686
+ if (result.actions.length > 0) {
687
+ llmStats.ok++;
688
+ logLlmStats();
576
689
  break; // success
577
- // Timeout → exponential backoff, keep retrying (never count as failure)
690
+ }
691
+ // Auth failure is terminal: retrying cannot help.
692
+ if (result.error === 'auth_error') {
693
+ fatalAuthError = true;
694
+ break;
695
+ }
696
+ // The screen changed while the LLM was thinking: its answer is stale.
697
+ // Bail out of the retry loop and re-analyze the NEW screen right away
698
+ // (speed matters: a 30s backoff here livelocked on dynamic pages).
699
+ if (result.error === 'screen_changed') {
700
+ screenChangedDuringLlm = true;
701
+ llmStats.screenAborts++;
702
+ logLlmStats();
703
+ break;
704
+ }
705
+ // Genuine timeout → SHORT backoff and retry (was 30/60/120/240/300s).
578
706
  if (result.error === 'timeout') {
579
- const backoff = Math.min(30 * Math.pow(2, attempt - 1), 300); // 30, 60, 120, 240, 300
707
+ llmStats.timeouts++;
708
+ logLlmStats();
709
+ const backoff = Math.min(3 * attempt, 12); // 3, 6, 9, 12, 12
580
710
  console.log(`[browse] LLM timed out — retrying in ${backoff}s (attempt ${attempt}/${maxRetries})...`);
581
711
  await sleep(backoff * 1000);
582
712
  continue;
583
713
  }
584
- // Other errors → short backoff
714
+ // Other errors → very short backoff
585
715
  if (attempt < maxRetries) {
586
- const backoff = attempt === 1 ? 3 : 5;
716
+ const backoff = 2;
587
717
  console.log(`[browse] ${result.error === 'empty' ? 'Empty response' : 'Parse failed'} (attempt ${attempt}/${maxRetries}), retrying in ${backoff}s...`);
588
718
  await sleep(backoff * 1000);
589
719
  }
590
720
  }
721
+ if (fatalAuthError) {
722
+ console.error('[browse] Stopping: the model rejected the API key. Set a valid provider key on the run form (or the agent) and re-run.');
723
+ stopped = true;
724
+ process.exitCode = 1;
725
+ break;
726
+ }
727
+ if (screenChangedDuringLlm) {
728
+ // No sleep: go straight back to the top, re-screenshot + re-analyze.
729
+ continue;
730
+ }
591
731
  if (!result || result.actions.length === 0) {
592
732
  consecutiveFailures++;
593
733
  console.log(`[browse] No valid response after ${maxRetries} attempts (${consecutiveFailures} consecutive failures)`);
@@ -884,11 +1024,11 @@ Do NOT simplify variable names or change the function signature. Only optimize t
884
1024
  * Connect directly to a page's WebSocket from /json/list.
885
1025
  * This avoids the Target.attachToTarget complexity.
886
1026
  */
887
- async function connectToPage(port) {
1027
+ async function connectToPage(port, preferTargetId) {
888
1028
  // Shared picker (cdp.ts): prefers a real page, accepts about:blank/new-tab,
889
1029
  // and opens a blank tab if none exists - so a Chrome launched on the NTP is
890
1030
  // still attachable. (Was a local chrome:// filter that could never match.)
891
- const pageTarget = await (0, cdp_1.resolveAttachablePage)(port);
1031
+ const pageTarget = await (0, cdp_1.resolveAttachablePage)(port, preferTargetId);
892
1032
  const wsUrl = pageTarget.webSocketDebuggerUrl;
893
1033
  return new Promise((resolve, reject) => {
894
1034
  const ws = new ws_1.default(wsUrl);
@@ -945,6 +1085,8 @@ async function connectToPage(port) {
945
1085
  }
946
1086
  });
947
1087
  ws.on('close', () => {
1088
+ // The endpoint is gone: flag it so the agent loop can re-attach.
1089
+ page.disconnected = true;
948
1090
  // Reject all pending
949
1091
  for (const [id, p] of page.pending) {
950
1092
  p.reject(new Error('WebSocket closed'));
@@ -1009,9 +1151,32 @@ function cdpSend(page, method, params) {
1009
1151
  page.ws.send(JSON.stringify({ id, method, params }));
1010
1152
  });
1011
1153
  }
1012
- async function captureScreenshot(page, quality) {
1154
+ /**
1155
+ * Capture the visible viewport as JPEG. When the viewport is larger than
1156
+ * `maxEdge` on either side the capture is scaled down (`clip.scale`) so the
1157
+ * vision call stays fast: a big page produced 100KB+ images that exceeded the
1158
+ * model timeout. Clicking is unaffected — actions use element coordinates
1159
+ * (CSS px), not image pixels.
1160
+ */
1161
+ async function captureScreenshot(page, quality, maxEdge = 0) {
1013
1162
  try {
1014
- const result = await cdpSend(page, 'Page.captureScreenshot', { format: 'jpeg', quality });
1163
+ const params = { format: 'jpeg', quality };
1164
+ if (maxEdge > 0) {
1165
+ try {
1166
+ const metrics = await cdpSend(page, 'Page.getLayoutMetrics', {});
1167
+ const vp = metrics?.cssLayoutViewport ?? metrics?.layoutViewport;
1168
+ const w = Number(vp?.clientWidth ?? 0);
1169
+ const h = Number(vp?.clientHeight ?? 0);
1170
+ if (w > 0 && h > 0 && (w > maxEdge || h > maxEdge)) {
1171
+ const scale = Math.min(maxEdge / w, maxEdge / h);
1172
+ params.clip = { x: 0, y: 0, width: w, height: h, scale };
1173
+ }
1174
+ }
1175
+ catch {
1176
+ /* metrics unavailable - capture unscaled */
1177
+ }
1178
+ }
1179
+ const result = await cdpSend(page, 'Page.captureScreenshot', params);
1015
1180
  return result?.data ?? null;
1016
1181
  }
1017
1182
  catch (err) {
@@ -2349,11 +2514,19 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2349
2514
  },
2350
2515
  ],
2351
2516
  temperature: 0.1,
2352
- max_tokens: 16384,
2517
+ max_tokens: 2048,
2353
2518
  stream: false,
2354
2519
  };
2355
2520
  const controller = new AbortController();
2356
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
2521
+ // Distinguish a genuine timeout from the heartbeat cancelling a stale request
2522
+ // because the page changed: they need opposite handling (backoff vs re-analyze
2523
+ // now). The old code reported both as "timed out (300s)" and backed off 30s+,
2524
+ // which livelocked on dynamic pages.
2525
+ let abortReason = null;
2526
+ const timeout = setTimeout(() => {
2527
+ abortReason = 'timeout';
2528
+ controller.abort();
2529
+ }, timeoutMs);
2357
2530
  // Heartbeat: check for screen changes every 5s, abort LLM if page changed
2358
2531
  const heartbeat = setInterval(async () => {
2359
2532
  const elapsed = Math.round((Date.now() - startTime) / 1000);
@@ -2399,6 +2572,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2399
2572
  const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
2400
2573
  if (changeScore >= 15) {
2401
2574
  console.log(`[browse] Fingerprint confirms change (score: ${changeScore}) — aborting LLM (${elapsed}s)`);
2575
+ abortReason = 'screen_change';
2402
2576
  controller.abort();
2403
2577
  return;
2404
2578
  }
@@ -2427,6 +2601,14 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2427
2601
  if (!res.ok) {
2428
2602
  const errText = await res.text().catch(() => '');
2429
2603
  console.error(`[browse] LLM HTTP ${res.status}: ${errText.slice(0, 200)}`);
2604
+ // 401/403 will never succeed on retry — surface a clear, actionable
2605
+ // diagnosis instead of burning 5 attempts x N rounds.
2606
+ if (res.status === 401 || res.status === 403) {
2607
+ console.error(`[browse] Model auth failed for ${baseUrl} (model ${model}). The API key is invalid, ` +
2608
+ `expired, or belongs to a different provider. Fix the provider key on the run form ` +
2609
+ `(or the agent's provider key) and re-run.`);
2610
+ return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'auth_error' };
2611
+ }
2430
2612
  return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'http_error' };
2431
2613
  }
2432
2614
  const json = (await res.json());
@@ -2457,6 +2639,13 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2457
2639
  }
2458
2640
  catch (err) {
2459
2641
  if (err instanceof Error && err.name === 'AbortError') {
2642
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
2643
+ if (abortReason === 'screen_change') {
2644
+ // The page changed while we were waiting: the answer is already stale.
2645
+ // Signal the caller to re-analyze right away (no backoff).
2646
+ console.log(`[browse] LLM aborted at ${elapsed}s — screen changed, re-analyzing now`);
2647
+ return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'screen_changed' };
2648
+ }
2460
2649
  console.error(`[browse] LLM request timed out (${Math.round(timeoutMs / 1000)}s)`);
2461
2650
  return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'timeout' };
2462
2651
  }
@@ -2488,11 +2677,15 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
2488
2677
  },
2489
2678
  ],
2490
2679
  temperature: 0.1,
2491
- max_tokens: 16384,
2680
+ max_tokens: 2048,
2492
2681
  stream: false,
2493
2682
  };
2494
2683
  const controller = new AbortController();
2495
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
2684
+ let optAbortReason = null;
2685
+ const timeout = setTimeout(() => {
2686
+ optAbortReason = 'timeout';
2687
+ controller.abort();
2688
+ }, timeoutMs);
2496
2689
  // Heartbeat: check for screen changes every 5s, abort if page changed
2497
2690
  const heartbeat = setInterval(async () => {
2498
2691
  const elapsed = Math.round((Date.now() - startTime) / 1000);
@@ -2509,6 +2702,7 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
2509
2702
  const flagTs = flagResult?.result?.value;
2510
2703
  if (flagTs && flagTs > startTime) {
2511
2704
  console.log(`[browse] DOM mutation detected in opt pass (${elapsed}s) — aborting`);
2705
+ optAbortReason = 'screen_change';
2512
2706
  controller.abort();
2513
2707
  return;
2514
2708
  }
@@ -2552,7 +2746,12 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
2552
2746
  }
2553
2747
  catch (err) {
2554
2748
  if (err instanceof Error && err.name === 'AbortError') {
2555
- return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'timeout' };
2749
+ return {
2750
+ actions: [],
2751
+ attempt,
2752
+ durationMs: Date.now() - startTime,
2753
+ error: optAbortReason === 'screen_change' ? 'screen_changed' : 'timeout',
2754
+ };
2556
2755
  }
2557
2756
  return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'http_error' };
2558
2757
  }