chrome-bridge-mcp 1.13.0 → 1.15.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
@@ -1,166 +1,141 @@
1
1
  # Chrome Bridge
2
2
 
3
- ![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-169%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
3
+ ![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-187%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
4
4
 
5
- **Chrome Bridge is an MCP server that connects Claude Code to your real, logged-in Chrome browser — measured 2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome" extension on a form-filling task, with ~3× the toolset and no paid plan.**
5
+ **An MCP server that gives Claude Code your real, logged-in Chrome — measured
6
+ 2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
7
+ extension on a form-filling task, with ~3× the toolset and no paid plan.**
6
8
 
7
- By using a local WebSocket bridge and a specialized Chrome extension, Chrome Bridge provides 62 web-development tools (navigation, DOM inspection, visual regression, audits, network mocking) and a dedicated headless instance for CI. It is self-hosted, local-only, and requires no paid plan.
9
+ 63 web-development tools (navigation, DOM inspection, visual regression, audits,
10
+ network mocking) over a local WebSocket bridge, plus a headless instance for CI.
11
+ Self-hosted, local-only.
8
12
 
9
- ## Why Chrome Bridge?
13
+ ## Quickstart
10
14
 
11
- Chrome Bridge is designed to be both more efficient and more powerful than Anthropic's official browser extension.
15
+ **Requires** Node.js 18+ and Chrome 135+.
12
16
 
13
- ### 1. Efficiency Benchmark
14
- Same model (Claude Sonnet 5), same task, paired runs on the same date and versions, **all runs included** (n=2 per arm — a small sample: direction, not precision):
17
+ ```bash
18
+ git clone git@github.com:frsorrentino/chrome-bridge.git
19
+ cd chrome-bridge && ./install.sh
20
+ ```
21
+
22
+ 1. Open `chrome://extensions`, enable **Developer mode**, click **Load
23
+ unpacked**, select the `extension/` folder.
24
+ 2. Restart Claude Code.
15
25
 
16
- | Task | Chrome Bridge | Claude in Chrome | Ratio |
17
- | :--- | :--- | :--- | :--- |
18
- | **Form fill** | 6.0 turns (6-6) / $0.211 | 16.5 turns (16-17) / $0.481 | **2.75× turns, 2.28× cost** |
19
- | **1500-row table lookup** | see note | not re-run on this version | *not published* |
26
+ Then ask for something like *"open localhost:3000, run an accessibility audit
27
+ and find the Sign Up button"*: Claude Code calls `navigate`,
28
+ `accessibility_audit` and `find_text`. Because `navigate` already returns
29
+ element refs, `click(ref="n1")` follows with no discovery turn in between.
20
30
 
21
- On the table-lookup task, an `extract_table` fix took our side from 13.5 to 4.0
22
- turns, but the Claude-in-Chrome arm has not been re-run on that version — so no
23
- ratio is published for it. The inclusion rule, every raw run (including the
24
- unfavourable ones) and the harness limits are in
25
- [bench/RESULTS.md](bench/RESULTS.md).
31
+ > **On ChromeOS/Crostini** install from the [Chrome Web
32
+ > Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
33
+ > instead: an unpacked extension is dropped on every reboot, because the
34
+ > container isn't mounted when Chrome starts.
26
35
 
27
- **Why it wins — it makes fewer round trips, not smaller payloads:**
28
- - **Compact References:** The agent acts on short element handles (e.g., `n1`, `n2`) returned by `navigate` or `get_interactives` instead of the slow screenshot → read-coordinates → click loop.
29
- - **Batching:** `fill_form` fills N fields and submits in one call — measured, 3 calls instead of 9 for the same form, at the same byte count.
30
- - **Server-Side Processing:** Filtering large tables happens server-side (`extract_table` with `where`): 236 bytes to find one row among 1500, against 50,070 bytes for `read_page`. The extension-to-model payload is the token bottleneck; Chrome Bridge moves the heavy lifting to localhost.
31
- - **Honest caveat:** per *turn*, Chrome Bridge costs slightly more than Claude in Chrome (41,985 vs 36,613 cache-read tokens; $0.0373 vs $0.0337). The win is in the number of turns.
36
+ `install.sh` registers the MCP server with `--scope user`. To do it by hand:
37
+ `claude mcp add --scope user chrome-bridge node /path/to/server/index.js`.
38
+ For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
39
+ Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
32
40
 
33
- ### 2. Feature Comparison
34
- Chrome Bridge ships 62 specialized tools (33 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
41
+ ## Why Chrome Bridge?
35
42
 
36
43
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
37
44
  |---|---|---|---|---|
38
45
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
39
- | **Tools** | **62** (33 core) | ~20 | ~50 | 23 core (71 total) |
40
- | **Requires Paid Plan** | **No** | Yes (Pro+) | No | No |
41
- | **Network Mocking** | **Yes** (stub/headers) | No | No | Yes |
42
- | **Visual Regression** | **Yes** (`screenshot_diff`) | No | No | No |
43
- | **Audits (A11y/SEO/Sec)** | **Yes** (Full suite) | No | Partial | No |
46
+ | **Tools** | **63** (34 core) | ~20 | ~50 | 23 core (71 total) |
47
+ | **Requires paid plan** | **No** | Yes (Pro+) | No | No |
48
+ | **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
49
+ | **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
50
+ | **Audits (a11y/SEO/sec)** | **Yes** (full suite) | No | Partial | No |
44
51
  | **Headless / CI** | **Yes** | No | Yes | Yes |
45
- | **GIF / Video** | No | **Yes** | Partial | No |
46
- | **Breakpoints / Heap** | No | No | **Yes** | No |
47
-
48
- ## Quickstart
49
-
50
- ```bash
51
- git clone git@github.com:frsorrentino/chrome-bridge.git && cd chrome-bridge && ./install.sh
52
- ```
53
-
54
- 1. Load the extension: Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select the `extension/` folder.
55
- 2. Restart Claude Code.
56
-
57
- > On **ChromeOS/Crostini**, install the published extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb) instead of loading it unpacked — a filesystem-loaded extension is dropped on every reboot (the container isn't mounted when Chrome starts).
58
-
59
- ### Example Session
60
- > *"Open localhost:3000, run an accessibility audit, and find the 'Sign Up' button"*
61
-
62
- Claude Code calls `navigate` (returning clickable element refs), `accessibility_audit`, and `find_text`. Because `navigate` returns refs like `n1`, the agent can immediately call `click(ref="n1")` without a discovery turn.
52
+ | **GIF / video** | No | **Yes** | Partial | No |
53
+ | **Breakpoints / heap** | No | No | **Yes** | No |
63
54
 
64
- ## Token-Efficient Design
55
+ It wins on **round trips, not payload size**: short element refs instead of the
56
+ screenshot-and-click loop, `fill_form` filling N fields in one call, table
57
+ filtering done server-side. Per single turn it actually costs slightly *more*.
65
58
 
66
- - **Opt-in Surface**: 33 core tools cost ≈8.5k tokens of `tools/list`, all 62 cost ≈15.5k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
67
- - **Act-from-Result**: Tools attach a capped preview of interactive elements with short refs. Actions only report a `page_changed` delta when the URL or title actually changes.
68
- - **Optimized Media**: Screenshots are downscaled to ≤1568px. Full-page captures are sliced into readable segments.
69
- - **Payload Escape Hatches**: `save_to` on `read_page`, `extract`, `screenshot` and `http_request` writes the result to a file and returns the path — the bytes never enter the context unless the agent decides to read them. `read_page(mode="markdown")` keeps headings, links and tables at a fraction of the HTML cost. The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
59
+ The full benchmark — method, every raw run including the unfavourable ones, and
60
+ what the harness can't measure — is in
61
+ [docs/EFFICIENCY.md](docs/EFFICIENCY.md).
70
62
 
71
- ## Highlights
63
+ ## Using it
72
64
 
73
- - **Launch Mode**: Start a dedicated Chromium instance with an ephemeral profile for isolated sessions or CI.
74
- - **Network Mocking**: Block/redirect requests, rewrite headers, or stub response bodies with synthetic data.
75
- - **Visual Regression**: Use `screenshot_diff` to compare current pages against named baselines.
76
- - **Structured Extraction**: One-call `extract` for repeated data and `extract_table` with server-side `where` filtering.
65
+ Beyond the MCP tools, two lanes keep work away from the model entirely.
77
66
 
78
- ## Architecture
67
+ **CLI** — batch operations, piped through `grep` or `jq` before anything reaches
68
+ the context:
79
69
 
70
+ ```bash
71
+ chrome-bridge navigate --url https://example.com
72
+ chrome-bridge read_console --level error | head -20
73
+ chrome-bridge assert --selector "#success" --text "Done"
74
+ chrome-bridge replay --file ./recordings/login.jsonl
80
75
  ```
81
- Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
82
- (server/) (extension/, MV3)
83
- ```
84
-
85
- The **MCP Server** (Node.js) handles the protocol and tool logic. The **Chrome Extension** (MV3) executes commands via Chrome APIs. User scripts (`execute_js`) run via `chrome.userScripts.execute()`, requiring the "Allow user scripts" toggle in extension settings.
86
-
87
- ## Tools (62 total)
88
76
 
89
- ### Core & Navigation (9)
90
- `get_status`, `get_tabs`, `create_tab`, `navigate`, `tab_action`, `move_tab` (between windows), `tile_windows` (split one monitor evenly), `get_frames`, `screenshot`.
77
+ **Launch mode** — a dedicated Chromium instance with an ephemeral profile, for
78
+ isolated sessions or CI:
91
79
 
92
- ### Interaction (11)
93
- `click`, `type_text`, `fill_form`, `hover`, `press_key`, `scroll`, `drag_and_drop`, `upload_file`, `dismiss_overlays`, `handle_dialogs`, `clipboard`.
94
-
95
- ### DOM & Inspection (11)
96
- `read_page`, `extract`, `get_page_info`, `query_dom`, `modify_dom`, `find_text`, `get_interactives`, `inject_css`, `highlight_elements`, `watch_dom`, `measure_spacing`.
80
+ ```bash
81
+ node server/index.js --launch --headless
82
+ ```
97
83
 
98
- ### Debugging & Network (9)
99
- `execute_js`, `read_console`, `monitor_network`, `monitor_websocket`, `network_rules` (block/redirect/stub/headers), `http_request` (sent with the user's session cookies), `get_performance`, `web_vitals`, `list_event_listeners`.
84
+ Pair it with `session_record` + `replay` for smoke tests with no model in the
85
+ loop. In launch mode `execute_js` falls back to `new Function` when the
86
+ user-script toggle isn't available.
100
87
 
101
- ### Visual & Responsive (7)
102
- `element_screenshot`, `full_page_screenshot`, `screenshot_diff`, `viewport_resize`, `set_zoom`, `emulate_media`, `set_geolocation`.
88
+ ## Tools
103
89
 
104
- ### Audits (6)
105
- `accessibility_audit`, `seo_audit`, `security_headers`, `check_links` (server-side verification), `unused_css`, `extract_table` (with `where` filtering).
90
+ 63 in total, in seven groups. Only `core` (34 tools) loads by default; the rest
91
+ are opt-in via `--caps`.
106
92
 
107
- ### State, Storage & Files (9)
108
- `get_storage`, `set_storage`, `session_fixture`, `http_auth`, `save_page` (MHTML), `manage_downloads`, `session_record`, `wait_for`, `assert`.
93
+ | Group | N | What's in it |
94
+ |---|---|---|
95
+ | Core & Navigation | 9 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
96
+ | Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
97
+ | DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `watch_dom` |
98
+ | Debugging & Network | 9 | `execute_js`, console, network log, mocking, Web Vitals |
99
+ | Visual & Responsive | 7 | `screenshot_diff`, viewport, zoom, media emulation |
100
+ | Audits | 6 | a11y, SEO, security headers, links, `extract_table` |
101
+ | State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
109
102
 
110
- ## Install
103
+ Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
111
104
 
112
- **Requirements:** Node.js 18+, Chrome 135+.
105
+ ## How it works
113
106
 
114
- ### From Source
115
- ```bash
116
- git clone git@github.com:frsorrentino/chrome-bridge.git
117
- cd chrome-bridge
118
- ./install.sh
119
107
  ```
120
- `install.sh` registers the MCP server in Claude Code (`--scope user`).
121
-
122
- ### Manual Registration
123
- ```bash
124
- claude mcp add --scope user chrome-bridge node /path/to/server/index.js
108
+ Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
109
+ (server/) (extension/, MV3)
125
110
  ```
126
111
 
127
- **Note:** For `execute_js`, you must enable **"Allow user scripts"** in `chrome://extensions` → Chrome Bridge → Details. On Chrome 135-137, enable Developer Mode instead.
112
+ The Node.js server handles the protocol and tool logic; the MV3 extension
113
+ executes commands through Chrome APIs. User scripts (`execute_js`) run via
114
+ `chrome.userScripts.execute()`.
128
115
 
129
- ## Configuration
116
+ ## Configuration and security
130
117
 
131
- Configure the server via environment variables (or the matching CLI flags):
132
- - `CHROME_BRIDGE_PORT`: Default `8765`.
133
- - `CHROME_BRIDGE_HOST` / `--host`: Bind address, default `127.0.0.1`. Set to `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and pair it with a token.
134
- - `CHROME_BRIDGE_TOKEN`: Shared secret required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind is not loopback.
135
- - `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (33 tools). `get_status` reports `caps_active` and `caps_available`.
118
+ Environment variables, each with a matching CLI flag:
136
119
 
137
- Run `node tools/measure-schema.mjs` to see the exact schema cost of the active set.
120
+ | Variable | Default | Notes |
121
+ |---|---|---|
122
+ | `CHROME_BRIDGE_PORT` | `8765` | |
123
+ | `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 |
124
+ | `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
125
+ | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
138
126
 
139
- ## CLI
127
+ The bridge binds loopback, accepts extension connections only from a
128
+ `chrome-extension://` origin, and — when a token is set — requires it on both
129
+ handshakes. Without one, any local process could act as a relay and reach
130
+ `execute_js` inside your authenticated browser session. Secondary MCP instances
131
+ connect via loopback and are acknowledged with `relay_init_ok`, so a foreign
132
+ process holding the port fails fast instead of timing out per command.
140
133
 
141
- The CLI allows batch operations and pipes output through `grep` or `jq` before it reaches the model:
142
- ```bash
143
- chrome-bridge navigate --url https://example.com
144
- chrome-bridge read_console --level error | head -20
145
- chrome-bridge assert --selector "#success" --text "Done"
146
- chrome-bridge replay --file ./recordings/login.jsonl
147
- ```
148
-
149
- ## Launch Mode (Headless / CI)
150
-
151
- Launch mode starts a dedicated Chromium instance with an ephemeral profile:
152
- ```bash
153
- node server/index.js --launch --headless
154
- ```
155
- Perfect for CI smoke tests using `session_record` + `replay`. Note: `execute_js` uses a `new Function` fallback in launch mode if the user-script toggle is unavailable.
156
-
157
- ## Security
158
-
159
- - **Loopback by default**: the WebSocket server binds `127.0.0.1`. Exposing it to the network is opt-in via `CHROME_BRIDGE_HOST`/`--host`.
160
- - **Origin validation**: extension connections are only accepted from a `chrome-extension://` origin, and — with a loopback bind — only from loopback.
161
- - **Token on both handshakes**: `CHROME_BRIDGE_TOKEN`, when set, is required for `ext_init` *and* `relay_init`. Without it, any local process could act as a relay and reach `execute_js` in your authenticated browser session.
162
- - **Relay security**: secondary MCP instances must connect via loopback and are acknowledged with `relay_init_ok`, so a foreign process holding the port fails fast instead of timing out per command.
163
- - **What is *not* protected**: page content reaches the model unfiltered, so a hostile page's text is untrusted input; `get_storage`, `session_fixture`, HAR exports and screenshots are **not** redacted and may carry cookies, tokens or personal data. Do not point the automation at pages holding secrets you would not paste into a chat.
134
+ **What is *not* protected:** page content reaches the model unfiltered, so a
135
+ hostile page's text is untrusted input. `get_storage`, `session_fixture`, HAR
136
+ exports and screenshots are **not** redacted and may carry cookies, tokens or
137
+ personal data. Don't point the automation at pages holding secrets you wouldn't
138
+ paste into a chat.
164
139
 
165
140
  ## Troubleshooting
166
141
 
@@ -170,16 +145,24 @@ Perfect for CI smoke tests using `session_record` + `replay`. Note: `execute_js`
170
145
  | Port 8765 already in use | Expected: a second MCP session becomes a **relay** and shares the one bridge. Set `CHROME_BRIDGE_PORT` for a separate one. |
171
146
  | `Port N is held by a process that is not chrome-bridge` | Something else owns the port. Free it or change `CHROME_BRIDGE_PORT`. |
172
147
  | `execute_js` fails | Enable **Allow user scripts** in `chrome://extensions` → Chrome Bridge → Details (Chrome 138+; on 135-137 enable Developer Mode). |
173
- | `read_console` returns a `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page is not injectable (`chrome://`). Reload the page. |
148
+ | `read_console` returns `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page isn't injectable (`chrome://`). Reload it. |
174
149
  | Screenshot times out | On ChromeOS a fully occluded window stops producing frames; captures fail after 10s. Bring the window forward. |
175
- | Commands work then stop after a while | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
176
- | Extension is dropped on every ChromeOS reboot | Install from the Chrome Web Store instead of Load unpacked: the Crostini filesystem isn't mounted when Chrome starts. |
177
- | Tool missing from the list | It's in an opt-in capability group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
150
+ | Commands work, then stop | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
151
+ | Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
152
+ | Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
153
+
154
+ ## Documentation
155
+
156
+ - [docs/TOOLS.md](docs/TOOLS.md) — all 63 tools, by group
157
+ - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
158
+ - [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
159
+ - [CHANGELOG.md](CHANGELOG.md)
178
160
 
179
161
  ## Tests
180
- - **Unit**: `npm test` (Chrome-free, ~22s).
181
- - **E2E**: `npm run test:e2e` (requires Chrome and a connected extension).
182
- - **Schema cost**: `npm run measure`.
162
+
163
+ `npm test` (Chrome-free, ~22s) · `npm run test:e2e` (needs Chrome and a
164
+ connected extension) · `npm run measure` (schema cost).
183
165
 
184
166
  ## License
167
+
185
168
  MIT
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.13.0",
5
- "description": "Bridge your browser to Claude Code: 62 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.15.0",
5
+ "description": "Bridge your browser to Claude Code: 63 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",
8
8
  "permissions": [
@@ -322,7 +322,7 @@ async function executeCommand(msg) {
322
322
 
323
323
  switch (type) {
324
324
  case 'get_tabs':
325
- return await cmdGetTabs();
325
+ return await cmdGetTabs(params);
326
326
  case 'navigate':
327
327
  return await cmdNavigate(params);
328
328
  case 'screenshot':
@@ -649,15 +649,36 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
649
649
 
650
650
  // --- Implementazione comandi ---
651
651
 
652
- async function cmdGetTabs() {
652
+ async function cmdGetTabs({ include_windows = false } = {}) {
653
653
  const tabs = await chrome.tabs.query({});
654
- return tabs.map((t) => ({
654
+ const list = tabs.map((t) => ({
655
655
  id: t.id,
656
656
  url: t.url,
657
657
  title: t.title,
658
658
  active: t.active,
659
659
  windowId: t.windowId,
660
660
  }));
661
+ if (!include_windows) return list;
662
+
663
+ // Senza geometria e stato non si può decidere dove mettere una finestra: si
664
+ // può solo indovinare. `left` in particolare è ciò che identifica il monitor
665
+ // su una scrivania virtuale multi-schermo.
666
+ const windows = (await chrome.windows.getAll({ populate: true })).map((w) => ({
667
+ id: w.id,
668
+ type: w.type,
669
+ state: w.state,
670
+ focused: w.focused,
671
+ left: w.left,
672
+ top: w.top,
673
+ width: w.width,
674
+ height: w.height,
675
+ tabs: (w.tabs || []).length,
676
+ // Una finestra le cui schede sono tutte chrome:// o chrome-untrusted:// non
677
+ // può fornire l'area del monitor a tile_windows: meglio saperlo prima di
678
+ // provarci.
679
+ scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
680
+ }));
681
+ return { tabs: list, windows };
661
682
  }
662
683
 
663
684
  async function cmdNavigate({ url, tab_id }) {
@@ -920,7 +941,24 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
920
941
 
921
942
  // --- create_tab ---
922
943
 
923
- async function cmdCreateTab({ url, active = true }) {
944
+ async function cmdCreateTab({ url, active = true, new_window = false, left, top, width, height }) {
945
+ if (new_window) {
946
+ // windows.create con i bounds apre direttamente sul monitor scelto: left è
947
+ // la coordinata sulla scrivania virtuale, quindi è ciò che sceglie lo schermo.
948
+ const winOpts = { focused: active !== false };
949
+ if (url) winOpts.url = url;
950
+ for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
951
+ if (typeof v === 'number') winOpts[k] = v;
952
+ }
953
+ const win = await chrome.windows.create(winOpts);
954
+ const tab = (win.tabs || [])[0];
955
+ if (url && tab) {
956
+ await waitForComplete(tab.id);
957
+ const updated = await chrome.tabs.get(tab.id);
958
+ return { id: updated.id, url: updated.url, title: updated.title, window_id: win.id };
959
+ }
960
+ return { id: tab?.id, url: tab?.url || 'chrome://newtab', title: tab?.title || '', window_id: win.id };
961
+ }
924
962
  const opts = { active };
925
963
  if (url) opts.url = url;
926
964
  const tab = await chrome.tabs.create(opts);
@@ -965,7 +1003,7 @@ async function cmdCreateTab({ url, active = true }) {
965
1003
  * chrome-untrusted:// non può fornire l'area, e il tool lo dice invece di
966
1004
  * ripiegare su misure inventate.
967
1005
  */
968
- async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid', padding = 0, include_types }) {
1006
+ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid', padding = 0, include_types, area: explicitArea }) {
969
1007
  const types = include_types?.length ? include_types : ['normal'];
970
1008
  const all = await chrome.windows.getAll({ populate: true });
971
1009
 
@@ -986,9 +1024,12 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
986
1024
  .flatMap((w) => (w.tabs || []).map((t) => ({ windowId: w.id, tabId: t.id, url: t.url || '' })))
987
1025
  .filter((t) => /^(https?|file):/i.test(t.url));
988
1026
 
989
- let area = null;
1027
+ // Un'area esplicita salta la lettura da una scheda: è l'unica strada quando
1028
+ // le finestre bersaglio hanno solo pagine interne di Chrome, come quelle del
1029
+ // Terminale ChromeOS.
1030
+ let area = explicitArea ?? null;
990
1031
  const failures = [];
991
- for (const cand of candidates) {
1032
+ for (const cand of (area ? [] : candidates)) {
992
1033
  try {
993
1034
  const [res] = await chrome.scripting.executeScript({
994
1035
  target: { tabId: cand.tabId },
@@ -1051,11 +1092,30 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
1051
1092
  };
1052
1093
  }
1053
1094
 
1054
- async function cmdMoveTab({ tab_id, window_id, index = -1 }) {
1095
+ async function cmdMoveTab({ tab_id, window_id, new_window = false, left, top, width, height, index = -1 }) {
1055
1096
  if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
1056
- if (typeof window_id !== 'number') throw new Error('Missing required parameter: window_id');
1097
+ if (typeof window_id !== 'number' && !new_window) throw new Error('Provide window_id or new_window: true');
1057
1098
 
1058
1099
  const before = await chrome.tabs.get(tab_id);
1100
+
1101
+ if (new_window) {
1102
+ // tabs.move vuole una finestra che esiste già; per estrarre in una finestra
1103
+ // nuova (e posizionata) l'unica via è windows.create({tabId}).
1104
+ const winOpts = { tabId: tab_id };
1105
+ for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
1106
+ if (typeof v === 'number') winOpts[k] = v;
1107
+ }
1108
+ const win = await chrome.windows.create(winOpts);
1109
+ return {
1110
+ moved: tab_id,
1111
+ url: before.url,
1112
+ from_window: before.windowId,
1113
+ to_window: win.id,
1114
+ new_window: true,
1115
+ index: 0,
1116
+ same_window: before.windowId === win.id,
1117
+ };
1118
+ }
1059
1119
  const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
1060
1120
  const tab = Array.isArray(moved) ? moved[0] : moved;
1061
1121
 
@@ -1079,6 +1139,21 @@ async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
1079
1139
  await chrome.tabs.remove(tabId);
1080
1140
  return { action, closed: tabId };
1081
1141
  }
1142
+ if (action === 'discard') {
1143
+ // La scheda resta nella barra e si ricarica al focus: il processo muore, la
1144
+ // RAM torna. La tmux session dietro un terminale non c'entra: vive nel
1145
+ // container, non nel renderer.
1146
+ const t = await chrome.tabs.discard(tabId);
1147
+ return { action, tabId: t?.id ?? tabId, discarded: true };
1148
+ }
1149
+ if (action === 'mute' || action === 'unmute') {
1150
+ await chrome.tabs.update(tabId, { muted: action === 'mute' });
1151
+ return { action, tabId, muted: action === 'mute' };
1152
+ }
1153
+ if (action === 'duplicate') {
1154
+ const d = await chrome.tabs.duplicate(tabId);
1155
+ return { action, source: tabId, duplicated: d.id, window_id: d.windowId };
1156
+ }
1082
1157
  if (action === 'activate') {
1083
1158
  const tab = await chrome.tabs.get(tabId);
1084
1159
  await chrome.tabs.update(tabId, { active: true });
@@ -1809,7 +1884,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
1809
1884
 
1810
1885
  // --- viewport_resize ---
1811
1886
 
1812
- async function cmdViewportResize({ preset, width, height, read_only = false, tab_id }) {
1887
+ async function cmdViewportResize({ preset, width, height, left, top, state, read_only = false, tab_id }) {
1813
1888
  const tabId = await resolveTabId(tab_id);
1814
1889
  const tab = await chrome.tabs.get(tabId);
1815
1890
 
@@ -1825,9 +1900,20 @@ async function cmdViewportResize({ preset, width, height, read_only = false, tab
1825
1900
  const updateOpts = {};
1826
1901
  if (targetW) updateOpts.width = targetW;
1827
1902
  if (targetH) updateOpts.height = targetH;
1903
+ if (typeof left === 'number') updateOpts.left = left;
1904
+ if (typeof top === 'number') updateOpts.top = top;
1828
1905
 
1829
1906
  if (!read_only) {
1830
- if (Object.keys(updateOpts).length === 0) throw new Error('Provide preset, width, or height');
1907
+ // Lo stato va cambiato PRIMA dei bounds: una finestra massimizzata li
1908
+ // accetta e li ignora, e la chiamata riuscirebbe senza spostare nulla.
1909
+ if (state) await chrome.windows.update(tab.windowId, { state });
1910
+ if (Object.keys(updateOpts).length === 0) {
1911
+ if (state) {
1912
+ const w = await chrome.windows.get(tab.windowId);
1913
+ return { requested: { state }, actual: { left: w.left, top: w.top, width: w.width, height: w.height, state: w.state } };
1914
+ }
1915
+ throw new Error('Provide preset, width, height, left, top or state');
1916
+ }
1831
1917
  await chrome.windows.update(tab.windowId, updateOpts);
1832
1918
  // La finestra si ridisegna in modo asincrono: senza la pausa si leggono le
1833
1919
  // dimensioni vecchie e si riporta un risultato falso.
@@ -1845,7 +1931,14 @@ async function cmdViewportResize({ preset, width, height, read_only = false, tab
1845
1931
  });
1846
1932
  const actual = results?.[0]?.result ?? {};
1847
1933
  if (read_only) return { actual };
1848
- return { requested: { width: targetW, height: targetH, preset: preset || null }, actual };
1934
+ const win = await chrome.windows.get(tab.windowId);
1935
+ return {
1936
+ requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
1937
+ actual,
1938
+ // Il window manager può accettare e ignorare: senza il confronto una
1939
+ // finestra rimasta ferma passerebbe per spostata.
1940
+ window: { left: win.left, top: win.top, width: win.width, height: win.height, state: win.state },
1941
+ };
1849
1942
  }
1850
1943
 
1851
1944
  // --- full_page_screenshot ---
@@ -3584,9 +3677,19 @@ async function cmdSetGeolocation({ latitude, longitude, accuracy = 10, reset = f
3584
3677
 
3585
3678
  // --- manage_downloads ---
3586
3679
 
3587
- async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
3680
+ async function cmdManageDownloads({ action, url, filename, timeout = 30000, limit = 10 }) {
3588
3681
  if (!action) throw new Error('Missing required parameter: action');
3589
3682
 
3683
+ if (action === 'download') {
3684
+ if (!url) throw new Error('action=download requires url');
3685
+ // Parte dal browser, quindi con i cookie di sessione dell'origine: un file
3686
+ // dietro login si scarica senza far passare i byte dal bridge.
3687
+ const opts = { url, saveAs: false };
3688
+ if (filename) opts.filename = filename;
3689
+ const id = await chrome.downloads.download(opts);
3690
+ return { started: true, id, url };
3691
+ }
3692
+
3590
3693
  if (action === 'list') {
3591
3694
  const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit });
3592
3695
  return {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.13.0",
4
+ "version": "1.15.0",
5
5
  "type": "module",
6
- "description": "MCP server with 62 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
6
+ "description": "MCP server with 63 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "server/index.js",
@@ -84,7 +84,7 @@ export const MessageType = Object.freeze({
84
84
  });
85
85
 
86
86
  // Versione
87
- export const VERSION = '1.13.0';
87
+ export const VERSION = '1.15.0';
88
88
 
89
89
  // Configurazione
90
90
  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, extname, join } from 'node:path';
10
+ import { basename, dirname, extname, join } 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';
@@ -19,6 +19,9 @@ import { evaluateSecurityHeaders } from './security-headers.js';
19
19
  import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
20
20
 
21
21
  const SESSIONS_DIR = join(homedir(), '.config', 'chrome-bridge', 'sessions');
22
+ // Sovrascrivibile nei test: i layout sono un file solo, non una directory.
23
+ const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
24
+ || join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
22
25
  const RECORDINGS_DIR = process.env.CHROME_BRIDGE_RECORD_DIR || join(homedir(), '.config', 'chrome-bridge', 'recordings');
23
26
 
24
27
  // Comandi rumore per un replay: letture interne (tabSnapshot) o senza effetto
@@ -291,6 +294,7 @@ export const TOOL_ANNOTATIONS = {
291
294
  create_tab: rw({ open: true }),
292
295
  move_tab: rw({ idempotent: true }), // rispostare dove è già = stesso esito
293
296
  tile_windows: rw({ idempotent: true }),
297
+ window_layout: rw({ destructive: true, idempotent: true }), // save sovrascrive l'omonimo, restore sposta finestre
294
298
  navigate: rw({ idempotent: true, open: true }),
295
299
  tab_action: rw({ destructive: true, open: true }), // close chiude una tab dell'utente
296
300
 
@@ -480,10 +484,15 @@ export function registerTools(server, wsManager, caps = 'all') {
480
484
  server.tool(
481
485
  'get_tabs',
482
486
  'List every open tab with id, url, title and active flag. Read-only. Use it to find a tab_id when the '
483
- + 'implicit target (last navigated tab, else the active one) is not the tab you mean.',
484
- {},
485
- async () => {
486
- const data = await send(MessageType.GET_TABS);
487
+ + 'implicit target (last navigated tab, else the active one) is not the tab you mean. '
488
+ + 'include_windows adds the windows themselves with their position, size, state and type — what you need '
489
+ + 'before moving or tiling anything, and the only way to tell which monitor a window is on.',
490
+ {
491
+ include_windows: z.boolean().optional().default(false)
492
+ .describe('Also return the windows with bounds, state, type and tab count'),
493
+ },
494
+ async ({ include_windows }) => {
495
+ const data = await send(MessageType.GET_TABS, { include_windows: include_windows === true });
487
496
  return {
488
497
  content: [{
489
498
  type: 'text',
@@ -871,9 +880,14 @@ export function registerTools(server, wsManager, caps = 'all') {
871
880
  {
872
881
  url: z.string().optional().describe('URL to open (default: new tab page)'),
873
882
  active: z.boolean().optional().default(true).describe('false opens the tab in the background, leaving the current one focused'),
883
+ new_window: z.boolean().optional().default(false).describe('Open in a fresh window instead of a tab; with left/top it lands on the chosen monitor'),
884
+ left: z.number().optional().describe('Window x on the virtual desktop (new_window)'),
885
+ top: z.number().optional().describe('Window y (new_window)'),
886
+ width: z.number().optional().describe('Window width px (new_window)'),
887
+ height: z.number().optional().describe('Window height px (new_window)'),
874
888
  },
875
- async ({ url, active }) => {
876
- const data = await send(MessageType.CREATE_TAB, { url, active });
889
+ async ({ url, active, new_window, left, top, width, height }) => {
890
+ const data = await send(MessageType.CREATE_TAB, { url, active, new_window: new_window === true || undefined, left, top, width, height });
877
891
  if (data?.id != null) sessionTabId = data.id;
878
892
  return {
879
893
  content: [{
@@ -1028,12 +1042,16 @@ export function registerTools(server, wsManager, caps = 'all') {
1028
1042
  {
1029
1043
  action: z.enum(['set', 'get']).optional().default('set').describe('get reports the current viewport without resizing anything'),
1030
1044
  preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
1045
+ left: z.number().optional().describe('Window x on the virtual desktop; on multi-monitor this is what picks the screen'),
1046
+ top: z.number().optional().describe('Window y on the virtual desktop'),
1047
+ state: z.enum(['normal', 'maximized', 'fullscreen', 'minimized']).optional()
1048
+ .describe('Applied before bounds: a maximized window accepts left/top/width/height and ignores them'),
1031
1049
  width: z.number().optional().describe('Overrides preset'),
1032
1050
  height: z.number().optional().describe('Overrides preset'),
1033
1051
  tab_id: tabId,
1034
1052
  },
1035
- async ({ action, preset, width, height, tab_id }) => {
1036
- const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, read_only: (action ?? 'set') === 'get', tab_id });
1053
+ async ({ action, preset, width, height, left, top, state, tab_id }) => {
1054
+ const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, left, top, state, read_only: (action ?? 'set') === 'get', tab_id });
1037
1055
  return {
1038
1056
  content: [{
1039
1057
  type: 'text',
@@ -1333,7 +1351,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1333
1351
  + 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
1334
1352
  + 'reload and navigation drop injected CSS, emulations and page hooks.',
1335
1353
  {
1336
- action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and page hooks'),
1354
+ action: z.enum(['close', 'activate', 'reload', 'back', 'forward', 'discard', 'mute', 'unmute', 'duplicate']).describe('close cannot be undone; discard frees memory, the tab reloads on focus; reload drops injected CSS and hooks'),
1337
1355
  bypass_cache: z.boolean().optional().default(false).describe('reload only'),
1338
1356
  tab_id: tabId,
1339
1357
  },
@@ -1650,12 +1668,15 @@ export function registerTools(server, wsManager, caps = 'all') {
1650
1668
  'manage_downloads',
1651
1669
  'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
1652
1670
  {
1653
- action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest download finishes or times out'),
1671
+ action: z.enum(['list', 'wait_for_complete', 'download']).describe('download starts one with the browser cookie jar; wait_for_complete blocks until the newest finishes'),
1672
+ url: z.string().optional().describe('What to download (action=download); sent with the session cookies of its origin'),
1673
+ filename: z.string().optional().describe('Relative path inside the Downloads folder (action=download)'),
1654
1674
  timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
1655
1675
  limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
1656
1676
  },
1657
- async ({ action, timeout, limit }) => {
1658
- const data = await send(MessageType.MANAGE_DOWNLOADS, { action, timeout, limit });
1677
+ async ({ action, url, filename, timeout, limit }) => {
1678
+ if (action === 'download' && !url) throw new Error('action=download requires url');
1679
+ const data = await send(MessageType.MANAGE_DOWNLOADS, { action, url, filename, timeout, limit });
1659
1680
  return { content: [{ type: 'text', text: jsonText(data) }] };
1660
1681
  }
1661
1682
  );
@@ -1742,11 +1763,19 @@ export function registerTools(server, wsManager, caps = 'all') {
1742
1763
  + 'an app window, and the refusal is reported verbatim rather than retried.',
1743
1764
  {
1744
1765
  tab_id: z.number().describe('Tab to move; get it from get_tabs'),
1745
- window_id: z.number().describe('Destination window; get_tabs reports windowId for every tab'),
1766
+ window_id: z.number().optional().describe('Destination window; get_tabs reports windowId for every tab'),
1767
+ new_window: z.boolean().optional().default(false).describe('Extract the tab into a fresh window instead — tabs.move needs an existing window, this does not'),
1768
+ left: z.number().optional().describe('New window x (new_window)'),
1769
+ top: z.number().optional().describe('New window y (new_window)'),
1770
+ width: z.number().optional().describe('New window width px (new_window)'),
1771
+ height: z.number().optional().describe('New window height px (new_window)'),
1746
1772
  index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
1747
1773
  },
1748
- async ({ tab_id, window_id, index }) => {
1749
- const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, index: index ?? -1 });
1774
+ async ({ tab_id, window_id, new_window, left, top, width, height, index }) => {
1775
+ if (window_id == null && new_window !== true) {
1776
+ throw new Error('Provide window_id (an existing window) or new_window: true');
1777
+ }
1778
+ const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, new_window: new_window === true || undefined, left, top, width, height, index: index ?? -1 });
1750
1779
  return { content: [{ type: 'text', text: jsonText(data) }] };
1751
1780
  }
1752
1781
  );
@@ -1765,15 +1794,145 @@ export function registerTools(server, wsManager, caps = 'all') {
1765
1794
  layout: z.enum(['grid', 'columns', 'rows']).optional().default('grid').describe('columns splits left to right, rows top to bottom, grid keeps tiles as square as it can'),
1766
1795
  padding: z.number().optional().default(0).describe('Px of empty margin kept inside the work area'),
1767
1796
  include_types: z.array(z.enum(['normal', 'popup', 'app'])).optional().describe('Window types to include; omitted = normal only'),
1797
+ area: z.object({
1798
+ left: z.number(), top: z.number(), width: z.number(), height: z.number(),
1799
+ }).optional().describe('Monitor area to fill, when no target window has a scriptable tab to read it from'),
1768
1800
  },
1769
- async ({ window_ids, reference_window_id, layout, padding, include_types }) => {
1801
+ async ({ window_ids, reference_window_id, layout, padding, include_types, area }) => {
1770
1802
  const data = await send(MessageType.TILE_WINDOWS, {
1771
- window_ids, reference_window_id, layout: layout ?? 'grid', padding: padding ?? 0, include_types,
1803
+ window_ids, reference_window_id, layout: layout ?? 'grid', padding: padding ?? 0, include_types, area,
1772
1804
  });
1773
1805
  return { content: [{ type: 'text', text: jsonText(data) }] };
1774
1806
  }
1775
1807
  );
1776
1808
 
1809
+ // --- window_layout ---
1810
+ server.tool(
1811
+ 'window_layout',
1812
+ 'Save the current window arrangement under a name, restore one, list or delete saved ones. save overwrites '
1813
+ + 'a same-named layout without asking. Window ids do not survive a browser restart, so restore recognises '
1814
+ + 'windows by the overlap of their tab URLs (same type required) and repositions the best match — windows '
1815
+ + 'it cannot recognise are reported, not guessed. Needs extension >= 1.14.0 for window geometry.',
1816
+ {
1817
+ action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
1818
+ name: z.string().optional().describe('Layout name, required except for list'),
1819
+ },
1820
+ async ({ action, name }) => {
1821
+ const readLayouts = async () => {
1822
+ try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
1823
+ };
1824
+
1825
+ if (action === 'list') {
1826
+ const all = await readLayouts();
1827
+ const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
1828
+ return { content: [{ type: 'text', text: jsonText({ layouts: rows }) }] };
1829
+ }
1830
+
1831
+ if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
1832
+
1833
+ if (action === 'delete') {
1834
+ const all = await readLayouts();
1835
+ const existed = Boolean(all[name]);
1836
+ delete all[name];
1837
+ await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
1838
+ await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
1839
+ return { content: [{ type: 'text', text: jsonText({ deleted: name, existed }) }] };
1840
+ }
1841
+
1842
+ // save e restore hanno bisogno della fotografia corrente
1843
+ const snap = await send(MessageType.GET_TABS, { include_windows: true });
1844
+ if (Array.isArray(snap) || !snap?.windows) {
1845
+ // Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
1846
+ // dirlo che fallire su una proprietà mancante.
1847
+ throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
1848
+ }
1849
+ const urlsByWindow = new Map();
1850
+ for (const t of snap.tabs || []) {
1851
+ if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
1852
+ urlsByWindow.get(t.windowId).push(t.url || '');
1853
+ }
1854
+
1855
+ if (action === 'save') {
1856
+ const all = await readLayouts();
1857
+ all[name] = {
1858
+ savedAt: new Date().toISOString(),
1859
+ windows: snap.windows.map((w) => ({
1860
+ type: w.type,
1861
+ state: w.state,
1862
+ left: w.left, top: w.top, width: w.width, height: w.height,
1863
+ tabs: urlsByWindow.get(w.id) || [],
1864
+ })),
1865
+ };
1866
+ await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
1867
+ await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
1868
+ return { content: [{ type: 'text', text: jsonText({ saved: name, windows: all[name].windows.length }) }] };
1869
+ }
1870
+
1871
+ // restore
1872
+ const all = await readLayouts();
1873
+ const layout = all[name];
1874
+ if (!layout) {
1875
+ throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
1876
+ }
1877
+
1878
+ // Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
1879
+ // sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
1880
+ // riavvio del browser.
1881
+ const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
1882
+ const pairs = [];
1883
+ layout.windows.forEach((savedWin, si) => {
1884
+ current.forEach((cur, ci) => {
1885
+ if (savedWin.type !== cur.type) return;
1886
+ const savedUrls = new Set(savedWin.tabs);
1887
+ let shared = 0;
1888
+ for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
1889
+ const union = new Set([...savedUrls, ...cur.urls]).size || 1;
1890
+ const score = shared / union;
1891
+ if (score > 0) pairs.push({ si, ci, score });
1892
+ });
1893
+ });
1894
+ pairs.sort((a, b) => b.score - a.score);
1895
+
1896
+ const usedSaved = new Set();
1897
+ const usedCurrent = new Set();
1898
+ const results = [];
1899
+ for (const { si, ci, score } of pairs) {
1900
+ if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
1901
+ usedSaved.add(si); usedCurrent.add(ci);
1902
+ const savedWin = layout.windows[si];
1903
+ const cur = current[ci];
1904
+ const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
1905
+ if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
1906
+ // Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
1907
+ // accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
1908
+ const params = savedWin.state === 'normal'
1909
+ ? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
1910
+ : { tab_id: anyTab.id, state: savedWin.state };
1911
+ try {
1912
+ const r = await send(MessageType.VIEWPORT_RESIZE, params);
1913
+ results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
1914
+ } catch (e) {
1915
+ results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
1916
+ }
1917
+ }
1918
+
1919
+ const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
1920
+ const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
1921
+ return {
1922
+ content: [{
1923
+ type: 'text',
1924
+ text: jsonText({
1925
+ restored: name,
1926
+ matched: results.length,
1927
+ unmatched_saved: unmatchedSaved,
1928
+ unmatched_current: unmatchedCurrent,
1929
+ results,
1930
+ }),
1931
+ }],
1932
+ };
1933
+ }
1934
+ );
1935
+
1777
1936
  // --- set_zoom ---
1778
1937
  server.tool(
1779
1938
  'set_zoom',