chrome-bridge-mcp 1.14.0 → 1.15.1
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 +5 -5
- package/extension/lib/download-state.js +43 -0
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +108 -18
- package/package.json +2 -2
- package/server/protocol.js +1 -1
- package/server/tools.js +174 -16
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Chrome Bridge
|
|
2
2
|
|
|
3
|
-
       [](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
4
4
|
|
|
5
5
|
**An MCP server that gives Claude Code your real, logged-in Chrome — measured
|
|
6
6
|
2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
|
|
7
7
|
extension on a form-filling task, with ~3× the toolset and no paid plan.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
63 web-development tools (navigation, DOM inspection, visual regression, audits,
|
|
10
10
|
network mocking) over a local WebSocket bridge, plus a headless instance for CI.
|
|
11
11
|
Self-hosted, local-only.
|
|
12
12
|
|
|
@@ -43,7 +43,7 @@ Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
|
|
|
43
43
|
| | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
|
|
44
44
|
|---|---|---|---|---|
|
|
45
45
|
| **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
|
|
46
|
-
| **Tools** | **
|
|
46
|
+
| **Tools** | **63** (34 core) | ~20 | ~50 | 23 core (71 total) |
|
|
47
47
|
| **Requires paid plan** | **No** | Yes (Pro+) | No | No |
|
|
48
48
|
| **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
|
|
49
49
|
| **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
|
|
@@ -87,7 +87,7 @@ user-script toggle isn't available.
|
|
|
87
87
|
|
|
88
88
|
## Tools
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
63 in total, in seven groups. Only `core` (34 tools) loads by default; the rest
|
|
91
91
|
are opt-in via `--caps`.
|
|
92
92
|
|
|
93
93
|
| Group | N | What's in it |
|
|
@@ -153,7 +153,7 @@ paste into a chat.
|
|
|
153
153
|
|
|
154
154
|
## Documentation
|
|
155
155
|
|
|
156
|
-
- [docs/TOOLS.md](docs/TOOLS.md) — all
|
|
156
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 63 tools, by group
|
|
157
157
|
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
158
158
|
- [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
|
|
159
159
|
- [CHANGELOG.md](CHANGELOG.md)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legge lo stato reale di un download.
|
|
3
|
+
*
|
|
4
|
+
* `chrome.downloads.download()` restituisce un id e basta: dire "started: true"
|
|
5
|
+
* e fermarsi lì è come fidarsi di un HTTP 200 senza guardare il corpo. Su
|
|
6
|
+
* ChromeOS, con l'impostazione «Chiedi dove salvare ogni file» attiva, Chrome
|
|
7
|
+
* ignora `saveAs: false` e apre il selettore: il file viene scaricato per intero
|
|
8
|
+
* ma non viene scritto da nessuna parte finché un umano non sceglie.
|
|
9
|
+
*
|
|
10
|
+
* La firma di quella condizione è precisa e verificata dal vivo:
|
|
11
|
+
* `state: 'in_progress'`, `filename: ''`, `bytesReceived === totalBytes`.
|
|
12
|
+
* Senza distinguerla, un agente aspetta un file che non arriverà mai da solo.
|
|
13
|
+
*/
|
|
14
|
+
export function classifyDownload(item) {
|
|
15
|
+
if (!item) return { state: 'unknown', done: false, reason: 'download not found' };
|
|
16
|
+
|
|
17
|
+
const { state, filename, bytesReceived = 0, totalBytes = 0, error } = item;
|
|
18
|
+
|
|
19
|
+
if (state === 'complete') return { state: 'complete', done: true, filename };
|
|
20
|
+
if (state === 'interrupted') {
|
|
21
|
+
return { state: 'interrupted', done: true, error: error || 'interrupted', filename };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Nessuna destinazione decisa: il selettore è aperto e aspetta una persona.
|
|
25
|
+
if (!filename) {
|
|
26
|
+
const fetched = totalBytes > 0 && bytesReceived >= totalBytes;
|
|
27
|
+
return {
|
|
28
|
+
state: 'waiting_for_user',
|
|
29
|
+
done: false,
|
|
30
|
+
bytes: { received: bytesReceived, total: totalBytes },
|
|
31
|
+
reason: fetched
|
|
32
|
+
? 'bytes fetched, but Chrome is asking where to save — the "Ask where to save each file" setting overrides saveAs:false'
|
|
33
|
+
: 'no destination chosen yet — Chrome is showing the save dialog',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
state: 'in_progress',
|
|
39
|
+
done: false,
|
|
40
|
+
filename,
|
|
41
|
+
bytes: { received: bytesReceived, total: totalBytes },
|
|
42
|
+
};
|
|
43
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Bridge your browser to Claude Code:
|
|
4
|
+
"version": "1.15.1",
|
|
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": [
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import './telemetry.js';
|
|
9
9
|
import { buildMarkdown } from './lib/page-markdown.js';
|
|
10
10
|
import { computeTiles } from './lib/tile-layout.js';
|
|
11
|
+
import { classifyDownload } from './lib/download-state.js';
|
|
11
12
|
const { pushError } = globalThis.__cbTelemetry;
|
|
12
13
|
|
|
13
14
|
const DEFAULT_PORT = 8765;
|
|
@@ -941,7 +942,24 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
|
|
|
941
942
|
|
|
942
943
|
// --- create_tab ---
|
|
943
944
|
|
|
944
|
-
async function cmdCreateTab({ url, active = true }) {
|
|
945
|
+
async function cmdCreateTab({ url, active = true, new_window = false, left, top, width, height }) {
|
|
946
|
+
if (new_window) {
|
|
947
|
+
// windows.create con i bounds apre direttamente sul monitor scelto: left è
|
|
948
|
+
// la coordinata sulla scrivania virtuale, quindi è ciò che sceglie lo schermo.
|
|
949
|
+
const winOpts = { focused: active !== false };
|
|
950
|
+
if (url) winOpts.url = url;
|
|
951
|
+
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
952
|
+
if (typeof v === 'number') winOpts[k] = v;
|
|
953
|
+
}
|
|
954
|
+
const win = await chrome.windows.create(winOpts);
|
|
955
|
+
const tab = (win.tabs || [])[0];
|
|
956
|
+
if (url && tab) {
|
|
957
|
+
await waitForComplete(tab.id);
|
|
958
|
+
const updated = await chrome.tabs.get(tab.id);
|
|
959
|
+
return { id: updated.id, url: updated.url, title: updated.title, window_id: win.id };
|
|
960
|
+
}
|
|
961
|
+
return { id: tab?.id, url: tab?.url || 'chrome://newtab', title: tab?.title || '', window_id: win.id };
|
|
962
|
+
}
|
|
945
963
|
const opts = { active };
|
|
946
964
|
if (url) opts.url = url;
|
|
947
965
|
const tab = await chrome.tabs.create(opts);
|
|
@@ -1075,11 +1093,36 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
|
|
|
1075
1093
|
};
|
|
1076
1094
|
}
|
|
1077
1095
|
|
|
1078
|
-
async function cmdMoveTab({ tab_id, window_id, index = -1 }) {
|
|
1096
|
+
async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type, left, top, width, height, index = -1 }) {
|
|
1079
1097
|
if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
|
|
1080
|
-
if (typeof window_id !== 'number') throw new Error('
|
|
1098
|
+
if (typeof window_id !== 'number' && !new_window) throw new Error('Provide window_id or new_window: true');
|
|
1081
1099
|
|
|
1082
1100
|
const before = await chrome.tabs.get(tab_id);
|
|
1101
|
+
|
|
1102
|
+
if (new_window) {
|
|
1103
|
+
// tabs.move vuole una finestra che esiste già; per estrarre in una finestra
|
|
1104
|
+
// nuova (e posizionata) l'unica via è windows.create({tabId}). windows.create
|
|
1105
|
+
// conosce solo normal e popup: le finestre app sono della SWA, non nostre —
|
|
1106
|
+
// popup è il massimo di "finestra terminale" ottenibile da un'estensione.
|
|
1107
|
+
const winOpts = { tabId: tab_id };
|
|
1108
|
+
if (window_type === 'popup') winOpts.type = 'popup';
|
|
1109
|
+
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
1110
|
+
if (typeof v === 'number') winOpts[k] = v;
|
|
1111
|
+
}
|
|
1112
|
+
const win = await chrome.windows.create(winOpts);
|
|
1113
|
+
return {
|
|
1114
|
+
moved: tab_id,
|
|
1115
|
+
url: before.url,
|
|
1116
|
+
from_window: before.windowId,
|
|
1117
|
+
to_window: win.id,
|
|
1118
|
+
// Il tipo lo dice Chrome, non la richiesta: se popup viene declassato a
|
|
1119
|
+
// normal il chiamante deve vederlo da qui.
|
|
1120
|
+
window_type: win.type,
|
|
1121
|
+
new_window: true,
|
|
1122
|
+
index: 0,
|
|
1123
|
+
same_window: before.windowId === win.id,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1083
1126
|
const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
|
|
1084
1127
|
const tab = Array.isArray(moved) ? moved[0] : moved;
|
|
1085
1128
|
|
|
@@ -1103,6 +1146,21 @@ async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
|
|
|
1103
1146
|
await chrome.tabs.remove(tabId);
|
|
1104
1147
|
return { action, closed: tabId };
|
|
1105
1148
|
}
|
|
1149
|
+
if (action === 'discard') {
|
|
1150
|
+
// La scheda resta nella barra e si ricarica al focus: il processo muore, la
|
|
1151
|
+
// RAM torna. La tmux session dietro un terminale non c'entra: vive nel
|
|
1152
|
+
// container, non nel renderer.
|
|
1153
|
+
const t = await chrome.tabs.discard(tabId);
|
|
1154
|
+
return { action, tabId: t?.id ?? tabId, discarded: true };
|
|
1155
|
+
}
|
|
1156
|
+
if (action === 'mute' || action === 'unmute') {
|
|
1157
|
+
await chrome.tabs.update(tabId, { muted: action === 'mute' });
|
|
1158
|
+
return { action, tabId, muted: action === 'mute' };
|
|
1159
|
+
}
|
|
1160
|
+
if (action === 'duplicate') {
|
|
1161
|
+
const d = await chrome.tabs.duplicate(tabId);
|
|
1162
|
+
return { action, source: tabId, duplicated: d.id, window_id: d.windowId };
|
|
1163
|
+
}
|
|
1106
1164
|
if (action === 'activate') {
|
|
1107
1165
|
const tab = await chrome.tabs.get(tabId);
|
|
1108
1166
|
await chrome.tabs.update(tabId, { active: true });
|
|
@@ -1859,7 +1917,7 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
|
|
|
1859
1917
|
if (Object.keys(updateOpts).length === 0) {
|
|
1860
1918
|
if (state) {
|
|
1861
1919
|
const w = await chrome.windows.get(tab.windowId);
|
|
1862
|
-
return { requested: { state },
|
|
1920
|
+
return { requested: { state }, window: { left: w.left, top: w.top, width: w.width, height: w.height, state: w.state } };
|
|
1863
1921
|
}
|
|
1864
1922
|
throw new Error('Provide preset, width, height, left, top or state');
|
|
1865
1923
|
}
|
|
@@ -1869,24 +1927,40 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
|
|
|
1869
1927
|
await new Promise((r) => setTimeout(r, 200));
|
|
1870
1928
|
}
|
|
1871
1929
|
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1930
|
+
// La misura del viewport passa da un'injection, e `chrome://` e
|
|
1931
|
+
// `chrome-untrusted://` la rifiutano: le finestre del Terminale e di
|
|
1932
|
+
// media-app sono esattamente quelle. Prima questo errore veniva propagato e
|
|
1933
|
+
// il tool falliva DOPO aver spostato la finestra — l'azione riuscita
|
|
1934
|
+
// riportata come guasto, che è il modo di far ritentare chi chiama qualcosa
|
|
1935
|
+
// che è già successo. La geometria della finestra non richiede injection:
|
|
1936
|
+
// arriva da chrome.windows.get e c'è sempre.
|
|
1937
|
+
let actual = {};
|
|
1938
|
+
let viewport_error = null;
|
|
1939
|
+
try {
|
|
1940
|
+
const results = await chrome.scripting.executeScript({
|
|
1941
|
+
target: { tabId },
|
|
1942
|
+
func: () => ({
|
|
1943
|
+
viewportWidth: window.innerWidth,
|
|
1944
|
+
viewportHeight: window.innerHeight,
|
|
1945
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
1946
|
+
}),
|
|
1947
|
+
world: 'MAIN',
|
|
1948
|
+
});
|
|
1949
|
+
actual = results?.[0]?.result ?? {};
|
|
1950
|
+
} catch (e) {
|
|
1951
|
+
viewport_error = e.message;
|
|
1952
|
+
}
|
|
1953
|
+
const winNow = await chrome.windows.get(tab.windowId);
|
|
1954
|
+
const geometry = { left: winNow.left, top: winNow.top, width: winNow.width, height: winNow.height, state: winNow.state };
|
|
1955
|
+
if (read_only) return { actual, window: geometry, viewport_error };
|
|
1956
|
+
const win = winNow;
|
|
1884
1957
|
return {
|
|
1885
1958
|
requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
|
|
1886
1959
|
actual,
|
|
1960
|
+
viewport_error,
|
|
1887
1961
|
// Il window manager può accettare e ignorare: senza il confronto una
|
|
1888
1962
|
// finestra rimasta ferma passerebbe per spostata.
|
|
1889
|
-
window:
|
|
1963
|
+
window: geometry,
|
|
1890
1964
|
};
|
|
1891
1965
|
}
|
|
1892
1966
|
|
|
@@ -3626,9 +3700,25 @@ async function cmdSetGeolocation({ latitude, longitude, accuracy = 10, reset = f
|
|
|
3626
3700
|
|
|
3627
3701
|
// --- manage_downloads ---
|
|
3628
3702
|
|
|
3629
|
-
async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
|
|
3703
|
+
async function cmdManageDownloads({ action, url, filename, timeout = 30000, limit = 10 }) {
|
|
3630
3704
|
if (!action) throw new Error('Missing required parameter: action');
|
|
3631
3705
|
|
|
3706
|
+
if (action === 'download') {
|
|
3707
|
+
if (!url) throw new Error('action=download requires url');
|
|
3708
|
+
// Parte dal browser, quindi con i cookie di sessione dell'origine: un file
|
|
3709
|
+
// dietro login si scarica senza far passare i byte dal bridge.
|
|
3710
|
+
const opts = { url, saveAs: false };
|
|
3711
|
+
if (filename) opts.filename = filename;
|
|
3712
|
+
const id = await chrome.downloads.download(opts);
|
|
3713
|
+
// Non basta l'id: su ChromeOS l'impostazione «Chiedi dove salvare ogni file»
|
|
3714
|
+
// scavalca saveAs:false e apre il selettore, quindi il download resta fermo
|
|
3715
|
+
// ad aspettare una persona. Una breve occhiata allo stato reale distingue
|
|
3716
|
+
// "partito" da "in attesa di te".
|
|
3717
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
3718
|
+
const [item] = await chrome.downloads.search({ id });
|
|
3719
|
+
return { id, url, ...classifyDownload(item) };
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3632
3722
|
if (action === 'list') {
|
|
3633
3723
|
const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit });
|
|
3634
3724
|
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.
|
|
4
|
+
"version": "1.15.1",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "MCP server with
|
|
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",
|
package/server/protocol.js
CHANGED
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
|
|
|
@@ -876,9 +880,14 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
876
880
|
{
|
|
877
881
|
url: z.string().optional().describe('URL to open (default: new tab page)'),
|
|
878
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)'),
|
|
879
888
|
},
|
|
880
|
-
async ({ url, active }) => {
|
|
881
|
-
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 });
|
|
882
891
|
if (data?.id != null) sessionTabId = data.id;
|
|
883
892
|
return {
|
|
884
893
|
content: [{
|
|
@@ -1340,9 +1349,12 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1340
1349
|
'tab_action',
|
|
1341
1350
|
'Tab lifecycle: close, activate (focus), reload (optional cache bypass), back, forward. close discards the '
|
|
1342
1351
|
+ 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
|
|
1343
|
-
+ 'reload and navigation drop injected CSS, emulations and page hooks.'
|
|
1352
|
+
+ 'reload and navigation drop injected CSS, emulations and page hooks. duplicate works where create_tab is '
|
|
1353
|
+
+ 'forbidden (chrome-untrusted://, verified on a ChromeOS Terminal tab) and lands in the source tab\'s own '
|
|
1354
|
+
+ 'window, app windows included — the only way to open a new Terminal session inside the Terminal window. '
|
|
1355
|
+
+ 'discard replaces the tab id: the result carries the new one, saved ids go stale.',
|
|
1344
1356
|
{
|
|
1345
|
-
action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and
|
|
1357
|
+
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'),
|
|
1346
1358
|
bypass_cache: z.boolean().optional().default(false).describe('reload only'),
|
|
1347
1359
|
tab_id: tabId,
|
|
1348
1360
|
},
|
|
@@ -1657,14 +1669,20 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1657
1669
|
// --- manage_downloads ---
|
|
1658
1670
|
server.tool(
|
|
1659
1671
|
'manage_downloads',
|
|
1660
|
-
'List downloads or wait for
|
|
1672
|
+
'List downloads, start one, or wait for the newest to finish. Files land in the browser Downloads folder, '
|
|
1673
|
+
+ 'not on the server. action=download reports the download\'s real state rather than just an id: with '
|
|
1674
|
+
+ 'Chrome set to ask where to save each file, saveAs:false is overridden and it comes back as '
|
|
1675
|
+
+ 'waiting_for_user — every byte fetched, no destination chosen, and nothing will move until someone picks one.',
|
|
1661
1676
|
{
|
|
1662
|
-
action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest
|
|
1677
|
+
action: z.enum(['list', 'wait_for_complete', 'download']).describe('download fetches with the browser cookie jar and reports the real state; wait_for_complete blocks until the newest finishes'),
|
|
1678
|
+
url: z.string().optional().describe('What to download (action=download); sent with the session cookies of its origin'),
|
|
1679
|
+
filename: z.string().optional().describe('Relative path inside the Downloads folder (action=download)'),
|
|
1663
1680
|
timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
|
|
1664
1681
|
limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
|
|
1665
1682
|
},
|
|
1666
|
-
async ({ action, timeout, limit }) => {
|
|
1667
|
-
|
|
1683
|
+
async ({ action, url, filename, timeout, limit }) => {
|
|
1684
|
+
if (action === 'download' && !url) throw new Error('action=download requires url');
|
|
1685
|
+
const data = await send(MessageType.MANAGE_DOWNLOADS, { action, url, filename, timeout, limit });
|
|
1668
1686
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1669
1687
|
}
|
|
1670
1688
|
);
|
|
@@ -1745,17 +1763,30 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1745
1763
|
// --- move_tab ---
|
|
1746
1764
|
server.tool(
|
|
1747
1765
|
'move_tab',
|
|
1748
|
-
'Move an existing tab into another window (chrome.tabs.move)
|
|
1749
|
-
+ '
|
|
1750
|
-
+ '
|
|
1751
|
-
+ 'an app window,
|
|
1766
|
+
'Move an existing tab into another window (chrome.tabs.move). Nothing is created or closed: the tab keeps '
|
|
1767
|
+
+ 'its id, history and page state, and this works even on chrome-untrusted:// tabs where creating one is '
|
|
1768
|
+
+ 'forbidden — verified on a ChromeOS Terminal tab. The **destination** must be a normal window: moving out '
|
|
1769
|
+
+ 'of an app or popup window is fine, moving into one is refused with "Tabs can only be moved to and from '
|
|
1770
|
+
+ 'normal windows". So a ChromeOS Terminal session can be pulled out to its own window (new_window, then '
|
|
1771
|
+
+ 'position it with viewport_resize) but cannot be merged back into the Terminal window — the closest look '
|
|
1772
|
+
+ 'is window_type popup: no tab strip, no omnibox, visually a terminal window. An extension cannot create '
|
|
1773
|
+
+ 'app windows: normal and popup are all Chrome offers.',
|
|
1752
1774
|
{
|
|
1753
1775
|
tab_id: z.number().describe('Tab to move; get it from get_tabs'),
|
|
1754
|
-
window_id: z.number().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1776
|
+
window_id: z.number().optional().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1777
|
+
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'),
|
|
1778
|
+
window_type: z.enum(['normal', 'popup']).optional().default('normal').describe('new_window only: popup has no tab strip and no omnibox — the terminal-window look for detached chrome-untrusted://terminal tabs'),
|
|
1779
|
+
left: z.number().optional().describe('New window x (new_window)'),
|
|
1780
|
+
top: z.number().optional().describe('New window y (new_window)'),
|
|
1781
|
+
width: z.number().optional().describe('New window width px (new_window)'),
|
|
1782
|
+
height: z.number().optional().describe('New window height px (new_window)'),
|
|
1755
1783
|
index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
|
|
1756
1784
|
},
|
|
1757
|
-
async ({ tab_id, window_id, index }) => {
|
|
1758
|
-
|
|
1785
|
+
async ({ tab_id, window_id, new_window, window_type, left, top, width, height, index }) => {
|
|
1786
|
+
if (window_id == null && new_window !== true) {
|
|
1787
|
+
throw new Error('Provide window_id (an existing window) or new_window: true');
|
|
1788
|
+
}
|
|
1789
|
+
const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, new_window: new_window === true || undefined, window_type, left, top, width, height, index: index ?? -1 });
|
|
1759
1790
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1760
1791
|
}
|
|
1761
1792
|
);
|
|
@@ -1786,6 +1817,133 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1786
1817
|
}
|
|
1787
1818
|
);
|
|
1788
1819
|
|
|
1820
|
+
// --- window_layout ---
|
|
1821
|
+
server.tool(
|
|
1822
|
+
'window_layout',
|
|
1823
|
+
'Save the current window arrangement under a name, restore one, list or delete saved ones. save overwrites '
|
|
1824
|
+
+ 'a same-named layout without asking. Window ids do not survive a browser restart, so restore recognises '
|
|
1825
|
+
+ 'windows by the overlap of their tab URLs (same type required) and repositions the best match — windows '
|
|
1826
|
+
+ 'it cannot recognise are reported, not guessed. Needs extension >= 1.14.0 for window geometry.',
|
|
1827
|
+
{
|
|
1828
|
+
action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
|
|
1829
|
+
name: z.string().optional().describe('Layout name, required except for list'),
|
|
1830
|
+
},
|
|
1831
|
+
async ({ action, name }) => {
|
|
1832
|
+
const readLayouts = async () => {
|
|
1833
|
+
try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
if (action === 'list') {
|
|
1837
|
+
const all = await readLayouts();
|
|
1838
|
+
const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
|
|
1839
|
+
return { content: [{ type: 'text', text: jsonText({ layouts: rows }) }] };
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
|
|
1843
|
+
|
|
1844
|
+
if (action === 'delete') {
|
|
1845
|
+
const all = await readLayouts();
|
|
1846
|
+
const existed = Boolean(all[name]);
|
|
1847
|
+
delete all[name];
|
|
1848
|
+
await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
|
|
1849
|
+
await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
|
|
1850
|
+
return { content: [{ type: 'text', text: jsonText({ deleted: name, existed }) }] };
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// save e restore hanno bisogno della fotografia corrente
|
|
1854
|
+
const snap = await send(MessageType.GET_TABS, { include_windows: true });
|
|
1855
|
+
if (Array.isArray(snap) || !snap?.windows) {
|
|
1856
|
+
// Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
|
|
1857
|
+
// dirlo che fallire su una proprietà mancante.
|
|
1858
|
+
throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
|
|
1859
|
+
}
|
|
1860
|
+
const urlsByWindow = new Map();
|
|
1861
|
+
for (const t of snap.tabs || []) {
|
|
1862
|
+
if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
|
|
1863
|
+
urlsByWindow.get(t.windowId).push(t.url || '');
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
if (action === 'save') {
|
|
1867
|
+
const all = await readLayouts();
|
|
1868
|
+
all[name] = {
|
|
1869
|
+
savedAt: new Date().toISOString(),
|
|
1870
|
+
windows: snap.windows.map((w) => ({
|
|
1871
|
+
type: w.type,
|
|
1872
|
+
state: w.state,
|
|
1873
|
+
left: w.left, top: w.top, width: w.width, height: w.height,
|
|
1874
|
+
tabs: urlsByWindow.get(w.id) || [],
|
|
1875
|
+
})),
|
|
1876
|
+
};
|
|
1877
|
+
await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
|
|
1878
|
+
await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
|
|
1879
|
+
return { content: [{ type: 'text', text: jsonText({ saved: name, windows: all[name].windows.length }) }] };
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// restore
|
|
1883
|
+
const all = await readLayouts();
|
|
1884
|
+
const layout = all[name];
|
|
1885
|
+
if (!layout) {
|
|
1886
|
+
throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
|
|
1890
|
+
// sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
|
|
1891
|
+
// riavvio del browser.
|
|
1892
|
+
const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
|
|
1893
|
+
const pairs = [];
|
|
1894
|
+
layout.windows.forEach((savedWin, si) => {
|
|
1895
|
+
current.forEach((cur, ci) => {
|
|
1896
|
+
if (savedWin.type !== cur.type) return;
|
|
1897
|
+
const savedUrls = new Set(savedWin.tabs);
|
|
1898
|
+
let shared = 0;
|
|
1899
|
+
for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
|
|
1900
|
+
const union = new Set([...savedUrls, ...cur.urls]).size || 1;
|
|
1901
|
+
const score = shared / union;
|
|
1902
|
+
if (score > 0) pairs.push({ si, ci, score });
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
pairs.sort((a, b) => b.score - a.score);
|
|
1906
|
+
|
|
1907
|
+
const usedSaved = new Set();
|
|
1908
|
+
const usedCurrent = new Set();
|
|
1909
|
+
const results = [];
|
|
1910
|
+
for (const { si, ci, score } of pairs) {
|
|
1911
|
+
if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
|
|
1912
|
+
usedSaved.add(si); usedCurrent.add(ci);
|
|
1913
|
+
const savedWin = layout.windows[si];
|
|
1914
|
+
const cur = current[ci];
|
|
1915
|
+
const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
|
|
1916
|
+
if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
|
|
1917
|
+
// Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
|
|
1918
|
+
// accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
|
|
1919
|
+
const params = savedWin.state === 'normal'
|
|
1920
|
+
? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
|
|
1921
|
+
: { tab_id: anyTab.id, state: savedWin.state };
|
|
1922
|
+
try {
|
|
1923
|
+
const r = await send(MessageType.VIEWPORT_RESIZE, params);
|
|
1924
|
+
results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
|
|
1931
|
+
const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
|
|
1932
|
+
return {
|
|
1933
|
+
content: [{
|
|
1934
|
+
type: 'text',
|
|
1935
|
+
text: jsonText({
|
|
1936
|
+
restored: name,
|
|
1937
|
+
matched: results.length,
|
|
1938
|
+
unmatched_saved: unmatchedSaved,
|
|
1939
|
+
unmatched_current: unmatchedCurrent,
|
|
1940
|
+
results,
|
|
1941
|
+
}),
|
|
1942
|
+
}],
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
);
|
|
1946
|
+
|
|
1789
1947
|
// --- set_zoom ---
|
|
1790
1948
|
server.tool(
|
|
1791
1949
|
'set_zoom',
|