chrome-bridge-mcp 1.16.1 → 1.18.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
@@ -40,20 +40,34 @@ element refs, `click(ref="n1")` follows with no discovery turn in between.
40
40
  For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
41
41
  Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
42
42
 
43
+ **As a plugin, without a clone:** in Claude Code, `/plugin marketplace add
44
+ frsorrentino/chrome-bridge` then `/plugin install chrome-bridge@chrome-bridge`
45
+ registers the MCP server from npm (all capabilities) together with the recipes
46
+ skill; clients that read [Agent Plugins 1.0](https://agent-plugins.org) get the
47
+ same from `plugin.json` + `mcp.json`. The extension still comes from the Web
48
+ Store or `extension/`. Pick one path: the plugin and `install.sh` would
49
+ register the same server twice.
50
+
43
51
  ## Why Chrome Bridge?
44
52
 
45
53
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
46
54
  |---|---|---|---|---|
47
55
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
48
- | **Tools** | **59** (38 core) | ~20 | ~50 | 23 core (71 total) |
56
+ | **Tools** | **59** (38 core) | 22 | 29 default (56 with flags) | 24 core (71 total) |
49
57
  | **Requires paid plan** | **No** | Yes (Pro+) | No | No |
50
58
  | **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
51
59
  | **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
52
- | **Audits (a11y/SEO/sec)** | **Yes** (one call, report on disk) | No | Partial | No |
60
+ | **Audits (a11y/SEO/sec)** | **Yes** (one call, report on disk) | No | Lighthouse | No |
53
61
  | **Headless / CI** | **Yes** | No | Yes | Yes |
54
62
  | **GIF / video** | No | **Yes** | Partial | No |
55
63
  | **Breakpoints / heap** | No | No | **Yes** | No |
56
64
 
65
+ Codex for Chrome (OpenAI, May 2026) sits in the Claude in Chrome column: an
66
+ official extension with the `debugger` permission, macOS and Windows only,
67
+ the ChatGPT app required. Claude in Chrome documents Linux desktop since
68
+ September 2026; ChromeOS and WSL stay out. Competitor figures measured on
69
+ 2026-09-01 and 2026-09-11 (`docs/analisi-2026-09-11-concorrenti.md`).
70
+
57
71
  It wins on **round trips, not payload size**: short element refs instead of the
58
72
  screenshot-and-click loop, `fill_form` filling N fields in one call, table
59
73
  filtering done server-side. Per single turn it actually costs slightly *more*.
@@ -141,6 +155,8 @@ Environment variables, each with a matching CLI flag:
141
155
  | `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 |
142
156
  | `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
143
157
  | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
158
+ | `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` |
159
+ | `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 |
144
160
 
145
161
  The bridge binds loopback, accepts extension connections only from a
146
162
  `chrome-extension://` origin, and — when a token is set — requires it on both
@@ -172,7 +188,9 @@ paste into a chat.
172
188
  ## Documentation
173
189
 
174
190
  - [docs/TOOLS.md](docs/TOOLS.md) — all 59 tools, by group
191
+ - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
175
192
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
193
+ - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
176
194
  - [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
177
195
  - [CHANGELOG.md](CHANGELOG.md)
178
196
 
@@ -181,7 +199,9 @@ paste into a chat.
181
199
  `npm test` (Chrome-free, ~22s) · `npm run test:e2e` (needs Chrome and a
182
200
  connected extension; with a bridge already on 8765:
183
201
  `CHROME_BRIDGE_PORT=8799 node test/test-devtools.js --launch`, which opens
184
- its own Chromium with `extension/`) · `npm run measure` (schema cost).
202
+ its own Chromium with `extension/`) · `npm run measure` (schema cost) ·
203
+ `npm run bench:latency` (milliseconds per tool, launches its own Chromium,
204
+ writes `docs/PERFORMANCE.md`).
185
205
 
186
206
  ## License
187
207
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Chrome concede 2 captureVisibleTab al secondo per estensione; la terza entro
3
+ * il secondo fallisce con MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND. Invece di
4
+ * far vedere la quota al modello, ogni cattura aspetta il tempo che manca a
5
+ * 520 ms dalla precedente. Orologio e sleep iniettabili per i test.
6
+ */
7
+ export function createPacer({ minIntervalMs = 520, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
8
+ let last = -Infinity;
9
+ const pace = async () => {
10
+ const wait = Math.max(0, last + minIntervalMs - now());
11
+ if (wait > 0) await sleep(wait);
12
+ last = now();
13
+ return wait;
14
+ };
15
+ pace.minIntervalMs = minIntervalMs;
16
+ return pace;
17
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Impronta della pagina: pochi interi che un'azione può cambiare. Il server
3
+ * la prende prima e dopo click/fill_form e riporta solo le differenze in
4
+ * page_changed: "il menu si è aperto" senza screenshot.
5
+ *
6
+ * Autocontenuta apposta: chrome.scripting.executeScript ne serializza il
7
+ * sorgente e la esegue nella pagina, dove niente di questo modulo esiste.
8
+ * Prende `doc` per essere testabile su un DOM finto (test/unit).
9
+ */
10
+ export function pageFingerprint(doc) {
11
+ const d = doc || document;
12
+ const count = (sel) => { try { return d.querySelectorAll(sel).length; } catch { return 0; } };
13
+ let checked = 0;
14
+ try { for (const el of d.querySelectorAll('input')) if (el.checked) checked += 1; } catch { /* nessun input */ }
15
+ let focus = 'body';
16
+ const a = d.activeElement;
17
+ if (a && a.tagName) {
18
+ const tag = a.tagName.toLowerCase();
19
+ const name = a.getAttribute ? a.getAttribute('name') : null;
20
+ focus = a.id ? `${tag}#${a.id}` : (name ? `${tag}[name="${name}"]` : tag);
21
+ }
22
+ const body = d.body;
23
+ const text = body ? (typeof body.innerText === 'string' ? body.innerText : (body.textContent || '')).length : 0;
24
+ return {
25
+ url: d.location ? d.location.href : null,
26
+ title: d.title || '',
27
+ nodes: d.getElementsByTagName('*').length,
28
+ text,
29
+ open: count('[open]'),
30
+ expanded: count('[aria-expanded="true"]'),
31
+ checked,
32
+ selected: count('[aria-selected="true"], [aria-pressed="true"]'),
33
+ dialogs: count('dialog[open], [role="dialog"], [role="alertdialog"]'),
34
+ focus,
35
+ };
36
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.16.1",
4
+ "version": "1.18.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",
@@ -7,6 +7,8 @@
7
7
 
8
8
  import './telemetry.js';
9
9
  import { buildMarkdown } from './lib/page-markdown.js';
10
+ import { pageFingerprint } from './lib/page-fingerprint.js';
11
+ import { createPacer } from './lib/capture-pacing.js';
10
12
  import { computeTiles } from './lib/tile-layout.js';
11
13
  import { classifyDownload } from './lib/download-state.js';
12
14
  const { pushError } = globalThis.__cbTelemetry;
@@ -448,6 +450,8 @@ async function executeCommand(msg) {
448
450
  return await cmdKeyboardWalk(params);
449
451
  case 'handoff':
450
452
  return await cmdHandoff(params);
453
+ case 'page_fingerprint':
454
+ return await cmdPageFingerprint(params);
451
455
  case 'watch':
452
456
  return await cmdWatch(params);
453
457
  case 'observe':
@@ -586,8 +590,13 @@ async function runViaFunction(target, code) {
586
590
  // Su ChromeOS una finestra totalmente occlusa/congelata può non produrre mai
587
591
  // un frame: la promise di captureVisibleTab resterebbe pendente per sempre.
588
592
  // Meglio un errore parlante dopo 10s che un comando appeso.
593
+ // Quota Chrome: 2 catture al secondo. Il pacer distanzia le chiamate di
594
+ // 520 ms invece di far comparire MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND al
595
+ // secondo screenshot ravvicinato (trovato dal bench di latenza, 2026-09-11).
596
+ const paceCapture = createPacer();
589
597
 
590
- function captureVisible(windowId, options = { format: 'png' }) {
598
+ async function captureVisible(windowId, options = { format: 'png' }) {
599
+ await paceCapture();
591
600
  return new Promise((resolve, reject) => {
592
601
  const timer = setTimeout(() => {
593
602
  reject(new Error('Capture timed out after 10s: the window is not rendering frames (fully occluded, minimized, or frozen). Bring the Chrome window at least partially on screen and retry.'));
@@ -674,7 +683,7 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
674
683
 
675
684
  // --- Implementazione comandi ---
676
685
 
677
- async function cmdGetTabs({ include_windows = false } = {}) {
686
+ async function cmdGetTabs({ include_windows = false, ended } = {}) {
678
687
  const tabs = await chrome.tabs.query({});
679
688
  const list = tabs.map((t) => ({
680
689
  id: t.id,
@@ -682,8 +691,17 @@ async function cmdGetTabs({ include_windows = false } = {}) {
682
691
  title: t.title,
683
692
  active: t.active,
684
693
  windowId: t.windowId,
694
+ // Una scheda scartata da Chrome (memory saver) esiste ancora ma non ha un
695
+ // renderer: execute_js e compagni falliscono finché non torna in primo piano.
696
+ ...(t.discarded ? { discarded: true } : {}),
685
697
  }));
686
- if (!include_windows) return list;
698
+ // ended = id che il server sa suoi e non trova più: la forma ad array resta
699
+ // quella di sempre per chi non lo chiede.
700
+ const open = new Set(list.map((t) => t.id));
701
+ const endings = Array.isArray(ended) && ended.length
702
+ ? Object.fromEntries(ended.filter((id) => !open.has(id)).map((id) => [id, tabEndings.get(id) ?? { reason: 'unknown' }]))
703
+ : null;
704
+ if (!include_windows) return endings ? { tabs: list, ended: endings } : list;
687
705
 
688
706
  // Senza geometria e stato non si può decidere dove mettere una finestra: si
689
707
  // può solo indovinare. `left` in particolare è ciò che identifica il monitor
@@ -703,7 +721,7 @@ async function cmdGetTabs({ include_windows = false } = {}) {
703
721
  // provarci.
704
722
  scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
705
723
  }));
706
- return { tabs: list, windows };
724
+ return endings ? { tabs: list, windows, ended: endings } : { tabs: list, windows };
707
725
  }
708
726
 
709
727
  async function cmdNavigate({ url, tab_id }) {
@@ -896,13 +914,17 @@ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
896
914
  await sleep(10);
897
915
  }
898
916
  el.dispatchEvent(new Event('change', { bubbles: true }));
899
- return { typed: true, tagName: tag, mode: 'keys' };
917
+ const afterKeys = String(getValue() ?? '');
918
+ return { typed: true, tagName: tag, mode: 'keys', value_after: afterKeys, mismatch: !afterKeys.endsWith(txt) };
900
919
  }
901
920
 
902
921
  setValue(txt);
903
922
  el.dispatchEvent(new Event('input', { bubbles: true }));
904
923
  el.dispatchEvent(new Event('change', { bubbles: true }));
905
- return { typed: true, tagName: tag };
924
+ // Rilettura: un campo controllato che scarta il valore risponde
925
+ // «ok» uguale, e il modello non ha modo di saperlo senza questo.
926
+ const after = String(getValue() ?? '');
927
+ return { typed: true, tagName: tag, value_after: after, mismatch: after !== txt };
906
928
  },
907
929
  args: [selector, text, mode],
908
930
  world: 'MAIN',
@@ -911,6 +933,18 @@ async function cmdTypeText({ selector, text, mode = 'set', tab_id, frame_id }) {
911
933
  return results?.[0]?.result ?? { typed: true };
912
934
  }
913
935
 
936
+ // Impronta per page_changed: pochi interi letti nell'ISOLATED world (nessuna
937
+ // CSP di mezzo), prima e dopo un'azione. Vedi lib/page-fingerprint.js.
938
+ async function cmdPageFingerprint({ tab_id, frame_id }) {
939
+ const tabId = await resolveTabId(tab_id);
940
+ const results = await chrome.scripting.executeScript({
941
+ target: scriptTarget(tabId, frame_id),
942
+ func: pageFingerprint,
943
+ args: [null],
944
+ });
945
+ return results?.[0]?.result ?? null;
946
+ }
947
+
914
948
  async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
915
949
  const tabId = await resolveTabId(tab_id);
916
950
 
@@ -1164,12 +1198,26 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
1164
1198
  };
1165
1199
  }
1166
1200
 
1201
+ // Perché una scheda non c'è più: «No tab with id» da solo non distingue una
1202
+ // chiusura a mano da una sostituzione. In memoria e limitato: se il service
1203
+ // worker riparte il registro si svuota e il motivo torna 'unknown'.
1204
+ const tabEndings = new Map();
1205
+ const TAB_ENDINGS_MAX = 200;
1206
+ const bridgeClosing = new Set();
1207
+
1208
+ function recordTabEnding(tabId, ending) {
1209
+ if (bridgeClosing.delete(tabId) && ending.reason === 'closed') ending = { reason: 'closed_by_bridge' };
1210
+ tabEndings.set(tabId, { ...ending, at: new Date().toISOString() });
1211
+ if (tabEndings.size > TAB_ENDINGS_MAX) tabEndings.delete(tabEndings.keys().next().value);
1212
+ }
1213
+
1167
1214
  async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
1168
1215
  if (!action) throw new Error('Missing required parameter: action');
1169
1216
  const tabId = await resolveTabId(tab_id);
1170
1217
 
1171
1218
  if (action === 'close') {
1172
- await chrome.tabs.remove(tabId);
1219
+ bridgeClosing.add(tabId);
1220
+ try { await chrome.tabs.remove(tabId); } catch (e) { bridgeClosing.delete(tabId); throw e; }
1173
1221
  return { action, closed: tabId };
1174
1222
  }
1175
1223
  if (action === 'discard') {
@@ -1879,6 +1927,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1879
1927
  const type = (el.type || '').toLowerCase();
1880
1928
  const disabled = el.disabled;
1881
1929
  const readOnly = el.readOnly;
1930
+ let chosen = null;
1882
1931
 
1883
1932
  if (disabled || readOnly) {
1884
1933
  report.push({ selector, success: true, tagName: tag, type, warning: disabled ? 'disabled' : 'readonly' });
@@ -1891,6 +1940,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1891
1940
  for (const opt of el.options) {
1892
1941
  if (opt.value === value || opt.textContent.trim() === value) {
1893
1942
  el.value = opt.value;
1943
+ chosen = opt.value;
1894
1944
  found = true;
1895
1945
  break;
1896
1946
  }
@@ -1916,7 +1966,17 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1916
1966
  el.dispatchEvent(new Event('focus', { bubbles: true }));
1917
1967
  el.dispatchEvent(new Event('input', { bubbles: true }));
1918
1968
  el.dispatchEvent(new Event('change', { bubbles: true }));
1919
- report.push({ selector, success: true, tagName: tag, type: type || tag });
1969
+ // Rilettura per campo: l'effetto, non l'azione
1970
+ const entry = { selector, success: true, tagName: tag, type: type || tag };
1971
+ if (tag === 'input' && (type === 'checkbox' || type === 'radio')) {
1972
+ const want = value === 'true' || value === '1';
1973
+ entry.checked_after = el.checked; entry.mismatch = el.checked !== want;
1974
+ } else if (tag === 'select') {
1975
+ entry.value_after = el.value; entry.mismatch = el.value !== chosen;
1976
+ } else {
1977
+ entry.value_after = String(el.value ?? ''); entry.mismatch = String(el.value ?? '') !== String(value);
1978
+ }
1979
+ report.push(entry);
1920
1980
  } catch (e) {
1921
1981
  report.push({ selector, success: false, error: e.message });
1922
1982
  }
@@ -2371,7 +2431,7 @@ async function cmdWatch({ action = 'add', name, selector, text, value_of, until,
2371
2431
 
2372
2432
  const handoffs = new Map(); // tabId → { id, resolve, timer, onNav }
2373
2433
 
2374
- function handoffBanner(id, message, pick) {
2434
+ function handoffBanner(id, message, pick, ask, pickMax) {
2375
2435
  const old = document.getElementById('cb-handoff-host');
2376
2436
  if (old) old.remove();
2377
2437
  const host = document.createElement('div');
@@ -2383,11 +2443,27 @@ function handoffBanner(id, message, pick) {
2383
2443
  .bar b{color:#ffb454}.bar span{flex:1}
2384
2444
  button{font:inherit;padding:6px 14px;border:0;border-radius:6px;cursor:pointer;background:#ffb454;color:#1a1a1a;font-weight:600}
2385
2445
  button.sec{background:#444;color:#fff}
2386
- </style><div class="bar"><b>Claude</b><span></span>${pick ? '<button class="pick">Pick element</button>' : ''}<button class="done">Done</button><button class="sec cancel">Cancel</button></div>`;
2446
+ input.ask{font:inherit;padding:6px 10px;border:0;border-radius:6px;min-width:220px;background:#fff;color:#1a1a1a}
2447
+ </style><div class="bar"><b>Claude</b><span></span>${ask ? '<input class="ask" type="text" placeholder="Your reply">' : ''}${pick ? '<button class="pick">Pick element</button>' : ''}<button class="done">Done</button><button class="sec cancel">Cancel</button></div>`;
2387
2448
  root.querySelector('span').textContent = message;
2388
2449
  document.documentElement.appendChild(host);
2389
- const finish = (result) => { host.remove(); cleanupPick(); chrome.runtime.sendMessage({ type: 'cb_handoff_done', id, result }); };
2390
- root.querySelector('.done').onclick = () => finish({ done: true, action: 'done' });
2450
+ // ask: la risposta scritta; pick_max: gli elementi indicati, con contorno
2451
+ // finché il banner resta. Done chiude in entrambi i casi (Invio nella casella).
2452
+ const picks = []; const marked = [];
2453
+ const askInput = root.querySelector('input.ask');
2454
+ const answerOf = () => (askInput ? askInput.value : undefined);
2455
+ const finish = (result) => { host.remove(); cleanupPick(); for (const m of marked) m.style.outline = m.__cbOutline ?? ''; chrome.runtime.sendMessage({ type: 'cb_handoff_done', id, result }); };
2456
+ const done = () => finish(picks.length
2457
+ ? { done: true, action: 'picked', answer: answerOf(), picked: picks[0], picked_all: picks }
2458
+ : { done: true, action: 'done', answer: answerOf() });
2459
+ const addPick = (el, p) => {
2460
+ picks.push(p);
2461
+ if (!marked.includes(el)) { el.__cbOutline = el.style.outline; el.style.outline = '2px solid #ffb454'; marked.push(el); }
2462
+ if (picks.length >= pickMax) { done(); return; }
2463
+ root.querySelector('span').textContent = `${message} — ${picks.length}/${pickMax} picked, click more or Done`;
2464
+ };
2465
+ root.querySelector('.done').onclick = done;
2466
+ if (askInput) { askInput.onkeydown = (e) => { if (e.key === 'Enter') done(); }; setTimeout(() => askInput.focus(), 0); }
2391
2467
  root.querySelector('.cancel').onclick = () => finish({ done: false, action: 'cancel' });
2392
2468
  let box = null; let onMove = null; let onClick = null;
2393
2469
  function cleanupPick() {
@@ -2415,7 +2491,7 @@ function handoffBanner(id, message, pick) {
2415
2491
  };
2416
2492
  const pickBtn = root.querySelector('.pick');
2417
2493
  if (pickBtn) pickBtn.onclick = () => {
2418
- root.querySelector('span').textContent = `${message} — now click the element`;
2494
+ root.querySelector('span').textContent = `${message} — ${pickMax > 1 ? `click up to ${pickMax} elements, then Done` : 'now click the element'}`;
2419
2495
  box = document.createElement('div');
2420
2496
  box.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483646;border:2px solid #ffb454;background:rgba(255,180,84,.15);transition:all .05s';
2421
2497
  document.documentElement.appendChild(box);
@@ -2435,7 +2511,7 @@ function handoffBanner(id, message, pick) {
2435
2511
  const isToggle = el.tagName === 'INPUT' && /^(checkbox|radio)$/.test(el.type);
2436
2512
  const label = (labelFor?.textContent || el.closest('label')?.textContent || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ');
2437
2513
  const text = (isToggle ? label || el.name : (el.innerText || el.value || el.getAttribute('aria-label') || label || '')).trim().replace(/\s+/g, ' ').slice(0, 80);
2438
- finish({ done: true, action: 'picked', picked: { selector: selectorOf(el), tag: el.tagName.toLowerCase(), text, rect: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) } } });
2514
+ addPick(el, { selector: selectorOf(el), tag: el.tagName.toLowerCase(), text, rect: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) } });
2439
2515
  };
2440
2516
  document.addEventListener('mousemove', onMove, true);
2441
2517
  document.addEventListener('click', onClick, true);
@@ -2443,12 +2519,12 @@ function handoffBanner(id, message, pick) {
2443
2519
  return true;
2444
2520
  }
2445
2521
 
2446
- async function cmdHandoff({ message, pick_element = false, timeout = 300000, tab_id }) {
2522
+ async function cmdHandoff({ message, pick_element = false, ask = false, pick_max = 1, timeout = 300000, tab_id }) {
2447
2523
  if (!message) throw new Error('Missing required parameter: message');
2448
2524
  const tabId = await resolveTabId(tab_id);
2449
2525
  if (handoffs.has(tabId)) throw new Error('A handoff is already waiting on this tab');
2450
2526
  const id = `h${Date.now()}`;
2451
- const inject = () => chrome.scripting.executeScript({ target: { tabId }, func: handoffBanner, args: [id, String(message), !!pick_element] });
2527
+ const inject = () => chrome.scripting.executeScript({ target: { tabId }, func: handoffBanner, args: [id, String(message), !!pick_element, !!ask, Math.max(1, Number(pick_max) || 1)] });
2452
2528
  try { await inject(); } catch (err) { throw new Error(`Cannot show the handoff banner on this page (${err.message}) — chrome:// and store pages are not injectable`); }
2453
2529
  await chrome.tabs.update(tabId, { active: true }).catch(() => {});
2454
2530
  return await new Promise((resolve) => {
@@ -4508,9 +4584,16 @@ async function cmdHttpAuth({ action, username, password }) {
4508
4584
 
4509
4585
  // --- Tab lifecycle: cleanup injection state ---
4510
4586
 
4511
- chrome.tabs.onRemoved.addListener((tabId) => {
4587
+ chrome.tabs.onRemoved.addListener((tabId, info) => {
4512
4588
  browserNetLog.delete(tabId);
4513
4589
  mainFrameHeaders.delete(tabId);
4590
+ recordTabEnding(tabId, { reason: info?.isWindowClosing ? 'window_closed' : 'closed' });
4591
+ });
4592
+
4593
+ // Chrome sostituisce l'id quando rimpiazza la scheda (prerender, alcuni
4594
+ // discard): per chi teneva il vecchio id è una scheda sparita senza chiusura.
4595
+ chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
4596
+ recordTabEnding(removedTabId, { reason: 'replaced', replaced_by: addedTabId });
4514
4597
  });
4515
4598
 
4516
4599
  // --- get_interactives ---
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.16.1",
4
+ "version": "1.18.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",
@@ -15,7 +15,8 @@
15
15
  "test:unit": "node --test test/unit/*.test.js",
16
16
  "test:e2e": "node test/test-devtools.js",
17
17
  "test:e2e:launch": "CHROME_BRIDGE_PORT=8799 node test/test-devtools.js --launch",
18
- "measure": "node tools/measure-schema.mjs"
18
+ "measure": "node tools/measure-schema.mjs",
19
+ "bench:latency": "node bench/latency.mjs"
19
20
  },
20
21
  "keywords": [
21
22
  "mcp",
package/server/cli.js CHANGED
@@ -22,6 +22,7 @@ import { consoleLines, networkLines, interactivesLines, linksLines } from './for
22
22
  import { runAssert } from './assertions.js';
23
23
  import { checkLinksBatch } from './link-checker.js';
24
24
  import { evaluateSecurityHeaders } from './security-headers.js';
25
+ import { windowLayout } from './layouts.js';
25
26
  import { toHar } from './har.js';
26
27
  import { decodeTrackingRequests, trackingLines } from './trackers.js';
27
28
  import { parseRedirectCsv, checkRedirects, redirectLines } from './redirects.js';
@@ -36,7 +37,7 @@ const INTERNAL_TYPES = new Set([
36
37
  ]);
37
38
 
38
39
  // Comandi virtuali: logica lato CLI (come i corrispondenti tool MCP lato server)
39
- const VIRTUAL_COMMANDS = new Set(['status', 'check_links', 'security_headers', 'replay', 'assert', 'track', 'redirects', 'audit', 'export', 'evidence']);
40
+ const VIRTUAL_COMMANDS = new Set(['status', 'window_layout', 'check_links', 'security_headers', 'replay', 'assert', 'track', 'redirects', 'audit', 'export', 'evidence']);
40
41
 
41
42
  const ALIASES = { tabs: 'get_tabs', js: 'execute_js', console: 'read_console', network: 'monitor_network', interactives: 'get_interactives' };
42
43
 
@@ -118,10 +119,13 @@ function connect(port) {
118
119
  sendCommand(type, params = {}) {
119
120
  return new Promise((res, rej) => {
120
121
  const command = createCommand(type, params);
122
+ // Come ws-manager: un timeout chiesto dal comando alza quello di trasporto.
123
+ const asked = Number(params?.timeout);
124
+ const timeout = Number.isFinite(asked) && asked > 0 ? Math.max(getTimeout(type), asked + 5000) : getTimeout(type);
121
125
  const timer = setTimeout(() => {
122
126
  pending.delete(command.id);
123
- rej(new Error(`Command ${type} timed out after ${getTimeout(type)}ms`));
124
- }, getTimeout(type));
127
+ rej(new Error(`Command ${type} timed out after ${timeout}ms`));
128
+ }, timeout);
125
129
  pending.set(command.id, { res, rej, timer });
126
130
  ws.send(JSON.stringify(command));
127
131
  });
@@ -250,6 +254,10 @@ async function run(client, command, params, opts) {
250
254
  return JSON.stringify(await runAssert(client.sendCommand, params));
251
255
  }
252
256
  // Comandi virtuali
257
+ if (command === 'window_layout') {
258
+ if (!['save', 'restore', 'list', 'delete'].includes(params.action)) throw new Error('window_layout needs --action save|restore|list|delete (and --name for all but list)');
259
+ return JSON.stringify(await windowLayout(client.sendCommand, params));
260
+ }
253
261
  if (command === 'status') {
254
262
  const tabs = await client.sendCommand(MessageType.GET_TABS);
255
263
  return `server=ok extension=connected tabs=${Array.isArray(tabs) ? tabs.length : 0}`;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Delta fra due impronte di pagina (extension/lib/page-fingerprint.js), prima
3
+ * e dopo un'azione. Solo ciò che è cambiato, con segno: null quando la pagina
4
+ * è stabile, così page_changed costa zero byte nel caso comune. Un'impronta
5
+ * parziale (estensione vecchia: solo url/title) confronta solo i campi che ha.
6
+ */
7
+
8
+ const COUNTS = ['nodes', 'text', 'open', 'expanded', 'checked', 'selected', 'dialogs'];
9
+
10
+ export function fingerprintDelta(before, after) {
11
+ if (!before || !after) return null;
12
+ const d = {};
13
+ if (before.url !== undefined && after.url !== undefined && after.url !== before.url) d.url = after.url;
14
+ if (before.title !== undefined && after.title !== undefined && after.title !== before.title) d.title = after.title;
15
+ for (const k of COUNTS) {
16
+ if (typeof before[k] !== 'number' || typeof after[k] !== 'number') continue;
17
+ const diff = after[k] - before[k];
18
+ if (diff !== 0) d[k] = `${diff > 0 ? '+' : ''}${diff}`;
19
+ }
20
+ if (typeof before.focus === 'string' && typeof after.focus === 'string' && after.focus !== before.focus) d.focus = after.focus;
21
+ return Object.keys(d).length ? d : null;
22
+ }
package/server/index.js CHANGED
@@ -31,6 +31,15 @@ const PORT = process.env.CHROME_BRIDGE_PORT
31
31
 
32
32
  // Capability: default = solo set core (30 tool). --caps audits,visual o
33
33
  // CHROME_BRIDGE_CAPS attivano i gruppi opt-in; "all" registra tutto.
34
+ // Interruttori di sicurezza (README, «Configuration and security»).
35
+ function parseSecurity() {
36
+ const on = (v) => v != null && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
37
+ return {
38
+ noJs: process.argv.includes('--no-js') || on(process.env.CHROME_BRIDGE_NO_JS),
39
+ writeRoot: argValue('--write-root') ?? process.env.CHROME_BRIDGE_WRITE_ROOT ?? null,
40
+ };
41
+ }
42
+
34
43
  function parseCaps() {
35
44
  const i = process.argv.indexOf('--caps');
36
45
  if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1];
@@ -94,7 +103,7 @@ async function main() {
94
103
  }
95
104
 
96
105
  // 3. Registra i tool MCP (filtrati per capability)
97
- session.tools = registerTools(mcpServer, wsManager, parseCaps());
106
+ session.tools = registerTools(mcpServer, wsManager, parseCaps(), parseSecurity());
98
107
 
99
108
  // 4. Avvia il trasporto stdio MCP
100
109
  const transport = new StdioServerTransport();
@@ -0,0 +1,134 @@
1
+ /**
2
+ * window_layout: salvare e ripristinare disposizioni di finestre per nome.
3
+ *
4
+ * Una sola implementazione per il tool MCP (tools.js) e per la CLI (cli.js):
5
+ * prima viveva inline nel tool e la CLI non la conosceva, e `claude-master
6
+ * layout save` usciva con «Unknown command: window_layout».
7
+ *
8
+ * Tutto lato server, componendo GET_TABS(include_windows) e VIEWPORT_RESIZE:
9
+ * nessun comando nuovo verso l'estensione. Gli id delle finestre non
10
+ * sopravvivono al riavvio del browser: il ripristino riconosce le finestre
11
+ * dagli URL delle loro schede (miglior sovrapposizione, a parità di tipo).
12
+ */
13
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { MessageType } from './protocol.js';
17
+
18
+ export const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
19
+ || join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
20
+
21
+ /**
22
+ * @param {(type: string, params?: object) => Promise<any>} send invio di un comando all'estensione
23
+ * @param {{ action: 'save'|'restore'|'list'|'delete', name?: string }} p
24
+ * @returns {Promise<object>} dati puri (il tool MCP li serializza, la CLI li stampa)
25
+ */
26
+ export async function windowLayout(send, { action, name }) {
27
+ const readLayouts = async () => {
28
+ try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
29
+ };
30
+
31
+ if (action === 'list') {
32
+ const all = await readLayouts();
33
+ const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
34
+ return { layouts: rows };
35
+ }
36
+
37
+ if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
38
+
39
+ if (action === 'delete') {
40
+ const all = await readLayouts();
41
+ const existed = Boolean(all[name]);
42
+ delete all[name];
43
+ await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
44
+ await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
45
+ return { deleted: name, existed };
46
+ }
47
+
48
+ // save e restore hanno bisogno della fotografia corrente
49
+ const snap = await send(MessageType.GET_TABS, { include_windows: true });
50
+ if (Array.isArray(snap) || !snap?.windows) {
51
+ // Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
52
+ // dirlo che fallire su una proprietà mancante.
53
+ throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
54
+ }
55
+ const urlsByWindow = new Map();
56
+ for (const t of snap.tabs || []) {
57
+ if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
58
+ urlsByWindow.get(t.windowId).push(t.url || '');
59
+ }
60
+
61
+ if (action === 'save') {
62
+ const all = await readLayouts();
63
+ all[name] = {
64
+ savedAt: new Date().toISOString(),
65
+ windows: snap.windows.map((w) => ({
66
+ type: w.type,
67
+ state: w.state,
68
+ left: w.left, top: w.top, width: w.width, height: w.height,
69
+ tabs: urlsByWindow.get(w.id) || [],
70
+ })),
71
+ };
72
+ await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
73
+ await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
74
+ return { saved: name, windows: all[name].windows.length };
75
+ }
76
+
77
+ // restore
78
+ const all = await readLayouts();
79
+ const layout = all[name];
80
+ if (!layout) {
81
+ throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
82
+ }
83
+
84
+ // Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
85
+ // sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
86
+ // riavvio del browser.
87
+ const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
88
+ const pairs = [];
89
+ layout.windows.forEach((savedWin, si) => {
90
+ current.forEach((cur, ci) => {
91
+ if (savedWin.type !== cur.type) return;
92
+ const savedUrls = new Set(savedWin.tabs);
93
+ let shared = 0;
94
+ for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
95
+ const union = new Set([...savedUrls, ...cur.urls]).size || 1;
96
+ const score = shared / union;
97
+ if (score > 0) pairs.push({ si, ci, score });
98
+ });
99
+ });
100
+ pairs.sort((a, b) => b.score - a.score);
101
+
102
+ const usedSaved = new Set();
103
+ const usedCurrent = new Set();
104
+ const results = [];
105
+ for (const { si, ci, score } of pairs) {
106
+ if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
107
+ usedSaved.add(si); usedCurrent.add(ci);
108
+ const savedWin = layout.windows[si];
109
+ const cur = current[ci];
110
+ const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
111
+ if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
112
+ // Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
113
+ // accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
114
+ const params = savedWin.state === 'normal'
115
+ ? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
116
+ : { tab_id: anyTab.id, state: savedWin.state };
117
+ try {
118
+ const r = await send(MessageType.VIEWPORT_RESIZE, params);
119
+ results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
120
+ } catch (e) {
121
+ results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
122
+ }
123
+ }
124
+
125
+ const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
126
+ const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
127
+ return {
128
+ restored: name,
129
+ matched: results.length,
130
+ unmatched_saved: unmatchedSaved,
131
+ unmatched_current: unmatchedCurrent,
132
+ results,
133
+ };
134
+ }
@@ -71,6 +71,7 @@ export const MessageType = Object.freeze({
71
71
  DISMISS_OVERLAYS: 'dismiss_overlays',
72
72
  KEYBOARD_WALK: 'keyboard_walk',
73
73
  HANDOFF: 'handoff',
74
+ PAGE_FINGERPRINT: 'page_fingerprint',
74
75
  WATCH: 'watch',
75
76
  OBSERVE: 'observe',
76
77
  READ_FORM: 'read_form',
@@ -91,7 +92,7 @@ export const MessageType = Object.freeze({
91
92
  });
92
93
 
93
94
  // Versione
94
- export const VERSION = '1.16.1';
95
+ export const VERSION = '1.18.0';
95
96
 
96
97
  // Configurazione
97
98
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
- import { basename, dirname, extname, join } from 'node:path';
10
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path';
11
11
  import { parse as parseHtml } from 'node-html-parser';
12
12
  import { z } from 'zod';
13
13
  import { runAssert } from './assertions.js';
@@ -21,17 +21,25 @@ import { runAudit, summarizeAudit, auditReport, AUDIT_KINDS, DEFAULT_KINDS } fro
21
21
  import { toPlaywrightTest } from './playwright-export.js';
22
22
  import { createResolver } from './sourcemaps.js';
23
23
  import { evaluateSecurityHeaders } from './security-headers.js';
24
+ import { windowLayout } from './layouts.js';
25
+ import { fingerprintDelta } from './effect.js';
24
26
  import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
25
27
 
26
28
  const SESSIONS_DIR = join(homedir(), '.config', 'chrome-bridge', 'sessions');
27
29
  // Sovrascrivibile nei test: i layout sono un file solo, non una directory.
28
- const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
29
- || join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
30
30
  const RECORDINGS_DIR = process.env.CHROME_BRIDGE_RECORD_DIR || join(homedir(), '.config', 'chrome-bridge', 'recordings');
31
31
  const FIXTURES_DIR = process.env.CHROME_BRIDGE_FIXTURES_DIR || join(homedir(), '.config', 'chrome-bridge', 'fixtures');
32
32
 
33
33
  // Comandi rumore per un replay: letture interne (tabSnapshot) o senza effetto
34
- const RECORD_EXCLUDE = new Set([MessageType.GET_TABS]);
34
+ const RECORD_EXCLUDE = new Set([MessageType.GET_TABS, MessageType.PAGE_FINGERPRINT]);
35
+
36
+ // CHROME_BRIDGE_NO_JS: i tool che eseguono codice arbitrario nella pagina.
37
+ // inject_css non c'è: il CSS non esegue niente. wait_for(condition=function)
38
+ // e le URL javascript:/data: sono rifiutate nel handler, non qui.
39
+ const JS_TOOLS = new Set(['execute_js', 'modify_dom']);
40
+ // Sotto CHROME_BRIDGE_WRITE_ROOT lo stato del server resta scrivibile: non è un
41
+ // percorso scelto dal modello.
42
+ const STATE_DIRS = [join(homedir(), '.config', 'chrome-bridge'), SESSIONS_DIR, RECORDINGS_DIR, FIXTURES_DIR];
35
43
 
36
44
  // `hint` è il parametro REALE del tool chiamante che riduce i dati. Suggerire
37
45
  // max_length quando 56 tool su 59 non lo espongono mandava il modello a
@@ -105,6 +113,34 @@ function fitArray(items, max, serialize) {
105
113
  return items.slice(0, lo);
106
114
  }
107
115
 
116
+ /**
117
+ * Output di execute_js sotto `max` senza rompere il JSON: tagliare la
118
+ * serializzazione a metà stringa dava «Invalid control character» a chi la
119
+ * parsava. Una stringa si accorcia dentro il suo valore, un array per elementi
120
+ * (jsonText), il resto diventa un prefisso dichiarato come tale.
121
+ */
122
+ export function jsResultText(data, max) {
123
+ const text = JSON.stringify(data);
124
+ if (text == null || text.length <= max) return text;
125
+ const hint = 'raise max_length, or save_to for the whole result';
126
+ const value = data?.result;
127
+ const wrap = (field, cut, total) => JSON.stringify({ [field]: cut, truncated: true, total_chars: total, hint });
128
+ const fitString = (field, full) => {
129
+ // Gli escape JSON allungano la stringa: il prefisso più lungo che sta nel
130
+ // tetto si cerca sulla serializzazione, non sui caratteri grezzi.
131
+ let lo = 0;
132
+ let hi = Math.min(full.length, max);
133
+ while (lo < hi) {
134
+ const mid = Math.ceil((lo + hi) / 2);
135
+ if (wrap(field, full.slice(0, mid), full.length).length <= max) lo = mid; else hi = mid - 1;
136
+ }
137
+ return wrap(field, full.slice(0, lo), full.length);
138
+ };
139
+ if (typeof value === 'string') return fitString('result', value);
140
+ if (Array.isArray(value)) return jsonText(data, max, hint);
141
+ return fitString('result_json_prefix', JSON.stringify(value));
142
+ }
143
+
108
144
  /** Una riga (oggetto colonna→valore o array di celle) soddisfa il filtro where. */
109
145
  function tableRowMatches(row, where) {
110
146
  return Object.entries(where).every(([col, needle]) => {
@@ -328,6 +364,7 @@ export const TOOL_ANNOTATIONS = {
328
364
  * @param {import('@modelcontextprotocol/sdk/server/index.js').McpServer} server - MCP Server
329
365
  * @param {import('./ws-manager.js').WSManager} wsManager - WebSocket manager
330
366
  * @param {string} [caps='all'] - 'all', 'core', o lista di gruppi "audits,visual"
367
+ * @param {{noJs?: boolean, writeRoot?: string|null}} [options] - CHROME_BRIDGE_NO_JS / CHROME_BRIDGE_WRITE_ROOT
331
368
  */
332
369
  /** Procedura leggibile da un'osservazione: un passo per riga, i campi sensibili marcati come passo umano. */
333
370
  function observedProcedure(name, d) {
@@ -345,8 +382,20 @@ function observedProcedure(name, d) {
345
382
  return lines.join('\n') + '\n';
346
383
  }
347
384
 
348
- export function registerTools(server, wsManager, caps = 'all') {
385
+ export function registerTools(server, wsManager, caps = 'all', options = {}) {
349
386
  const startedAt = Date.now();
387
+ const noJs = Boolean(options.noJs);
388
+ const WRITE_ROOT = options.writeRoot ? resolve(String(options.writeRoot)) : null;
389
+ // Rifiuta PRIMA del comando all'estensione: niente screenshot fatto per un
390
+ // file che non verrà scritto. Il percorso torna com'è, così i chiamanti
391
+ // possono usarlo inline.
392
+ const guardWrite = (p) => {
393
+ if (!WRITE_ROOT || p == null) return p;
394
+ const abs = resolve(String(p));
395
+ const inside = (root) => abs === root || abs.startsWith(root + sep);
396
+ if (inside(WRITE_ROOT) || STATE_DIRS.some((d) => inside(resolve(d)))) return p;
397
+ throw new Error(`Refusing to write ${abs}: outside CHROME_BRIDGE_WRITE_ROOT (${WRITE_ROOT})`);
398
+ };
350
399
  const activeCaps = caps === 'all'
351
400
  ? ['core', ...Object.keys(TOOL_CAPS)]
352
401
  : ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
@@ -362,6 +411,7 @@ export function registerTools(server, wsManager, caps = 'all') {
362
411
  : null;
363
412
  server = {
364
413
  tool(name, desc, schema, handler) {
414
+ if (noJs && JS_TOOLS.has(name)) return;
365
415
  if (enabled) {
366
416
  const group = TOOL_TO_CAP.get(name);
367
417
  if (group && !enabled.has(group) && !enabled.has('all')) return;
@@ -438,8 +488,15 @@ export function registerTools(server, wsManager, caps = 'all') {
438
488
  throw new Error('Either selector or ref is required');
439
489
  }
440
490
 
441
- // Snapshot url/title del tab target, per il delta post-azione.
491
+ // Impronta della pagina per il delta post-azione (page_changed): DOM in
492
+ // pochi interi più url/title. Un'estensione vecchia o una pagina non
493
+ // iniettabile (chrome://) tornano a url/title da get_tabs: nessun errore,
494
+ // solo meno dettaglio.
442
495
  async function tabSnapshot(tab_id) {
496
+ try {
497
+ const fp = await send(MessageType.PAGE_FINGERPRINT, { tab_id });
498
+ if (fp && typeof fp === 'object') return fp;
499
+ } catch { /* fallback sotto */ }
443
500
  try {
444
501
  const tabs = await send(MessageType.GET_TABS);
445
502
  const list = Array.isArray(tabs) ? tabs : [];
@@ -449,16 +506,11 @@ export function registerTools(server, wsManager, caps = 'all') {
449
506
  } catch { return null; }
450
507
  }
451
508
 
452
- // Delta compatto dopo un'azione: presente solo se url/title sono cambiati.
453
- // Costa pochi token quando scatta, zero quando la pagina è stabile, e
454
- // risparmia al client un giro di ispezione per capire "cosa è successo".
455
- function pageDelta(before, after) {
456
- if (!before || !after) return null;
457
- const delta = {};
458
- if (after.url !== before.url) delta.url = after.url;
459
- if (after.title !== before.title) delta.title = after.title;
460
- return Object.keys(delta).length ? delta : null;
461
- }
509
+ // Delta compatto dopo un'azione: solo ciò che è cambiato (url, title, conteggi
510
+ // DOM con segno, fuoco). Costa pochi token quando scatta, zero quando la
511
+ // pagina è stabile, e risparmia al client lo screenshot per capire "cosa è
512
+ // successo".
513
+ const pageDelta = fingerprintDelta;
462
514
 
463
515
  // Anteprima compatta dei primi interactives (con ref), allegata a navigate:
464
516
  // il client può agire subito senza un giro di discovery. Cappata e best-effort.
@@ -493,6 +545,8 @@ export function registerTools(server, wsManager, caps = 'all') {
493
545
  extension_version: wsManager.extVersion ?? null,
494
546
  // Un agente che non trova accessibility_audit non aveva modo di
495
547
  // scoprire che esiste ma è in un gruppo disattivato.
548
+ js_evaluation: !noJs,
549
+ write_root: WRITE_ROOT,
496
550
  caps_active: activeCaps,
497
551
  caps_available: ['core', ...Object.keys(TOOL_CAPS)],
498
552
  session_tab_id: sessionTabId,
@@ -508,21 +562,38 @@ export function registerTools(server, wsManager, caps = 'all') {
508
562
  server.tool(
509
563
  'get_tabs',
510
564
  'List every open tab with id, url, title, active flag and mine (created by this session). Read-only. Find a tab_id when the implicit '
511
- + 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling.',
565
+ + 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling. '
566
+ + 'Session tabs gone since the last call come back once in closed_session_tabs with the reason (closed, window_closed, replaced, closed_by_bridge).',
512
567
  {
513
568
  include_windows: z.boolean().optional().default(false)
514
569
  .describe('Also return the windows with bounds, state, type and tab count'),
515
570
  },
516
571
  async ({ include_windows }) => {
517
- const data = await send(MessageType.GET_TABS, { include_windows: include_windows === true });
572
+ // `ended` = le schede che il server sa sue: l'estensione dà il motivo di
573
+ // quelle che non trova più. Senza schede di sessione la risposta resta
574
+ // quella di sempre.
575
+ const data = await send(MessageType.GET_TABS, {
576
+ include_windows: include_windows === true,
577
+ ...(ownedTabs.size ? { ended: [...ownedTabs] } : {}),
578
+ });
518
579
  const list = Array.isArray(data) ? data : (data?.tabs ?? []);
580
+ const windows = Array.isArray(data) ? null : data?.windows;
519
581
  for (const t of list) if (ownedTabs.has(t.id)) t.mine = true;
520
- return {
521
- content: [{
522
- type: 'text',
523
- text: jsonText(data),
524
- }],
525
- };
582
+ const missing = [...ownedTabs].filter((id) => !list.some((t) => t.id === id));
583
+ const shaped = windows ? { tabs: list, windows } : list;
584
+ if (!missing.length) return { content: [{ type: 'text', text: jsonText(shaped) }] };
585
+
586
+ // Un'estensione precedente ignora `ended` e risponde con l'array: motivo ignoto.
587
+ const ended = (!Array.isArray(data) && data?.ended) || {};
588
+ const closed = missing.map((id) => ({ id, ...(ended[id] ?? { reason: 'unknown' }) }));
589
+ for (const c of closed) {
590
+ ownedTabs.delete(c.id);
591
+ const successor = c.replaced_by != null ? list.find((t) => t.id === c.replaced_by) : null;
592
+ // Scheda sostituita da Chrome: resta della sessione con il nuovo id.
593
+ if (successor) { ownedTabs.add(successor.id); successor.mine = true; }
594
+ if (sessionTabId === c.id) sessionTabId = successor?.id ?? null;
595
+ }
596
+ return { content: [{ type: 'text', text: jsonText({ tabs: list, ...(windows ? { windows } : {}), closed_session_tabs: closed }) }] };
526
597
  }
527
598
  );
528
599
 
@@ -535,6 +606,9 @@ export function registerTools(server, wsManager, caps = 'all') {
535
606
  tab_id: tabId,
536
607
  },
537
608
  async ({ url, tab_id }) => {
609
+ if (noJs && /^\s*(javascript|data|vbscript):/i.test(url)) {
610
+ throw new Error(`Refusing to navigate to ${url.slice(0, 40)}: JavaScript evaluation is disabled (CHROME_BRIDGE_NO_JS)`);
611
+ }
538
612
  const data = await send(MessageType.NAVIGATE, { url, tab_id });
539
613
  // Il tab navigato diventa il default di sessione per i comandi successivi
540
614
  if (data?.tabId != null) sessionTabId = data.tabId;
@@ -560,6 +634,7 @@ export function registerTools(server, wsManager, caps = 'all') {
560
634
  presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('One capture per preset, window restored; save_to = directory, one file each'),
561
635
  },
562
636
  async ({ tab_id, save_to, presets }) => {
637
+ guardWrite(save_to);
563
638
  // Matrice responsive: viewport_resize + screenshot per preset erano 2N
564
639
  // turni; qui è una chiamata, e la finestra torna com'era.
565
640
  if (presets?.length) {
@@ -615,21 +690,25 @@ export function registerTools(server, wsManager, caps = 'all') {
615
690
  // --- execute_js ---
616
691
  server.tool(
617
692
  'execute_js',
618
- 'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled.',
693
+ 'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled. '
694
+ + 'A promise is awaited up to timeout. A cut result stays valid JSON and says truncated; save_to writes the whole result to disk.',
619
695
  {
620
696
  code: z.string().describe('JS evaluated in the page; the value of the last expression is returned'),
621
697
  tab_id: tabId,
622
698
  frame_id: frameId,
623
699
  max_length: z.number().optional().default(20000).describe('Max output chars'),
700
+ timeout: z.number().optional().describe('Max ms to wait for the result (default 30000); raise it for long async work'),
701
+ save_to: saveToField('the result (a string as is, anything else as JSON)'),
624
702
  },
625
- async ({ code, tab_id, frame_id, max_length }) => {
626
- const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id });
627
- return {
628
- content: [{
629
- type: 'text',
630
- text: truncateText(JSON.stringify(data), max_length ?? DEFAULT_MAX_OUTPUT, 'max_length'),
631
- }],
632
- };
703
+ async ({ code, tab_id, frame_id, max_length, timeout, save_to }) => {
704
+ guardWrite(save_to);
705
+ const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id, ...(timeout ? { timeout } : {}) });
706
+ if (save_to) {
707
+ const value = data?.result;
708
+ const isString = typeof value === 'string';
709
+ return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' });
710
+ }
711
+ return { content: [{ type: 'text', text: jsResultText(data, max_length ?? DEFAULT_MAX_OUTPUT) }] };
633
712
  }
634
713
  );
635
714
 
@@ -637,7 +716,8 @@ export function registerTools(server, wsManager, caps = 'all') {
637
716
  server.tool(
638
717
  'click',
639
718
  'Click an element by CSS selector or by a ref (n1, n2…) from get_interactives or navigate. A real pointer sequence: it can submit, '
640
- + 'open a dialog or navigate — use wait_after. Not idempotent; a native confirm() blocks the bridge: handle_dialogs first.',
719
+ + 'open a dialog or navigate — use wait_after. Not idempotent; a native confirm() blocks the bridge: handle_dialogs first. '
720
+ + 'Returns page_changed (url, title and DOM deltas: nodes, text, open, expanded, checked, dialogs, focus): the effect, without a screenshot.',
641
721
  {
642
722
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
643
723
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
@@ -654,6 +734,9 @@ export function registerTools(server, wsManager, caps = 'all') {
654
734
  const data = await send(MessageType.CLICK, { selector: target, force, button: button ?? 'left', count: count ?? 1, frame_id, tab_id });
655
735
  // Niente attesa se il click non è andato a buon fine (es. elemento occluso)
656
736
  const waited = data?.occluded ? null : await applyWaitAfter(send, wait_after, tab_id);
737
+ // Senza wait_after un breve settle: i framework aggiornano il DOM dopo il
738
+ // click, non dentro; senza, l'impronta "dopo" vedrebbe quella "prima".
739
+ if (!data?.occluded && (wait_after ?? 'none') === 'none') await new Promise((r) => setTimeout(r, 150));
657
740
  const changed = data?.occluded ? null : pageDelta(before, await tabSnapshot(tab_id));
658
741
  const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
659
742
  return {
@@ -670,7 +753,8 @@ export function registerTools(server, wsManager, caps = 'all') {
670
753
  'type_text',
671
754
  'Put text into an input, textarea or contenteditable, by selector or ref. Replaces the whole value through the '
672
755
  + 'native setter (React/Vue controlled inputs register it) and fires input and change. mode=keys emits '
673
- + 'keydown/input/keyup per character for autocomplete and masked fields: slower, use it only when mode=set leaves the field empty.',
756
+ + 'keydown/input/keyup per character for autocomplete and masked fields: slower, use it only when mode=set leaves the field empty. '
757
+ + 'Returns value_after; mismatch=true means the field did not keep the value: retry with mode=keys.',
674
758
  {
675
759
  selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
676
760
  ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
@@ -685,10 +769,13 @@ export function registerTools(server, wsManager, caps = 'all') {
685
769
  const data = await send(MessageType.TYPE_TEXT, { selector: target, text, mode, tab_id, frame_id });
686
770
  const waited = await applyWaitAfter(send, wait_after, tab_id);
687
771
  const out = waited ? { ...data, wait_after: waited } : data;
772
+ const warn = data?.mismatch
773
+ ? `\nthe field did not keep the value (value_after=${JSON.stringify(data.value_after ?? null)}): retry with mode=keys; if it still differs, the page rewrites it`
774
+ : '';
688
775
  return {
689
776
  content: [{
690
777
  type: 'text',
691
- text: jsonText(out),
778
+ text: jsonText(out) + warn,
692
779
  }],
693
780
  };
694
781
  }
@@ -708,6 +795,7 @@ export function registerTools(server, wsManager, caps = 'all') {
708
795
  save_to: saveToField('the page'),
709
796
  },
710
797
  async ({ mode, tab_id, frame_id, max_length, save_to }) => {
798
+ guardWrite(save_to);
711
799
  const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
712
800
  const text = typeof data === 'string' ? data : JSON.stringify(data);
713
801
  if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
@@ -963,6 +1051,7 @@ export function registerTools(server, wsManager, caps = 'all') {
963
1051
  text, selector, timeout: timeout ?? 10000, interval: interval ?? 200, tab_id, frame_id,
964
1052
  });
965
1053
  } else if (condition === 'function') {
1054
+ if (noJs) throw new Error('wait_for condition=function evaluates JavaScript, which is disabled (CHROME_BRIDGE_NO_JS): use condition=element or text');
966
1055
  data = await send(MessageType.WAIT_FOR_FUNCTION, {
967
1056
  expression, timeout: timeout ?? 10000, polling_ms: interval ?? 100, tab_id, frame_id,
968
1057
  });
@@ -1039,7 +1128,8 @@ export function registerTools(server, wsManager, caps = 'all') {
1039
1128
  server.tool(
1040
1129
  'fill_form',
1041
1130
  'Batch fill form fields with React-compatible events. Handles input, select, checkbox, radio, and textarea. '
1042
- + 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly.',
1131
+ + 'With submit_selector it also submits, so a repeated call submits twice — not safe to retry blindly. '
1132
+ + 'Each field reports value_after and mismatch; page_changed carries the DOM delta.',
1043
1133
  {
1044
1134
  fields: z.array(z.object({
1045
1135
  selector: z.string(),
@@ -1056,10 +1146,14 @@ export function registerTools(server, wsManager, caps = 'all') {
1056
1146
  const waited = await applyWaitAfter(send, wait_after, tab_id);
1057
1147
  const changed = pageDelta(before, await tabSnapshot(tab_id));
1058
1148
  const out = { ...data, ...(waited && { wait_after: waited }), ...(changed && { page_changed: changed }) };
1149
+ // I campi che non hanno tenuto il valore in testa, in chiaro: sono la
1150
+ // riga che il modello deve leggere prima del JSON.
1151
+ const mism = (data?.fields ?? []).filter((f) => f && f.mismatch);
1152
+ const head = mism.length ? mism.map((f) => `mismatch: ${f.selector} (${'checked_after' in f ? `checked_after=${f.checked_after}` : `value_after=${JSON.stringify(f.value_after ?? null)}`})`).join('\n') + '\n' : '';
1059
1153
  return {
1060
1154
  content: [{
1061
1155
  type: 'text',
1062
- text: jsonText(out),
1156
+ text: head + jsonText(out),
1063
1157
  }],
1064
1158
  };
1065
1159
  }
@@ -1322,6 +1416,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1322
1416
  return { content: [{ type: 'text', text: jsonText({ closed }) }] };
1323
1417
  }
1324
1418
  const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
1419
+ if (action === 'close') ownedTabs.delete(data?.closed ?? tab_id ?? sessionTabId);
1325
1420
  if (action === 'close' && (tab_id == null || tab_id === sessionTabId)) sessionTabId = null;
1326
1421
  if (action === 'activate' && tab_id != null) sessionTabId = tab_id;
1327
1422
  return { content: [{ type: 'text', text: jsonText(data) }] };
@@ -1615,18 +1710,25 @@ export function registerTools(server, wsManager, caps = 'all') {
1615
1710
  'handoff',
1616
1711
  'Hand the browser to the user for what only a person can do — 2FA, CAPTCHA, login, a choice — and wait: a banner in the page shows your '
1617
1712
  + 'message with Done/Cancel, the call returns on click or timeout, redirects included. pick_element: the user clicks an element and you get '
1618
- + 'its selector. Never type credentials yourself.',
1713
+ + 'its selector. Never type credentials yourself. ask: the user types a reply in the banner ("which of the three?") and you get it as answer. '
1714
+ + 'pick_max: with pick_element, up to N elements; the user presses Done when finished.',
1619
1715
  {
1620
1716
  message: z.string().describe('What the user should do, one line'),
1621
1717
  pick_element: z.boolean().optional().default(false).describe('Ask the user to click an element; returns its selector, text and box'),
1718
+ ask: z.boolean().optional().default(false).describe('Show a text box in the banner; the reply comes back as answer'),
1719
+ pick_max: z.number().optional().default(1).describe('With pick_element: up to N elements, the user presses Done when finished'),
1622
1720
  timeout: z.number().optional().default(300000).describe('ms to wait for the click, default 5 min'),
1623
1721
  tab_id: tabId,
1624
1722
  },
1625
- async ({ message, pick_element, timeout, tab_id }) => {
1626
- const d = await send(MessageType.HANDOFF, { message, pick_element, timeout, tab_id });
1723
+ async ({ message, pick_element, ask, pick_max, timeout, tab_id }) => {
1724
+ const d = await send(MessageType.HANDOFF, { message, pick_element, ask, pick_max, timeout, tab_id });
1627
1725
  const lines = [`handoff ${d.action}${d.url ? ` url=${d.url}` : ''}`];
1628
- if (d.picked) {
1629
- lines.push(`picked ${d.picked.selector}\t${d.picked.tag}\t${d.picked.text}\t@${d.picked.rect.x},${d.picked.rect.y} ${d.picked.rect.width}x${d.picked.rect.height}`);
1726
+ if (ask && typeof d.answer === 'string' && d.answer.trim()) lines.push(`answer: ${d.answer.trim()}`);
1727
+ 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}`;
1728
+ if (Array.isArray(d.picked_all) && d.picked_all.length) {
1729
+ d.picked_all.forEach((p, i) => lines.push(`picked ${i + 1}/${d.picked_all.length} ${fmt(p)}`));
1730
+ } else if (d.picked) {
1731
+ lines.push(`picked ${fmt(d.picked)}`);
1630
1732
  }
1631
1733
  if (d.action === 'timeout') lines.push('the user did not click within the timeout: ask before retrying');
1632
1734
  return { content: [{ type: 'text', text: lines.join('\n') }] };
@@ -1697,6 +1799,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1697
1799
  tab_id: tabId,
1698
1800
  },
1699
1801
  async ({ kinds, save_to, scope, max_links, tab_id }) => {
1802
+ guardWrite(save_to);
1700
1803
  const info = await send(MessageType.GET_PAGE_INFO, { tab_id });
1701
1804
  const results = await runAudit(send, { kinds, scope, max_links, tab_id });
1702
1805
  const summary = summarizeAudit(results);
@@ -1866,6 +1969,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1866
1969
  tab_id: tabId,
1867
1970
  },
1868
1971
  async ({ output_path, tab_id }) => {
1972
+ guardWrite(output_path);
1869
1973
  const data = await send(MessageType.SAVE_PAGE, { tab_id });
1870
1974
  await writeFile(output_path, Buffer.from(data.mhtml_b64, 'base64'));
1871
1975
  return { content: [{ type: 'text', text: jsonText({ saved: output_path, size: data.size }) }] };
@@ -1887,6 +1991,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1887
1991
  max_length: z.number().optional().default(DEFAULT_MAX_OUTPUT).describe('Max chars of body returned inline'),
1888
1992
  },
1889
1993
  async ({ url, method, headers, body, save_to, max_length }) => {
1994
+ guardWrite(save_to);
1890
1995
  const data = await send(MessageType.HTTP_REQUEST, { url, method, headers, body, binary: Boolean(save_to) });
1891
1996
 
1892
1997
  if (save_to) {
@@ -1988,120 +2093,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1988
2093
  action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
1989
2094
  name: z.string().optional().describe('Layout name, required except for list'),
1990
2095
  },
1991
- async ({ action, name }) => {
1992
- const readLayouts = async () => {
1993
- try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
1994
- };
1995
-
1996
- if (action === 'list') {
1997
- const all = await readLayouts();
1998
- const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
1999
- return { content: [{ type: 'text', text: jsonText({ layouts: rows }) }] };
2000
- }
2001
-
2002
- if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
2003
-
2004
- if (action === 'delete') {
2005
- const all = await readLayouts();
2006
- const existed = Boolean(all[name]);
2007
- delete all[name];
2008
- await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
2009
- await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
2010
- return { content: [{ type: 'text', text: jsonText({ deleted: name, existed }) }] };
2011
- }
2012
-
2013
- // save e restore hanno bisogno della fotografia corrente
2014
- const snap = await send(MessageType.GET_TABS, { include_windows: true });
2015
- if (Array.isArray(snap) || !snap?.windows) {
2016
- // Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
2017
- // dirlo che fallire su una proprietà mancante.
2018
- throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
2019
- }
2020
- const urlsByWindow = new Map();
2021
- for (const t of snap.tabs || []) {
2022
- if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
2023
- urlsByWindow.get(t.windowId).push(t.url || '');
2024
- }
2025
-
2026
- if (action === 'save') {
2027
- const all = await readLayouts();
2028
- all[name] = {
2029
- savedAt: new Date().toISOString(),
2030
- windows: snap.windows.map((w) => ({
2031
- type: w.type,
2032
- state: w.state,
2033
- left: w.left, top: w.top, width: w.width, height: w.height,
2034
- tabs: urlsByWindow.get(w.id) || [],
2035
- })),
2036
- };
2037
- await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
2038
- await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
2039
- return { content: [{ type: 'text', text: jsonText({ saved: name, windows: all[name].windows.length }) }] };
2040
- }
2041
-
2042
- // restore
2043
- const all = await readLayouts();
2044
- const layout = all[name];
2045
- if (!layout) {
2046
- throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
2047
- }
2048
-
2049
- // Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
2050
- // sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
2051
- // riavvio del browser.
2052
- const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
2053
- const pairs = [];
2054
- layout.windows.forEach((savedWin, si) => {
2055
- current.forEach((cur, ci) => {
2056
- if (savedWin.type !== cur.type) return;
2057
- const savedUrls = new Set(savedWin.tabs);
2058
- let shared = 0;
2059
- for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
2060
- const union = new Set([...savedUrls, ...cur.urls]).size || 1;
2061
- const score = shared / union;
2062
- if (score > 0) pairs.push({ si, ci, score });
2063
- });
2064
- });
2065
- pairs.sort((a, b) => b.score - a.score);
2066
-
2067
- const usedSaved = new Set();
2068
- const usedCurrent = new Set();
2069
- const results = [];
2070
- for (const { si, ci, score } of pairs) {
2071
- if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
2072
- usedSaved.add(si); usedCurrent.add(ci);
2073
- const savedWin = layout.windows[si];
2074
- const cur = current[ci];
2075
- const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
2076
- if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
2077
- // Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
2078
- // accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
2079
- const params = savedWin.state === 'normal'
2080
- ? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
2081
- : { tab_id: anyTab.id, state: savedWin.state };
2082
- try {
2083
- const r = await send(MessageType.VIEWPORT_RESIZE, params);
2084
- results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
2085
- } catch (e) {
2086
- results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
2087
- }
2088
- }
2089
-
2090
- const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
2091
- const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
2092
- return {
2093
- content: [{
2094
- type: 'text',
2095
- text: jsonText({
2096
- restored: name,
2097
- matched: results.length,
2098
- unmatched_saved: unmatchedSaved,
2099
- unmatched_current: unmatchedCurrent,
2100
- results,
2101
- }),
2102
- }],
2103
- };
2104
- }
2096
+ async ({ action, name }) => ({ content: [{ type: 'text', text: jsonText(await windowLayout(send, { action, name })) }] })
2105
2097
  );
2106
2098
 
2107
2099
  // --- http_auth ---
@@ -2246,6 +2238,7 @@ export function registerTools(server, wsManager, caps = 'all') {
2246
2238
  save_to: saveToField('the records as JSON'),
2247
2239
  },
2248
2240
  async ({ item_selector, fields, max_items, format, tab_id, max_length , save_to }) => {
2241
+ guardWrite(save_to);
2249
2242
  max_length = max_length ?? DEFAULT_MAX_OUTPUT;
2250
2243
  const html = await send(MessageType.READ_PAGE, { mode: 'html', tab_id });
2251
2244
  if (typeof html !== 'string') throw new Error('Could not read page HTML');
@@ -2314,6 +2307,8 @@ export function registerTools(server, wsManager, caps = 'all') {
2314
2307
  tab_id: tabId,
2315
2308
  },
2316
2309
  async ({ action, name, save_to, values, tab_id }) => {
2310
+ guardWrite(save_to);
2311
+ if (name && name.endsWith('.jsonl')) guardWrite(name);
2317
2312
  // "Guarda come faccio": la procedura la esegue l'umano nel suo browser,
2318
2313
  // noi la trascriviamo — mai i valori dei campi sensibili — nello stesso
2319
2314
  // jsonl che replay esegue, più un .md leggibile con i passi umani marcati.