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.
- package/dist/cli/commands/browse.js +163 -112
- package/dist/cli/commands/browse.js.map +1 -1
- package/dist/cli/commands/research.js +3 -0
- package/dist/cli/commands/research.js.map +1 -1
- package/dist/lib/browser/screen-change.d.ts +43 -0
- package/dist/lib/browser/screen-change.js +122 -0
- package/dist/lib/browser/screen-change.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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') ?? '
|
|
472
|
+
const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '82', 10), 50), 100);
|
|
479
473
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
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
|
|
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 +
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
2872
|
-
*
|
|
2873
|
-
*
|
|
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
|
-
|
|
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
|
-
},
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3138
|
-
|
|
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
|
(() => {
|