teamshare-bridge 0.21.29 → 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,6 +117,34 @@ 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. */
@@ -327,90 +356,55 @@ async function instrumentPage(page) {
327
356
  `);
328
357
  }
329
358
  catch { /* non-critical */ }
330
- // Register CDP event: Page.frameNavigated (URL/navigation changes)
331
- onCdpEvent(page, 'Page.frameNavigated', () => {
332
- page._cdpNavigated = Date.now();
333
- });
334
- // Inject MutationObserver: watches DOM for significant content changes
335
- // Uses same fingerprint comparison as Node.js side to prevent false positives.
336
- // Filters out: timers, animations, scripts, styles, notifications, toasts.
337
- // Debounces mutations (500ms) to batch rapid changes.
338
- // 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) {
339
386
  try {
340
- await cdpEval(page, `
341
- (() => {
342
- const KEY = Symbol('__ts_obs');
343
- if (window[KEY]) return 'already attached';
344
- 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;
345
- let timer = null;
346
- let lastText = '';
347
- function getText() {
348
- const els = document.querySelectorAll('input[type="radio"],input[type="checkbox"],input[type="text"],input[type="email"],input[type="password"],textarea,label,button');
349
- return [...els].map(e => {
350
- const t = (e.innerText || e.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
351
- return e.type + '|' + t + '|' + (e.checked || false);
352
- }).join('\\n');
353
- }
354
- function check() {
355
- try {
356
- const curr = getText();
357
- if (curr === lastText) return;
358
- const prev = lastText;
359
- lastText = curr;
360
- const prevGroups = prev ? prev.split('\\n').filter(Boolean) : [];
361
- const currGroups = curr.split('\\n').filter(Boolean);
362
- const prevSet = new Set(prevGroups);
363
- const currSet = new Set(currGroups);
364
- let removed = 0, changed = 0;
365
- for (const g of prevGroups) {
366
- if (!currSet.has(g)) {
367
- if (prevGroups.indexOf(g) < currGroups.length) changed++;
368
- else removed++;
369
- }
370
- }
371
- const added = currGroups.filter(g => !prevSet.has(g)).length;
372
- const score = added * 10 + removed * 10 + changed * 8;
373
- if (score >= 15) {
374
- window[KEY].changed = Date.now();
375
- }
376
- } catch {}
377
- }
378
- const obs = new MutationObserver((mutations) => {
379
- let hasRelevant = false;
380
- for (const m of mutations) {
381
- if (m.target instanceof Element) {
382
- const tag = m.target.tagName;
383
- if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'HEAD') continue;
384
- const cls = m.target.className || '';
385
- if (typeof cls === 'string' && IGNORE.test(cls)) continue;
386
- const id = m.target.id || '';
387
- if (IGNORE.test(id)) continue;
388
- if (m.type === 'characterData' && m.target.parentElement) {
389
- const ptag = m.target.parentElement.tagName;
390
- if (ptag === 'SCRIPT' || ptag === 'STYLE') continue;
391
- }
392
- }
393
- hasRelevant = true;
394
- }
395
- if (hasRelevant) {
396
- clearTimeout(timer);
397
- timer = setTimeout(check, 500);
398
- }
399
- });
400
- obs.observe(document.body || document.documentElement, {
401
- childList: true, subtree: true, characterData: true
402
- });
403
- window[KEY] = { observer: obs, changed: 0 };
404
- lastText = getText();
405
- return 'ok';
406
- })()
407
- `);
387
+ await cdpEval(page, (0, screen_change_1.buildObserverScript)());
408
388
  console.log('[browse] MutationObserver injected (stealth, debounced, filtered)');
409
389
  }
410
390
  catch (err) {
411
391
  console.error(`[browse] MutationObserver injection failed: ${(0, config_1.msg)(err)}`);
412
392
  }
413
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
+ }
414
408
  async function cmdBrowse(flags) {
415
409
  const autoAnswer = flags.get('auto-answer') === 'true';
416
410
  if (!autoAnswer) {
@@ -475,13 +469,18 @@ async function cmdBrowse(flags) {
475
469
  // the fast retry below abandons a slow easy answer at ~45s and escalates.
476
470
  const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '300', 10), 30), 600);
477
471
  const timeoutMs = timeoutSec * 1000;
478
- 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);
479
473
  /**
480
- * Downscale the FAST-path screenshot (attempt 1) to keep easy questions
481
- * quick. The deep retry re-captures at full resolution so code/text stays
482
- * 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).
483
479
  */
484
- 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;
485
484
  const maxConsecutiveFailures = parseInt(flags.get('max-consecutive-failures') ?? '0', 10); // 0 = unlimited
486
485
  const charDelayMs = Math.min(Math.max(parseInt(flags.get('char-delay') ?? '150', 10), 5), 200);
487
486
  const optimize = flags.has('optimize');
@@ -711,7 +710,7 @@ async function cmdBrowse(flags) {
711
710
  const currentFingerprint = createFingerprint(elements);
712
711
  if (lastFingerprint) {
713
712
  const changeScore = calculateChangeScore(lastFingerprint, currentFingerprint);
714
- if (changeScore >= 15) {
713
+ if (questionSetChanged(lastFingerprint, currentFingerprint) || changeScore >= 15) {
715
714
  console.log(`[browse] Screen changed (score: ${changeScore}) — resetting state`);
716
715
  codeConfirmedOptimal = false;
717
716
  consecutiveFailures = 0;
@@ -731,20 +730,17 @@ async function cmdBrowse(flags) {
731
730
  /** The page changed under the LLM: re-analyze immediately, no backoff. */
732
731
  let screenChangedDuringLlm = false;
733
732
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
734
- // Fast-first ladder: attempt 1 uses a small budget + downscaled shot so
735
- // easy objective questions answer quickly. If it fails, attempts 2+
736
- // switch to the DEEP budget (full timeout + tokens, full-res capture)
737
- // 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
738
737
  // deep path never slows a question that answers on the fast path.
739
738
  const deep = attempt > 1;
740
739
  const attemptTimeoutMs = deep ? timeoutMs : Math.min(FAST_ATTEMPT_TIMEOUT_MS, timeoutMs);
741
740
  if (deep && attempt === 2) {
742
741
  console.log('[browse] Fast attempt did not answer — escalating to the deep budget (more time + tokens)');
743
742
  }
744
- const attemptShot = deep
745
- ? ((await captureScreenshot(page, quality)) ?? screenshot)
746
- : screenshot;
747
- 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 });
748
744
  llmCallCount++;
749
745
  llmStats.calls++;
750
746
  llmStats.totalMs += result.durationMs;
@@ -822,10 +818,12 @@ async function cmdBrowse(flags) {
822
818
  const freshFingerprint = createFingerprint(freshElements);
823
819
  if (lastFingerprint) {
824
820
  const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
825
- // Only discard a (costly) LLM answer for a MATERIAL change. The old
826
- // threshold of 15 threw away whole 40-170s calls on score-20 noise.
827
- if (changeScore >= 40) {
828
- console.log(`[browse] Screen changed materially 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`);
829
827
  lastFingerprint = freshFingerprint;
830
828
  lastScreenshot = freshScreenshot;
831
829
  await sleep(intervalMs);
@@ -2625,6 +2623,26 @@ async function extractPageContext(page) {
2625
2623
  break;
2626
2624
  }
2627
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 */ }
2628
2646
  return ctx.join('\\n');
2629
2647
  })()
2630
2648
  `;
@@ -2868,9 +2886,9 @@ function extractModelText(json) {
2868
2886
  * Non-streaming never resolves until the provider finishes the whole answer, so
2869
2887
  * a long reasoning response looked like a 150s+ "hang" with zero feedback. With
2870
2888
  * streaming we see the first token, log its latency, and can catch a genuinely
2871
- * stalled request: a `first-token` watchdog (nothing on the wire) and an `idle`
2872
- * watchdog (no new token). `: PROCESSING` keepalive comments are ignored and do
2873
- * NOT count as progress (so a stuck model still trips the watchdog).
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.
2874
2892
  *
2875
2893
  * The caller's `signal` (screen-change / overall timeout) still aborts this.
2876
2894
  */
@@ -2885,13 +2903,21 @@ async function streamChatCompletion(opts) {
2885
2903
  let sawToken = false;
2886
2904
  let watchdog = null;
2887
2905
  const start = Date.now();
2888
- const arm = (ms) => {
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 = () => {
2889
2915
  if (watchdog)
2890
2916
  clearTimeout(watchdog);
2891
2917
  watchdog = setTimeout(() => {
2892
2918
  stalled = sawToken ? 'idle' : 'first-token';
2893
2919
  ac.abort();
2894
- }, ms);
2920
+ }, sawToken ? opts.idleTimeoutMs : opts.firstTokenTimeoutMs);
2895
2921
  };
2896
2922
  const cleanup = () => {
2897
2923
  if (watchdog)
@@ -2899,7 +2925,7 @@ async function streamChatCompletion(opts) {
2899
2925
  watchdog = null;
2900
2926
  opts.signal.removeEventListener('abort', forwardAbort);
2901
2927
  };
2902
- arm(opts.firstTokenTimeoutMs);
2928
+ arm();
2903
2929
  try {
2904
2930
  const res = await fetch(opts.url, {
2905
2931
  method: 'POST',
@@ -2933,6 +2959,9 @@ async function streamChatCompletion(opts) {
2933
2959
  const { done, value } = await reader.read();
2934
2960
  if (done)
2935
2961
  break;
2962
+ // Any bytes at all (comment keepalives included) = the connection is
2963
+ // alive, so reset the stall watchdog.
2964
+ arm();
2936
2965
  buffer += decoder.decode(value, { stream: true });
2937
2966
  let nl;
2938
2967
  while ((nl = buffer.indexOf('\n')) >= 0) {
@@ -2957,28 +2986,29 @@ async function streamChatCompletion(opts) {
2957
2986
  const delta = j?.choices?.[0]?.delta;
2958
2987
  const dc = delta?.content;
2959
2988
  const dr = delta?.reasoning_content;
2960
- let progressed = false;
2961
2989
  if (typeof dc === 'string' && dc) {
2962
2990
  content += dc;
2963
- progressed = true;
2991
+ if (!sawToken) {
2992
+ sawToken = true;
2993
+ opts.onFirstToken?.(Date.now() - start);
2994
+ }
2964
2995
  }
2965
2996
  else if (Array.isArray(dc)) {
2966
2997
  const t = dc.map((x) => x?.text ?? '').join('');
2967
2998
  if (t) {
2968
2999
  content += t;
2969
- progressed = true;
3000
+ if (!sawToken) {
3001
+ sawToken = true;
3002
+ opts.onFirstToken?.(Date.now() - start);
3003
+ }
2970
3004
  }
2971
3005
  }
2972
3006
  if (typeof dr === 'string' && dr) {
2973
3007
  reasoning += dr;
2974
- progressed = true;
2975
- }
2976
- if (progressed) {
2977
3008
  if (!sawToken) {
2978
3009
  sawToken = true;
2979
3010
  opts.onFirstToken?.(Date.now() - start);
2980
3011
  }
2981
- arm(opts.idleTimeoutMs);
2982
3012
  }
2983
3013
  }
2984
3014
  }
@@ -3092,6 +3122,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
3092
3122
  controller.abort();
3093
3123
  }, timeoutMs);
3094
3124
  // Heartbeat: check for screen changes every 5s, abort LLM if page changed
3125
+ let heartbeatTicks = 0;
3095
3126
  const heartbeat = setInterval(async () => {
3096
3127
  const elapsed = Math.round((Date.now() - startTime) / 1000);
3097
3128
  // Check for screen change via CDP navigation event or MutationObserver flag
@@ -3128,14 +3159,26 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
3128
3159
  }
3129
3160
  catch { /* page disconnected */ }
3130
3161
  }
3131
- 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) {
3132
3170
  // Verify with fingerprint comparison before aborting
3133
3171
  try {
3134
3172
  const freshElements = await extractElements(page);
3135
3173
  const freshFingerprint = createFingerprint(freshElements);
3136
3174
  const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
3137
- if (changeScore >= 15) {
3138
- 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)`);
3139
3182
  abortReason = 'screen_change';
3140
3183
  controller.abort();
3141
3184
  return;
@@ -3244,9 +3287,17 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
3244
3287
  optAbortReason = 'timeout';
3245
3288
  controller.abort();
3246
3289
  }, timeoutMs);
3247
- // 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.
3248
3293
  const heartbeat = setInterval(async () => {
3249
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
+ }
3250
3301
  try {
3251
3302
  const flagResult = await cdpEval(page, `
3252
3303
  (() => {