chrome-bridge-mcp 1.31.2 → 1.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.31.3",
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.31.3",
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.31.3",
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.31.3';
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);
@@ -1061,7 +1089,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1061
1089
  }
1062
1090
  const data = await send(MessageType.SCREENSHOT, { tab_id });
1063
1091
  const b64 = data?.image ?? data?.data;
1064
- if (save_to && b64) return savedSummary(save_to, Buffer.from(b64, 'base64'), { mimeType: 'image/png' });
1092
+ if (save_to && b64) return savedSummary(save_to, Buffer.from(b64, 'base64'), { mimeType: 'image/png' }, '.png');
1065
1093
  // data.image è base64 PNG. data.viewport (CSS px) è il sistema di
1066
1094
  // riferimento di element_screenshot.region: senza, il modello non sa
1067
1095
  // mappare ciò che vede sull'immagine ridotta a ≤1568px.
@@ -1111,7 +1139,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1111
1139
  if (save_to) {
1112
1140
  const value = data?.result;
1113
1141
  const isString = typeof value === 'string';
1114
- return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' });
1142
+ return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' }, isString ? '.txt' : '.json');
1115
1143
  }
1116
1144
  return { content: [{ type: 'text', text: jsResultText(data, max_length ?? DEFAULT_MAX_OUTPUT) }] };
1117
1145
  }
@@ -1205,7 +1233,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1205
1233
  guardWrite(save_to);
1206
1234
  const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
1207
1235
  const text = typeof data === 'string' ? data : JSON.stringify(data);
1208
- if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
1236
+ if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' }, mode === 'html' ? '.html' : mode === 'markdown' ? '.md' : '.txt');
1209
1237
  return {
1210
1238
  content: [{
1211
1239
  type: 'text',
@@ -2733,8 +2761,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2733
2761
  const summary = summarizeAudit(results);
2734
2762
  let text = `audit ${info?.url ?? ''}\n${summary.lines.join('\n')}`;
2735
2763
  if (save_to) {
2736
- await writeFile(save_to, auditReport({ url: info?.url, title: info?.title, results, summary }));
2737
- text += `\nreport: ${save_to}`;
2764
+ text += `\nreport: ${await writeSaved(save_to, auditReport({ url: info?.url, title: info?.title, results, summary }), '.md')}`;
2738
2765
  } else {
2739
2766
  text += '\n(pass save_to for the full report with every finding)';
2740
2767
  }
@@ -2993,12 +3020,12 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
2993
3020
  if (save_to) {
2994
3021
  // Il base64 non deve mai raggiungere il modello: un PDF di 300 kB sono
2995
3022
  // ~100k token di rumore per un contenuto che va letto da file.
2996
- await writeFile(save_to, Buffer.from(data.body_b64 ?? '', 'base64'));
3023
+ const saved = await writeSaved(save_to, Buffer.from(data.body_b64 ?? '', 'base64'), extname(new URL(data.url ?? url).pathname) || '.bin');
2997
3024
  return {
2998
3025
  content: [{
2999
3026
  type: 'text',
3000
3027
  text: jsonText({
3001
- saved: save_to,
3028
+ saved,
3002
3029
  status: data.status,
3003
3030
  ok: data.ok,
3004
3031
  content_type: data.content_type,
@@ -3322,7 +3349,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
3322
3349
  }
3323
3350
  return row;
3324
3351
  });
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 });
3352
+ 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
3353
  if ((format ?? 'lines') === 'json') {
3327
3354
  return { content: [{ type: 'text', text: truncateText(JSON.stringify({ total: nodes.length, shown: items.length, items }), max_length, 'max_items or max_length') }] };
3328
3355
  }