teamshare-bridge 0.21.28 → 0.21.30

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.
@@ -61,6 +61,7 @@ const launch_1 = require("../../lib/browser/launch");
61
61
  const session_1 = require("../../lib/browser/session");
62
62
  const personal_fields_1 = require("../../lib/browser/personal-fields");
63
63
  const human_input_guard_1 = require("../../lib/browser/human-input-guard");
64
+ const screen_change_1 = require("../../lib/browser/screen-change");
64
65
  /** Create a fingerprint from the current element list. */
65
66
  function createFingerprint(elements) {
66
67
  const groups = new Map();
@@ -116,12 +117,51 @@ function calculateChangeScore(prev, curr) {
116
117
  }
117
118
  return score;
118
119
  }
120
+ /**
121
+ * True when the QUESTION SET changed - a new/removed question, different
122
+ * question text, or different option text. This is the signal to abort a stale
123
+ * LLM call / discard its answer. It deliberately ignores checked-state-only
124
+ * changes (a human answering) and low-level noise (timers, animations) because
125
+ * those never alter question text or the group set - so a countdown can't
126
+ * trigger a false "screen changed".
127
+ */
128
+ function questionSetChanged(prev, curr) {
129
+ const prevMap = new Map(prev.map((fp) => [fp.groupId, fp]));
130
+ const currMap = new Map(curr.map((fp) => [fp.groupId, fp]));
131
+ for (const gid of currMap.keys())
132
+ if (!prevMap.has(gid))
133
+ return true;
134
+ for (const gid of prevMap.keys())
135
+ if (!currMap.has(gid))
136
+ return true;
137
+ for (const [gid, c] of currMap) {
138
+ const p = prevMap.get(gid);
139
+ if (!p)
140
+ continue;
141
+ if (p.questionText !== c.questionText)
142
+ return true;
143
+ if (p.options.join('|') !== c.options.join('|'))
144
+ return true;
145
+ }
146
+ return false;
147
+ }
119
148
  /** Fast path (easy objective questions): small reasoning budget. */
120
149
  const FAST_MAX_TOKENS = 2048;
121
150
  /** Deep path (hard coding questions): enough tokens to emit a full solution. */
122
151
  const DEEP_MAX_TOKENS = 16384;
123
- /** Attempt-1 ceiling: abandon a slow easy answer and escalate to the deep path. */
124
- const FAST_ATTEMPT_TIMEOUT_MS = 45_000;
152
+ /** Attempt-1 ceiling: most calls finish in 28-60s, so 120s avoids escalating. */
153
+ const FAST_ATTEMPT_TIMEOUT_MS = 120_000;
154
+ /**
155
+ * Streaming watchdogs. `firstToken` = nothing arrived at all (a silently
156
+ * stalled request); `idle` = the stream went quiet mid-answer. Tuned to match
157
+ * the attempt budget so a slow-but-working call is never killed early.
158
+ */
159
+ const FAST_FIRST_TOKEN_TIMEOUT_MS = 60_000;
160
+ const FAST_IDLE_TIMEOUT_MS = 60_000;
161
+ const DEEP_FIRST_TOKEN_TIMEOUT_MS = 90_000;
162
+ const DEEP_IDLE_TIMEOUT_MS = 60_000;
163
+ /** Pause after an answering action, so the human can see/navigate (ms). */
164
+ const ANSWER_PAUSE_MS = 6_000;
125
165
  /**
126
166
  * Rule 0 is swapped per attempt: the fast path forbids long deliberation, the
127
167
  * deep path (entered when the fast attempt fails) explicitly allows it so hard
@@ -316,90 +356,55 @@ async function instrumentPage(page) {
316
356
  `);
317
357
  }
318
358
  catch { /* non-critical */ }
319
- // Register CDP event: Page.frameNavigated (URL/navigation changes)
320
- onCdpEvent(page, 'Page.frameNavigated', () => {
321
- page._cdpNavigated = Date.now();
322
- });
323
- // Inject MutationObserver: watches DOM for significant content changes
324
- // Uses same fingerprint comparison as Node.js side to prevent false positives.
325
- // Filters out: timers, animations, scripts, styles, notifications, toasts.
326
- // Debounces mutations (500ms) to batch rapid changes.
327
- // Stealth: uses Symbol-keyed property (invisible to Object.keys / anti-bot scans).
359
+ // Register CDP event: Page.frameNavigated (URL/navigation changes). Bind
360
+ // ONCE per page object - instrumentPage also runs on re-attach.
361
+ //
362
+ // The MutationObserver is DOCUMENT-scoped: a full navigation wipes it. It used
363
+ // to be injected only here (attach time, frequently on about:blank), so after
364
+ // the human navigated to the real page the heartbeat's DOM-change path was
365
+ // dead for the rest of the run - a question swap during a 50-120s vision call
366
+ // went undetected and its stale answer was applied to the next screen. We now
367
+ // re-inject whenever the MAIN frame finishes navigating; subframe/iframe
368
+ // navigations (ads, widgets) are ignored.
369
+ if (!page._frameNavBound) {
370
+ page._frameNavBound = true;
371
+ onCdpEvent(page, 'Page.frameNavigated', (params) => {
372
+ if (!(0, screen_change_1.isMainFrameNavigation)(params))
373
+ return;
374
+ page._cdpNavigated = Date.now();
375
+ scheduleObserverReinject(page);
376
+ });
377
+ }
378
+ await reinjectObserver(page);
379
+ }
380
+ /**
381
+ * Re-inject the document-scoped MutationObserver into the page's CURRENT
382
+ * document. Idempotent: the injected script early-returns when its Symbol key
383
+ * already exists. Must run again after every (main-frame) navigation.
384
+ */
385
+ async function reinjectObserver(page) {
328
386
  try {
329
- await cdpEval(page, `
330
- (() => {
331
- const KEY = Symbol('__ts_obs');
332
- if (window[KEY]) return 'already attached';
333
- 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;
334
- let timer = null;
335
- let lastText = '';
336
- function getText() {
337
- const els = document.querySelectorAll('input[type="radio"],input[type="checkbox"],input[type="text"],input[type="email"],input[type="password"],textarea,label,button');
338
- return [...els].map(e => {
339
- const t = (e.innerText || e.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
340
- return e.type + '|' + t + '|' + (e.checked || false);
341
- }).join('\\n');
342
- }
343
- function check() {
344
- try {
345
- const curr = getText();
346
- if (curr === lastText) return;
347
- const prev = lastText;
348
- lastText = curr;
349
- const prevGroups = prev ? prev.split('\\n').filter(Boolean) : [];
350
- const currGroups = curr.split('\\n').filter(Boolean);
351
- const prevSet = new Set(prevGroups);
352
- const currSet = new Set(currGroups);
353
- let removed = 0, changed = 0;
354
- for (const g of prevGroups) {
355
- if (!currSet.has(g)) {
356
- if (prevGroups.indexOf(g) < currGroups.length) changed++;
357
- else removed++;
358
- }
359
- }
360
- const added = currGroups.filter(g => !prevSet.has(g)).length;
361
- const score = added * 10 + removed * 10 + changed * 8;
362
- if (score >= 15) {
363
- window[KEY].changed = Date.now();
364
- }
365
- } catch {}
366
- }
367
- const obs = new MutationObserver((mutations) => {
368
- let hasRelevant = false;
369
- for (const m of mutations) {
370
- if (m.target instanceof Element) {
371
- const tag = m.target.tagName;
372
- if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'HEAD') continue;
373
- const cls = m.target.className || '';
374
- if (typeof cls === 'string' && IGNORE.test(cls)) continue;
375
- const id = m.target.id || '';
376
- if (IGNORE.test(id)) continue;
377
- if (m.type === 'characterData' && m.target.parentElement) {
378
- const ptag = m.target.parentElement.tagName;
379
- if (ptag === 'SCRIPT' || ptag === 'STYLE') continue;
380
- }
381
- }
382
- hasRelevant = true;
383
- }
384
- if (hasRelevant) {
385
- clearTimeout(timer);
386
- timer = setTimeout(check, 500);
387
- }
388
- });
389
- obs.observe(document.body || document.documentElement, {
390
- childList: true, subtree: true, characterData: true
391
- });
392
- window[KEY] = { observer: obs, changed: 0 };
393
- lastText = getText();
394
- return 'ok';
395
- })()
396
- `);
387
+ await cdpEval(page, (0, screen_change_1.buildObserverScript)());
397
388
  console.log('[browse] MutationObserver injected (stealth, debounced, filtered)');
398
389
  }
399
390
  catch (err) {
400
391
  console.error(`[browse] MutationObserver injection failed: ${(0, config_1.msg)(err)}`);
401
392
  }
402
393
  }
394
+ /**
395
+ * Debounced observer re-inject after a main-frame navigation: `frameNavigated`
396
+ * can fire before the new document is ready, and rapid redirects would
397
+ * otherwise inject several times.
398
+ */
399
+ function scheduleObserverReinject(page) {
400
+ if (page._reinjectTimer)
401
+ clearTimeout(page._reinjectTimer);
402
+ page._reinjectTimer = setTimeout(() => {
403
+ page._reinjectTimer = undefined;
404
+ if (!page.disconnected)
405
+ void reinjectObserver(page);
406
+ }, 600);
407
+ }
403
408
  async function cmdBrowse(flags) {
404
409
  const autoAnswer = flags.get('auto-answer') === 'true';
405
410
  if (!autoAnswer) {
@@ -464,16 +469,26 @@ async function cmdBrowse(flags) {
464
469
  // the fast retry below abandons a slow easy answer at ~45s and escalates.
465
470
  const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '300', 10), 30), 600);
466
471
  const timeoutMs = timeoutSec * 1000;
467
- const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '70', 10), 50), 100);
472
+ const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '82', 10), 50), 100);
468
473
  /**
469
- * Downscale the FAST-path screenshot (attempt 1) to keep easy questions
470
- * quick. The deep retry re-captures at full resolution so code/text stays
471
- * legible. Clicks are unaffected either way (actions use element coords).
474
+ * Screenshot detail: the viewport is captured at native resolution and only
475
+ * scaled down when BOTH sides exceed this many px. Default 1600 keeps
476
+ * question/option text crisp for the vision model; pass `--max-edge 0` for
477
+ * native full resolution, or a smaller value for speed. Clicks are unaffected
478
+ * (actions use element coordinates, not image pixels).
472
479
  */
473
- const SCREENSHOT_MAX_EDGE = 1024;
480
+ const maxEdgeRaw = parseInt(flags.get('max-edge') ?? '1600', 10);
481
+ const SCREENSHOT_MAX_EDGE = Number.isFinite(maxEdgeRaw)
482
+ ? Math.max(0, Math.min(maxEdgeRaw, 4096))
483
+ : 1600;
474
484
  const maxConsecutiveFailures = parseInt(flags.get('max-consecutive-failures') ?? '0', 10); // 0 = unlimited
475
485
  const charDelayMs = Math.min(Math.max(parseInt(flags.get('char-delay') ?? '150', 10), 5), 200);
476
486
  const optimize = flags.has('optimize');
487
+ /**
488
+ * Redundancy pre-check off by default: it costs an EXTRA LLM round-trip per
489
+ * action (a big latency tax). Opt in with --verify-actions.
490
+ */
491
+ const verifyActions = flags.has('verify-actions');
477
492
  const stealth = flags.has('stealth');
478
493
  const hybrid = flags.has('hybrid');
479
494
  const autoLaunch = flags.has('auto-launch');
@@ -577,19 +592,28 @@ async function cmdBrowse(flags) {
577
592
  /** A 401/403 from the model API: terminal, retrying cannot succeed. */
578
593
  let fatalAuthError = false;
579
594
  const maxRetries = 5;
595
+ /**
596
+ * Click health. Some custom widgets ignore synthetic CDP mouse events; we
597
+ * detect "no visible change" and retry with a real DOM click, then cool the
598
+ * element down (it is still retried on a later pass).
599
+ */
600
+ const clickFailures = new Map();
601
+ const clickCooldown = new Map();
602
+ /** "done but unanswered" verification retries for the current screen. */
603
+ let doneRetries = 0;
580
604
  /** How often to re-check which tab is active (ms). */
581
605
  const TAB_CHECK_MS = 2000;
582
606
  let lastTabCheck = 0;
583
607
  let consecutiveScreenshotFailures = 0;
584
608
  let reattachFailures = 0;
585
609
  // Latency observability: rolling average of vision-call latency plus
586
- // abort/timeout counts, so the operator can see how slow the model is.
587
- const llmStats = { calls: 0, ok: 0, screenAborts: 0, timeouts: 0, totalMs: 0 };
610
+ // abort/timeout/stall counts, so the operator can see how slow the model is.
611
+ const llmStats = { calls: 0, ok: 0, screenAborts: 0, timeouts: 0, stalls: 0, totalMs: 0 };
588
612
  const logLlmStats = () => {
589
613
  if (llmStats.calls === 0 || llmStats.calls % 5 !== 0)
590
614
  return;
591
615
  const avg = Math.round(llmStats.totalMs / llmStats.calls / 1000);
592
- console.log(`[browse] LLM latency: avg ${avg}s over ${llmStats.calls} call(s) — ${llmStats.ok} ok, ${llmStats.screenAborts} screen-abort, ${llmStats.timeouts} timeout`);
616
+ console.log(`[browse] LLM latency: avg ${avg}s over ${llmStats.calls} call(s) — ${llmStats.ok} ok, ${llmStats.screenAborts} screen-abort, ${llmStats.timeouts} timeout, ${llmStats.stalls} stall`);
593
617
  };
594
618
  /**
595
619
  * (Re)attach to a tab. A closed or switched tab used to leave the loop
@@ -686,10 +710,14 @@ async function cmdBrowse(flags) {
686
710
  const currentFingerprint = createFingerprint(elements);
687
711
  if (lastFingerprint) {
688
712
  const changeScore = calculateChangeScore(lastFingerprint, currentFingerprint);
689
- if (changeScore >= 15) {
713
+ if (questionSetChanged(lastFingerprint, currentFingerprint) || changeScore >= 15) {
690
714
  console.log(`[browse] Screen changed (score: ${changeScore}) — resetting state`);
691
715
  codeConfirmedOptimal = false;
692
716
  consecutiveFailures = 0;
717
+ // A new screen resets per-screen bookkeeping.
718
+ doneRetries = 0;
719
+ clickFailures.clear();
720
+ clickCooldown.clear();
693
721
  }
694
722
  }
695
723
  lastFingerprint = currentFingerprint;
@@ -702,20 +730,17 @@ async function cmdBrowse(flags) {
702
730
  /** The page changed under the LLM: re-analyze immediately, no backoff. */
703
731
  let screenChangedDuringLlm = false;
704
732
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
705
- // Fast-first ladder: attempt 1 uses a small budget + downscaled shot so
706
- // easy objective questions answer quickly. If it fails, attempts 2+
707
- // switch to the DEEP budget (full timeout + tokens, full-res capture)
708
- // for hard coding questions. `max_tokens`/timeout are ceilings, so the
733
+ // Fast-first ladder: attempt 1 uses a small token budget; attempts 2+
734
+ // switch to the DEEP budget (full timeout + tokens) for hard coding
735
+ // questions. Both use the SAME full-detail screenshot (maxEdge/quality)
736
+ // so text is always legible; `max_tokens`/timeout are ceilings, so the
709
737
  // deep path never slows a question that answers on the fast path.
710
738
  const deep = attempt > 1;
711
739
  const attemptTimeoutMs = deep ? timeoutMs : Math.min(FAST_ATTEMPT_TIMEOUT_MS, timeoutMs);
712
740
  if (deep && attempt === 2) {
713
741
  console.log('[browse] Fast attempt did not answer — escalating to the deep budget (more time + tokens)');
714
742
  }
715
- const attemptShot = deep
716
- ? ((await captureScreenshot(page, quality)) ?? screenshot)
717
- : screenshot;
718
- result = await analyzeScreenshot(baseUrl, apiKey, model, attemptShot, elements, pageContext, attemptTimeoutMs, attempt, maxRetries, page, lastFingerprint, { maxTokens: deep ? DEEP_MAX_TOKENS : FAST_MAX_TOKENS, deep });
743
+ result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, attemptTimeoutMs, attempt, maxRetries, page, lastFingerprint, { maxTokens: deep ? DEEP_MAX_TOKENS : FAST_MAX_TOKENS, deep });
719
744
  llmCallCount++;
720
745
  llmStats.calls++;
721
746
  llmStats.totalMs += result.durationMs;
@@ -793,8 +818,12 @@ async function cmdBrowse(flags) {
793
818
  const freshFingerprint = createFingerprint(freshElements);
794
819
  if (lastFingerprint) {
795
820
  const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
796
- if (changeScore >= 15) {
797
- console.log(`[browse] Screen changed during LLM call (score: ${changeScore}) skipping actions, re-analyzing`);
821
+ // Discard a (costly) LLM answer when the QUESTION SET changed under
822
+ // it (its answer is stale), or on a material whole-screen change.
823
+ // A pure question-text swap scores only 8 on the generic scale, so
824
+ // the targeted check catches what the score alone missed.
825
+ if (questionSetChanged(lastFingerprint, freshFingerprint) || changeScore >= 40) {
826
+ console.log(`[browse] Question set changed during LLM call (score: ${changeScore}) — skipping actions, re-analyzing`);
798
827
  lastFingerprint = freshFingerprint;
799
828
  lastScreenshot = freshScreenshot;
800
829
  await sleep(intervalMs);
@@ -815,28 +844,33 @@ async function cmdBrowse(flags) {
815
844
  let wroteCode = false;
816
845
  for (const act of result.actions) {
817
846
  if (act.action === 'done') {
818
- // Validate: are there actually unanswered questions?
819
- if (hasUnansweredQuestions(elements)) {
820
- console.log(`[browse] LLM says done but unanswered questions remain retrying...`);
821
- // Force retry with stronger prompt
822
- result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, 1, 1, page, lastFingerprint, { maxTokens: DEEP_MAX_TOKENS, deep: true });
823
- if (result.actions.length > 0 && result.actions[0].action !== 'done') {
824
- // LLM returned actual actions re-process from the start of this loop
825
- executedAny = false;
826
- wroteCode = false;
827
- // Fall through to process new actions below
828
- }
829
- else {
830
- // LLM still says done trust it this time and continue
831
- console.log(`[browse] LLM confirms done after verification`);
832
- if (continuous && !executedAny) {
833
- const changed = await waitForScreenChange(page, lastScreenshot ?? screenshot, intervalMs, quality, () => stopped);
834
- if (!changed)
835
- break;
836
- executedAny = false;
847
+ // Validate: are there actually unanswered questions? (The heuristic is
848
+ // authoritative — a "done" while questions are open means the earlier
849
+ // clicks did not register, NOT that the gate is wrong.)
850
+ if (hasUnansweredQuestions(elements) && doneRetries < 1) {
851
+ doneRetries++;
852
+ console.log(`[browse] LLM says done but questions remain unanswered — asking once more`);
853
+ const followUp = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, 1, 1, page, lastFingerprint,
854
+ // Fast budget: this is a short "act now" nudge, not a hard problem.
855
+ { maxTokens: FAST_MAX_TOKENS, deep: false });
856
+ const followActions = followUp.actions.filter((a) => a.action !== 'done');
857
+ if (followActions.length > 0) {
858
+ // Execute the follow-up actions here — the old code reassigned
859
+ // `result` inside the for-of, so these were silently discarded and
860
+ // the loop spun on the same unanswered question forever.
861
+ console.log(`[browse] Follow-up: executing ${followActions.length} action(s)`);
862
+ for (const fa of followActions) {
863
+ await (hybrid ? executeActionHybrid : executeAction)(page, fa, elements, charDelayMs);
864
+ actionCount++;
865
+ executedAny = true;
866
+ await sleep(ANSWER_PAUSE_MS);
837
867
  }
868
+ continue; // re-analyze the screen after acting
838
869
  }
839
- continue;
870
+ console.log(`[browse] LLM confirms done after verification`);
871
+ }
872
+ else if (hasUnansweredQuestions(elements)) {
873
+ console.log(`[browse] Questions still open after verification — leaving them for the model/next screen`);
840
874
  }
841
875
  // All questions genuinely answered
842
876
  if (continuous && !executedAny) {
@@ -877,8 +911,10 @@ async function cmdBrowse(flags) {
877
911
  continue;
878
912
  }
879
913
  }
880
- // Ask LLM to decide if action is redundant
881
- const redundancy = await isActionRedundant(baseUrl, apiKey, model, page, elements, act, timeoutMs);
914
+ // Redundancy pre-check (extra LLM round-trip) only when opted in.
915
+ const redundancy = verifyActions
916
+ ? await isActionRedundant(baseUrl, apiKey, model, page, elements, act, timeoutMs)
917
+ : { decision: 'proceed' };
882
918
  if (redundancy.decision === 'skip') {
883
919
  continue;
884
920
  }
@@ -975,6 +1011,15 @@ async function cmdBrowse(flags) {
975
1011
  else {
976
1012
  console.log(`[browse] Action: ${act.action} - ${act.reasoning || ''}`);
977
1013
  }
1014
+ // Element cooled down after repeated non-responsive clicks: skip it for
1015
+ // this pass (it is retried on a later pass).
1016
+ if (act.action === 'click' && act.elementIndex !== undefined) {
1017
+ const coolUntil = clickCooldown.get(act.elementIndex) ?? 0;
1018
+ if (coolUntil > Date.now()) {
1019
+ console.log(`[browse] Skipping element ${act.elementIndex} — no response to earlier clicks (will retry later)`);
1020
+ continue;
1021
+ }
1022
+ }
978
1023
  // Human-like hesitation before acting (200-600ms)
979
1024
  if (act.action === 'click' || act.action === 'type' || act.action === 'write_code') {
980
1025
  await sleep(200 + Math.random() * 400);
@@ -985,9 +1030,42 @@ async function cmdBrowse(flags) {
985
1030
  if (act.action === 'write_code' || act.action === 'type') {
986
1031
  wroteCode = true;
987
1032
  }
988
- // Natural delay between actions
1033
+ // Verify a click actually changed something. Some custom widgets ignore
1034
+ // synthetic CDP mouse events, which is why a question stayed "unanswered"
1035
+ // no matter how often the model clicked it. Retry with a real DOM click,
1036
+ // then cool the element down after 3 fruitless attempts (retried later).
1037
+ if (act.action === 'click' && act.elementIndex !== undefined) {
1038
+ const idx = act.elementIndex;
1039
+ await sleep(400);
1040
+ try {
1041
+ const after = await extractElements(page);
1042
+ const score = calculateChangeScore(createFingerprint(elements), createFingerprint(after));
1043
+ if (score < 15) {
1044
+ const n = (clickFailures.get(idx) ?? 0) + 1;
1045
+ clickFailures.set(idx, n);
1046
+ console.log(`[browse] Click on element ${idx} produced no visible change (${n}/3)`);
1047
+ const el = elements.find((e) => e.i === idx);
1048
+ if (el && n <= 2) {
1049
+ const ok = await domClickAt(page, el.x, el.y);
1050
+ console.log(`[browse] DOM-click fallback on element ${idx}: ${ok ? 'dispatched' : 'failed'}`);
1051
+ }
1052
+ else if (el && n >= 3) {
1053
+ clickCooldown.set(idx, Date.now() + 30_000);
1054
+ clickFailures.delete(idx);
1055
+ console.log(`[browse] Element ${idx} not responding — skipping for 30s`);
1056
+ }
1057
+ }
1058
+ else {
1059
+ clickFailures.delete(idx);
1060
+ }
1061
+ }
1062
+ catch {
1063
+ /* verification is best-effort */
1064
+ }
1065
+ }
1066
+ // Pause after answering so the human can read/navigate (was 2s: too short).
989
1067
  if (act.action === 'click' || act.action === 'type' || act.action === 'write_code') {
990
- await sleep(2000);
1068
+ await sleep(ANSWER_PAUSE_MS);
991
1069
  }
992
1070
  }
993
1071
  // Optimization pass: if --optimize and code was written, ask LLM to optimize it
@@ -1559,6 +1637,32 @@ async function extractElements(page) {
1559
1637
  return [];
1560
1638
  }
1561
1639
  }
1640
+ /**
1641
+ * Fallback click: dispatch a REAL DOM click at (x, y) via
1642
+ * document.elementFromPoint + the nearest interactive ancestor. Custom widgets
1643
+ * that ignore synthetic CDP mouse events usually respond to this.
1644
+ */
1645
+ async function domClickAt(page, x, y) {
1646
+ try {
1647
+ const r = await cdpEval(page, `
1648
+ (() => {
1649
+ const el = document.elementFromPoint(${Math.round(x)}, ${Math.round(y)});
1650
+ if (!el) return false;
1651
+ const t = (el.closest && el.closest('button,label,a,[role],[onclick],input,li,span')) || el;
1652
+ try { if (t.focus) t.focus(); } catch (e) {}
1653
+ try {
1654
+ if (typeof t.click === 'function') { t.click(); return true; }
1655
+ } catch (e) {}
1656
+ t.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
1657
+ return true;
1658
+ })()
1659
+ `);
1660
+ return r?.result?.value === true;
1661
+ }
1662
+ catch {
1663
+ return false;
1664
+ }
1665
+ }
1562
1666
  async function executeAction(page, act, elements, charDelayMs) {
1563
1667
  try {
1564
1668
  switch (act.action) {
@@ -2519,6 +2623,26 @@ async function extractPageContext(page) {
2519
2623
  break;
2520
2624
  }
2521
2625
  }
2626
+ // FULL PAGE CONTENT (2026-09): the model must be fully informed from the
2627
+ // PAGE TEXT, not just the screenshot or the element list. Prefer the main
2628
+ // content region, else the body; collapse whitespace and cap the size.
2629
+ try {
2630
+ const root =
2631
+ document.querySelector('main, article, [role="main"]') || document.body;
2632
+ let text = (root && (root.innerText || root.textContent)) || '';
2633
+ text = text
2634
+ .replace(/[ \\t\\u00a0]+/g, ' ')
2635
+ .replace(/\\n\\s*\\n+/g, '\\n')
2636
+ .trim();
2637
+ if (text) {
2638
+ const CAP = 12000;
2639
+ ctx.push('Page content:');
2640
+ ctx.push(text.slice(0, CAP));
2641
+ if (text.length > CAP) {
2642
+ ctx.push('... (page content truncated at ' + CAP + ' chars)');
2643
+ }
2644
+ }
2645
+ } catch (e) { /* best-effort */ }
2522
2646
  return ctx.join('\\n');
2523
2647
  })()
2524
2648
  `;
@@ -2740,6 +2864,169 @@ function extractScatteredActions(str) {
2740
2864
  }
2741
2865
  return actions;
2742
2866
  }
2867
+ /**
2868
+ * Extract the model text from a non-streaming chat-completions JSON body
2869
+ * (handles content arrays and reasoning_content on reasoning models).
2870
+ */
2871
+ function extractModelText(json) {
2872
+ let content = json?.choices?.[0]?.message?.content;
2873
+ if (Array.isArray(content)) {
2874
+ content = content.filter((c) => c?.type === 'text').map((c) => c.text).join('');
2875
+ }
2876
+ if (typeof content !== 'string' || !content) {
2877
+ const reasoning = json?.choices?.[0]?.message?.reasoning_content;
2878
+ if (typeof reasoning === 'string' && reasoning)
2879
+ content = reasoning;
2880
+ }
2881
+ return typeof content === 'string' ? content : '';
2882
+ }
2883
+ /**
2884
+ * POST /chat/completions with `stream: true` and read the SSE body.
2885
+ *
2886
+ * Non-streaming never resolves until the provider finishes the whole answer, so
2887
+ * a long reasoning response looked like a 150s+ "hang" with zero feedback. With
2888
+ * streaming we see the first token, log its latency, and can catch a genuinely
2889
+ * stalled request (no bytes on the wire at all). `: PROCESSING` keepalives are
2890
+ * NOT tokens but DO prove the connection is alive, so they reset the watchdog;
2891
+ * only real wire silence trips it.
2892
+ *
2893
+ * The caller's `signal` (screen-change / overall timeout) still aborts this.
2894
+ */
2895
+ async function streamChatCompletion(opts) {
2896
+ const ac = new AbortController();
2897
+ const forwardAbort = () => ac.abort();
2898
+ if (opts.signal.aborted)
2899
+ ac.abort();
2900
+ else
2901
+ opts.signal.addEventListener('abort', forwardAbort, { once: true });
2902
+ let stalled = null;
2903
+ let sawToken = false;
2904
+ let watchdog = null;
2905
+ const start = Date.now();
2906
+ /**
2907
+ * Any bytes on the wire (including `: PROCESSING` keepalive comments) prove
2908
+ * the connection is alive, so they RESET the watchdog. This provider sends
2909
+ * keepalives every few seconds and only emits real tokens much later - the
2910
+ * old watchdog ignored comments and killed a healthy request at 60s
2911
+ * ("LLM stalled (no token received)" while the model was actively working).
2912
+ * The stall now means genuine wire silence: no bytes at all.
2913
+ */
2914
+ const arm = () => {
2915
+ if (watchdog)
2916
+ clearTimeout(watchdog);
2917
+ watchdog = setTimeout(() => {
2918
+ stalled = sawToken ? 'idle' : 'first-token';
2919
+ ac.abort();
2920
+ }, sawToken ? opts.idleTimeoutMs : opts.firstTokenTimeoutMs);
2921
+ };
2922
+ const cleanup = () => {
2923
+ if (watchdog)
2924
+ clearTimeout(watchdog);
2925
+ watchdog = null;
2926
+ opts.signal.removeEventListener('abort', forwardAbort);
2927
+ };
2928
+ arm();
2929
+ try {
2930
+ const res = await fetch(opts.url, {
2931
+ method: 'POST',
2932
+ signal: ac.signal,
2933
+ headers: {
2934
+ 'Content-Type': 'application/json',
2935
+ Authorization: `Bearer ${opts.apiKey}`,
2936
+ 'api-key': opts.apiKey,
2937
+ Accept: 'text/event-stream',
2938
+ },
2939
+ body: JSON.stringify(opts.body),
2940
+ });
2941
+ if (!res.ok) {
2942
+ const errorText = await res.text().catch(() => '');
2943
+ cleanup();
2944
+ return { kind: 'http', status: res.status, errorText };
2945
+ }
2946
+ const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
2947
+ // Provider ignored stream:true (plain JSON) — parse it whole.
2948
+ if (!contentType.includes('event-stream') || !res.body) {
2949
+ const json = await res.json().catch(() => null);
2950
+ cleanup();
2951
+ return { kind: 'ok', content: extractModelText(json) };
2952
+ }
2953
+ const reader = res.body.getReader();
2954
+ const decoder = new TextDecoder();
2955
+ let buffer = '';
2956
+ let content = '';
2957
+ let reasoning = '';
2958
+ for (;;) {
2959
+ const { done, value } = await reader.read();
2960
+ if (done)
2961
+ break;
2962
+ // Any bytes at all (comment keepalives included) = the connection is
2963
+ // alive, so reset the stall watchdog.
2964
+ arm();
2965
+ buffer += decoder.decode(value, { stream: true });
2966
+ let nl;
2967
+ while ((nl = buffer.indexOf('\n')) >= 0) {
2968
+ const raw = buffer.slice(0, nl).trim();
2969
+ buffer = buffer.slice(nl + 1);
2970
+ if (!raw || raw.startsWith(':'))
2971
+ continue; // ": PROCESSING" keepalive
2972
+ if (!raw.startsWith('data:'))
2973
+ continue;
2974
+ const data = raw.slice(5).trim();
2975
+ if (data === '[DONE]') {
2976
+ cleanup();
2977
+ return { kind: 'ok', content: content || reasoning };
2978
+ }
2979
+ let j;
2980
+ try {
2981
+ j = JSON.parse(data);
2982
+ }
2983
+ catch {
2984
+ continue;
2985
+ }
2986
+ const delta = j?.choices?.[0]?.delta;
2987
+ const dc = delta?.content;
2988
+ const dr = delta?.reasoning_content;
2989
+ if (typeof dc === 'string' && dc) {
2990
+ content += dc;
2991
+ if (!sawToken) {
2992
+ sawToken = true;
2993
+ opts.onFirstToken?.(Date.now() - start);
2994
+ }
2995
+ }
2996
+ else if (Array.isArray(dc)) {
2997
+ const t = dc.map((x) => x?.text ?? '').join('');
2998
+ if (t) {
2999
+ content += t;
3000
+ if (!sawToken) {
3001
+ sawToken = true;
3002
+ opts.onFirstToken?.(Date.now() - start);
3003
+ }
3004
+ }
3005
+ }
3006
+ if (typeof dr === 'string' && dr) {
3007
+ reasoning += dr;
3008
+ if (!sawToken) {
3009
+ sawToken = true;
3010
+ opts.onFirstToken?.(Date.now() - start);
3011
+ }
3012
+ }
3013
+ }
3014
+ }
3015
+ cleanup();
3016
+ return { kind: 'ok', content: content || reasoning };
3017
+ }
3018
+ catch (err) {
3019
+ cleanup();
3020
+ if (stalled) {
3021
+ return {
3022
+ kind: 'stalled',
3023
+ phase: stalled,
3024
+ elapsedMs: Date.now() - start,
3025
+ };
3026
+ }
3027
+ throw err; // caller abort (screen change / timeout) or network error
3028
+ }
3029
+ }
2743
3030
  async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, elements, pageContext, timeoutMs, attempt, maxAttempts, page, lastFingerprint, budget = {
2744
3031
  maxTokens: DEEP_MAX_TOKENS,
2745
3032
  deep: false,
@@ -2822,7 +3109,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2822
3109
  ],
2823
3110
  temperature: 0.1,
2824
3111
  max_tokens: budget.maxTokens,
2825
- stream: false,
3112
+ stream: true,
2826
3113
  };
2827
3114
  const controller = new AbortController();
2828
3115
  // Distinguish a genuine timeout from the heartbeat cancelling a stale request
@@ -2835,6 +3122,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2835
3122
  controller.abort();
2836
3123
  }, timeoutMs);
2837
3124
  // Heartbeat: check for screen changes every 5s, abort LLM if page changed
3125
+ let heartbeatTicks = 0;
2838
3126
  const heartbeat = setInterval(async () => {
2839
3127
  const elapsed = Math.round((Date.now() - startTime) / 1000);
2840
3128
  // Check for screen change via CDP navigation event or MutationObserver flag
@@ -2871,14 +3159,26 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2871
3159
  }
2872
3160
  catch { /* page disconnected */ }
2873
3161
  }
2874
- if (screenChanged && lastFingerprint && page) {
3162
+ // Fallback (2026-09): the in-page observer is document-scoped (re-injected on
3163
+ // navigation, but a same-document SPA swap may not have ticked it yet) and a
3164
+ // continuously-mutating page can starve its debounce. So every ~10s verify the
3165
+ // fingerprint directly even when no flag fired - a question swap during a
3166
+ // 50-120s vision call must never slip through unnoticed.
3167
+ heartbeatTicks++;
3168
+ const periodicCheck = !screenChanged && heartbeatTicks % 2 === 0;
3169
+ if ((screenChanged || periodicCheck) && lastFingerprint && page) {
2875
3170
  // Verify with fingerprint comparison before aborting
2876
3171
  try {
2877
3172
  const freshElements = await extractElements(page);
2878
3173
  const freshFingerprint = createFingerprint(freshElements);
2879
3174
  const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
2880
- if (changeScore >= 15) {
2881
- console.log(`[browse] Fingerprint confirms change (score: ${changeScore}) aborting LLM (${elapsed}s)`);
3175
+ // Abort on a real question change even when the aggregate score is below
3176
+ // the generic threshold (a pure question-text swap scores only 8), while
3177
+ // still ignoring checked-only / timer noise.
3178
+ if (questionSetChanged(lastFingerprint, freshFingerprint) || changeScore >= 15) {
3179
+ console.log(screenChanged
3180
+ ? `[browse] Fingerprint confirms change (score: ${changeScore}) — aborting LLM (${elapsed}s)`
3181
+ : `[browse] Periodic fingerprint found change (score: ${changeScore}) — aborting LLM (${elapsed}s)`);
2882
3182
  abortReason = 'screen_change';
2883
3183
  controller.abort();
2884
3184
  return;
@@ -2895,22 +3195,27 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2895
3195
  const contextKB = Math.round(pageContext.length / 1024);
2896
3196
  console.log(`[browse] LLM request: ${model} | ${screenshotKB}KB image | ${contextKB}KB context | ${elements.length} elements | timeout ${Math.round(timeoutMs / 1000)}s | attempt ${attempt}/${maxAttempts}`);
2897
3197
  try {
2898
- const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
2899
- method: 'POST',
3198
+ const outcome = await streamChatCompletion({
3199
+ url: `${baseUrl.replace(/\/$/, '')}/chat/completions`,
3200
+ apiKey,
3201
+ body,
2900
3202
  signal: controller.signal,
2901
- headers: {
2902
- 'Content-Type': 'application/json',
2903
- 'Authorization': `Bearer ${apiKey}`,
2904
- 'api-key': apiKey,
2905
- },
2906
- body: JSON.stringify(body),
3203
+ firstTokenTimeoutMs: budget.deep
3204
+ ? DEEP_FIRST_TOKEN_TIMEOUT_MS
3205
+ : FAST_FIRST_TOKEN_TIMEOUT_MS,
3206
+ idleTimeoutMs: budget.deep ? DEEP_IDLE_TIMEOUT_MS : FAST_IDLE_TIMEOUT_MS,
3207
+ onFirstToken: (ms) => console.log(`[browse] first token in ${Math.round(ms / 1000)}s (${budget.deep ? 'deep' : 'fast'})`),
2907
3208
  });
2908
- if (!res.ok) {
2909
- const errText = await res.text().catch(() => '');
2910
- console.error(`[browse] LLM HTTP ${res.status}: ${errText.slice(0, 200)}`);
3209
+ if (outcome.kind === 'stalled') {
3210
+ console.error(`[browse] LLM stalled (${outcome.phase === 'first-token' ? 'no token received' : 'stream went idle'}) after ${Math.round((outcome.elapsedMs ?? 0) / 1000)}s — aborting`);
3211
+ return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'stalled' };
3212
+ }
3213
+ if (outcome.kind === 'http') {
3214
+ const status = outcome.status ?? 0;
3215
+ console.error(`[browse] LLM HTTP ${status}: ${(outcome.errorText ?? '').slice(0, 200)}`);
2911
3216
  // 401/403 will never succeed on retry — surface a clear, actionable
2912
3217
  // diagnosis instead of burning 5 attempts x N rounds.
2913
- if (res.status === 401 || res.status === 403) {
3218
+ if (status === 401 || status === 403) {
2914
3219
  console.error(`[browse] Model auth failed for ${baseUrl} (model ${model}). The API key is invalid, ` +
2915
3220
  `expired, or belongs to a different provider. Fix the provider key on the run form ` +
2916
3221
  `(or the agent's provider key) and re-run.`);
@@ -2918,19 +3223,8 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2918
3223
  }
2919
3224
  return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'http_error' };
2920
3225
  }
2921
- const json = (await res.json());
2922
- let content = json.choices?.[0]?.message?.content;
2923
- if (Array.isArray(content)) {
2924
- content = content.filter((c) => c.type === 'text').map((c) => c.text).join('');
2925
- }
2926
- // Reasoning models (MiMo) put thinking in reasoning_content; fall back to it
2927
- if (typeof content !== 'string' || !content) {
2928
- const reasoning = json.choices?.[0]?.message?.reasoning_content;
2929
- if (typeof reasoning === 'string' && reasoning) {
2930
- content = reasoning;
2931
- }
2932
- }
2933
- if (typeof content !== 'string' || !content) {
3226
+ const content = outcome.content ?? '';
3227
+ if (!content) {
2934
3228
  console.error('[browse] Empty content from LLM');
2935
3229
  return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'empty' };
2936
3230
  }
@@ -2993,9 +3287,17 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
2993
3287
  optAbortReason = 'timeout';
2994
3288
  controller.abort();
2995
3289
  }, timeoutMs);
2996
- // Heartbeat: check for screen changes every 5s, abort if page changed
3290
+ // Heartbeat: check for screen changes every 5s, abort if page changed.
3291
+ // The observer flag lives in the current document, so any navigation (which
3292
+ // wipes it) must also abort - check `_cdpNavigated` too, as in the main call.
2997
3293
  const heartbeat = setInterval(async () => {
2998
3294
  const elapsed = Math.round((Date.now() - startTime) / 1000);
3295
+ if (page?._cdpNavigated && page._cdpNavigated > startTime) {
3296
+ console.log(`[browse] CDP navigation detected in opt pass (${elapsed}s) — aborting`);
3297
+ optAbortReason = 'screen_change';
3298
+ controller.abort();
3299
+ return;
3300
+ }
2999
3301
  try {
3000
3302
  const flagResult = await cdpEval(page, `
3001
3303
  (() => {