chrome-bridge-mcp 1.31.2 → 1.32.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
@@ -4,120 +4,158 @@
4
4
 
5
5
  ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-388%20unit%20%2B%2041%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb) [![Chrome Bridge MCP on mcprush](https://mcprush.com/badge/chrome-bridge-mcp/score.svg)](https://mcprush.com/to/chrome-bridge-mcp?ref=badge)
6
6
 
7
- **Claude Code borrows a tab from the Chrome you are already using — signed in,
8
- with your extensions and your cookies — works in it, and hands it back. What
9
- only a person can do (a login, a 2FA code, a CAPTCHA, a choice) comes back to
10
- you through `handoff`: a banner in the page, you act, the agent continues.**
11
-
12
- Measured against the official browser extension (paired runs, 29/09/2026, n=5
13
- per task, same model): filling a form, 4.7× fewer turns and 2.8× lower cost;
14
- finding one row in a 1,500-row table, 2.3× fewer turns and 3.4× lower cost;
15
- debugging a broken page, 9.3× fewer turns and 4.3× lower cost. 15 correct
16
- answers out of 15, against 12 ([bench/RESULTS.md](bench/RESULTS.md)). ~3× the toolset and no paid plan. 60
17
- web-development tools (navigation, DOM inspection, visual regression, audits,
18
- network mocking) over a local WebSocket bridge, plus a headless instance for
19
- CI. Self-hosted, local-only. Works on ChromeOS.
20
-
21
- ![The same form filled in 3 turns instead of 14 — 4.7× fewer turns on the form, 2.8-4.3× lower cost on form, 1,500-row table and debugging (paired runs, 29/09/2026, n=5)](assets/readme/card1-race.png)
7
+ **Claude Code drives the Chrome you are already using — signed in, with your
8
+ extensions and your cookies. 69 tools: navigation, forms, DOM inspection,
9
+ network mocking, visual regression, audits, performance traces and the
10
+ WebMCP tools a page declares. Self-hosted, local-only, no paid plan, works on
11
+ ChromeOS.**
12
+
13
+ Measured against the official Claude in Chrome extension on the same tasks
14
+ (paired runs, 17/10/2026, Claude Code 2.1.302, n=5 per task, medians):
15
+
16
+ | Task | Chrome Bridge | Claude in Chrome | Fewer turns | Lower cost |
17
+ |---|---|---|---:|---:|
18
+ | Fill a form | 3 turns · $0.043 | 15 turns · $0.236 | **5.0×** | **5.5×** |
19
+ | Find one row in a 1,500-row table | 3 turns · $0.039 | 6 turns · $0.154 | **2.0×** | **3.9×** |
20
+ | Debug a broken page | 6 turns · $0.091 | 31 turns · $0.559 | **5.2×** | **6.1×** |
21
+
22
+ 15 correct answers out of 15, against 14. Method, every run and the caveats in
23
+ [docs/EFFICIENCY.md](docs/EFFICIENCY.md).
22
24
 
23
25
  ## Quickstart
24
26
 
25
- **Requires** Node.js 18+ and Chrome 135+.
27
+ 1. In a Claude Code session:
28
+ ```
29
+ /plugin install chrome-bridge --marketplace frsorrentino/chrome-bridge
30
+ ```
31
+ From a shell, the same: `claude plugin install chrome-bridge --marketplace frsorrentino/chrome-bridge`.
32
+ 2. Add the extension from the [Chrome Web
33
+ Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb).
34
+ 3. Restart Claude Code.
35
+
36
+ Then ask for something like *"open localhost:3000, run an accessibility audit
37
+ and find the Sign Up button"*: Claude Code calls `navigate`, `audit` and
38
+ `find_text`. Because `navigate` already returns element refs,
39
+ `click(ref="n1")` follows with no discovery turn in between.
40
+
41
+ For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
42
+ Chrome Bridge → Details.
43
+
44
+ The plugin registers the MCP server from npm together with the recipes skill.
45
+ Requires Node.js 18+ and Chrome 135+.
46
+
47
+ <details>
48
+ <summary>Older Claude Code, from source, Windows, Chrome 135-137, one path only</summary>
49
+
50
+ **Older Claude Code.** The one-command install needs Claude Code 2.1.275+ in a
51
+ session and 2.1.292+ from a shell. Before that, add the marketplace first:
52
+ `/plugin marketplace add frsorrentino/chrome-bridge`, then
53
+ `/plugin install chrome-bridge@chrome-bridge` (from a shell:
54
+ `claude plugin marketplace add frsorrentino/chrome-bridge`, then
55
+ `claude plugin install chrome-bridge@chrome-bridge`).
56
+
57
+ **From source:**
26
58
 
27
59
  ```bash
28
60
  git clone https://github.com/frsorrentino/chrome-bridge.git
29
61
  cd chrome-bridge && ./install.sh
30
62
  ```
31
63
 
32
- 1. Open `chrome://extensions`, enable **Developer mode**, click **Load
33
- unpacked**, select the `extension/` folder.
34
- 2. Restart Claude Code.
35
-
36
- Then ask for something like *"open localhost:3000, run an accessibility audit
37
- and find the Sign Up button"*: Claude Code calls `navigate`,
38
- `audit` and `find_text`. Because `navigate` already returns
39
- element refs, `click(ref="n1")` follows with no discovery turn in between.
40
-
41
- > **On ChromeOS/Crostini** install from the [Chrome Web
42
- > Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
43
- > instead: an unpacked extension is dropped on every reboot, because the
44
- > container isn't mounted when Chrome starts.
45
-
46
- > **On Windows** `install.sh` does not run from PowerShell or cmd. Use the
47
- > plugin path below (`claude plugin marketplace add frsorrentino/chrome-bridge`,
48
- > then `claude plugin install chrome-bridge@chrome-bridge`) with the extension
49
- > from the Web Store, or run `install.sh` from Git Bash. The plugin's error
50
- > log hooks run in Git Bash, as Claude Code runs every hook, and need Python
51
- > 3.8+ as `python3`, `python` or `py` (the Microsoft Store `python3` alias is
52
- > skipped); without Python they stay silent and the tools work the same. For
53
- > launch mode see `CHROME_BRIDGE_BROWSER` below: Google Chrome cannot load the
54
- > extension there, Microsoft Edge can.
55
-
56
- `install.sh` registers the MCP server with `--scope user`. To do it by hand:
64
+ Then open `chrome://extensions`, enable **Developer mode**, click **Load
65
+ unpacked**, select the `extension/` folder, and restart Claude Code.
66
+ `install.sh` registers the MCP server with `--scope user`; by hand:
57
67
  `claude mcp add --scope user chrome-bridge node /path/to/server/index.js`.
58
- For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
59
- Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
60
-
61
- **As a plugin, without a clone:** inside a Claude Code session,
62
- `/plugin install chrome-bridge --marketplace frsorrentino/chrome-bridge`
63
- (one command, Claude Code 2.1.275+; on older versions `/plugin marketplace add
64
- frsorrentino/chrome-bridge` then `/plugin install chrome-bridge@chrome-bridge`).
65
- From a shell, `claude plugin install` has no `--marketplace` option: run
66
- `claude plugin marketplace add frsorrentino/chrome-bridge`, then
67
- `claude plugin install chrome-bridge@chrome-bridge`. Either way the plugin
68
- registers the MCP server from npm (all capabilities) together with the recipes
69
- skill; clients that read [Agent Plugins 1.0](https://agent-plugins.org) get the
70
- same from `plugin.json` + `mcp.json`. The extension still comes from the Web
71
- Store or `extension/`. Pick one path: the plugin and `install.sh` would
72
- register the same server twice, and so would a plugin added from the
73
- [Anthropic directory](https://claude.ai/directory) on claude.ai (it reaches
74
- Claude Code as `chrome-bridge@synced`) next to a marketplace install. In
75
- claude.ai chat the plugin loads only the recipes skill: the tools need Claude
76
- Code, or Cowork running on your computer, where the local MCP server can start.
68
+ On ChromeOS/Crostini use the Web Store extension instead: an unpacked one is
69
+ dropped on every reboot, because the container isn't mounted when Chrome
70
+ starts.
71
+
72
+ **Windows.** `install.sh` does not run from PowerShell or cmd: use the plugin
73
+ with the Web Store extension, or run `install.sh` from Git Bash. The plugin's
74
+ error log hooks run in Git Bash, as Claude Code runs every hook, and need
75
+ Python 3.8+ as `python3`, `python` or `py` (the Microsoft Store `python3` alias
76
+ is skipped); without Python they stay silent and the tools work the same. For
77
+ launch mode see `CHROME_BRIDGE_BROWSER` below: Google Chrome cannot load the
78
+ extension there, Microsoft Edge can.
79
+
80
+ **Chrome 135-137.** `execute_js` needs Developer Mode instead of the **Allow
81
+ user scripts** toggle.
82
+
83
+ **One path only.** The plugin and `install.sh` would register the same server
84
+ twice, and so would a plugin added from the [Anthropic
85
+ directory](https://claude.ai/directory) on claude.ai (it reaches Claude Code as
86
+ `chrome-bridge@synced`) next to a marketplace install. In claude.ai chat the
87
+ plugin loads only the recipes skill: the tools need Claude Code, or Cowork
88
+ running on your computer, where the local MCP server can start. Clients that
89
+ read [Agent Plugins 1.0](https://agent-plugins.org) get the same from
90
+ `plugin.json` + `mcp.json`.
91
+
92
+ </details>
93
+
94
+ ![The same form filled in 3 turns instead of 15 — 5× fewer turns on the form, 3.9-6.1× lower cost on form, 1,500-row table and debugging (paired runs, 17/10/2026, n=5)](assets/readme/card1-race.png)
95
+
96
+ ## Why it takes fewer turns
97
+
98
+ - **Refs, not screenshots.** `navigate` returns the page's interactive
99
+ elements as short refs (`n1`, `n2`…), so the next call is the click, not a
100
+ screenshot and a coordinate guess.
101
+ - **One call for a whole form.** `fill_form` fills N fields and submits in the
102
+ same call.
103
+ - **Filtering on the server.** `extract_table` with `where` and `columns`
104
+ returns the matching rows of a 1,500-row table, not the table.
105
+ - **Debugging starts with the answer.** `navigate` reports the first console
106
+ error mapped to its source line and the failed requests with their status;
107
+ `get_css_styles` names the rule behind a style and flags undefined CSS
108
+ variables.
109
+ - **Deferred loading.** 22 stay in context; the rest of the 46 in the
110
+ default set wait behind Claude Code's tool search (2.1.285+), and loading
111
+ one mid-session does not invalidate the prompt cache. The other 23, in seven
112
+ optional groups, switch on mid-session with `get_status({enable: [...]})`,
113
+ no restart.
77
114
 
78
115
  ## Your browser, your login
79
116
 
80
117
  No blank profile, no debugging port, no cloud browser. The extension runs in
81
118
  your Chrome, so every site you are signed into is already open to the agent:
82
- webmail, the hosting panel, a client's back office, a staging site behind
83
- basic auth. Tabs the agent opens are marked as its own and closed when the
84
- session ends; your tabs stay yours.
119
+ webmail, the hosting panel, a back office, a staging site behind basic auth.
120
+ Tabs the agent opens are marked as its own and closed when the session ends;
121
+ your tabs stay yours. Several Claude Code sessions share the same browser: the
122
+ second one becomes a relay on the first one's bridge.
123
+
124
+ **WebMCP.** When a page declares its own tools through
125
+ [WebMCP](https://github.com/webmachinelearning/webmcp), `navigate` and
126
+ `get_interactives` say so in one line, `webmcp_tools` lists them and
127
+ `webmcp_call` runs one, with a timeout. Needs the WebMCP flag or origin trial
128
+ in Chrome; `get_status` reports whether it is on.
85
129
 
86
- When a page asks for something only you can give, the agent does not guess and
87
- never types credentials. `handoff` shows a banner in the page with what it
88
- needs — "complete the login with your 2FA, then press Done", "click the button
89
- you mean" — waits for you, and continues from where you left it, redirects
90
- included. It can also ask a question in the banner and read your typed reply,
91
- or let you pick one or more elements on the page.
130
+ **Limits you can set.** `upload_file` refuses keys and credentials
131
+ (`~/.ssh`, `.env*`, `*.pem`, …) and `--read-root` fences it to one directory;
132
+ `--write-root` fences every path the model writes; `--no-js` removes arbitrary
133
+ JavaScript. The agent never types credentials: for a login, a 2FA code or a
134
+ choice, `handoff` shows a banner in the page and waits for you.
92
135
 
93
- ## Why Chrome Bridge?
136
+ ## Compared
94
137
 
95
138
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
96
139
  |---|---|---|---|---|
97
140
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
98
- | **Tools** | **60** (43 core) | 22 | 29 default (56 with flags) | 24 core (71 total) |
141
+ | **Tools** | **69** (46 default, 22 always loaded) | 22 | 29 default (56 with flags) | 24 core (71 total) |
99
142
  | **Requires paid plan** | **No** | Yes (Pro+) | No | No |
143
+ | **WebMCP (tools the page declares)** | **Yes** | No | Yes | Yes |
100
144
  | **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
101
145
  | **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
102
146
  | **Audits (a11y/SEO/sec)** | **Yes** (one call, report on disk) | No | Lighthouse | No |
147
+ | **Performance trace** | **Yes** (`perf_trace`, `lighthouse`) | No | Yes | Playwright trace only |
148
+ | **Screen recording** | **Yes** (`screencast`, mp4/webm) | Yes (GIF) | Partial | Yes (video) |
149
+ | **Heap snapshot** | **Yes** (launch mode) | No | Yes | No |
150
+ | **Breakpoints** | No | No | **Yes** | No |
103
151
  | **Headless / CI** | **Yes** | No | Yes | Yes |
104
- | **GIF / video** | No | **Yes** | Partial | No |
105
- | **Breakpoints / heap** | No | No | **Yes** | No |
106
152
 
107
153
  Codex for Chrome (OpenAI, May 2026) sits in the Claude in Chrome column: an
108
154
  official extension with the `debugger` permission, macOS and Windows only,
109
155
  the ChatGPT app required. Claude in Chrome documents Linux desktop since
110
- September 2026; ChromeOS and WSL stay out. Competitor figures measured on
111
- 2026-09-01 and 2026-09-11 (`docs/analisi-2026-09-11-concorrenti.md`).
112
-
113
- It wins on **round trips** — short element refs instead of the
114
- screenshot-and-click loop, `fill_form` filling N fields in one call — and, on
115
- big pages, on **payload**: table filtering is done server-side. On the form, per single turn it actually costs
116
- slightly *more*.
117
-
118
- The full benchmark — method, every raw run including the unfavourable ones, and
119
- what the harness can't measure — is in
120
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md).
156
+ September 2026; ChromeOS and WSL stay out. Competitor tool counts measured on
157
+ 2026-09-01 and 2026-09-11 (`docs/analisi-2026-09-11-concorrenti.md`); WebMCP, recording and
158
+ tracing rows checked in their documentation on 2026-10-28.
121
159
 
122
160
  ![Claude checks its own work: console errors, pixel diffs, network mocking and audits](assets/readme/card5-devloop.png)
123
161
 
@@ -165,11 +203,12 @@ user-script toggle isn't available.
165
203
  ## Tools
166
204
 
167
205
  69 in total. `core` (46 tools: every tool used in 101 real sessions, plus the
168
- two WebMCP tools) loads by default; the other 23 sit in seven optional caps that the agent switches on
169
- mid-session with `get_status({enable: ["visual"]})`, with no restart
170
- (`--caps` still sets them at startup).
206
+ two WebMCP tools) loads by default, 22 of them always in context; the other 23
207
+ sit in seven optional caps that the agent switches on mid-session with
208
+ `get_status({enable: ["visual"]})`, with no restart (`--caps` still sets them
209
+ at startup).
171
210
 
172
- ![69 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
211
+ ![69 tools in nine groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
173
212
 
174
213
  | Group | N | What's in it |
175
214
  |---|---|---|
@@ -177,6 +216,7 @@ mid-session with `get_status({enable: ["visual"]})`, with no restart
177
216
  | Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
178
217
  | DOM & Inspection | 12 | `read_page`, `extract`, `extract_table`, `query_dom`, `get_css_styles`, `watch_dom` |
179
218
  | Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
219
+ | WebMCP | 2 | `webmcp_tools`, `webmcp_call`: the tools a page declares |
180
220
  | Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
181
221
  | Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
182
222
  | State, Storage & Files | 10 | storage, fixtures, MHTML, `capture_downloads` (exports saved with no "Save as" dialog), recording, `assert` |
@@ -199,7 +239,8 @@ executes commands through Chrome APIs. User scripts (`execute_js`) run via
199
239
 
200
240
  ## Configuration and security
201
241
 
202
- Environment variables, each with a matching CLI flag:
242
+ <details>
243
+ <summary>Environment variables, each with a matching CLI flag</summary>
203
244
 
204
245
  | Variable | Default | Notes |
205
246
  |---|---|---|
@@ -214,6 +255,8 @@ Environment variables, each with a matching CLI flag:
214
255
  | `CHROME_BRIDGE_OBSERVE` | unset | `off` stops the local error log below. The plugin sets `hook`: its hooks keep the log and the server stays out of it |
215
256
  | `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 |
216
257
 
258
+ </details>
259
+
217
260
  The bridge binds loopback, accepts extension connections only from a
218
261
  `chrome-extension://` origin, and — when a token is set — requires it on both
219
262
  handshakes. Without one, any local process could act as a relay and reach
@@ -296,6 +339,9 @@ count.
296
339
 
297
340
  ## Troubleshooting
298
341
 
342
+ <details>
343
+ <summary>Symptoms and fixes</summary>
344
+
299
345
  | Symptom | Cause / fix |
300
346
  |---|---|
301
347
  | `Chrome extension not connected` | Extension disabled, or its port differs from the server's. The error names the actual host/port; check them in the popup (⚙). |
@@ -314,11 +360,14 @@ count.
314
360
  | `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
361
  | 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. |
316
362
 
363
+ </details>
364
+
317
365
  ## Documentation
318
366
 
319
367
  - [docs/TOOLS.md](docs/TOOLS.md) — all 69 tools, by group
320
368
  - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
321
369
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
370
+ - [docs/misure/2026-10-token-cache-vs-claude-in-chrome.md](docs/misure/2026-10-token-cache-vs-claude-in-chrome.md) — the 17/10 paired set and the prompt-cache breakdown (Italian)
322
371
  - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
323
372
  - [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
324
373
  - [CHANGELOG.md](CHANGELOG.md)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
- "version": "1.31.2",
3
+ "version": "1.32.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "chrome-bridge-mcp",
9
- "version": "1.31.2",
9
+ "version": "1.32.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@jridgewell/trace-mapping": "^0.3.31",
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.31.2",
4
+ "version": "1.32.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). 69 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",
@@ -105,7 +105,7 @@ export const MessageType = Object.freeze({
105
105
  });
106
106
 
107
107
  // Versione
108
- export const VERSION = '1.31.2';
108
+ export const VERSION = '1.32.0';
109
109
 
110
110
  // Configurazione
111
111
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -242,14 +242,20 @@ const MIME_BY_EXT = {
242
242
  * @returns {Promise<object|null>} risultato dell'attesa, o null se none
243
243
  */
244
244
  async function applyWaitAfter(send, wait_after, tab_id, since_url) {
245
- if (wait_after === 'navigation') {
246
- // since_url: l'URL prima dell'azione. Una navigazione veloce può essere già
247
- // finita quando l'attesa parte: senza, l'estensione aspettava 5 s un
248
- // 'loading' che non arrivava più e rispondeva «No navigation started».
249
- return await send(MessageType.WAIT_FOR_NAVIGATION, { timeout: 15000, tab_id, ...(since_url && { since_url }) });
250
- }
251
- if (wait_after === 'networkidle') {
252
- return await send(MessageType.WAIT_FOR_NETWORK_IDLE, { idle_ms: 500, timeout: 15000, tab_id });
245
+ try {
246
+ if (wait_after === 'navigation') {
247
+ // since_url: l'URL prima dell'azione. Una navigazione veloce può essere già
248
+ // finita quando l'attesa parte: senza, l'estensione aspettava 5 s un
249
+ // 'loading' che non arrivava più e rispondeva «No navigation started».
250
+ return await send(MessageType.WAIT_FOR_NAVIGATION, { timeout: 15000, tab_id, ...(since_url && { since_url }) });
251
+ }
252
+ if (wait_after === 'networkidle') {
253
+ return await send(MessageType.WAIT_FOR_NETWORK_IDLE, { idle_ms: 500, timeout: 15000, tab_id });
254
+ }
255
+ } catch (err) {
256
+ // L'azione è già avvenuta: un errore qui faceva leggere al modello un click
257
+ // fallito, e lo ripeteva (nota di claude-observe del 21/10).
258
+ return { done: false, error: err?.message ?? String(err), note: 'the action itself ran; only the wait failed: do not repeat it, check the page instead' };
253
259
  }
254
260
  return null;
255
261
  }
@@ -302,9 +308,23 @@ const DEFER_META = { 'anthropic/alwaysLoad': false };
302
308
  const saveToField = (what) => z.string().optional()
303
309
  .describe(`Absolute path: write ${what} there and return the path instead of the content`);
304
310
 
305
- async function savedSummary(path, bytes, extra = {}) {
306
- await writeFile(path, bytes);
307
- return { content: [{ type: 'text', text: jsonText({ saved: path, bytes: bytes.length, ...extra }) }] };
311
+ // save_to scritto così com'è dava ENOENT con una cartella da creare e EISDIR
312
+ // con una cartella esistente, a lavoro già fatto (note di claude-observe,
313
+ // ottobre). Le cartelle mancanti si creano; una cartella riceve un file con un
314
+ // nome generato. Torna il percorso davvero scritto.
315
+ async function writeSaved(path, bytes, ext = '') {
316
+ let target = path;
317
+ if (/[\\/]$/.test(String(path)) || (await stat(path).catch(() => null))?.isDirectory()) {
318
+ target = join(path, `${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}${ext}`);
319
+ }
320
+ await mkdir(dirname(target), { recursive: true });
321
+ await writeFile(target, bytes);
322
+ return target;
323
+ }
324
+
325
+ async function savedSummary(path, bytes, extra = {}, ext = '') {
326
+ const saved = await writeSaved(path, bytes, ext);
327
+ return { content: [{ type: 'text', text: jsonText({ saved, bytes: bytes.length, ...extra }) }] };
308
328
  }
309
329
 
310
330
  const selectorField = (extra = '') => z.string()
@@ -597,7 +617,15 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
597
617
  // fallisce fino al prossimo navigate. Un solo ritentativo sulla tab
598
618
  // attiva salva un turno intero al modello.
599
619
  const gone = /No tab with id|No active tab|No tab found/i.test(err?.message ?? '');
600
- if (!gone || !implicitTab) throw err;
620
+ if (!gone) throw err;
621
+ // tab_id esplicito di una scheda chiusa: il messaggio nudo faceva
622
+ // ritentare lo stesso id (note di claude-observe, ottobre).
623
+ if (!implicitTab) {
624
+ if (/No tab with id/i.test(err.message) && !/get_tabs/.test(err.message)) {
625
+ err.message = `${err.message.replace(/\.$/, '')} — the tab was closed or replaced: get_tabs lists the open ones and why this one ended; omit tab_id for the active tab`;
626
+ }
627
+ throw err;
628
+ }
601
629
  sessionTabId = null;
602
630
  const { tab_id: _drop, ...retry } = params;
603
631
  const data = await wsManager.sendCommand(type, retry);
@@ -643,6 +671,9 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
643
671
  reg.bySel.set(selKey(frame, e.selector), ref);
644
672
  reg.byRef.set(ref, { selector: e.selector, frame });
645
673
  }
674
+ // Quel che il modello ha visto per ultimo con questo ref: il controllo
675
+ // STALE_REF lo confronta con la pagina prima di agire.
676
+ if (e.tag) Object.assign(reg.byRef.get(ref), { tag: String(e.tag).toLowerCase(), text: e.text ?? '' });
646
677
  e.ref = ref;
647
678
  }
648
679
  while (reg.byRef.size > REFS_MAX) {
@@ -717,6 +748,47 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
717
748
  throw new Error('Either selector or ref is required');
718
749
  }
719
750
 
751
+ // STALE_REF: fra get_interactives e l'azione la pagina può cambiare, e lo
752
+ // stesso selettore trovare un altro elemento (una riga tolta da una lista:
753
+ // "li:nth-of-type(2)" è ora la successiva). Prima di agire per ref si
754
+ // rilegge l'elemento: assente, tag diverso o etichetta che non c'è più vuol
755
+ // dire scaduto, e l'azione non parte. L'etichetta si controlla solo dove
756
+ // viene dall'elemento stesso (testo o attributi): un campo con <label for>
757
+ // o aria-labelledby la prende da fuori e lì conta il tag. Se la rilettura
758
+ // fallisce (chrome://, estensione vecchia) si agisce come prima.
759
+ const FIELD_TAGS = new Set(['input', 'select', 'textarea']);
760
+ const norm = (v) => String(v ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
761
+ async function assertFresh(ref, target, tab_id) {
762
+ const entry = interactivesRefs.get(refsKey(tab_id))?.byRef.get(ref);
763
+ if (!entry?.tag) return;
764
+ let el;
765
+ try {
766
+ const r = await send(MessageType.QUERY_DOM, { selector: target.selector, limit: 1, tab_id, frame_id: target.frame_id });
767
+ if (!r || !Array.isArray(r.elements)) return;
768
+ el = r.elements[0];
769
+ } catch {
770
+ return;
771
+ }
772
+ const seen = `${entry.tag}${entry.text ? ` «${entry.text}»` : ''}`;
773
+ const stale = (now) => new Error(`STALE_REF: ref ${ref} was ${seen}, but the page changed and ${now}. Nothing was done: run get_interactives again and use the new ref`);
774
+ if (!el) throw stale('its selector no longer matches anything');
775
+ const tag = norm(el.tagName);
776
+ if (tag !== entry.tag) throw stale(`its selector now finds a <${tag}>`);
777
+ const attrs = el.attributes ?? {};
778
+ const label = norm(entry.text).slice(0, 40);
779
+ if (label.length < 2 || FIELD_TAGS.has(tag) || attrs['aria-labelledby'] != null) return;
780
+ if (!norm(`${el.textContent ?? ''} ${Object.values(attrs).join(' ')}`).includes(label)) {
781
+ throw stale(`its selector now finds ${el.textContent ? `«${String(el.textContent).trim().slice(0, 60)}»` : 'another element'}`);
782
+ }
783
+ }
784
+
785
+ // resolveTarget più il controllo STALE_REF quando il bersaglio è un ref.
786
+ async function freshTarget(selector, ref, tab_id, frame_id) {
787
+ const t = resolveTarget(selector, ref, tab_id, frame_id);
788
+ if (!selector && ref) await assertFresh(ref, t, tab_id);
789
+ return t;
790
+ }
791
+
720
792
  // Impronta della pagina per il delta post-azione (page_changed): DOM in
721
793
  // pochi interi più url/title. Un'estensione vecchia o una pagina non
722
794
  // iniettabile (chrome://) tornano a url/title da get_tabs: nessun errore,
@@ -1061,7 +1133,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1061
1133
  }
1062
1134
  const data = await send(MessageType.SCREENSHOT, { tab_id });
1063
1135
  const b64 = data?.image ?? data?.data;
1064
- if (save_to && b64) return savedSummary(save_to, Buffer.from(b64, 'base64'), { mimeType: 'image/png' });
1136
+ if (save_to && b64) return savedSummary(save_to, Buffer.from(b64, 'base64'), { mimeType: 'image/png' }, '.png');
1065
1137
  // data.image è base64 PNG. data.viewport (CSS px) è il sistema di
1066
1138
  // riferimento di element_screenshot.region: senza, il modello non sa
1067
1139
  // mappare ciò che vede sull'immagine ridotta a ≤1568px.
@@ -1111,7 +1183,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1111
1183
  if (save_to) {
1112
1184
  const value = data?.result;
1113
1185
  const isString = typeof value === 'string';
1114
- return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' });
1186
+ return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' }, isString ? '.txt' : '.json');
1115
1187
  }
1116
1188
  return { content: [{ type: 'text', text: jsResultText(data, max_length ?? DEFAULT_MAX_OUTPUT) }] };
1117
1189
  }
@@ -1136,7 +1208,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1136
1208
  frame_id: frameId,
1137
1209
  },
1138
1210
  async ({ selector, ref, force, button, count, trusted, wait_after, tab_id, frame_id }) => {
1139
- const { selector: target, frame_id: frame } = resolveTarget(selector, ref, tab_id, frame_id);
1211
+ const { selector: target, frame_id: frame } = await freshTarget(selector, ref, tab_id, frame_id);
1140
1212
  const before = await tabSnapshot(tab_id);
1141
1213
  const data = await send(MessageType.CLICK, { selector: target, force, button: button ?? 'left', count: count ?? 1, ...(trusted && { trusted: true }), frame_id: frame, tab_id });
1142
1214
  // Niente attesa se il click non è andato a buon fine (es. elemento occluso)
@@ -1172,7 +1244,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1172
1244
  frame_id: frameId,
1173
1245
  },
1174
1246
  async ({ selector, ref, text, mode, wait_after, tab_id, frame_id }) => {
1175
- const { selector: target, frame_id: frame } = resolveTarget(selector, ref, tab_id, frame_id);
1247
+ const { selector: target, frame_id: frame } = await freshTarget(selector, ref, tab_id, frame_id);
1176
1248
  const data = await send(MessageType.TYPE_TEXT, { selector: target, text, mode, tab_id, frame_id: frame });
1177
1249
  const waited = await applyWaitAfter(send, wait_after, tab_id);
1178
1250
  const out = waited ? { ...data, wait_after: waited } : data;
@@ -1205,7 +1277,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1205
1277
  guardWrite(save_to);
1206
1278
  const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
1207
1279
  const text = typeof data === 'string' ? data : JSON.stringify(data);
1208
- if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
1280
+ if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' }, mode === 'html' ? '.html' : mode === 'markdown' ? '.md' : '.txt');
1209
1281
  return {
1210
1282
  content: [{
1211
1283
  type: 'text',
@@ -1606,13 +1678,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1606
1678
  const refFrames = new Set(fields.filter((f) => f.ref).map((f) => resolveTarget(undefined, f.ref, tab_id, frame_id).frame_id ?? 0));
1607
1679
  if (refFrames.size > 1) throw new Error(`fill_form: the refs come from frames ${[...refFrames].join(', ')}; one call fills one frame`);
1608
1680
  if (frame_id == null && refFrames.size === 1) { const [f0] = refFrames; if (f0) frame_id = f0; }
1609
- const resolved = fields.map((f) => {
1610
- const { selector } = resolveTarget(f.ref ? undefined : f.selector, f.ref, tab_id, frame_id);
1681
+ const resolved = await Promise.all(fields.map(async (f) => {
1682
+ const { selector } = await freshTarget(f.ref ? undefined : f.selector, f.ref, tab_id, frame_id);
1611
1683
  const value = f.value ?? (typeof f.checked === 'boolean' ? String(f.checked) : undefined);
1612
1684
  if (value === undefined) throw new Error(`fill_form: field ${f.ref ?? f.selector} needs value or checked`);
1613
1685
  return { selector, value };
1614
- });
1615
- const submitSel = submit_ref ? resolveTarget(undefined, submit_ref, tab_id, frame_id).selector : submit_selector;
1686
+ }));
1687
+ const submitSel = submit_ref ? (await freshTarget(undefined, submit_ref, tab_id, frame_id)).selector : submit_selector;
1616
1688
  const [before, textBefore] = await Promise.all([tabSnapshot(tab_id), submitSel ? pageText(tab_id, frame_id) : null]);
1617
1689
  const data = await send(MessageType.FILL_FORM, { fields: resolved, submit_selector: submitSel, tab_id, frame_id });
1618
1690
  const waited = await applyWaitAfter(send, wait_after, tab_id, before?.url);
@@ -2218,7 +2290,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2218
2290
  frame_id: frameId,
2219
2291
  },
2220
2292
  async ({ selector, ref, trusted, tab_id, frame_id }) => {
2221
- const t = resolveTarget(selector, ref, tab_id, frame_id);
2293
+ const t = await freshTarget(selector, ref, tab_id, frame_id);
2222
2294
  const data = await send(MessageType.HOVER, { selector: t.selector, ...(trusted && { trusted: true }), tab_id, frame_id: t.frame_id });
2223
2295
  return {
2224
2296
  content: [{
@@ -2733,8 +2805,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2733
2805
  const summary = summarizeAudit(results);
2734
2806
  let text = `audit ${info?.url ?? ''}\n${summary.lines.join('\n')}`;
2735
2807
  if (save_to) {
2736
- await writeFile(save_to, auditReport({ url: info?.url, title: info?.title, results, summary }));
2737
- text += `\nreport: ${save_to}`;
2808
+ text += `\nreport: ${await writeSaved(save_to, auditReport({ url: info?.url, title: info?.title, results, summary }), '.md')}`;
2738
2809
  } else {
2739
2810
  text += '\n(pass save_to for the full report with every finding)';
2740
2811
  }
@@ -2993,12 +3064,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2993
3064
  if (save_to) {
2994
3065
  // Il base64 non deve mai raggiungere il modello: un PDF di 300 kB sono
2995
3066
  // ~100k token di rumore per un contenuto che va letto da file.
2996
- await writeFile(save_to, Buffer.from(data.body_b64 ?? '', 'base64'));
3067
+ const saved = await writeSaved(save_to, Buffer.from(data.body_b64 ?? '', 'base64'), extname(new URL(data.url ?? url).pathname) || '.bin');
2997
3068
  return {
2998
3069
  content: [{
2999
3070
  type: 'text',
3000
3071
  text: jsonText({
3001
- saved: save_to,
3072
+ saved,
3002
3073
  status: data.status,
3003
3074
  ok: data.ok,
3004
3075
  content_type: data.content_type,
@@ -3322,7 +3393,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
3322
3393
  }
3323
3394
  return row;
3324
3395
  });
3325
- if (save_to) return savedSummary(save_to, Buffer.from(JSON.stringify({ total: nodes.length, shown: items.length, items }), 'utf8'), { total: nodes.length, shown: items.length });
3396
+ if (save_to) return savedSummary(save_to, Buffer.from(JSON.stringify({ total: nodes.length, shown: items.length, items }), 'utf8'), { total: nodes.length, shown: items.length }, '.json');
3326
3397
  if ((format ?? 'lines') === 'json') {
3327
3398
  return { content: [{ type: 'text', text: truncateText(JSON.stringify({ total: nodes.length, shown: items.length, items }), max_length, 'max_items or max_length') }] };
3328
3399
  }