chrome-bridge-mcp 1.23.0 → 1.23.3

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
@@ -2,20 +2,22 @@
2
2
 
3
3
  > By [frsorrentino](https://github.com/frsorrentino) · npm `chrome-bridge-mcp` · not affiliated with other projects named "chrome-bridge".
4
4
 
5
- ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-375%20unit%20%2B%2032%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
5
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-388%20unit%20%2B%2041%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
6
6
 
7
7
  **Claude Code borrows a tab from the Chrome you are already using — signed in,
8
8
  with your extensions and your cookies — works in it, and hands it back. What
9
9
  only a person can do (a login, a 2FA code, a CAPTCHA, a choice) comes back to
10
10
  you through `handoff`: a banner in the page, you act, the agent continues.**
11
11
 
12
- Measured on a form-filling task: 2.75× fewer turns and 2.28× lower cost than
13
- the official browser extension, with ~3× the toolset and no paid plan. 60
12
+ Measured against the official browser extension (paired runs, 27/09/2026, n=5
13
+ per task, same model): filling a form, 2.5× fewer turns and 1.9× lower cost;
14
+ finding one row in a 1,500-row table, 2.3× lower cost in about the same number
15
+ of turns. ~3× the toolset and no paid plan. 60
14
16
  web-development tools (navigation, DOM inspection, visual regression, audits,
15
17
  network mocking) over a local WebSocket bridge, plus a headless instance for
16
18
  CI. Self-hosted, local-only. Works on ChromeOS.
17
19
 
18
- ![The same form filled in 6 turns instead of 16.5 — 2.75× fewer turns, 2.28× lower cost](assets/readme/card1-race.png)
20
+ ![The same form filled in 6 turns instead of 15 — 2.5× fewer turns on the form, about half the cost on the form and on a 1,500-row table (paired runs, 27/09/2026, n=5)](assets/readme/card1-race.png)
19
21
 
20
22
  ## Quickstart
21
23
 
@@ -40,6 +42,16 @@ element refs, `click(ref="n1")` follows with no discovery turn in between.
40
42
  > instead: an unpacked extension is dropped on every reboot, because the
41
43
  > container isn't mounted when Chrome starts.
42
44
 
45
+ > **On Windows** `install.sh` does not run from PowerShell or cmd. Use the
46
+ > plugin path below (`claude plugin marketplace add frsorrentino/chrome-bridge`,
47
+ > then `claude plugin install chrome-bridge@chrome-bridge`) with the extension
48
+ > from the Web Store, or run `install.sh` from Git Bash. The plugin's error
49
+ > log hooks run in Git Bash, as Claude Code runs every hook, and need Python
50
+ > 3.8+ as `python3`, `python` or `py` (the Microsoft Store `python3` alias is
51
+ > skipped); without Python they stay silent and the tools work the same. For
52
+ > launch mode see `CHROME_BRIDGE_BROWSER` below: Google Chrome cannot load the
53
+ > extension there, Microsoft Edge can.
54
+
43
55
  `install.sh` registers the MCP server with `--scope user`. To do it by hand:
44
56
  `claude mcp add --scope user chrome-bridge node /path/to/server/index.js`.
45
57
  For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
@@ -94,9 +106,10 @@ the ChatGPT app required. Claude in Chrome documents Linux desktop since
94
106
  September 2026; ChromeOS and WSL stay out. Competitor figures measured on
95
107
  2026-09-01 and 2026-09-11 (`docs/analisi-2026-09-11-concorrenti.md`).
96
108
 
97
- It wins on **round trips, not payload size**: short element refs instead of the
98
- screenshot-and-click loop, `fill_form` filling N fields in one call, table
99
- filtering done server-side. Per single turn it actually costs slightly *more*.
109
+ It wins on **round trips** — short element refs instead of the
110
+ screenshot-and-click loop, `fill_form` filling N fields in one call — and, on
111
+ big pages, on **payload**: table filtering is done server-side. On the form, per single turn it actually costs
112
+ slightly *more*.
100
113
 
101
114
  The full benchmark — method, every raw run including the unfavourable ones, and
102
115
  what the harness can't measure — is in
@@ -135,6 +148,12 @@ isolated sessions or CI:
135
148
  node server/index.js --launch --headless
136
149
  ```
137
150
 
151
+ It looks for Chromium, Microsoft Edge, Brave and last Google Chrome in their
152
+ standard paths on Linux, macOS and Windows; `CHROME_BRIDGE_BROWSER` picks
153
+ another binary. Google Chrome 137+ ignores `--load-extension`, so the
154
+ extension never connects in it: use Chromium, Edge, Brave or Chrome for
155
+ Testing.
156
+
138
157
  Pair it with `session_record` + `replay` for smoke tests with no model in the
139
158
  loop. In launch mode `execute_js` falls back to `new Function` when the
140
159
  user-script toggle isn't available.
@@ -183,6 +202,7 @@ Environment variables, each with a matching CLI flag:
183
202
  | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
184
203
  | `CHROME_BRIDGE_NO_JS` / `--no-js` | unset | No arbitrary JavaScript in the page: `execute_js` and `modify_dom` leave the schema, `wait_for(condition=function)` and `javascript:`/`data:` URLs are refused. `get_status` reports `js_evaluation` |
185
204
  | `CHROME_BRIDGE_READ_ROOT` / `--read-root` | unset | Where `upload_file` may read from. Unset: any file except keys and credentials (`~/.ssh`, `~/.aws`, `~/.gnupg`, `.env*`, `id_*`, `*.pem`, `*.key`, …), symlinks resolved first. Set: only files under this directory, whatever their name — the way to upload a certificate key on purpose |
205
+ | `CHROME_BRIDGE_BROWSER` | unset | Launch mode only: the browser binary, e.g. `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`. Unset: the first of Chromium, Edge, Brave, Google Chrome found in the standard paths (on Linux Chromium, then Google Chrome). Google Chrome 137+ cannot load the extension |
186
206
  | `CHROME_BRIDGE_OBSERVE` | unset | `off` stops the local error log below. The plugin sets `hook`: its hooks keep the log and the server stays out of it |
187
207
  | `CHROME_BRIDGE_WRITE_ROOT` / `--write-root` | unset | Every path the model chooses (`save_to`, `output_path`, exports) must be under this directory, checked before the browser does any work; the server's own state under `~/.config/chrome-bridge` stays writable. The CLI is your shell and is not restricted |
188
208
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.23.0",
4
+ "version": "1.23.3",
5
5
  "type": "module",
6
- "description": "Chrome Bridge by frsorrentino: MCP server that lets Claude Code borrow a tab from your own logged-in Chrome and hand it back — logins, 2FA and CAPTCHAs stay with you (handoff). 60 tools, 2.75× fewer turns measured, ChromeOS included.",
6
+ "description": "Chrome Bridge by frsorrentino: MCP server that lets Claude Code borrow a tab from your own logged-in Chrome and hand it back — logins, 2FA and CAPTCHAs stay with you (handoff). 60 tools; about half the cost of Claude in Chrome, measured; ChromeOS included.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "server/index.js",
package/server/index.js CHANGED
@@ -13,7 +13,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
13
13
  import { WSManager } from './ws-manager.js';
14
14
  import { registerTools } from './tools.js';
15
15
  import { createObserver } from './observe.js';
16
- import { launchBrowser } from './launcher.js';
16
+ import { BRANDED_CHROME_HINT, launchBrowser } from './launcher.js';
17
17
  import { DEFAULT_PORT, VERSION } from './protocol.js';
18
18
 
19
19
  // Launch mode: browser dedicato (profilo effimero + estensione unpacked).
@@ -59,7 +59,7 @@ async function main() {
59
59
  instructions: [
60
60
  'Selectors on DOM tools pierce shadow DOM with ">>>" ("my-app >>> button.save").',
61
61
  'tab_id omitted = the tab last navigated/created in this session, else the active tab. frame_id omitted = main frame (list frames with get_frames).',
62
- 'To find targets use get_interactives, not read_page(html); its refs (n1, n2…) are the ref param of click/type_text/hover, and navigate already returns them.',
62
+ 'To find targets use get_interactives, not read_page(html); its refs (n1, n2…) are the ref param of click/type_text/hover and of fill_form fields, and navigate already returns them.',
63
63
  // Il costo dominante sono i TURNI, non i byte: un turno vale ~15-30 volte
64
64
  // un KB di output risparmiato. Queste due clausole si pagano una volta
65
65
  // qui e valgono più di qualunque ottimizzazione di schema.
@@ -74,7 +74,9 @@ async function main() {
74
74
  });
75
75
 
76
76
  // 2. Avvia il WebSocket server
77
- const wsManager = new WSManager(PORT, { host: HOST });
77
+ // Con --launch l'estensione arriva solo dopo l'avvio a freddo di Chrome: su
78
+ // una macchina carica servono decine di secondi, non i 10 s del default.
79
+ const wsManager = new WSManager(PORT, { host: HOST, ...(LAUNCH && !process.env.CHROME_BRIDGE_CONNECT_WAIT_MS && { connectWait: 30000 }) });
78
80
  await wsManager.start();
79
81
 
80
82
  // 2b. Launch mode: browser dedicato che si connette alla nostra porta.
@@ -106,6 +108,8 @@ async function main() {
106
108
  throw new Error(`--launch requires a dedicated port, but ${wsManager.port} is owned by another chrome-bridge. Unset CHROME_BRIDGE_PORT (ephemeral) or pick a free one.`);
107
109
  }
108
110
  browser = await launchBrowser({ port: wsManager.port, headless: HEADLESS });
111
+ // Il Chrome brandizzato parte ma resta muto: l'errore di connessione dice perche'
112
+ if (browser.branded) wsManager.notConnectedHint = BRANDED_CHROME_HINT;
109
113
  }
110
114
 
111
115
  // 3. Registra i tool MCP (filtrati per capability)
@@ -11,17 +11,50 @@ import { spawn } from 'node:child_process';
11
11
  import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
- import { dirname, join } from 'node:path';
14
+ import { dirname, join, win32 } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
 
17
17
  const EXTENSION_SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'extension');
18
18
 
19
- const BROWSER_CANDIDATES = [
20
- '/usr/bin/chromium',
21
- '/usr/bin/chromium-browser',
22
- '/usr/bin/google-chrome',
23
- '/usr/bin/google-chrome-stable',
24
- ];
19
+ /**
20
+ * Percorsi standard per piattaforma. Chromium, Edge e Brave prima di Google
21
+ * Chrome: il Chrome brandizzato dalla 137 ignora --load-extension, parte ma
22
+ * l'estensione non si connette mai (test Windows del 27/09: Chrome 155 muto,
23
+ * Edge 154 connesso in meno di 6 s).
24
+ */
25
+ export function browserCandidates(platform = process.platform, env = process.env) {
26
+ if (platform === 'win32') {
27
+ const bases = [env.LOCALAPPDATA, env.PROGRAMFILES, env['PROGRAMFILES(X86)']].filter(Boolean);
28
+ return [
29
+ ['Chromium', 'Application', 'chrome.exe'],
30
+ ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
31
+ ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
32
+ ['Google', 'Chrome', 'Application', 'chrome.exe'],
33
+ ].flatMap((parts) => bases.map((b) => win32.join(b, ...parts)));
34
+ }
35
+ if (platform === 'darwin') {
36
+ return [
37
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
38
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
39
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
40
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
41
+ ];
42
+ }
43
+ return [
44
+ '/usr/bin/chromium',
45
+ '/usr/bin/chromium-browser',
46
+ '/usr/bin/google-chrome',
47
+ '/usr/bin/google-chrome-stable',
48
+ ];
49
+ }
50
+
51
+ /** Google Chrome brandizzato (non Chromium, non Chrome for Testing). */
52
+ export function isBrandedChrome(path) {
53
+ return /google[\\/ _-]?chrome/i.test(path) && !/for[ _-]?testing/i.test(path);
54
+ }
55
+
56
+ export const BRANDED_CHROME_HINT = 'Google Chrome 137+ ignores --load-extension, so launch mode cannot load the extension into it:'
57
+ + ' set CHROME_BRIDGE_BROWSER to Chromium, Microsoft Edge, Brave or Chrome for Testing';
25
58
 
26
59
  export function findBrowser() {
27
60
  if (process.env.CHROME_BRIDGE_BROWSER) {
@@ -30,9 +63,10 @@ export function findBrowser() {
30
63
  }
31
64
  return process.env.CHROME_BRIDGE_BROWSER;
32
65
  }
33
- const found = BROWSER_CANDIDATES.find((p) => existsSync(p));
66
+ const candidates = browserCandidates();
67
+ const found = candidates.find((p) => existsSync(p));
34
68
  if (!found) {
35
- throw new Error(`No Chromium/Chrome binary found (tried: ${BROWSER_CANDIDATES.join(', ')}). Set CHROME_BRIDGE_BROWSER.`);
69
+ throw new Error(`No Chromium-based browser found (tried: ${candidates.join(', ')}). Set CHROME_BRIDGE_BROWSER to Chromium, Edge, Brave or Chrome for Testing.`);
36
70
  }
37
71
  return found;
38
72
  }
@@ -93,5 +127,7 @@ export async function launchBrowser({ port, headless = false }) {
93
127
  };
94
128
 
95
129
  console.error(`[chrome-bridge] launched ${browser}${headless ? ' (headless)' : ''} pid=${proc.pid}, ws port ${port}`);
96
- return { pid: proc.pid, stop };
130
+ const branded = isBrandedChrome(browser);
131
+ if (branded) console.error(`[chrome-bridge] warning: ${BRANDED_CHROME_HINT}`);
132
+ return { pid: proc.pid, stop, branded };
97
133
  }
package/server/observe.js CHANGED
@@ -40,12 +40,51 @@ const DEFAULTS = { enabled: true, dir: '', max_records: 2000, max_days: 90 };
40
40
  // \w di Python è Unicode: qui lo stesso insieme, esplicito.
41
41
  const W = '[\\p{L}\\p{N}_]';
42
42
 
43
+ // Una home in qualunque forma, come observe.py (claude-observe d3fa492, invio
44
+ // anonimo da Windows): C:\Users\x, C:/Users/x, /c/Users/x, /mnt/c/Users/x,
45
+ // /cygdrive/c/Users/x, \\?\C:\Users\x, i \\ dentro JSON, /home/x, /Users/x.
46
+ const SEP = '[\\\\/]+';
47
+ const DRIVE = '(?:(?:[\\\\/]{2}\\?[\\\\/]+)?\\b[a-z]:|/mnt/[a-z]|/cygdrive/[a-z]|/[a-z](?=[\\\\/]))';
48
+ const SEG = '[^\\\\/\\s"\'`<>|:*?,;()\\[\\]]';
49
+ // il nome della cartella puo' avere spazi: con gli spazi solo se dopo viene un separatore
50
+ const HOME_ANY_RE = new RegExp(`${DRIVE}?${SEP}(?:users|home|documents and settings)${SEP}(?:${SEG}+(?: ${SEG}+)+(?=[\\\\/])|${SEG}+)`, 'giu');
51
+ const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&');
52
+
53
+ // Le home di questa macchina senza distinzione di separatore, unita' e
54
+ // maiuscole (anche una USERPROFILE fuori da Users), e il nome utente come
55
+ // segmento di percorso.
56
+ function homeRes(env = process.env) {
57
+ const homes = [...new Set([homedir(), env.USERPROFILE || '', env.HOME || ''])];
58
+ const pats = [];
59
+ for (const h of homes.sort((a, b) => b.length - a.length)) {
60
+ let parts = h.split(/[\\/]+/u).filter((x) => x && x !== '?');
61
+ if (!parts.length) continue;
62
+ const drive = parts.length > 1 ? /^([A-Za-z]):?$/u.exec(parts[0]) : null;
63
+ let head = '';
64
+ if (drive && (parts[0].endsWith(':') || parts[0].length === 1)) {
65
+ const d = drive[1];
66
+ head = `(?:(?:[\\\\/]{2}\\?[\\\\/]+)?\\b${d}:|/mnt/${d}|/cygdrive/${d}|/${d}(?=[\\\\/]))`;
67
+ parts = parts.slice(1);
68
+ }
69
+ pats.push(head + parts.map((x) => SEP + reEscape(x)).join(''));
70
+ }
71
+ const names = new Set(homes.filter(Boolean).map((h) => basename(h.replace(/\\/gu, '/').replace(/\/+$/u, ''))));
72
+ for (const k of ['USERNAME', 'USER', 'LOGNAME']) names.add(env[k] || '');
73
+ const list = [...names].filter((n) => n.length >= 2 && !['users', 'home'].includes(n.toLowerCase())).sort((a, b) => b.length - a.length);
74
+ return [
75
+ pats.map((p) => new RegExp(`${p}(?![^\\\\/\\s"'\`<>|:*?,;()\\[\\]])`, 'giu')),
76
+ list.length ? new RegExp(`(?<=[\\\\/])(?:${list.map(reEscape).join('|')})(?=[\\\\/\\s"'\`<>|:*?,;()\\[\\]]|$)`, 'giu') : null,
77
+ ];
78
+ }
79
+ const [HOME_RES, USER_SEG_RE] = homeRes();
80
+
43
81
  export function scrub(text, limit = 300) {
44
82
  let t = String(text ?? '');
45
- const home = homedir();
46
- if (home && home !== '/') t = t.split(home).join('~');
47
83
  t = t.replace(new RegExp(`(?:${W}|[.+-])+@(?:${W}|-)+\\.(?:${W}|[.-])+`, 'gu'), '<EMAIL>');
48
84
  t = t.replace(/(https?:\/\/[^/\s?#"'»]+)[^\s"'»]*/gu, '$1/…');
85
+ for (const r of HOME_RES) t = t.replace(r, '~');
86
+ t = t.replace(HOME_ANY_RE, '~');
87
+ if (USER_SEG_RE) t = t.replace(USER_SEG_RE, '<USER>');
49
88
  t = t.replace(/\b(token|password|passwd|secret|api[_-]?key|authorization|bearer)("?\s*[:=]\s*|\s+)\S+/giu, '$1$2<SECRET>');
50
89
  t = t.replace(/\b(value_after|value|text)("?\s*[:=]?\s*)("[^"]*"|'[^']*'|«[^»]*»)/giu, '$1$2<STR>');
51
90
  t = t.replace(/\b(?=[A-Za-z0-9_\-+/]*\d)(?=[A-Za-z0-9_\-+/]*[A-Za-z])[A-Za-z0-9_\-+/=]{24,}\b/gu, '<SECRET>');
@@ -93,7 +93,7 @@ export const MessageType = Object.freeze({
93
93
  });
94
94
 
95
95
  // Versione
96
- export const VERSION = '1.23.0';
96
+ export const VERSION = '1.23.3';
97
97
 
98
98
  // Configurazione
99
99
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -149,13 +149,25 @@ export function jsResultText(data, max) {
149
149
  return fitString('result_json_prefix', JSON.stringify(value));
150
150
  }
151
151
 
152
+ // Nome di colonna del where → chiave della riga. Il nome esatto vince; poi
153
+ // senza maiuscole e spazi ai bordi: con {"sku": …} contro l'intestazione
154
+ // "SKU" il filtro non trovava nulla e il modello ritentava (3 run su 5 del
155
+ // benchmark del 27/09/2026).
156
+ const normCol = (c) => String(c).trim().toLowerCase();
157
+ function tableCell(row, col) {
158
+ if (Object.hasOwn(row, col)) return row[col];
159
+ const k = normCol(col);
160
+ const hit = Object.keys(row).find((h) => normCol(h) === k);
161
+ return hit === undefined ? undefined : row[hit];
162
+ }
163
+
152
164
  /** Una riga (oggetto colonna→valore o array di celle) soddisfa il filtro where. */
153
165
  function tableRowMatches(row, where) {
154
166
  return Object.entries(where).every(([col, needle]) => {
155
167
  const n = String(needle).toLowerCase();
156
168
  if (Array.isArray(row)) return row.some((cell) => String(cell).toLowerCase().includes(n));
157
- if (col === 'any') return Object.values(row).some((cell) => String(cell).toLowerCase().includes(n));
158
- const cell = row[col];
169
+ if (normCol(col) === 'any') return Object.values(row).some((cell) => String(cell).toLowerCase().includes(n));
170
+ const cell = tableCell(row, col);
159
171
  return cell != null && String(cell).toLowerCase().includes(n);
160
172
  });
161
173
  }
@@ -254,6 +266,12 @@ async function savedSummary(path, bytes, extra = {}) {
254
266
 
255
267
  const selectorField = (extra = '') => z.string()
256
268
  .describe(('CSS selector; ">>>" pierces shadow DOM. ' + extra).trim());
269
+ // fill_form con invio: quanto aspettare che il testo della pagina cambi, ogni
270
+ // quanto rileggerlo, quanto testo nuovo restituire al modello.
271
+ const SUBMIT_SETTLE_MS = 2000;
272
+ const SUBMIT_POLL_MS = 250;
273
+ const SUBMIT_TEXT_MAX = 800;
274
+
257
275
  const waitAfter = z.enum(['none', 'navigation', 'networkidle']).optional().default('none')
258
276
  .describe('Settle before returning: navigation waits for a page load, networkidle for quiet traffic');
259
277
 
@@ -605,6 +623,44 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
605
623
  } catch { return null; }
606
624
  }
607
625
 
626
+ // Testo della pagina (o del frame) per il confronto prima/dopo un invio.
627
+ // Resta nel server: al modello arriva solo la differenza.
628
+ async function pageText(tab_id, frame_id) {
629
+ try {
630
+ const t = await send(MessageType.READ_PAGE, { mode: 'text', tab_id, frame_id });
631
+ return typeof t === 'string' ? t : null;
632
+ } catch { return null; }
633
+ }
634
+
635
+ // Esito di un invio: url, titolo e le righe di testo che la pagina ha
636
+ // guadagnato. Il testo può arrivare dopo (fetch, redirect): si rilegge per
637
+ // al più SUBMIT_SETTLE_MS e, al primo cambiamento, una volta ancora dopo una
638
+ // pausa breve, perché un «Invio in corso…» non venga preso per l'esito.
639
+ const cutText = (t, max) => (t.length <= max ? t : `${t.slice(0, max)}…[+${t.length - max} chars: read_page for the rest]`);
640
+
641
+ async function submitOutcome(tab_id, frame_id, textBefore, snap) {
642
+ const deadline = Date.now() + SUBMIT_SETTLE_MS;
643
+ let text = await pageText(tab_id, frame_id);
644
+ while ((text == null || text === textBefore) && Date.now() < deadline) {
645
+ await new Promise((r) => setTimeout(r, SUBMIT_POLL_MS));
646
+ text = await pageText(tab_id, frame_id);
647
+ }
648
+ if (text != null && text !== textBefore) {
649
+ await new Promise((r) => setTimeout(r, SUBMIT_POLL_MS));
650
+ text = (await pageText(tab_id, frame_id)) ?? text;
651
+ }
652
+ const seen = new Set(textBefore.split('\n').map((l) => l.trim()));
653
+ const gained = text == null ? [] : text.split('\n').map((l) => l.trim()).filter((l) => l && !seen.has(l));
654
+ const snapNow = await tabSnapshot(tab_id) ?? snap;
655
+ return {
656
+ url: snapNow?.url ?? null,
657
+ title: snapNow?.title ?? null,
658
+ // truncateText suggerirebbe parametri che fill_form non ha: taglio secco.
659
+ new_text: gained.length ? cutText(gained.join('\n'), SUBMIT_TEXT_MAX) : null,
660
+ ...(!gained.length && { note: text === textBefore ? 'page text unchanged after submit' : 'page text unavailable' }),
661
+ };
662
+ }
663
+
608
664
  // Delta compatto dopo un'azione: solo ciò che è cambiato (url, title, conteggi
609
665
  // DOM con segno, fuoco). Costa pochi token quando scatta, zero quando la
610
666
  // pagina è stabile, e risparmia al client lo screenshot per capire "cosa è
@@ -1268,24 +1324,55 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1268
1324
  server.tool(
1269
1325
  'fill_form',
1270
1326
  'Batch fill form fields with React-compatible events. Handles input, select, checkbox, radio, and textarea. '
1271
- + 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly. '
1327
+ + 'Fields and submit take a CSS selector or a ref (n1, n2…) from navigate/get_interactives. '
1328
+ + 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly; '
1329
+ + 'after a submit, after_submit carries url, title and the text the page gained (the confirmation or the errors). '
1272
1330
  + 'Each field reports value_after and mismatch; page_changed carries the DOM delta.',
1273
1331
  {
1274
1332
  fields: z.array(z.object({
1275
- selector: z.string(),
1276
- value: z.string(),
1277
- })).describe('{selector, value} pairs'),
1333
+ selector: z.string().optional().describe('CSS selector; ignored when ref is given'),
1334
+ ref: z.string().optional().describe('From navigate or get_interactives, e.g. "n3"'),
1335
+ value: z.string().optional().describe('Text, option value or label; for checkbox/radio "true"/"false"'),
1336
+ checked: z.boolean().optional().describe('Checkbox/radio instead of value: true checks, false unchecks'),
1337
+ })).describe('{selector or ref, value or checked} per field'),
1278
1338
  submit_selector: z.string().optional().describe('Submit button to click after filling'),
1339
+ submit_ref: z.string().optional().describe('Submit button as a ref, instead of submit_selector'),
1279
1340
  wait_after: waitAfter,
1280
1341
  tab_id: tabId,
1281
1342
  frame_id: frameId,
1282
1343
  },
1283
- async ({ fields, submit_selector, wait_after, tab_id, frame_id }) => {
1344
+ async ({ fields, submit_selector, submit_ref, wait_after, tab_id, frame_id }) => {
1345
+ // I ref che navigate e get_interactives danno valevano per click,
1346
+ // type_text e hover ma non qui: il modello li passava comunque e
1347
+ // perdeva un turno sull'errore di validazione. Il ref vince sul
1348
+ // selettore, come negli altri tool.
1349
+ // checked: il modello lo scriveva per le checkbox e lo schema, che
1350
+ // voleva solo value, lo rifiutava (un turno, benchmark del 27/09/2026).
1351
+ // L'estensione capisce "true"/"false": la traduzione resta qui.
1352
+ const resolved = fields.map((f) => {
1353
+ const selector = resolveTarget(f.ref ? undefined : f.selector, f.ref, tab_id);
1354
+ const value = f.value ?? (typeof f.checked === 'boolean' ? String(f.checked) : undefined);
1355
+ if (value === undefined) throw new Error(`fill_form: field ${f.ref ?? f.selector} needs value or checked`);
1356
+ return { selector, value };
1357
+ });
1358
+ const submitSel = submit_ref ? resolveTarget(undefined, submit_ref, tab_id) : submit_selector;
1284
1359
  const before = await tabSnapshot(tab_id);
1285
- const data = await send(MessageType.FILL_FORM, { fields, submit_selector, tab_id, frame_id });
1360
+ const textBefore = submitSel ? await pageText(tab_id, frame_id) : null;
1361
+ const data = await send(MessageType.FILL_FORM, { fields: resolved, submit_selector: submitSel, tab_id, frame_id });
1286
1362
  const waited = await applyWaitAfter(send, wait_after, tab_id);
1287
- const changed = pageDelta(before, await tabSnapshot(tab_id));
1288
- const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
1363
+ const after = await tabSnapshot(tab_id);
1364
+ const changed = pageDelta(before, after);
1365
+ // Dopo un invio il modello vuole sapere com'è andata: prima spendeva 1-3
1366
+ // turni fra find_text, extract e read_page per leggere la conferma.
1367
+ const afterSubmit = submitSel && textBefore != null
1368
+ ? await submitOutcome(tab_id, frame_id, textBefore, after)
1369
+ : null;
1370
+ const out = {
1371
+ ...data,
1372
+ ...(waited && { wait_after: waited }),
1373
+ ...(changed && { page_changed: changed }),
1374
+ ...(afterSubmit && { after_submit: afterSubmit }),
1375
+ };
1289
1376
  // I campi che non hanno tenuto il valore in testa, in chiaro: sono la
1290
1377
  // riga che il modello deve leggere prima del JSON.
1291
1378
  const mism = (data?.fields ?? []).filter((f) => f && f.mismatch);
@@ -2014,7 +2101,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2014
2101
  selector: z.string().optional().default('table').describe('The table to read; ">>>" pierces shadow DOM'),
2015
2102
  index: z.number().optional().default(0).describe('Which table to take when the selector matches several, 0-based'),
2016
2103
  max_rows: z.number().optional().default(100).describe('Max rows returned to you (output cap).'),
2017
- where: z.record(z.string(), z.string()).optional().describe('{column: substring} rows must match, case-insensitive contains. Key "any" matches any cell.'),
2104
+ where: z.record(z.string(), z.string()).optional().describe('{column: substring} rows must match, case-insensitive (names too). Key "any" matches any cell.'),
2018
2105
  columns: z.array(z.string()).optional().describe('Return only these columns per row.'),
2019
2106
  offset: z.number().optional().default(0).describe('Skip N rows of the (filtered) set before applying max_rows.'),
2020
2107
  scan_rows: z.number().optional().default(2000).describe('Max rows materialized in-page to scan/filter; raise for very large tables.'),
@@ -37,6 +37,13 @@ export class WSManager {
37
37
  this.pingIntervalMs = opts.pingInterval ?? PING_INTERVAL_MS;
38
38
  this.pongGraceMs = opts.pongGrace ?? 10000;
39
39
  this.lastPong = 0;
40
+ // Quanto un comando aspetta un'estensione non ancora collegata prima di
41
+ // fallire. Rifiutare subito costava turni: con --launch il primo navigate
42
+ // arrivava prima che il Chrome appena avviato si collegasse (4 run su 10
43
+ // del benchmark del 27/09/2026), e il modello si arrendeva o indagava.
44
+ this.connectWaitMs = opts.connectWait ?? (Number(process.env.CHROME_BRIDGE_CONNECT_WAIT_MS) || 10000);
45
+ // Launch mode con un browser che non puo' caricare l'estensione: sostituisce il consiglio generico
46
+ this.notConnectedHint = null;
40
47
  this.stopped = false;
41
48
  this.mode = null; // 'primary' | 'relay'
42
49
  this.relayExtConnected = undefined; // relay mode: stato estensione riportato dal primary
@@ -101,18 +108,39 @@ export class WSManager {
101
108
  * Funziona identicamente in primary e relay mode.
102
109
  */
103
110
  sendCommand(type, params = {}) {
104
- return new Promise((resolve, reject) => {
105
- if (!this.isConnected()) {
111
+ if (this.isConnected()) return this._send(type, params);
112
+ return this._waitConnected(this.connectWaitMs).then((ok) => {
113
+ if (!ok) {
106
114
  // Un errore vago qui costa un turno intero al modello: dice cosa
107
115
  // osservare e qual è la prossima azione.
108
- reject(new Error(
116
+ throw new Error(
109
117
  `Chrome extension not connected (server ${this.mode} on ${this.host}:${this.port}`
110
- + `${this.mode === 'relay' ? ', reached through another chrome-bridge instance' : ''})`
111
- + ' — open Chrome, check the chrome-bridge extension is enabled and its port matches',
112
- ));
113
- return;
118
+ + `${this.mode === 'relay' ? ', reached through another chrome-bridge instance' : ''}`
119
+ + `, waited ${Math.round(this.connectWaitMs / 1000)}s)`
120
+ + ` — ${this.notConnectedHint || 'open Chrome, check the chrome-bridge extension is enabled and its port matches'}`,
121
+ );
114
122
  }
123
+ return this._send(type, params);
124
+ });
125
+ }
115
126
 
127
+ /** Attende che isConnected() diventi vero, al più `ms`; false allo scadere o allo stop. */
128
+ async _waitConnected(ms) {
129
+ // Un tool fa spesso più invii di fila (snapshot, comando, snapshot): dopo
130
+ // un'attesa appena scaduta si fallisce subito, invece di sommare N attese.
131
+ if (this._waitFailedAt && Date.now() - this._waitFailedAt < 5000) return false;
132
+ const deadline = Date.now() + ms;
133
+ while (!this.stopped && Date.now() < deadline) {
134
+ if (this.isConnected()) return true;
135
+ await new Promise((r) => setTimeout(r, Math.min(100, Math.max(1, deadline - Date.now()))));
136
+ }
137
+ const ok = !this.stopped && this.isConnected();
138
+ this._waitFailedAt = ok ? 0 : Date.now();
139
+ return ok;
140
+ }
141
+
142
+ _send(type, params) {
143
+ return new Promise((resolve, reject) => {
116
144
  const command = createCommand(type, params);
117
145
  // Il timeout di trasporto non può essere più basso di quello chiesto dal
118
146
  // chiamante: `wait_for --timeout 90000` moriva a 60 s con un messaggio