chrome-bridge-mcp 1.27.0 → 1.28.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
@@ -169,13 +169,13 @@ default; the other 23 sit in seven optional caps that the agent switches on
169
169
  mid-session with `get_status({enable: ["visual"]})`, with no restart
170
170
  (`--caps` still sets them at startup).
171
171
 
172
- ![The tools by group, from clicking a button to auditing a whole page](assets/readme/card4-toolbox.png)
172
+ ![66 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
173
173
 
174
174
  | Group | N | What's in it |
175
175
  |---|---|---|
176
176
  | Core & Navigation | 13 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
177
177
  | Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
178
- | DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `get_css_styles`, `watch_dom` |
178
+ | DOM & Inspection | 12 | `read_page`, `extract`, `extract_table`, `query_dom`, `get_css_styles`, `watch_dom` |
179
179
  | Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
180
180
  | Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
181
181
  | Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
@@ -207,6 +207,7 @@ Environment variables, each with a matching CLI flag:
207
207
  | `CHROME_BRIDGE_HOST` / `--host` | `127.0.0.1` | `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and only with a token |
208
208
  | `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
209
209
  | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. Optional caps can also be switched on at runtime with `get_status({enable})` |
210
+ | `CHROME_BRIDGE_ALWAYS_LOAD` / `--always-load` | 22 most used tools | Tools kept in context when the client loads the server whole; the others carry `_meta['anthropic/alwaysLoad'] = false` and wait behind tool search (Claude Code 2.1.285+). `all` loads every tool; a comma list picks them |
210
211
  | `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` |
211
212
  | `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 |
212
213
  | `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 |
@@ -304,12 +305,14 @@ count.
304
305
  | `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. |
305
306
  | 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. |
306
307
  | `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. |
307
- | `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. |
308
+ | `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 use `emulate_media` with `device` (via debugger), or a headless browser for larger viewports. |
308
309
  | `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`. |
309
310
  | `[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`. |
310
311
  | 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. |
311
312
  | Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
312
313
  | Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
314
+ | `claude mcp get` shows no command, arguments or environment | Expected with the plugin: since Claude Code 2.1.285 it hides them for stdio servers that plugins provide (variable names stay). `get_status` reports the server and extension versions, mode, host, port and caps; `/mcp` shows the connection. |
315
+ | Popup says «In attesa: un altro browser è collegato», `get_status` lists `refused_browsers` | Two browsers with the extension reach the same port. The one already connected keeps the bridge; the other retries every 15 s and takes over when the first disconnects. `get_status` → `browser` names the connected one. Before this, the last one to connect took over silently. |
313
316
 
314
317
  ## Documentation
315
318
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.27.0",
4
+ "version": "1.28.0",
5
5
  "description": "Bridge your browser to Claude Code: 66 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",
@@ -58,6 +58,7 @@ body {
58
58
  .dot.connected { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
59
59
  .dot.connecting { background: #f39c12; }
60
60
  .dot.disconnected { background: var(--err); }
61
+ .dot.refused { background: #f39c12; }
61
62
  #status-text { font-weight: 600; }
62
63
  .status-row .ws-url {
63
64
  margin-left: auto;
@@ -4,6 +4,7 @@ const stateLabels = {
4
4
  connected: 'Connesso',
5
5
  connecting: 'Connessione…',
6
6
  disconnected: 'Disconnesso',
7
+ refused: 'In attesa: un altro browser è collegato',
7
8
  };
8
9
 
9
10
  function renderState(state) {
@@ -99,7 +99,38 @@ const KEEPALIVE_ALARM = 'chrome-bridge-keepalive';
99
99
  let ws = null;
100
100
  let reconnectTimer = null;
101
101
  let reconnectDelay = RECONNECT_BASE_MS;
102
- let connectionState = 'disconnected'; // 'connected' | 'connecting' | 'disconnected'
102
+ let connectionState = 'disconnected'; // 'connected' | 'connecting' | 'disconnected' | 'refused'
103
+ // Rifiutata perché un altro browser tiene la porta: si riprova piano, senza
104
+ // riempire il log del server a ogni secondo.
105
+ const REFUSED_RETRY_MS = 15000;
106
+ let refusedBy = null;
107
+
108
+ // Identità del browser per il server: un id che resta fra un riavvio e
109
+ // l'altro (lo stesso browser che si riconnette sostituisce la sua vecchia
110
+ // connessione, un altro browser no) e un'etichetta leggibile in get_status.
111
+ // Calcolata una volta: all'apertura del socket l'ext_init deve partire
112
+ // subito, o il server chiude la connessione non identificata (sotto carico lo
113
+ // storage può metterci secondi). Prima di loadConfig launchMode non è noto:
114
+ // si chiede dopo il primo connect.
115
+ let identityPromise = null;
116
+ function cachedIdentity() {
117
+ identityPromise ??= browserIdentity().catch(() => ({ id: null, label: 'Chromium-based browser' }));
118
+ return identityPromise;
119
+ }
120
+
121
+ async function browserIdentity() {
122
+ let { browserId } = await chrome.storage.local.get({ browserId: null });
123
+ if (!browserId) {
124
+ browserId = crypto.randomUUID();
125
+ await chrome.storage.local.set({ browserId });
126
+ }
127
+ const brands = navigator.userAgentData?.brands ?? [];
128
+ const brand = brands.find((b) => !/Not.?A.?Brand|^Chromium$/i.test(b.brand)) ?? brands.find((b) => /Chromium/i.test(b.brand));
129
+ const os = (await chrome.runtime.getPlatformInfo().catch(() => null))?.os ?? navigator.userAgentData?.platform ?? '';
130
+ const label = [brand ? `${brand.brand} ${brand.version}` : 'Chromium-based browser', os && `on ${os}`, launchMode && '(launch mode)']
131
+ .filter(Boolean).join(' ');
132
+ return { id: browserId, label };
133
+ }
103
134
 
104
135
  let serverVersion = null;
105
136
  let sessionStats = { toolCallCount: 0, lastTool: null, lastToolTs: null, recentErrors: [] };
@@ -184,6 +215,7 @@ function connect() {
184
215
  }
185
216
 
186
217
  setConnectionState('connecting');
218
+ cachedIdentity();
187
219
 
188
220
  try {
189
221
  ws = new WebSocket(wsUrl);
@@ -195,12 +227,14 @@ function connect() {
195
227
 
196
228
  const socket = ws;
197
229
 
198
- ws.onopen = () => {
230
+ ws.onopen = async () => {
199
231
  console.log('[chrome-bridge] Connected to MCP server');
200
- const init = { type: 'ext_init', version: chrome.runtime.getManifest().version };
232
+ const init = { type: 'ext_init', version: chrome.runtime.getManifest().version, browser: await cachedIdentity() };
201
233
  if (extToken) init.token = extToken;
202
- ws.send(JSON.stringify(init));
234
+ if (socket.readyState !== WebSocket.OPEN) return;
235
+ socket.send(JSON.stringify(init));
203
236
  setConnectionState('connected');
237
+ refusedBy = null;
204
238
  reconnectDelay = RECONNECT_BASE_MS; // Reset backoff
205
239
  };
206
240
 
@@ -209,7 +243,7 @@ function connect() {
209
243
  console.log('[chrome-bridge] Disconnected from MCP server');
210
244
  ws = null;
211
245
  serverVersion = null;
212
- setConnectionState('disconnected');
246
+ setConnectionState(refusedBy ? 'refused' : 'disconnected');
213
247
  scheduleReconnect();
214
248
  };
215
249
 
@@ -233,6 +267,12 @@ function connect() {
233
267
  return;
234
268
  }
235
269
 
270
+ if (msg.type === 'ext_init_refused') {
271
+ refusedBy = msg.connected || 'another browser';
272
+ console.warn(`[chrome-bridge] Refused: ${msg.reason}`);
273
+ return;
274
+ }
275
+
236
276
  // Handshake: il server dichiara la sua versione
237
277
  if (msg.type === 'ext_init_ok') {
238
278
  serverVersion = msg.version || null;
@@ -271,7 +311,7 @@ function scheduleReconnect() {
271
311
  reconnectTimer = setTimeout(() => {
272
312
  reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
273
313
  connect();
274
- }, reconnectDelay);
314
+ }, refusedBy ? REFUSED_RETRY_MS : reconnectDelay);
275
315
  }
276
316
 
277
317
  function sendMessage(obj) {
@@ -458,6 +498,10 @@ async function executeCommand(msg) {
458
498
  return await cmdScreencast(params);
459
499
  case 'cdp_target':
460
500
  return await cmdCdpTarget(params);
501
+ case 'upload_chunk':
502
+ return await cmdUploadChunk(params);
503
+ case 'handoff_end':
504
+ return await cmdHandoffEnd(params);
461
505
  case 'list_event_listeners':
462
506
  return await cmdListEventListeners(params);
463
507
  case 'monitor_websocket':
@@ -2762,6 +2806,19 @@ async function cmdHandoff({ message, pick_element = false, ask = false, pick_max
2762
2806
  });
2763
2807
  }
2764
2808
 
2809
+ // handoff_end: l'utente ha risposto nel terminale (elicitation MCP) prima che
2810
+ // nella pagina. Il banner va tolto e l'handoff in attesa finisce con quella
2811
+ // risposta, così la chiamata handoff originale ritorna una sola volta.
2812
+ async function cmdHandoffEnd({ action = 'done', answer, tab_id }) {
2813
+ const tabId = await resolveTabId(tab_id);
2814
+ const entry = handoffs.get(tabId);
2815
+ if (!entry) return { ended: false };
2816
+ try { await chrome.scripting.executeScript({ target: { tabId }, func: () => document.getElementById('cb-handoff-host')?.remove() }); } catch { /* tab chiusa */ }
2817
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
2818
+ entry.resolve({ done: action === 'done', action, url: tab?.url ?? null, via: 'terminal', ...(typeof answer === 'string' && { answer }) });
2819
+ return { ended: true };
2820
+ }
2821
+
2765
2822
  // --- keyboard_walk ---
2766
2823
  // Ordine di tabulazione calcolato (tabindex>0 crescente, poi ordine DOM) e
2767
2824
  // focus programmatico elemento per elemento. NON sono veri tasti Tab: un
@@ -3575,27 +3632,55 @@ async function cmdGetFrames({ tab_id }) {
3575
3632
 
3576
3633
  // --- upload_file ---
3577
3634
 
3578
- async function cmdUploadFile({ selector, name, mime_type, content_b64, tab_id }) {
3635
+ // upload_chunk: i file oltre 6 MB arrivano a pezzi (server/upload.js). La
3636
+ // pagina li tiene in window.__cbUploads finché upload_file li monta, o un
3637
+ // abort li butta.
3638
+ async function cmdUploadChunk({ upload_id, index, total, content_b64, abort = false, tab_id }) {
3639
+ if (!upload_id) throw new Error('Missing required parameter: upload_id');
3640
+ const tabId = await resolveTabId(tab_id);
3641
+ const results = await chrome.scripting.executeScript({
3642
+ target: { tabId },
3643
+ func: (id, i, n, b64, drop) => {
3644
+ const all = (window.__cbUploads ??= {});
3645
+ if (drop) { delete all[id]; return { aborted: true }; }
3646
+ const u = (all[id] ??= { parts: [], total: n });
3647
+ u.parts[i] = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
3648
+ return { received: i + 1, total: n };
3649
+ },
3650
+ args: [upload_id, index ?? 0, total ?? 1, content_b64 ?? '', abort],
3651
+ world: 'MAIN',
3652
+ });
3653
+ return results?.[0]?.result ?? { received: 0 };
3654
+ }
3655
+
3656
+ async function cmdUploadFile({ selector, name, mime_type, content_b64, upload_id, size, tab_id }) {
3579
3657
  if (!selector) throw new Error('Missing required parameter: selector');
3580
- if (!content_b64) throw new Error('Missing required parameter: content_b64');
3658
+ if (!content_b64 && !upload_id) throw new Error('Missing required parameter: content_b64');
3581
3659
  const tabId = await resolveTabId(tab_id);
3582
3660
  const results = await chrome.scripting.executeScript({
3583
3661
  target: { tabId },
3584
- func: (sel, fname, mime, b64) => {
3662
+ func: (sel, fname, mime, b64, id, expected) => {
3663
+ const pieces = id ? window.__cbUploads?.[id] : null;
3664
+ if (id) delete window.__cbUploads?.[id];
3585
3665
  const el = document.querySelector(sel);
3586
3666
  if (!el) throw new Error(`Element not found: ${sel}`);
3587
3667
  // Un throw qui si perderebbe (elementOutcome lo leggerebbe come «not found»): esito esplicito.
3588
3668
  if (!(el instanceof HTMLInputElement) || el.type !== 'file') return { uploaded: false, error: 'Element is not an input[type=file]' };
3589
- const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
3669
+ if (id && (!pieces || pieces.parts.filter(Boolean).length !== pieces.total)) {
3670
+ return { uploaded: false, error: `Upload ${id} incomplete: ${pieces ? pieces.parts.filter(Boolean).length : 0}/${pieces?.total ?? '?'} pieces (page reloaded meanwhile?)` };
3671
+ }
3672
+ const bytes = id ? new Blob(pieces.parts) : Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
3673
+ const length = id ? bytes.size : bytes.length;
3674
+ if (id && expected != null && length !== expected) return { uploaded: false, error: `Upload ${id}: ${length} bytes arrived, ${expected} expected` };
3590
3675
  const file = new File([bytes], fname, { type: mime });
3591
3676
  const dt = new DataTransfer();
3592
3677
  dt.items.add(file);
3593
3678
  el.files = dt.files;
3594
3679
  el.dispatchEvent(new Event('input', { bubbles: true }));
3595
3680
  el.dispatchEvent(new Event('change', { bubbles: true }));
3596
- return { uploaded: fname, size: bytes.length, mime };
3681
+ return { uploaded: fname, size: length, mime, ...(id && { pieces: pieces.total }) };
3597
3682
  },
3598
- args: [selector, name, mime_type, content_b64],
3683
+ args: [selector, name, mime_type, content_b64 ?? null, upload_id ?? null, size ?? null],
3599
3684
  world: 'MAIN',
3600
3685
  });
3601
3686
  return elementOutcome(results, selector);
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.27.0",
4
+ "version": "1.28.0",
5
5
  "type": "module",
6
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). 66 tools; 2.8-4.3× cheaper and up to 9.3× fewer turns than Claude in Chrome, measured; ChromeOS included.",
7
7
  "main": "server/index.js",
package/server/cli.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * chrome-bridge screenshot --out /tmp/shot.png
14
14
  */
15
15
 
16
+ import { uploadBuffer } from './upload.js';
16
17
  import WebSocket from 'ws';
17
18
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
18
19
  import { basename, extname, join } from 'node:path';
@@ -421,11 +422,9 @@ async function run(client, command, params, opts) {
421
422
  // Comandi con file I/O lato CLI
422
423
  if (command === 'upload_file') {
423
424
  const buf = await readFile(params.path);
424
- if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
425
425
  const mime = params.mime_type || MIME_BY_EXT[extname(params.path).toLowerCase()] || 'application/octet-stream';
426
- const data = await client.sendCommand(MessageType.UPLOAD_FILE, {
427
- selector: params.selector, name: basename(params.path), mime_type: mime,
428
- content_b64: buf.toString('base64'), tab_id: params.tab_id,
426
+ const data = await uploadBuffer((t, p) => client.sendCommand(t, p), {
427
+ selector: params.selector, name: basename(params.path), mime_type: mime, buf, tab_id: params.tab_id,
429
428
  });
430
429
  return JSON.stringify(data);
431
430
  }
package/server/index.js CHANGED
@@ -39,6 +39,7 @@ function parseSecurity() {
39
39
  noJs: process.argv.includes('--no-js') || on(process.env.CHROME_BRIDGE_NO_JS),
40
40
  writeRoot: argValue('--write-root') ?? process.env.CHROME_BRIDGE_WRITE_ROOT ?? null,
41
41
  readRoot: argValue('--read-root') ?? process.env.CHROME_BRIDGE_READ_ROOT ?? null,
42
+ alwaysLoad: argValue('--always-load') ?? process.env.CHROME_BRIDGE_ALWAYS_LOAD ?? null,
42
43
  };
43
44
  }
44
45
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { spawn } from 'node:child_process';
11
- import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
11
+ import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { dirname, join, win32 } from 'node:path';
@@ -71,9 +71,45 @@ export function findBrowser() {
71
71
  return found;
72
72
  }
73
73
 
74
+ const LAUNCH_PREFIX = 'chrome-bridge-launch-';
75
+ const STALE_WITHOUT_OWNER_MS = 24 * 3600 * 1000;
76
+
77
+ function processAlive(pid) {
78
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
79
+ }
80
+
81
+ // Cartelle di sessioni launch finite male o mai pulite: su Windows Edge tiene
82
+ // i file del profilo anche dopo la chiusura, rm falliva in silenzio e restavano
83
+ // circa 47 MB a sessione (01-02/10/2026: 73 cartelle, 3,5 GB in %TEMP%). Ogni
84
+ // cartella dice di chi è (owner.json con il pid del server): si cancella se
85
+ // quel processo non esiste più. Senza owner.json (versioni fino alla 1.27) solo
86
+ // dopo 24 ore, per non toccare una sessione in corso.
87
+ export async function sweepStaleLaunchDirs({ dir = tmpdir(), now = Date.now(), alive = processAlive } = {}) {
88
+ const removed = [];
89
+ let names = [];
90
+ try { names = await readdir(dir); } catch { return removed; }
91
+ for (const name of names.filter((n) => n.startsWith(LAUNCH_PREFIX))) {
92
+ const path = join(dir, name);
93
+ let stale;
94
+ try {
95
+ const owner = JSON.parse(await readFile(join(path, 'owner.json'), 'utf8'));
96
+ stale = !alive(owner.pid);
97
+ } catch {
98
+ try { stale = now - (await stat(path)).mtimeMs > STALE_WITHOUT_OWNER_MS; } catch { stale = false; }
99
+ }
100
+ if (!stale) continue;
101
+ try {
102
+ await rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
103
+ removed.push(name);
104
+ } catch { /* ancora bloccata: ci riprova il prossimo avvio */ }
105
+ }
106
+ return removed;
107
+ }
108
+
74
109
  /** Prepara dir temporanee: copia estensione + launch.json, profilo con dev mode. */
75
110
  export async function prepareLaunch(port) {
76
- const base = await mkdtemp(join(tmpdir(), 'chrome-bridge-launch-'));
111
+ const base = await mkdtemp(join(tmpdir(), LAUNCH_PREFIX));
112
+ await writeFile(join(base, 'owner.json'), JSON.stringify({ pid: process.pid, started: new Date().toISOString() }));
77
113
  const extDir = join(base, 'ext');
78
114
  const profileDir = join(base, 'profile');
79
115
 
@@ -95,6 +131,8 @@ export async function prepareLaunch(port) {
95
131
  */
96
132
  export async function launchBrowser({ port, headless = false }) {
97
133
  const browser = findBrowser();
134
+ const swept = await sweepStaleLaunchDirs();
135
+ if (swept.length) console.error(`[chrome-bridge] removed ${swept.length} leftover launch profile(s) from earlier sessions`);
98
136
  const { base, extDir, profileDir } = await prepareLaunch(port);
99
137
 
100
138
  const args = [
@@ -128,7 +166,13 @@ export async function launchBrowser({ port, headless = false }) {
128
166
  proc.once('exit', () => { clearTimeout(t); resolve(); });
129
167
  });
130
168
  }
131
- await rm(base, { recursive: true, force: true }).catch(() => {});
169
+ // Su Windows i processi figli del browser rilasciano i file qualche
170
+ // istante dopo l'uscita del principale: ritentativi invece di un solo
171
+ // tentativo silenzioso. Se non basta, la spazzata del prossimo avvio la
172
+ // trova (owner.json con un pid morto).
173
+ await rm(base, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }).catch((e) => {
174
+ console.error(`[chrome-bridge] could not remove ${base} (${e.code ?? e.message}); the next launch removes it`);
175
+ });
132
176
  };
133
177
 
134
178
  console.error(`[chrome-bridge] launched ${browser}${headless ? ' (headless)' : ''} pid=${proc.pid}, ws port ${port}`);
@@ -44,6 +44,7 @@ export const MessageType = Object.freeze({
44
44
  MOVE_TAB: 'move_tab',
45
45
  TILE_WINDOWS: 'tile_windows',
46
46
  UPLOAD_FILE: 'upload_file',
47
+ UPLOAD_CHUNK: 'upload_chunk',
47
48
  WAIT_FOR_NAVIGATION: 'wait_for_navigation',
48
49
  WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
49
50
  WAIT_FOR_TEXT: 'wait_for_text',
@@ -82,6 +83,7 @@ export const MessageType = Object.freeze({
82
83
  PERF_TRACE: 'perf_trace',
83
84
  SCREENCAST: 'screencast',
84
85
  CDP_TARGET: 'cdp_target',
86
+ HANDOFF_END: 'handoff_end',
85
87
 
86
88
  // Risposte (extension → server)
87
89
  RESULT: 'result',
@@ -97,7 +99,7 @@ export const MessageType = Object.freeze({
97
99
  });
98
100
 
99
101
  // Versione
100
- export const VERSION = '1.27.0';
102
+ export const VERSION = '1.28.0';
101
103
 
102
104
  // Configurazione
103
105
  export const DEFAULT_PORT = 8765;
@@ -149,6 +151,7 @@ export function getTimeout(type) {
149
151
  || type === MessageType.WAIT_FOR_NETWORK_IDLE
150
152
  || type === MessageType.WAIT_FOR_TEXT
151
153
  || type === MessageType.UPLOAD_FILE
154
+ || type === MessageType.UPLOAD_CHUNK
152
155
  || type === MessageType.MANAGE_DOWNLOADS
153
156
  || type === MessageType.SAVE_PAGE
154
157
  || type === MessageType.HTTP_REQUEST
package/server/tools.js CHANGED
@@ -26,6 +26,7 @@ import { windowLayout } from './layouts.js';
26
26
  import { fingerprintDelta } from './effect.js';
27
27
  import { analyzeTrace, traceEventsOf } from './trace-analysis.js';
28
28
  import { assembleVideo } from './video.js';
29
+ import { uploadBuffer, MAX_UPLOAD_BYTES } from './upload.js';
29
30
  import { cdpConnect, targetWsUrl, takeHeapSnapshot, summarizeHeap, diffHeap } from './heap.js';
30
31
  import { lighthouseArgs, runLighthouse, summarizeLighthouse } from './lighthouse.js';
31
32
  import { consoleLines, networkLines, interactivesLines, interactiveLine, linksLines } from './formatters.js';
@@ -273,6 +274,24 @@ export const TOOL_CAPS = {
273
274
  files: ['save_page', 'manage_downloads', 'session_record'],
274
275
  };
275
276
 
277
+ // Tool sempre caricati nel contesto quando il client carica il server per
278
+ // intero (alwaysLoad del plugin). Gli altri portano
279
+ // _meta['anthropic/alwaysLoad'] = false e restano dietro la ricerca dei tool
280
+ // (Claude Code 2.1.285+): lo schema pagato a ogni sessione scende, e un tool
281
+ // rimandato si trova con ToolSearch. Scelti dall'uso reale: ogni tool usato
282
+ // almeno 5 volte in 61 sessioni dell'autore (docs/analisi-2026-09-01.md, B.4),
283
+ // più extract_table e get_css_styles: le istruzioni del server li indicano per
284
+ // tabelle e debug, e nel benchmark appaiato del 01/10 (w128a) ogni run heavy
285
+ // caricava extract_table con ToolSearch, un turno in più, e le debug
286
+ // get_css_styles.
287
+ // CHROME_BRIDGE_ALWAYS_LOAD=all li rimette tutti, o una lista ne sceglie altri.
288
+ export const EAGER_TOOLS = [
289
+ 'execute_js', 'navigate', 'screenshot', 'find_text', 'click', 'get_interactives', 'create_tab', 'get_tabs',
290
+ 'tab_action', 'tile_windows', 'full_page_screenshot', 'extract', 'read_page', 'get_status', 'scroll',
291
+ 'wait_for', 'element_screenshot', 'type_text', 'window_layout', 'fill_form', 'extract_table', 'get_css_styles',
292
+ ];
293
+ const DEFER_META = { 'anthropic/alwaysLoad': false };
294
+
276
295
  // Parametri ubiqui: un solo testo, così `tab_id` non significa una cosa in un
277
296
  // tool e un'altra nel gemello. test/unit/tool-parameters.test.js lo verifica.
278
297
  // save_to: il payload va su disco e nel contesto resta solo il percorso più un
@@ -499,8 +518,14 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
499
518
  // fuori dai gruppi attivi, e le annotations vengono applicate da TOOL_ANNOTATIONS.
500
519
  // Un solo wrapper per entrambe le cose, altrimenti con caps != 'all' le
501
520
  // annotations sparivano insieme al filtro.
521
+ // Il server MCP a basso livello (capability del client, elicitation): il
522
+ // wrapper qui sotto espone solo tool().
523
+ const lowLevelServer = server.server ?? null;
502
524
  {
503
525
  const target = server;
526
+ const eagerOpt = options.alwaysLoad ?? null;
527
+ const eager = eagerOpt === 'all' ? null
528
+ : new Set(eagerOpt ? String(eagerOpt).split(',').map((s) => s.trim()).filter(Boolean) : EAGER_TOOLS);
504
529
  const enabled = caps !== 'all'
505
530
  ? new Set(String(caps).split(',').map((s) => s.trim()).filter(Boolean))
506
531
  : null;
@@ -517,6 +542,9 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
517
542
  // Un tool senza voce resta registrato (meglio di un crash all'avvio):
518
543
  // è il test tool-annotations a segnalarlo.
519
544
  const reg = annotations ? target.tool(name, desc, schema, annotations, run) : target.tool(name, desc, schema, run);
545
+ // Assegnato e non con update(): update() manderebbe tools/list_changed
546
+ // per ogni tool, prima ancora che il client sia collegato.
547
+ if (eager && !eager.has(name) && reg && typeof reg === 'object') reg._meta = { ...(reg._meta ?? {}), ...DEFER_META };
520
548
  if (inactive) { reg.disable(); dormant.set(name, reg); }
521
549
  },
522
550
  };
@@ -843,6 +871,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
843
871
  port: wsManager.port,
844
872
  version: VERSION,
845
873
  extension_version: wsManager.extVersion ?? null,
874
+ ...(wsManager.browser && { browser: wsManager.browser.label }),
875
+ ...(wsManager.refusedBrowsers?.length && { refused_browsers: wsManager.refusedBrowsers.map((r) => `${r.label} at ${r.at}`) }),
846
876
  // Un agente che non trova accessibility_audit non aveva modo di
847
877
  // scoprire che esiste ma è in un gruppo disattivato.
848
878
  js_evaluation: !noJs,
@@ -2219,7 +2249,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2219
2249
  // --- upload_file ---
2220
2250
  server.tool(
2221
2251
  'upload_file',
2222
- 'Set a file on input[type=file] from the server filesystem via DataTransfer (max 10MB). Keys and credentials (~/.ssh, ~/.aws, '
2252
+ 'Set a file on input[type=file] from the server filesystem via DataTransfer (max 200 MB; over 6 MB it travels in pieces). Keys and credentials (~/.ssh, ~/.aws, '
2223
2253
  + '~/.gnupg, .env*, *.pem, *.key, id_*…) are refused, and a --read-root on the server limits the readable tree: the call fails '
2224
2254
  + 'with an error, nothing is read.',
2225
2255
  {
@@ -2229,12 +2259,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2229
2259
  tab_id: tabId,
2230
2260
  },
2231
2261
  async ({ selector, path, mime_type, tab_id }) => {
2232
- const buf = await readFile(await guardRead(path));
2233
- if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
2262
+ const abs = await guardRead(path);
2263
+ const { size } = await stat(abs);
2264
+ if (size > MAX_UPLOAD_BYTES) throw new Error(`File too large: ${size} bytes (max ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
2265
+ const buf = await readFile(abs);
2234
2266
  const mime = mime_type || MIME_BY_EXT[extname(path).toLowerCase()] || 'application/octet-stream';
2235
- const data = await send(MessageType.UPLOAD_FILE, {
2236
- selector, name: basename(path), mime_type: mime, content_b64: buf.toString('base64'), tab_id,
2237
- });
2267
+ const data = await uploadBuffer(send, { selector, name: basename(path), mime_type: mime, buf, tab_id });
2238
2268
  return { content: [{ type: 'text', text: jsonText(data) }] };
2239
2269
  }
2240
2270
  );
@@ -2516,11 +2546,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2516
2546
  ask: z.boolean().optional().default(false).describe('Show a text box in the banner; the reply comes back as answer'),
2517
2547
  pick_max: z.number().optional().default(1).describe('With pick_element: up to N elements, the user presses Done when finished'),
2518
2548
  timeout: z.number().optional().default(300000).describe('ms to wait for the click, default 5 min'),
2549
+ in_terminal: z.boolean().optional().default(true).describe('Also ask in the Claude Code terminal (MCP elicitation) when the client supports it; the first answer wins. Not for pick_element'),
2519
2550
  tab_id: tabId,
2520
2551
  },
2521
- async ({ message, pick_element, ask, pick_max, timeout, tab_id }) => {
2522
- const d = await send(MessageType.HANDOFF, { message, pick_element, ask, pick_max, timeout, tab_id });
2523
- const lines = [`handoff ${d.action}${d.url ? ` url=${d.url}` : ''}`];
2552
+ async ({ message, pick_element, ask, pick_max, timeout, in_terminal, tab_id }) => {
2553
+ const banner = send(MessageType.HANDOFF, { message, pick_element, ask, pick_max, timeout, tab_id });
2554
+ const d = in_terminal !== false && !pick_element ? await withTerminalAsk(banner, { message, ask, tab_id }) : await banner;
2555
+ const lines = [`handoff ${d.action}${d.url ? ` url=${d.url}` : ''}${d.via === 'terminal' ? ' via=terminal' : ''}`];
2524
2556
  if (ask && typeof d.answer === 'string' && d.answer.trim()) lines.push(`answer: ${d.answer.trim()}`);
2525
2557
  const fmt = (p) => `${p.selector}\t${p.tag}\t${p.text}\t@${p.rect.x},${p.rect.y} ${p.rect.width}x${p.rect.height}`;
2526
2558
  if (Array.isArray(d.picked_all) && d.picked_all.length) {
@@ -2529,10 +2561,45 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2529
2561
  lines.push(`picked ${fmt(d.picked)}`);
2530
2562
  }
2531
2563
  if (d.action === 'timeout') lines.push('the user did not click within the timeout: ask before retrying');
2564
+ if (d.banner_left_open) lines.push('answered in the terminal; the page banner is still open (extension older than the server): the user can close it');
2532
2565
  return { content: [{ type: 'text', text: lines.join('\n') }] };
2533
2566
  }
2534
2567
  );
2535
2568
 
2569
+ // handoff anche nel terminale: chi lavora con Claude Code spesso guarda il
2570
+ // terminale, non il browser. Una richiesta form (elicitation MCP) corre in
2571
+ // parallelo al banner e vince la prima risposta: dal terminale si chiude il
2572
+ // banner (handoff_end), dal banner si annulla la richiesta nel terminale.
2573
+ // La modalità URL dell'elicitation non serve qui: aprirebbe il link nel
2574
+ // browser predefinito, che può non essere il Chrome in cui lavora l'agente.
2575
+ async function withTerminalAsk(banner, { message, ask, tab_id }) {
2576
+ const lowLevel = lowLevelServer;
2577
+ if (!lowLevel?.getClientCapabilities?.()?.elicitation) return banner;
2578
+ const abort = new AbortController();
2579
+ const requestedSchema = ask
2580
+ ? { type: 'object', properties: { answer: { type: 'string', title: 'Reply' } }, required: ['answer'] }
2581
+ : { type: 'object', properties: {} };
2582
+ const terminal = lowLevel.elicitInput(
2583
+ { mode: 'form', message: `${message}\n\nDo it in Chrome (a banner there waits too), then accept here; decline to cancel.`, requestedSchema },
2584
+ { signal: abort.signal, timeout: 24 * 3600 * 1000 },
2585
+ ).then((r) => ({ from: 'terminal', r }), () => null);
2586
+ const first = await Promise.race([banner.then((d) => ({ from: 'banner', d })), terminal.then((t) => t ?? banner.then((d) => ({ from: 'banner', d })))]);
2587
+ if (first.from === 'banner') {
2588
+ abort.abort();
2589
+ return first.d;
2590
+ }
2591
+ const accepted = first.r.action === 'accept';
2592
+ const action = accepted ? 'done' : 'cancel';
2593
+ const answer = accepted && ask ? String(first.r.content?.answer ?? '') : undefined;
2594
+ try {
2595
+ const end = await send(MessageType.HANDOFF_END, { action, ...(answer != null && { answer }), tab_id });
2596
+ if (end?.ended) return banner;
2597
+ } catch { /* estensione fino alla 1.27: handoff_end non esiste */ }
2598
+ // Il banner non si è potuto chiudere (estensione vecchia, o handoff già
2599
+ // finito): vale la risposta del terminale, senza aspettare il banner.
2600
+ return { done: accepted, action, url: null, via: 'terminal', ...(answer != null && { answer }), banner_left_open: true };
2601
+ }
2602
+
2536
2603
  // --- watch ---
2537
2604
  server.tool(
2538
2605
  'watch',
@@ -0,0 +1,37 @@
1
+ /**
2
+ * upload_file oltre i 10 MB: il file passa a pezzi.
3
+ *
4
+ * Fino alla 1.27 il file viaggiava intero in un solo messaggio, in base64,
5
+ * con un tetto di 10 MB (il 01/10/2026 un video per X è stato ricompresso
6
+ * apposta). Ora sopra CHUNK_BYTES il server manda upload_chunk uno alla volta
7
+ * (ciascuno ben sotto i 32 MB di maxPayload del bridge) e la pagina li
8
+ * accumula; upload_file monta il File dai pezzi. Sotto CHUNK_BYTES resta il
9
+ * messaggio unico, che funziona anche con le estensioni fino alla 1.27.
10
+ */
11
+ import { randomBytes } from 'node:crypto';
12
+ import { MessageType } from './protocol.js';
13
+
14
+ export const CHUNK_BYTES = 6 * 1024 * 1024;
15
+ export const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
16
+
17
+ export async function uploadBuffer(send, { selector, name, mime_type, buf, tab_id }) {
18
+ if (buf.length > MAX_UPLOAD_BYTES) throw new Error(`File too large: ${buf.length} bytes (max ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
19
+ if (buf.length <= CHUNK_BYTES) {
20
+ return send(MessageType.UPLOAD_FILE, { selector, name, mime_type, content_b64: buf.toString('base64'), tab_id });
21
+ }
22
+ const upload_id = `u${randomBytes(6).toString('hex')}`;
23
+ const total = Math.ceil(buf.length / CHUNK_BYTES);
24
+ try {
25
+ for (let index = 0; index < total; index++) {
26
+ const part = buf.subarray(index * CHUNK_BYTES, (index + 1) * CHUNK_BYTES);
27
+ await send(MessageType.UPLOAD_CHUNK, { upload_id, index, total, content_b64: part.toString('base64'), tab_id });
28
+ }
29
+ } catch (err) {
30
+ await send(MessageType.UPLOAD_CHUNK, { upload_id, abort: true, tab_id }).catch(() => {});
31
+ if (/Unknown command type: upload_chunk/.test(err.message)) {
32
+ throw new Error(`Files over ${CHUNK_BYTES / 1024 / 1024} MB need the extension from version 1.28: update it, or upload a smaller file`);
33
+ }
34
+ throw err;
35
+ }
36
+ return send(MessageType.UPLOAD_FILE, { selector, name, mime_type, upload_id, size: buf.length, tab_id });
37
+ }
@@ -23,6 +23,16 @@ export function relayExpiry(msg) {
23
23
  return Math.max(PENDING_RELAY_TTL_MS, base, Number.isFinite(asked) && asked > 0 ? asked + 5000 : 0) + 5000;
24
24
  }
25
25
 
26
+ /**
27
+ * Stesso browser: stesso id persistente. Le estensioni fino alla 1.27 non
28
+ * mandano un id: fra due di queste vale la sostituzione di sempre (non si
29
+ * possono distinguere), mentre un id contro nessun id sono browser diversi.
30
+ */
31
+ export function sameBrowser(a, b) {
32
+ if (!a?.id && !b?.id) return true;
33
+ return Boolean(a?.id && b?.id && a.id === b.id);
34
+ }
35
+
26
36
  export class WSManager {
27
37
  constructor(port = DEFAULT_PORT, opts = {}) {
28
38
  this.port = port;
@@ -49,6 +59,8 @@ export class WSManager {
49
59
  this.relayExtConnected = undefined; // relay mode: stato estensione riportato dal primary
50
60
  this.relayExtVersion = null;
51
61
  this._extVersion = null; // versione estensione da ext_init (primary)
62
+ this._browser = null; // { id, label } del browser collegato, da ext_init
63
+ this.refusedBrowsers = []; // ultimi browser rifiutati perché un altro era già collegato
52
64
 
53
65
  // --- primary mode ---
54
66
  this.wss = null;
@@ -99,6 +111,11 @@ export class WSManager {
99
111
  }
100
112
 
101
113
  /** Versione dell'estensione collegata (da ext_init), o null. */
114
+ /** Browser collegato ({ id, label }) e ultimi rifiutati; in relay mode non noti. */
115
+ get browser() {
116
+ return this.mode === 'relay' ? null : this._browser;
117
+ }
118
+
102
119
  get extVersion() {
103
120
  return this.mode === 'relay' ? (this.relayExtVersion ?? null) : (this._extVersion ?? null);
104
121
  }
@@ -304,7 +321,7 @@ export class WSManager {
304
321
  ws.terminate();
305
322
  return;
306
323
  }
307
- this._setupChromeClient(ws, msg.version ?? null);
324
+ this._setupChromeClient(ws, msg.version ?? null, msg.browser ?? null);
308
325
  return;
309
326
  }
310
327
 
@@ -316,8 +333,29 @@ export class WSManager {
316
333
  ws.on('close', () => clearTimeout(idTimer));
317
334
  }
318
335
 
319
- _setupChromeClient(ws, extVersion = null) {
336
+ _setupChromeClient(ws, extVersion = null, browser = null) {
337
+ // Un secondo browser non prende più la connessione a quello collegato:
338
+ // il 01/10/2026 un Chromium di prova sulla stessa porta ha rubato la
339
+ // connessione al Chrome dell'utente, e i comandi delle altre sessioni
340
+ // finivano nel browser sbagliato. Lo stesso browser che si riconnette
341
+ // (service worker riavviato) sostituisce la sua vecchia connessione; un
342
+ // browser diverso aspetta che l'altro si scolleghi o smetta di rispondere.
343
+ const current = this.client;
344
+ const alive = current && current.readyState === WebSocket.OPEN
345
+ && Date.now() - this.lastPong <= this.pingIntervalMs * 2 + this.pongGraceMs;
346
+ if (alive && !sameBrowser(this._browser, browser)) {
347
+ const refused = { label: browser?.label ?? 'a browser with an older extension', id: browser?.id ?? null, at: new Date().toISOString() };
348
+ this.refusedBrowsers = [refused, ...this.refusedBrowsers.filter((r) => r.id !== refused.id || r.label !== refused.label)].slice(0, 5);
349
+ const connected = this._browser?.label ?? 'another browser';
350
+ console.error(`[chrome-bridge] Refused ${refused.label}: ${connected} is already connected on this port`);
351
+ try {
352
+ ws.send(JSON.stringify({ type: 'ext_init_refused', connected, reason: `${connected} is already connected to this chrome-bridge` }));
353
+ } catch {}
354
+ ws.close(4409, 'Another browser is connected');
355
+ return;
356
+ }
320
357
  this._extVersion = extVersion;
358
+ this._browser = browser;
321
359
  // Lo skew server/estensione era invisibile: con la latenza di review del
322
360
  // Chrome Web Store è la norma, non l'eccezione.
323
361
  if (extVersion && extVersion !== VERSION) {
@@ -363,6 +401,7 @@ export class WSManager {
363
401
  if (this.client === ws) {
364
402
  this.client = null;
365
403
  this._extVersion = null;
404
+ this._browser = null;
366
405
  this._broadcastExtState();
367
406
  // Rigetta pending locali
368
407
  this._rejectAllPending('Extension disconnected');