chrome-bridge-mcp 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,7 +180,11 @@ paste into a chat.
180
180
  | `Port N is held by a process that is not chrome-bridge` | Something else owns the port. Free it or change `CHROME_BRIDGE_PORT`. |
181
181
  | `execute_js` fails | Enable **Allow user scripts** in `chrome://extensions` → Chrome Bridge → Details (Chrome 138+; on 135-137 enable Developer Mode). |
182
182
  | `read_console` returns `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page isn't injectable (`chrome://`). Reload it. |
183
- | Screenshot times out | On ChromeOS a fully occluded window stops producing frames; captures fail after 10s. Bring the window forward. |
183
+ | Screenshot times out, or `image readback failed` | A minimized or fully covered window stops producing frames; captures fail after 10 s. Bring the window forward. |
184
+ | `wait_for`/`scroll` return `page_hidden: true`, pages stop updating | Same cause: Chrome does not render a hidden page and slows its timers (after a few minutes, to one wake-up per minute). `get_page_info` reports `visibility`. Bring the window on screen, or `create_tab` with `new_window` and bounds. |
185
+ | `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation or larger viewports use a headless browser. |
186
+ | `type_text` returns `mismatch: true` | The field rejected the value. Events from an extension have `isTrusted: false`, and some widgets (date pickers, search boxes with tokenizers) discard them. Try `mode: 'keys'`, then the site's own controls (the calendar buttons), or `handoff`. |
187
+ | `[media removed: request limit]` instead of a screenshot | Written by the MCP client, not by the bridge: too many images in one request. Use `save_to`, or `element_screenshot` with `region` and a small `scale`. |
184
188
  | Commands work, then stop | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
185
189
  | Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
186
190
  | Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
@@ -0,0 +1,97 @@
1
+ /**
2
+ * findTextInPage gira nella pagina: chrome.scripting ne serializza il
3
+ * sorgente, quindi è autocontenuta (niente import, niente chiusure).
4
+ *
5
+ * Cerca sul testo degli elementi, non dei singoli nodi di testo: un'etichetta
6
+ * spezzata in più nodi («<span>Prestazioni</span> e <b>clic</b>», o con
7
+ * &nbsp;) prima non veniva mai trovata, ed è la forma normale dei menu di
8
+ * Meta. Fra le parole dell'ago vale qualunque sequenza di spazi o &nbsp;.
9
+ * Ogni occorrenza è riportata una volta, dall'elemento più interno che la
10
+ * contiene tutta; si entra anche negli shadow root aperti.
11
+ *
12
+ * Il costo resta basso sulle pagine grandi: si scende solo nei rami il cui
13
+ * testo contiene l'ago.
14
+ */
15
+ export function findTextInPage(needleRaw, caseSensitive, maxResults, doc = document, win = window) {
16
+ const words = String(needleRaw || '').split(/[\s ]+/).filter(Boolean);
17
+ if (!words.length) return { count: 0, matches: [] };
18
+ const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\s\\u00a0]+');
19
+ const flags = caseSensitive ? 'g' : 'gi';
20
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
21
+
22
+ const textOf = (node) => {
23
+ if (node.nodeType === 3) return node.textContent || '';
24
+ if (node.nodeType !== 1 || SKIP.has(node.tagName)) return '';
25
+ let out = '';
26
+ for (const c of node.childNodes) out += textOf(c);
27
+ return out;
28
+ };
29
+ const ranges = (text) => {
30
+ const re = new RegExp(pattern, flags);
31
+ const out = [];
32
+ let m;
33
+ while ((m = re.exec(text))) out.push([m.index, m.index + m[0].length]);
34
+ return out;
35
+ };
36
+ const selectorFor = (el) => {
37
+ if (el.id) return `#${el.id}`;
38
+ const tag = el.tagName.toLowerCase();
39
+ const cls = el.classList && el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
40
+ return `${tag}${cls}`;
41
+ };
42
+
43
+ const matches = [];
44
+ const visit = (el, inShadow) => {
45
+ if (matches.length >= maxResults || SKIP.has(el.tagName)) return;
46
+ const text = textOf(el);
47
+ const found = ranges(text);
48
+ if (!found.length) return;
49
+ // Intervalli dei figli elemento nel testo di el: textOf concatena i figli
50
+ // in ordine, quindi gli offset coincidono.
51
+ const kids = [];
52
+ let off = 0;
53
+ for (const c of el.childNodes) {
54
+ const len = textOf(c).length;
55
+ if (c.nodeType === 1) kids.push({ el: c, from: off, to: off + len });
56
+ off += len;
57
+ }
58
+ const owned = found.filter(([a, b]) => !kids.some((k) => a >= k.from && b <= k.to));
59
+ if (owned.length) {
60
+ const rect = el.getBoundingClientRect();
61
+ const flat = text.replace(/[\s ]/g, ' ');
62
+ for (const [a, b] of owned) {
63
+ if (matches.length >= maxResults) break;
64
+ matches.push({
65
+ selector: selectorFor(el),
66
+ context: flat.substring(Math.max(0, a - 40), b + 40).replace(/ +/g, ' ').trim(),
67
+ visible: rect.width > 0 && rect.height > 0,
68
+ position: { x: Math.round(rect.x + win.scrollX), y: Math.round(rect.y + win.scrollY) },
69
+ ...(inShadow ? { in_shadow: true } : {}),
70
+ });
71
+ }
72
+ }
73
+ for (const k of kids) {
74
+ if (found.some(([a, b]) => a >= k.from && b <= k.to)) visit(k.el, inShadow);
75
+ }
76
+ };
77
+
78
+ // Gli shadow root non compaiono nel testo dell'ospite: si cercano a parte,
79
+ // anche annidati, dopo il documento.
80
+ const roots = [];
81
+ const collectShadows = (root) => {
82
+ for (const el of root.querySelectorAll('*')) {
83
+ if (el.shadowRoot) {
84
+ roots.push(el.shadowRoot);
85
+ collectShadows(el.shadowRoot);
86
+ }
87
+ }
88
+ };
89
+ if (doc.body) {
90
+ visit(doc.body, false);
91
+ collectShadows(doc.body);
92
+ }
93
+ for (const root of roots) {
94
+ for (const c of root.children) visit(c, true);
95
+ }
96
+ return { count: matches.length, matches };
97
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
6
6
  "homepage_url": "https://github.com/frsorrentino/chrome-bridge",
7
7
  "minimum_chrome_version": "135",
@@ -11,6 +11,7 @@ import { pageFingerprint } from './lib/page-fingerprint.js';
11
11
  import { createPacer } from './lib/capture-pacing.js';
12
12
  import { computeTiles } from './lib/tile-layout.js';
13
13
  import { classifyDownload } from './lib/download-state.js';
14
+ import { findTextInPage } from './lib/find-text.js';
14
15
  const { pushError } = globalThis.__cbTelemetry;
15
16
 
16
17
  const DEFAULT_PORT = 8765;
@@ -603,11 +604,60 @@ async function captureVisible(windowId, options = { format: 'png' }) {
603
604
  }, 10000);
604
605
  chrome.tabs.captureVisibleTab(windowId, options).then(
605
606
  (dataUrl) => { clearTimeout(timer); resolve(dataUrl); },
606
- (err) => { clearTimeout(timer); reject(err); },
607
+ (err) => {
608
+ clearTimeout(timer);
609
+ // «image readback failed» è lo stesso guasto della finestra che non
610
+ // disegna, detto dal compositor: stesso messaggio, stesso rimedio.
611
+ if (/readback failed/i.test(err?.message || '')) {
612
+ reject(new Error(`Capture failed (${err.message}): the window is not rendering frames (fully occluded, minimized, or frozen). Bring the Chrome window at least partially on screen and retry.`));
613
+ } else {
614
+ reject(err);
615
+ }
616
+ },
607
617
  );
608
618
  });
609
619
  }
610
620
 
621
+ // --- Utility: scadenze tenute dal service worker ---
622
+ // Le attese che girano nella pagina usano setTimeout, e Chrome raggruppa i
623
+ // timer delle pagine nascoste fino a un risveglio al minuto: il controllo
624
+ // della scadenza scattava tardi e vinceva il timeout di trasporto (60 s per
625
+ // un'attesa da 25, osservato su Meta Ads Manager il 23/09/2026). Il service
626
+ // worker non è rallentato: la scadenza la tiene lui.
627
+ const HIDDEN_HINT = 'The page is hidden (window minimized, covered or in the background): Chrome slows its timers and stops rendering. Bring the window on screen, or create_tab new_window with bounds, then retry.';
628
+
629
+ async function pageHidden(tabId) {
630
+ try {
631
+ const q = chrome.scripting.executeScript({ target: { tabId }, func: () => document.visibilityState === 'hidden' });
632
+ const r = await Promise.race([q, new Promise((res) => setTimeout(() => res(null), 1000))]);
633
+ return r?.[0]?.result === true;
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
638
+
639
+ // Esegue run(); se non risponde entro deadlineMs restituisce onTimeout. In
640
+ // entrambi i casi un esito negativo su pagina nascosta porta page_hidden e il
641
+ // suggerimento, così chi chiama non ritenta tre volte la stessa attesa.
642
+ async function withDeadline(tabId, deadlineMs, run, onTimeout, isNegative) {
643
+ const DEADLINE = Symbol('deadline');
644
+ let timer;
645
+ let result;
646
+ try {
647
+ result = await Promise.race([
648
+ run(),
649
+ new Promise((res) => { timer = setTimeout(() => res(DEADLINE), deadlineMs); }),
650
+ ]);
651
+ } finally {
652
+ clearTimeout(timer);
653
+ }
654
+ if (result === DEADLINE) result = { ...onTimeout };
655
+ if (result && isNegative(result) && await pageHidden(tabId)) {
656
+ return { ...result, page_hidden: true, hint: HIDDEN_HINT };
657
+ }
658
+ return result;
659
+ }
660
+
611
661
  // --- Utility: rendi visibile un tab per la cattura SENZA rubare il focus OS ---
612
662
  // captureVisibleTab richiede solo che il tab sia attivo nella sua finestra,
613
663
  // non che la finestra sia in primo piano. Al termine ripristina il tab che
@@ -1170,7 +1220,14 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
1170
1220
  if (typeof v === 'number') winOpts[k] = v;
1171
1221
  }
1172
1222
  const win = await chrome.windows.create(winOpts);
1223
+ // La finestra nuova può nascere dietro un'altra (lo decide il window
1224
+ // manager): allora la pagina resta hidden e non disegna. Lo si dice qui,
1225
+ // non dopo tre attese scadute.
1226
+ await new Promise((r) => setTimeout(r, 300));
1227
+ const hidden = await pageHidden(tab_id);
1173
1228
  return {
1229
+ visibility: hidden ? 'hidden' : 'visible',
1230
+ ...(hidden ? { hint: HIDDEN_HINT } : {}),
1174
1231
  moved: tab_id,
1175
1232
  url: before.url,
1176
1233
  from_window: before.windowId,
@@ -1344,6 +1401,9 @@ async function cmdGetPageInfo({ tab_id, frame_id }) {
1344
1401
  url: location.href,
1345
1402
  doctype: document.doctype ? document.doctype.name : null,
1346
1403
  charset: document.characterSet,
1404
+ // hidden = finestra minimizzata, coperta o in secondo piano: la pagina
1405
+ // non disegna e i suoi timer rallentano. Va saputo prima di attendere.
1406
+ visibility: document.visibilityState,
1347
1407
  metas,
1348
1408
  scripts,
1349
1409
  stylesheets,
@@ -1754,7 +1814,7 @@ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, vi
1754
1814
  const tabId = await resolveTabId(tab_id);
1755
1815
  const clampedInterval = Math.max(interval, 50);
1756
1816
 
1757
- const results = await chrome.scripting.executeScript({
1817
+ const run = () => chrome.scripting.executeScript({
1758
1818
  target: scriptTarget(tabId, frame_id),
1759
1819
  func: (sel, tout, intv, vis) => {
1760
1820
  function deepQuery(sel) {
@@ -1806,8 +1866,9 @@ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, vi
1806
1866
  },
1807
1867
  args: [selector, timeout, clampedInterval, visible],
1808
1868
  world: 'MAIN',
1809
- });
1810
- return results?.[0]?.result ?? { found: false, error: 'No result' };
1869
+ }).then((results) => results?.[0]?.result ?? { found: false, error: 'No result' });
1870
+ return withDeadline(tabId, timeout + 1000, run,
1871
+ { found: false, error: `Element not found within ${timeout}ms: ${selector}` }, (r) => r.found === false);
1811
1872
  }
1812
1873
 
1813
1874
  // --- scroll_to ---
@@ -2058,11 +2119,16 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
2058
2119
  }
2059
2120
  const winNow = await chrome.windows.get(tab.windowId);
2060
2121
  const geometry = { left: winNow.left, top: winNow.top, width: winNow.width, height: winNow.height, state: winNow.state };
2061
- if (read_only) return { actual, window: geometry, viewport_error };
2122
+ // Uno zoom sotto il 100% allarga il viewport in CSS px oltre lo schermo:
2123
+ // senza questo numero un 2226 px su uno schermo da 1536 non si spiega.
2124
+ let zoom = null;
2125
+ try { zoom = await chrome.tabs.getZoom(tabId); } catch { /* scheda senza zoom (chrome://) */ }
2126
+ if (read_only) return { actual, window: geometry, zoom, viewport_error };
2062
2127
  const win = winNow;
2063
2128
  return {
2064
2129
  requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
2065
2130
  actual,
2131
+ zoom,
2066
2132
  viewport_error,
2067
2133
  // Il window manager può accettare e ignorare: senza il confronto una
2068
2134
  // finestra rimasta ferma passerebbe per spostata.
@@ -3536,41 +3602,7 @@ async function cmdFindText({ text, case_sensitive = false, max_results = 20, tab
3536
3602
  const tabId = await resolveTabId(tab_id);
3537
3603
  const results = await chrome.scripting.executeScript({
3538
3604
  target: { tabId },
3539
- func: (needleRaw, caseSensitive, maxResults) => {
3540
- const needle = caseSensitive ? needleRaw : needleRaw.toLowerCase();
3541
- const matches = [];
3542
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
3543
- acceptNode: (n) => {
3544
- const p = n.parentElement;
3545
- if (!p || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(p.tagName)) return NodeFilter.FILTER_REJECT;
3546
- return NodeFilter.FILTER_ACCEPT;
3547
- },
3548
- });
3549
- const selectorFor = (el) => {
3550
- if (el.id) return `#${el.id}`;
3551
- const tag = el.tagName.toLowerCase();
3552
- const cls = el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
3553
- return `${tag}${cls}`;
3554
- };
3555
- let node;
3556
- while ((node = walker.nextNode()) && matches.length < maxResults) {
3557
- const hay = caseSensitive ? node.textContent : node.textContent.toLowerCase();
3558
- let idx = hay.indexOf(needle);
3559
- while (idx !== -1 && matches.length < maxResults) {
3560
- const parent = node.parentElement;
3561
- const rect = parent.getBoundingClientRect();
3562
- const ctx = node.textContent.substring(Math.max(0, idx - 40), idx + needleRaw.length + 40);
3563
- matches.push({
3564
- selector: selectorFor(parent),
3565
- context: ctx.trim(),
3566
- visible: rect.width > 0 && rect.height > 0,
3567
- position: { x: Math.round(rect.x + window.scrollX), y: Math.round(rect.y + window.scrollY) },
3568
- });
3569
- idx = hay.indexOf(needle, idx + 1);
3570
- }
3571
- }
3572
- return { count: matches.length, matches };
3573
- },
3605
+ func: findTextInPage,
3574
3606
  args: [text, case_sensitive, max_results],
3575
3607
  world: 'MAIN',
3576
3608
  });
@@ -4448,7 +4480,7 @@ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200,
4448
4480
  if (!text) throw new Error('Missing required parameter: text');
4449
4481
  const tabId = await resolveTabId(tab_id);
4450
4482
 
4451
- const results = await chrome.scripting.executeScript({
4483
+ const run = () => chrome.scripting.executeScript({
4452
4484
  target: scriptTarget(tabId, frame_id),
4453
4485
  func: (needle, scopeSel, tout, intv) => new Promise((resolve) => {
4454
4486
  const started = Date.now();
@@ -4477,9 +4509,10 @@ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200,
4477
4509
  }),
4478
4510
  args: [text, selector || null, timeout, Math.max(50, interval)],
4479
4511
  world: 'MAIN',
4480
- });
4512
+ }).then((results) => results?.[0]?.result ?? { found: false, error: 'No result' });
4481
4513
 
4482
- return results?.[0]?.result ?? { found: false, error: 'No result' };
4514
+ return withDeadline(tabId, timeout + 1000, run,
4515
+ { found: false, error: `Text not found within ${timeout}ms: ${text}` }, (r) => r.found === false);
4483
4516
  }
4484
4517
 
4485
4518
  async function cmdSavePage({ tab_id }) {
@@ -4707,52 +4740,92 @@ async function cmdWaitForFunction({ expression, timeout = 10000, polling_ms = 10
4707
4740
 
4708
4741
  // --- scroll_until ---
4709
4742
 
4710
- async function cmdScrollUntil({ until = 'no_new_content', selector, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
4743
+ async function cmdScrollUntil({ until = 'no_new_content', selector, container, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
4711
4744
  const tabId = await resolveTabId(tab_id);
4712
4745
  if (until === 'network_idle') await ensureNetworkHook(tabId);
4713
- const results = await chrome.scripting.executeScript({
4746
+ const run = () => chrome.scripting.executeScript({
4714
4747
  target: { tabId },
4715
- func: (mode, sel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
4748
+ func: (mode, sel, containerSel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
4716
4749
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4750
+ const docEl = document.scrollingElement || document.documentElement;
4751
+ const scrollable = (el) => {
4752
+ if (el.scrollHeight <= el.clientHeight + 2) return false;
4753
+ const oy = getComputedStyle(el).overflowY;
4754
+ return oy === 'auto' || oy === 'scroll' || oy === 'overlay';
4755
+ };
4756
+ const describe = (el) => {
4757
+ if (el.id) return `#${el.id}`;
4758
+ const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
4759
+ return el.tagName.toLowerCase() + cls;
4760
+ };
4761
+ // Le app come Ads Manager bloccano il documento e scorrono in un div
4762
+ // interno: scorrendo solo window la condizione «bottom» era vera al
4763
+ // primo giro. Senza container esplicito si prende il contenitore
4764
+ // scorrevole più grande, ma solo se il documento non scorre.
4765
+ let box = null;
4766
+ if (containerSel) {
4767
+ box = document.querySelector(containerSel);
4768
+ if (!box) { resolve({ stopped_reason: 'error', error: `Container not found: ${containerSel}`, scrolls: 0 }); return; }
4769
+ } else if (docEl.scrollHeight <= innerHeight + 2
4770
+ // overflow hidden su html o body: l'utente non può scorrere il
4771
+ // documento anche se è più alto della finestra (e scrollBy sì).
4772
+ || [document.documentElement, document.body].some((e) => e && getComputedStyle(e).overflowY === 'hidden')) {
4773
+ let best = null;
4774
+ let bestArea = 0;
4775
+ for (const el of document.querySelectorAll('body *')) {
4776
+ if (el.clientHeight < 50 || !scrollable(el)) continue;
4777
+ const area = el.clientWidth * el.clientHeight;
4778
+ if (area > bestArea) { best = el; bestArea = area; }
4779
+ }
4780
+ box = best;
4781
+ }
4782
+ const s = box
4783
+ ? { by: (d) => box.scrollBy(0, d), pos: () => box.scrollTop, height: () => box.scrollHeight, view: () => box.clientHeight, name: describe(box) }
4784
+ : { by: (d) => window.scrollBy(0, d), pos: () => window.scrollY, height: () => docEl.scrollHeight, view: () => innerHeight, name: 'document' };
4785
+ const done = (stopped_reason, scrolls) => resolve({ stopped_reason, scrolls, finalScrollY: Math.round(s.pos()), container: s.name });
4717
4786
  (async () => {
4718
4787
  let scrolls = 0;
4719
- let lastHeight = document.documentElement.scrollHeight;
4788
+ let lastHeight = s.height();
4720
4789
  let stableCount = 0;
4721
- const step = stepPx || window.innerHeight;
4790
+ const step = stepPx || s.view();
4722
4791
  for (scrolls = 0; scrolls < maxScrolls; scrolls++) {
4723
4792
  if (mode === 'element' && sel) {
4724
4793
  const el = document.querySelector(sel);
4725
4794
  if (el) {
4726
4795
  const r = el.getBoundingClientRect();
4727
4796
  const vis = r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
4728
- if (vis) { resolve({ stopped_reason: 'element_found', scrolls, finalScrollY: Math.round(window.scrollY) }); return; }
4797
+ if (vis) { done('element_found', scrolls); return; }
4729
4798
  }
4730
4799
  }
4731
- window.scrollBy(0, step);
4800
+ s.by(step);
4732
4801
  await sleep(settleMs);
4733
4802
 
4734
4803
  if (mode === 'network_idle') {
4735
4804
  const inflight = window.__chromeBridge_inflight || 0;
4736
4805
  const last = window.__chromeBridge_lastNetActivity || Date.now();
4737
- if (inflight === 0 && Date.now() - last >= settleMs) { resolve({ stopped_reason: 'network_idle', scrolls: scrolls + 1, finalScrollY: Math.round(window.scrollY) }); return; }
4806
+ if (inflight === 0 && Date.now() - last >= settleMs) { done('network_idle', scrolls + 1); return; }
4738
4807
  }
4739
4808
 
4740
- const h = document.documentElement.scrollHeight;
4809
+ const h = s.height();
4741
4810
  if (h === lastHeight) {
4742
4811
  stableCount++;
4743
- if (mode === 'no_new_content' && stableCount >= 2) { resolve({ stopped_reason: 'no_new_content', scrolls: scrolls + 1, finalScrollY: Math.round(window.scrollY) }); return; }
4812
+ if (mode === 'no_new_content' && stableCount >= 2) { done('no_new_content', scrolls + 1); return; }
4744
4813
  } else { stableCount = 0; lastHeight = h; }
4745
4814
 
4746
4815
  // bottom reached
4747
- if (window.innerHeight + window.scrollY >= h - 2) { resolve({ stopped_reason: 'bottom', scrolls: scrolls + 1, finalScrollY: Math.round(window.scrollY) }); return; }
4816
+ if (s.view() + s.pos() >= h - 2) { done('bottom', scrolls + 1); return; }
4748
4817
  }
4749
- resolve({ stopped_reason: 'max_scrolls', scrolls, finalScrollY: Math.round(window.scrollY) });
4818
+ done('max_scrolls', scrolls);
4750
4819
  })();
4751
4820
  }),
4752
- args: [until, selector || null, max_scrolls, step_px || null, settle_ms],
4821
+ args: [until, selector || null, container || null, max_scrolls, step_px || null, settle_ms],
4753
4822
  world: 'MAIN',
4754
- });
4755
- return results?.[0]?.result ?? { stopped_reason: 'error', scrolls: 0 };
4823
+ }).then((results) => results?.[0]?.result ?? { stopped_reason: 'error', scrolls: 0 });
4824
+ // Stesse sleep in pagina delle attese: su una scheda nascosta rallentano.
4825
+ const deadline = Math.min(55000, max_scrolls * (settle_ms + 250) + 5000);
4826
+ return withDeadline(tabId, deadline, run,
4827
+ { stopped_reason: 'deadline', error: `Scrolling did not finish within ${deadline}ms` },
4828
+ (r) => r.stopped_reason === 'deadline');
4756
4829
  }
4757
4830
 
4758
4831
  // --- Avvia la connessione + registra l'instrumentation ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "type": "module",
6
6
  "description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
package/server/index.js CHANGED
@@ -64,6 +64,10 @@ async function main() {
64
64
  'For more than one field use fill_form once (with submit_selector to submit in the same call) instead of repeated type_text: one turn instead of N.',
65
65
  'For tables use extract_table (server-side where/columns filtering) or extract, never read_page: read_page on a big table costs tens of thousands of tokens for data you filter anyway.',
66
66
  'To read detail in a screenshot, crop and zoom with element_screenshot (selector or region, scale) instead of taking another full one; to check an outcome, assert or wait_for poll for you — one call, no screenshot.',
67
+ // Il blocco più frequente sul campo (Meta Ads Manager, 23/09/2026): tre
68
+ // attese scadute e due screenshot falliti prima di capire che la
69
+ // finestra era dietro un'altra.
70
+ 'A tab in a minimized, covered or background window does not render: screenshots fail and page timers slow down. get_page_info reports visibility; page_hidden in a result means the same. Bring the window on screen, or create_tab new_window with bounds.',
67
71
  ].join(' '),
68
72
  });
69
73
 
@@ -92,12 +92,15 @@ export const MessageType = Object.freeze({
92
92
  });
93
93
 
94
94
  // Versione
95
- export const VERSION = '1.18.0';
95
+ export const VERSION = '1.19.0';
96
96
 
97
97
  // Configurazione
98
98
  export const DEFAULT_PORT = 8765;
99
99
  export const COMMAND_TIMEOUT_MS = 30000; // 30s per comandi normali
100
- export const SCREENSHOT_TIMEOUT_MS = 10000; // 10s per screenshot
100
+ // Più del timer interno di captureVisible (10 s, dopo il pacer da 520 ms):
101
+ // a parità vinceva il trasporto col messaggio generico «the tab may be busy»,
102
+ // e quello utile («the window is not rendering frames») non arrivava mai.
103
+ export const SCREENSHOT_TIMEOUT_MS = 15000;
101
104
  export const PING_INTERVAL_MS = 15000; // 15s heartbeat
102
105
  export const IDENT_TIMEOUT_MS = 5000; // tempo max per identificarsi
103
106
  export const PENDING_RELAY_TTL_MS = 150000; // deve superare il timeout comando più lungo (120s full_page_screenshot)
package/server/tools.js CHANGED
@@ -472,17 +472,73 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
472
472
  }
473
473
  };
474
474
 
475
- // Mappa ref → selector per tab, popolata da get_interactives.
476
- // Permette click/type_text/hover per ref (n1, n2…) senza ripetere selettori lunghi.
475
+ // Registro ref → selector per tab, popolato da get_interactives, dalla
476
+ // preview di navigate e dai vicini di find_text. Permette click/type_text/
477
+ // hover per ref (n1, n2…) senza ripetere selettori lunghi.
478
+ // Un selettore tiene lo stesso ref finché la pagina non cambia, e un numero
479
+ // non viene mai riassegnato: prima ogni chiamata di scoperta ripartiva da n1
480
+ // sulla stessa mappa, e dopo un find_text n3 indicava un altro elemento —
481
+ // il click andava a segno sull'elemento sbagliato senza errori.
477
482
  const interactivesRefs = new Map();
483
+ const REFS_MAX = 2000;
478
484
 
479
485
  const refsKey = (tab_id) => tab_id ?? sessionTabId ?? 'active';
480
486
 
487
+ function refRegistry(tab_id) {
488
+ const key = refsKey(tab_id);
489
+ let reg = interactivesRefs.get(key);
490
+ if (!reg) {
491
+ reg = { next: 1, floor: 1, bySel: new Map(), byRef: new Map() };
492
+ interactivesRefs.set(key, reg);
493
+ }
494
+ return reg;
495
+ }
496
+
497
+ // Assegna i ref agli elementi (in place). Solo gli elementi con selector
498
+ // ricevono un ref: senza, non ci sarebbe nulla da risolvere.
499
+ function assignRefs(tab_id, elements) {
500
+ const reg = refRegistry(tab_id);
501
+ for (const e of elements) {
502
+ if (!e.selector) continue;
503
+ let ref = reg.bySel.get(e.selector);
504
+ if (!ref) {
505
+ ref = `n${reg.next++}`;
506
+ reg.bySel.set(e.selector, ref);
507
+ reg.byRef.set(ref, e.selector);
508
+ }
509
+ e.ref = ref;
510
+ }
511
+ while (reg.byRef.size > REFS_MAX) {
512
+ const [oldRef, oldSel] = reg.byRef.entries().next().value;
513
+ reg.byRef.delete(oldRef);
514
+ reg.bySel.delete(oldSel);
515
+ }
516
+ return reg.byRef.size;
517
+ }
518
+
519
+ // Una navigazione cambia pagina: i selettori vecchi potrebbero trovare altro.
520
+ // La mappa si svuota ma il contatore no, così un ref vecchio dà errore
521
+ // invece di indicare un elemento nuovo con lo stesso numero.
522
+ function resetRefs(tab_id) {
523
+ const reg = refRegistry(tab_id);
524
+ reg.bySel.clear();
525
+ reg.byRef.clear();
526
+ reg.floor = reg.next;
527
+ }
528
+
481
529
  function resolveTarget(selector, ref, tab_id) {
482
530
  if (selector) return selector;
483
531
  if (ref) {
484
- const sel = interactivesRefs.get(refsKey(tab_id))?.get(ref);
485
- if (!sel) throw new Error(`Unknown ref ${ref} — run get_interactives first`);
532
+ const reg = interactivesRefs.get(refsKey(tab_id));
533
+ const sel = reg?.byRef.get(ref);
534
+ if (!sel) {
535
+ const num = Number(String(ref).replace(/^n/, ''));
536
+ const why = !reg || reg.next === 1 ? 'no refs issued for this tab yet'
537
+ : num >= reg.next ? `refs issued so far go up to n${reg.next - 1}`
538
+ : num < reg.floor ? 'it was issued before the last navigate, which resets refs'
539
+ : 'it was evicted from the ref cache';
540
+ throw new Error(`Unknown ref ${ref} (${why}) — run get_interactives or find_text again`);
541
+ }
486
542
  return sel;
487
543
  }
488
544
  throw new Error('Either selector or ref is required');
@@ -517,13 +573,9 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
517
573
  async function interactivesPreview(tab_id, limit = 12) {
518
574
  try {
519
575
  const data = await send(MessageType.GET_INTERACTIVES, { limit, visible_only: true, tab_id });
520
- const refMap = new Map();
521
- (data?.elements ?? []).forEach((e, i) => {
522
- e.ref = `n${i + 1}`;
523
- if (e.selector) refMap.set(e.ref, e.selector);
524
- });
525
- interactivesRefs.set(refsKey(tab_id), refMap);
526
- return refMap.size ? truncateText(interactivesLines(data), 1500) : null;
576
+ const elements = data?.elements ?? [];
577
+ assignRefs(tab_id, elements);
578
+ return elements.some((e) => e.ref) ? truncateText(interactivesLines(data), 1500) : null;
527
579
  } catch { return null; }
528
580
  }
529
581
 
@@ -612,6 +664,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
612
664
  const data = await send(MessageType.NAVIGATE, { url, tab_id });
613
665
  // Il tab navigato diventa il default di sessione per i comandi successivi
614
666
  if (data?.tabId != null) sessionTabId = data.tabId;
667
+ resetRefs(data?.tabId ?? tab_id);
615
668
  const preview = await interactivesPreview(data?.tabId ?? tab_id);
616
669
  return {
617
670
  content: [{
@@ -623,6 +676,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
623
676
  );
624
677
 
625
678
  // --- screenshot ---
679
+ // Larghezze dei preset, le stesse di viewport_resize nell'estensione.
680
+ const PRESET_WIDTHS = { mobile: 375, tablet: 768, desktop: 1440 };
626
681
  server.tool(
627
682
  'screenshot',
628
683
  'Screenshot of the visible viewport only (PNG), at the current scroll position, or one per viewport preset with presets. Read-only. '
@@ -631,7 +686,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
631
686
  {
632
687
  tab_id: tabId,
633
688
  save_to: saveToField('the PNG'),
634
- presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('One capture per preset, window restored; save_to = directory, one file each'),
689
+ presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('Resizes the window per preset, then restores it; not phone emulation. A refused width is reported, not shot. save_to = directory'),
635
690
  },
636
691
  async ({ tab_id, save_to, presets }) => {
637
692
  guardWrite(save_to);
@@ -643,10 +698,24 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
643
698
  const notes = [];
644
699
  try {
645
700
  for (const preset of presets) {
646
- await send(MessageType.VIEWPORT_RESIZE, { preset, tab_id });
701
+ const resized = await send(MessageType.VIEWPORT_RESIZE, { preset, tab_id });
647
702
  await new Promise((r) => setTimeout(r, 400));
648
703
  const shot = await send(MessageType.SCREENSHOT, { tab_id });
649
704
  if (!shot?.image) { notes.push(`${preset}: no image`); continue; }
705
+ // presets ridimensiona la finestra, non emula un dispositivo: il
706
+ // window manager può imporre una larghezza minima o ignorare la
707
+ // richiesta. Un'immagine desktop etichettata «mobile» porta a
708
+ // conclusioni sbagliate (2226 px su med-systems.it, 23/09/2026):
709
+ // meglio nessuna immagine e una nota esplicita.
710
+ const got = shot.viewport?.width;
711
+ const want = PRESET_WIDTHS[preset];
712
+ if (got && want && Math.abs(got - want) > Math.max(40, want * 0.15)) {
713
+ const zoom = resized?.zoom && resized.zoom !== 1 ? `, page zoom ${Math.round(resized.zoom * 100)}%` : '';
714
+ notes.push(`${preset}: NOT APPLIED — viewport stayed ${got}×${shot.viewport.height} CSS px (asked ~${want}${zoom}); `
715
+ + 'the window manager refused the width. No capture saved: this would not be a ' + preset + ' rendering. '
716
+ + 'Device emulation needs a headless browser.');
717
+ continue;
718
+ }
650
719
  if (save_to) {
651
720
  await mkdir(save_to, { recursive: true });
652
721
  const path = join(save_to, `${preset}.png`);
@@ -1083,12 +1152,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1083
1152
  max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps, so an infinite feed terminates'),
1084
1153
  step_px: z.number().optional().describe('px per step, default viewport height'),
1085
1154
  settle_ms: z.number().optional().default(400).describe('Pause ms after each step'),
1155
+ container: z.string().optional().describe('until: selector that scrolls; default the document, or the largest scrollable element if the document does not scroll'),
1086
1156
  tab_id: tabId,
1087
1157
  frame_id: frameId,
1088
1158
  },
1089
- async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, tab_id, frame_id }) => {
1159
+ async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, container, tab_id, frame_id }) => {
1090
1160
  const data = (action ?? 'to') === 'until'
1091
- ? await send(MessageType.SCROLL_UNTIL, { until, selector, max_scrolls, step_px, settle_ms, tab_id })
1161
+ ? await send(MessageType.SCROLL_UNTIL, { until, selector, container, max_scrolls, step_px, settle_ms, tab_id })
1092
1162
  : await send(MessageType.SCROLL_TO, { selector, x, y, behavior, offset_y, tab_id, frame_id });
1093
1163
  return { content: [{ type: 'text', text: jsonText(data) }] };
1094
1164
  }
@@ -1502,12 +1572,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1502
1572
  .slice(0, 5)
1503
1573
  .map((c) => c.e);
1504
1574
  if (els.length) {
1505
- const refMap = new Map();
1506
- els.forEach((e, i) => {
1507
- e.ref = `n${i + 1}`;
1508
- if (e.selector) refMap.set(e.ref, e.selector);
1509
- });
1510
- interactivesRefs.set(refsKey(tab_id), refMap);
1575
+ assignRefs(tab_id, els);
1511
1576
  near = truncateText(interactivesLines({ count: els.length, elements: els, note: 'near first match' }), 1200);
1512
1577
  }
1513
1578
  } catch {}
@@ -2200,13 +2265,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2200
2265
  },
2201
2266
  async ({ scope, limit, visible_only, format, frame_id, tab_id }) => {
2202
2267
  const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
2203
- // Assegna ref n1..nN e memorizza la mappa ref → selector per click/type_text/hover
2204
- const refMap = new Map();
2205
- (data?.elements ?? []).forEach((e, i) => {
2206
- e.ref = `n${i + 1}`;
2207
- if (e.selector) refMap.set(e.ref, e.selector);
2208
- });
2209
- interactivesRefs.set(refsKey(tab_id), refMap);
2268
+ // Ref stabili per selettore, memorizzati per click/type_text/hover
2269
+ assignRefs(tab_id, data?.elements ?? []);
2210
2270
  if ((format ?? 'lines') === 'json') {
2211
2271
  return { content: [{ type: 'text', text: jsonText(data) }] };
2212
2272
  }
@@ -150,8 +150,10 @@ highlighted diff; zoom into a region with `element_screenshot({region, scale:2})
150
150
  ### Three viewports
151
151
  Triggers: "check it on mobile/tablet/desktop", «com'è su telefono?».
152
152
  `screenshot({presets:['mobile','tablet','desktop'], save_to:'./shots'})`: one
153
- call, one file per viewport, window restored. Report overflow, overlapping
154
- elements, hidden CTAs. Dark mode: `emulate_media({colorScheme:'dark'})`.
153
+ call, one file per viewport, window restored. Presets resize the window and
154
+ do not emulate a phone: a width the window manager refuses comes back as
155
+ `NOT APPLIED`, with no image — say so instead of judging the mobile layout.
156
+ Report overflow, overlapping elements, hidden CTAs. Dark mode: `emulate_media({colorScheme:'dark'})`.
155
157
  Print stylesheet: `emulate_media({printMode:true})` then `screenshot`.
156
158
 
157
159
  ### Accessibility and keyboard navigation
@@ -264,6 +266,21 @@ Triggers: "download the orders export and tell me…".
264
266
  `click` the export button → `manage_downloads({action:'wait_for_complete'})` → read the file
265
267
  with Claude Code. Spam in signups: `extract_table({where:{email:'/\\.ru$|xn--/'}})`.
266
268
 
269
+ ### Heavy web apps (Meta Ads Manager, Google Ads, big back offices)
270
+ Triggers: "change the campaign budget", "read the ad set breakdown", «modifica
271
+ la campagna su Ads Manager», «leggi i dati dell'inserzione».
272
+ Keep the tab visible first: `get_page_info` must say `visibility: 'visible'`,
273
+ else `create_tab({url, new_window:true, left, top, width, height})` on screen.
274
+ Menus without ARIA roles: `find_text({text})` on the label, not
275
+ `get_interactives` scoped by role. The app scrolls inside a panel:
276
+ `scroll({action:'until', container})` (it picks the largest scrollable panel
277
+ when the page itself does not scroll). Virtualized lists render the next rows
278
+ only after a frame: scroll in one call, click in the next. Fields that save on
279
+ Enter: `type_text` then `press_key({key:'Enter'})`. Several clicks on a React
280
+ UI: one call per click, or `execute_js` with about 350 ms between them. A
281
+ button that opens `window.open('')` navigates the tab away: override
282
+ `window.open` with `execute_js` before clicking.
283
+
267
284
  ### Email deliverability
268
285
  Triggers: "do the site emails land in spam?". `navigate('https://www.mail-tester.com')`
269
286
  → `clipboard({action:'read'})` after copying the address → send from the site