chrome-bridge-mcp 1.26.0 → 1.28.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
@@ -164,22 +164,23 @@ user-script toggle isn't available.
164
164
 
165
165
  ## Tools
166
166
 
167
- 60 in total. `core` (43 tools, every tool used in 101 real sessions) loads by
168
- default; the other 17 sit in six optional caps that the agent switches on
167
+ 66 in total. `core` (43 tools, every tool used in 101 real sessions) loads by
168
+ default; the other 23 sit in seven optional caps that the agent switches on
169
169
  mid-session with `get_status({enable: ["visual"]})`, with no restart
170
170
  (`--caps` still sets them at startup).
171
171
 
172
- ![60 tools in seven groups, from clicking a button to auditing a whole page](assets/readme/card4-toolbox.png)
172
+ ![66 tools in eight groups, from clicking a button to tracing a page](assets/readme/card4-toolbox.png)
173
173
 
174
174
  | Group | N | What's in it |
175
175
  |---|---|---|
176
176
  | Core & Navigation | 13 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
177
177
  | Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
178
- | DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `get_css_styles`, `watch_dom` |
178
+ | DOM & Inspection | 12 | `read_page`, `extract`, `extract_table`, `query_dom`, `get_css_styles`, `watch_dom` |
179
179
  | Debugging & Network | 8 | `execute_js`, console, network log, mocking, `track_events` |
180
180
  | Visual & Responsive | 5 | `screenshot_diff`, viewport and zoom, media emulation |
181
181
  | Audits | 2 | `audit` (a11y, keyboard, SEO, security, links, vitals, css, resources, cache in one call), `cookie_audit` |
182
182
  | State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
183
+ | Motion & Performance | 6 | `animations`, `frames` (long frames, CLS, INP), `perf_trace`, `screencast`, `lighthouse` and `heap_snapshot` (launch mode) |
183
184
 
184
185
  Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
185
186
 
@@ -206,6 +207,7 @@ Environment variables, each with a matching CLI flag:
206
207
  | `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 |
207
208
  | `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
208
209
  | `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. Optional caps can also be switched on at runtime with `get_status({enable})` |
210
+ | `CHROME_BRIDGE_ALWAYS_LOAD` / `--always-load` | 22 most used tools | Tools kept in context when the client loads the server whole; the others carry `_meta['anthropic/alwaysLoad'] = false` and wait behind tool search (Claude Code 2.1.285+). `all` loads every tool; a comma list picks them |
209
211
  | `CHROME_BRIDGE_NO_JS` / `--no-js` | unset | No arbitrary JavaScript in the page: `execute_js` and `modify_dom` leave the schema, `wait_for(condition=function)` and `javascript:`/`data:` URLs are refused. `get_status` reports `js_evaluation` |
210
212
  | `CHROME_BRIDGE_READ_ROOT` / `--read-root` | unset | Where `upload_file` may read from. Unset: any file except keys and credentials (`~/.ssh`, `~/.aws`, `~/.gnupg`, `.env*`, `id_*`, `*.pem`, `*.key`, …), symlinks resolved first. Set: only files under this directory, whatever their name — the way to upload a certificate key on purpose |
211
213
  | `CHROME_BRIDGE_BROWSER` | unset | Launch mode only: the browser binary, e.g. `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`. Unset: the first of Chromium, Edge, Brave, Google Chrome found in the standard paths (on Linux Chromium, then Google Chrome). Google Chrome 137+ cannot load the extension |
@@ -236,6 +238,14 @@ do with them.
236
238
 
237
239
  - **Who can connect:** see above — loopback bind, `chrome-extension://` origin
238
240
  check, `CHROME_BRIDGE_TOKEN` on both handshakes.
241
+ - **Launch mode also opens a DevTools port** on 127.0.0.1 (chosen by the
242
+ system), for `lighthouse` and `heap_snapshot`. CDP has no authentication:
243
+ any local process can drive that dedicated browser while it runs. Its profile
244
+ is temporary and holds nothing of yours; your own Chrome never gets a port.
245
+ - **The debugger bar is the signal.** `click`/`press_key` with `trusted`,
246
+ `emulate_media via: "debugger"`, `perf_trace` and `screencast` attach
247
+ `chrome.debugger` to one tab only for that work; Chrome shows its "started
248
+ debugging this browser" bar meanwhile, and cancelling it ends the work.
239
249
  - **The real risk is prompt injection.** A page can ask the agent to run
240
250
  JavaScript, send a request with your cookies, submit a form or upload a file.
241
251
  Treat every page you automate as untrusted input, and review what the agent
@@ -295,16 +305,18 @@ count.
295
305
  | `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. |
296
306
  | Screenshot times out, or `image readback failed` | A minimized or fully covered window stops producing frames; captures fail after 10 s. Bring the window forward. |
297
307
  | `wait_for`/`scroll` return `page_hidden: true`, pages stop updating | Same cause: Chrome does not render a hidden page and slows its timers (after a few minutes, to one wake-up per minute). `get_page_info` reports `visibility`. Bring the window on screen, or `create_tab` with `new_window` and bounds. |
298
- | `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation or larger viewports use a headless browser. |
308
+ | `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation use `emulate_media` with `device` (via debugger), or a headless browser for larger viewports. |
299
309
  | `type_text` returns `mismatch: true` | The field rejected the value. Events from an extension have `isTrusted: false`, and some widgets (date pickers, search boxes with tokenizers) discard them. Try `mode: 'keys'`, then the site's own controls (the calendar buttons), or `handoff`. |
300
310
  | `[media removed: request limit]` instead of a screenshot | Written by the MCP client, not by the bridge: too many images in one request. Use `save_to`, or `element_screenshot` with `region` and a small `scale`. |
301
311
  | 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. |
302
312
  | Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
303
313
  | Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
314
+ | `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
+ | 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. |
304
316
 
305
317
  ## Documentation
306
318
 
307
- - [docs/TOOLS.md](docs/TOOLS.md) — all 60 tools, by group
319
+ - [docs/TOOLS.md](docs/TOOLS.md) — all 66 tools, by group
308
320
  - [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — what the bridge gets past and what it does not, one dated state per wall
309
321
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
310
322
  - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — latency per tool on the real path, `npm run bench:latency`
@@ -0,0 +1,61 @@
1
+ /**
2
+ * emulate_media via chrome.debugger: dalle opzioni del tool ai comandi CDP.
3
+ *
4
+ * Con il debugger l'emulazione è quella di DevTools: le regole
5
+ * @media (prefers-reduced-motion) dei fogli del sito valgono davvero, mentre
6
+ * il ripiego nella pagina cambia solo matchMedia e azzera tutte le durate.
7
+ * CPU, rete, viewport con DPR e touch esistono solo qui.
8
+ *
9
+ * Pura: restituisce coppie [metodo, parametri], così i test la provano senza
10
+ * browser. Il service worker le manda in ordine sulla scheda tenuta.
11
+ */
12
+
13
+ // Profili di rete di DevTools (Chrome 154): latenza in ms, banda in byte/s,
14
+ // già scalate come le applica DevTools.
15
+ export const NETWORK_PROFILES = {
16
+ offline: { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 },
17
+ '3g': { offline: false, latency: 2000, downloadThroughput: 45000, uploadThroughput: 45000 },
18
+ 'slow-4g': { offline: false, latency: 562.5, downloadThroughput: 180000, uploadThroughput: 84375 },
19
+ 'fast-4g': { offline: false, latency: 165, downloadThroughput: 1012500, uploadThroughput: 168750 },
20
+ none: { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
21
+ };
22
+
23
+ /**
24
+ * @param {object} o - colorScheme, reducedMotion, contrast, printMode,
25
+ * cpu_throttle, network, device {width,height,dpr,mobile}, touch
26
+ * @returns {{ commands: Array<[string, object]>, emulated: object }}
27
+ */
28
+ export function emulationCommands(o = {}) {
29
+ const commands = [];
30
+ const emulated = {};
31
+ const features = [];
32
+ if (o.colorScheme) { features.push({ name: 'prefers-color-scheme', value: o.colorScheme }); emulated.colorScheme = o.colorScheme; }
33
+ if (o.reducedMotion) { features.push({ name: 'prefers-reduced-motion', value: o.reducedMotion }); emulated.reducedMotion = o.reducedMotion; }
34
+ if (o.contrast) { features.push({ name: 'prefers-contrast', value: o.contrast }); emulated.contrast = o.contrast; }
35
+ if (features.length || o.printMode) {
36
+ commands.push(['Emulation.setEmulatedMedia', { media: o.printMode ? 'print' : '', features }]);
37
+ if (o.printMode) emulated.printMode = true;
38
+ }
39
+ if (o.cpu_throttle != null) {
40
+ const rate = Math.max(1, Number(o.cpu_throttle));
41
+ commands.push(['Emulation.setCPUThrottlingRate', { rate }]);
42
+ emulated.cpu_throttle = rate;
43
+ }
44
+ if (o.network) {
45
+ const p = NETWORK_PROFILES[o.network];
46
+ if (!p) throw new Error(`Unknown network profile ${o.network}: use ${Object.keys(NETWORK_PROFILES).join(', ')}`);
47
+ commands.push(['Network.emulateNetworkConditions', p]);
48
+ emulated.network = o.network;
49
+ }
50
+ if (o.device) {
51
+ const { width, height, dpr = 1, mobile = false } = o.device;
52
+ if (!(width > 0 && height > 0)) throw new Error('device needs width and height in CSS px');
53
+ commands.push(['Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dpr, mobile }]);
54
+ emulated.device = { width, height, dpr, mobile };
55
+ }
56
+ if (o.touch != null) {
57
+ commands.push(['Emulation.setTouchEmulationEnabled', { enabled: Boolean(o.touch), ...(o.touch && { maxTouchPoints: 5 }) }]);
58
+ emulated.touch = Boolean(o.touch);
59
+ }
60
+ return { commands, emulated };
61
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Misure di movimento nella pagina, per i tool `animations` e `frames`.
3
+ *
4
+ * Tutto da script di pagina: il dominio CDP `Animation` e
5
+ * `PerformanceTimeline` non sono ammessi a chrome.debugger da un'estensione
6
+ * (prova del 01/10/2026, Chromium 154), quindi si leggono
7
+ * document.getAnimations() e i PerformanceObserver come farebbe il sito.
8
+ *
9
+ * Tre operazioni su globalThis.__cbMotion:
10
+ * - snapshot(opts): le animazioni presenti adesso;
11
+ * - start(opts): apre una finestra di registrazione (animazioni viste a ogni
12
+ * fotogramma, Long Animation Frames, layout shift, interazioni, ritmo rAF);
13
+ * - stop(): chiude la finestra e restituisce la sintesi.
14
+ * Lo stato della finestra vive nella pagina: una navigazione lo perde, e stop
15
+ * lo dice.
16
+ *
17
+ * Non è un modulo ES: chrome.scripting serializza solo la funzione iniettata,
18
+ * quindi il service worker inietta questo file (files:, world MAIN) e poi
19
+ * chiama __cbMotion. Nei test Node si importa per side effect, come
20
+ * element-label.js.
21
+ */
22
+ (() => {
23
+ // Proprietà che il compositor di Chrome anima senza il thread principale.
24
+ // È una stima: will-change, layer e dimensioni possono cambiare l'esito.
25
+ const COMPOSITABLE = new Set(['transform', 'translate', 'rotate', 'scale', 'opacity', 'filter', 'backdrop-filter']);
26
+ // Una «dissolvenza»: con prefers-reduced-motion restano accettabili.
27
+ const FADE = new Set(['opacity', 'visibility']);
28
+ const KF_META = new Set(['offset', 'computedOffset', 'easing', 'composite']);
29
+ const EASING_MAX = 200;
30
+
31
+ const round = (n, d = 0) => (typeof n === 'number' && Number.isFinite(n) ? Math.round(n * 10 ** d) / 10 ** d : n);
32
+ const kebab = (p) => (p.startsWith('--') ? p : p.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`));
33
+ // linear() di una molla ha decine di punti: si taglia il testo e si dice
34
+ // quanti punti aveva, così due curve restano distinguibili.
35
+ const shortEasing = (e) => {
36
+ const s = String(e);
37
+ if (s.length <= EASING_MAX) return s;
38
+ const points = s.startsWith('linear(') ? s.slice(7, -1).split(',').length : null;
39
+ return `${s.slice(0, EASING_MAX)}…${points ? ` (${points} points)` : ''}`;
40
+ };
41
+ // Durate e tempi: numeri in ms, oppure CSSNumericValue (percentuali delle
42
+ // timeline di scroll) e 'auto', resi come testo.
43
+ const timeValue = (v) => {
44
+ if (v == null) return null;
45
+ if (typeof v === 'number') return Number.isFinite(v) ? round(v) : 'infinite';
46
+ return String(v);
47
+ };
48
+
49
+ const stableSelector = (el) => {
50
+ if (!el || el.nodeType !== 1) return null;
51
+ if (el.id && /^[A-Za-z][\w-]*$/.test(el.id)) return `#${el.id}`;
52
+ const testid = el.getAttribute && el.getAttribute('data-testid');
53
+ if (testid) return `[data-testid="${testid.replace(/"/g, '\\"')}"]`;
54
+ const parts = [];
55
+ let node = el;
56
+ const root = el.ownerDocument && el.ownerDocument.documentElement;
57
+ while (node && node.nodeType === 1 && node !== root) {
58
+ let part = node.tagName.toLowerCase();
59
+ if (node.id && /^[A-Za-z][\w-]*$/.test(node.id)) { parts.unshift(`#${node.id}`); break; }
60
+ const parent = node.parentElement;
61
+ if (parent) {
62
+ const sameTag = [...parent.children].filter((c) => c.tagName === node.tagName);
63
+ if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
64
+ }
65
+ parts.unshift(part);
66
+ node = node.parentElement;
67
+ }
68
+ return parts.join(' > ') || el.tagName.toLowerCase();
69
+ };
70
+
71
+ const timelineKind = (a) => {
72
+ const t = a.timeline;
73
+ if (t == null) return 'none';
74
+ if (typeof ViewTimeline !== 'undefined' && t instanceof ViewTimeline) return 'view';
75
+ if (typeof ScrollTimeline !== 'undefined' && t instanceof ScrollTimeline) return 'scroll';
76
+ if (typeof document !== 'undefined' && t === document.timeline) return 'document';
77
+ return 'other';
78
+ };
79
+
80
+ function describe(a) {
81
+ const effect = a.effect || null;
82
+ const target = effect && effect.target;
83
+ const pseudo = (effect && effect.pseudoElement) || null;
84
+ let keyframes = [];
85
+ try { keyframes = effect && effect.getKeyframes ? effect.getKeyframes() : []; } catch { /* effetto senza keyframe leggibili */ }
86
+ const props = new Set();
87
+ for (const k of keyframes) for (const p of Object.keys(k)) if (!KF_META.has(p)) props.add(kebab(p));
88
+ if (!props.size && a.transitionProperty) props.add(a.transitionProperty);
89
+ const properties = [...props];
90
+
91
+ const timing = effect && effect.getTiming ? effect.getTiming() : {};
92
+ const computed = effect && effect.getComputedTiming ? effect.getComputedTiming() : {};
93
+ // Animazioni CSS: la curva sta nei keyframe (animation-timing-function), e
94
+ // la timing dell'effetto è 'linear'. Transizioni e WAAPI: nella timing.
95
+ const kfEasings = [...new Set(keyframes.map((k) => k.easing).filter((e) => e && e !== 'linear'))];
96
+ let easing = timing.easing || 'linear';
97
+ if (easing === 'linear' && kfEasings.length) easing = kfEasings.length === 1 ? kfEasings[0] : kfEasings;
98
+ easing = Array.isArray(easing) ? easing.map(shortEasing) : shortEasing(easing);
99
+
100
+ const kind = a.constructor && a.constructor.name ? a.constructor.name : 'Animation';
101
+ const out = {
102
+ kind,
103
+ name: a.animationName || a.transitionProperty || a.id || null,
104
+ selector: stableSelector(target),
105
+ ...(pseudo && { pseudo }),
106
+ properties,
107
+ duration_ms: timeValue(timing.duration),
108
+ delay_ms: timeValue(timing.delay),
109
+ ...(timing.endDelay ? { end_delay_ms: timeValue(timing.endDelay) } : {}),
110
+ easing,
111
+ iterations: timing.iterations === Infinity ? 'infinite' : timing.iterations,
112
+ ...(timing.direction && timing.direction !== 'normal' ? { direction: timing.direction } : {}),
113
+ ...(timing.fill && timing.fill !== 'auto' && timing.fill !== 'none' ? { fill: timing.fill } : {}),
114
+ play_state: a.playState,
115
+ progress: computed.progress == null ? null : round(computed.progress, 2),
116
+ timeline: timelineKind(a),
117
+ view_transition: Boolean(pseudo && pseudo.startsWith('::view-transition')),
118
+ composited_estimate: properties.length > 0 && properties.every((p) => COMPOSITABLE.has(p)),
119
+ fade_only: properties.length > 0 && properties.every((p) => FADE.has(p)),
120
+ };
121
+ if (a.playbackRate != null && a.playbackRate !== 1) out.playback_rate = a.playbackRate;
122
+ return out;
123
+ }
124
+
125
+ const inScope = (a, scope) => {
126
+ if (!scope) return true;
127
+ const t = a.effect && a.effect.target;
128
+ try { return Boolean(t && t.closest && t.closest(scope)); } catch { return false; }
129
+ };
130
+
131
+ function summarize(list) {
132
+ const by = (key) => list.reduce((m, x) => { m[x[key]] = (m[x[key]] || 0) + 1; return m; }, {});
133
+ const props = {};
134
+ for (const x of list) for (const p of x.properties) props[p] = (props[p] || 0) + 1;
135
+ const durations = list.map((x) => x.duration_ms).filter((d) => typeof d === 'number');
136
+ const easings = [...new Set(list.flatMap((x) => (Array.isArray(x.easing) ? x.easing : [x.easing])))];
137
+ return {
138
+ count: list.length,
139
+ by_kind: by('kind'),
140
+ by_play_state: by('play_state'),
141
+ infinite: list.filter((x) => x.iterations === 'infinite').length,
142
+ max_duration_ms: durations.length ? Math.max(...durations) : null,
143
+ properties: props,
144
+ not_composited_estimate: list.filter((x) => !x.composited_estimate).length,
145
+ not_fade_only: list.filter((x) => !x.fade_only).length,
146
+ view_transitions: list.filter((x) => x.view_transition).length,
147
+ easings,
148
+ };
149
+ }
150
+
151
+ const reducedMotion = () => {
152
+ try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return null; }
153
+ };
154
+
155
+ function snapshot({ scope = null, limit = 50 } = {}) {
156
+ const list = document.getAnimations().filter((a) => inScope(a, scope)).map(describe);
157
+ return {
158
+ prefers_reduced_motion: reducedMotion(),
159
+ summary: summarize(list),
160
+ animations: list.slice(0, limit),
161
+ ...(list.length > limit ? { truncated: list.length - limit } : {}),
162
+ };
163
+ }
164
+
165
+ // --- finestra di registrazione ---
166
+
167
+ const supported = () => (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes) || [];
168
+
169
+ function start({ scope = null } = {}) {
170
+ if (globalThis.__cbMotionRec) stopObservers(globalThis.__cbMotionRec);
171
+ const t0 = performance.now();
172
+ const rec = {
173
+ t0, scope, observers: [], seen: new Map(), loaf: [], shifts: [], events: [],
174
+ frames: [], raf: 0, active: true, hiddenDuring: false,
175
+ };
176
+ globalThis.__cbMotionRec = rec;
177
+ const types = supported();
178
+ const observe = (type, opts, onEntry) => {
179
+ if (!types.includes(type)) return false;
180
+ const po = new PerformanceObserver((l) => { for (const e of l.getEntries()) onEntry(e); });
181
+ try { po.observe({ type, ...opts }); } catch { return false; }
182
+ rec.observers.push({ po, onEntry });
183
+ return true;
184
+ };
185
+ rec.support = {
186
+ long_animation_frame: observe('long-animation-frame', {}, (e) => { if (e.startTime >= t0) rec.loaf.push(e); }),
187
+ layout_shift: observe('layout-shift', {}, (e) => { if (e.startTime >= t0) rec.shifts.push(e); }),
188
+ event_timing: observe('event', { durationThreshold: 16 }, (e) => { if (e.startTime >= t0) rec.events.push(e); }),
189
+ };
190
+ const sample = () => {
191
+ if (!rec.active) return;
192
+ const now = performance.now();
193
+ rec.frames.push(now);
194
+ for (const a of document.getAnimations()) {
195
+ if (!rec.seen.has(a) && inScope(a, scope)) rec.seen.set(a, round(now - t0));
196
+ }
197
+ rec.raf = requestAnimationFrame(sample);
198
+ };
199
+ rec.onVisibility = () => { if (document.visibilityState === 'hidden') rec.hiddenDuring = true; };
200
+ document.addEventListener('visibilitychange', rec.onVisibility);
201
+ // Una transizione di 150 ms può nascere e finire fra due campioni rAF solo
202
+ // se la pagina perde fotogrammi: transitionrun e animationstart la
203
+ // segnalano comunque, e il campione successivo la trova nel documento.
204
+ rec.onStartEvent = () => {
205
+ for (const a of document.getAnimations()) {
206
+ if (!rec.seen.has(a) && inScope(a, scope)) rec.seen.set(a, round(performance.now() - t0));
207
+ }
208
+ };
209
+ document.addEventListener('transitionrun', rec.onStartEvent, true);
210
+ document.addEventListener('animationstart', rec.onStartEvent, true);
211
+ sample();
212
+ return { recording: true, support: rec.support };
213
+ }
214
+
215
+ function stopObservers(rec) {
216
+ rec.active = false;
217
+ cancelAnimationFrame(rec.raf);
218
+ // takeRecords: le voci già prodotte ma non ancora consegnate al callback
219
+ // (l'ultimo fotogramma della finestra) non vanno perse.
220
+ for (const { po, onEntry } of rec.observers) {
221
+ try { for (const e of po.takeRecords()) onEntry(e); po.disconnect(); } catch { /* già scollegato */ }
222
+ }
223
+ document.removeEventListener('visibilitychange', rec.onVisibility);
224
+ document.removeEventListener('transitionrun', rec.onStartEvent, true);
225
+ document.removeEventListener('animationstart', rec.onStartEvent, true);
226
+ }
227
+
228
+ // Ritmo dei fotogrammi del thread principale. L'intervallo tipico (mediana)
229
+ // stima la frequenza dello schermo; un intervallo oltre 1,5 volte la mediana
230
+ // è almeno un fotogramma perso. Le animazioni composte possono restare
231
+ // fluide anche quando il thread principale salta.
232
+ function frameStats(times) {
233
+ if (times.length < 3) return { count: times.length, note: 'Too few frames: was the page hidden or the window too short?' };
234
+ const gaps = [];
235
+ for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
236
+ const sorted = [...gaps].sort((x, y) => x - y);
237
+ const median = sorted[Math.floor(sorted.length / 2)];
238
+ const span = times[times.length - 1] - times[0];
239
+ const dropped = gaps.reduce((n, g) => n + (g > median * 1.5 ? Math.round(g / median) - 1 : 0), 0);
240
+ return {
241
+ count: times.length,
242
+ fps: round((gaps.length * 1000) / span, 1),
243
+ refresh_estimate_hz: round(1000 / median),
244
+ max_gap_ms: round(sorted[sorted.length - 1]),
245
+ dropped_estimate: dropped,
246
+ };
247
+ }
248
+
249
+ function loafEntry(e) {
250
+ const scripts = (e.scripts || [])
251
+ .map((s) => ({
252
+ invoker: s.invoker || null,
253
+ type: s.invokerType || null,
254
+ source: s.sourceURL ? `${s.sourceURL}${s.sourceFunctionName ? ` ${s.sourceFunctionName}` : ''}${s.sourceCharPosition >= 0 ? `:${s.sourceCharPosition}` : ''}` : (s.sourceFunctionName || null),
255
+ duration_ms: round(s.duration),
256
+ ...(s.forcedStyleAndLayoutDuration ? { forced_layout_ms: round(s.forcedStyleAndLayoutDuration) } : {}),
257
+ }))
258
+ .sort((x, y) => y.duration_ms - x.duration_ms)
259
+ .slice(0, 3);
260
+ const start = e.startTime;
261
+ return {
262
+ duration_ms: round(e.duration),
263
+ blocking_ms: round(e.blockingDuration),
264
+ // Fasi del fotogramma: script prima del rendering, poi stile e layout.
265
+ ...(e.renderStart ? { render_start_ms: round(e.renderStart - start) } : {}),
266
+ ...(e.styleAndLayoutStart ? { style_layout_start_ms: round(e.styleAndLayoutStart - start) } : {}),
267
+ scripts,
268
+ };
269
+ }
270
+
271
+ function shiftsSummary(entries) {
272
+ const counted = entries.filter((e) => !e.hadRecentInput);
273
+ const cls = counted.reduce((s, e) => s + e.value, 0);
274
+ const top = [...counted].sort((x, y) => y.value - x.value).slice(0, 5).map((e) => ({
275
+ value: round(e.value, 4),
276
+ sources: (e.sources || []).slice(0, 3).map((s) => ({
277
+ selector: stableSelector(s.node),
278
+ ...(s.previousRect && s.currentRect ? { moved_px: { x: round(s.currentRect.x - s.previousRect.x), y: round(s.currentRect.y - s.previousRect.y) } } : {}),
279
+ })),
280
+ }));
281
+ return {
282
+ cls: round(cls, 4),
283
+ count: counted.length,
284
+ ...(entries.length > counted.length ? { after_input_excluded: entries.length - counted.length } : {}),
285
+ top,
286
+ };
287
+ }
288
+
289
+ // INP sulla finestra: la più lunga fra le interazioni (interactionId > 0).
290
+ // Con meno di 50 interazioni è anche il valore che web-vitals riporterebbe.
291
+ // Le durate degli Event Timing sono arrotondate a 8 ms dal browser. Le fasi
292
+ // si calcolano sull'insieme delle voci dell'interazione (pointerdown,
293
+ // pointerup, click…), come web-vitals/attribution: la voce più lunga da
294
+ // sola è spesso un pointerup senza handler e senza target.
295
+ function interactionsSummary(entries) {
296
+ const byId = new Map();
297
+ for (const e of entries) {
298
+ if (!e.interactionId) continue;
299
+ if (!byId.has(e.interactionId)) byId.set(e.interactionId, []);
300
+ byId.get(e.interactionId).push(e);
301
+ }
302
+ const list = [...byId.values()].map((group) => ({ group, duration: Math.max(...group.map((e) => e.duration)) }))
303
+ .sort((x, y) => y.duration - x.duration);
304
+ if (!list.length) return { count: 0, inp_ms: null, note: 'No interaction in the window. Synthetic events have no interactionId: use action.trusted for INP.' };
305
+ const { group, duration } = list[0];
306
+ const start = Math.min(...group.map((e) => e.startTime));
307
+ const procStart = Math.min(...group.map((e) => e.processingStart));
308
+ const procEnd = Math.max(...group.map((e) => e.processingEnd));
309
+ const main = [...group].sort((x, y) => (y.processingEnd - y.processingStart) - (x.processingEnd - x.processingStart))
310
+ .find((e) => e.target) || group[0];
311
+ return {
312
+ count: list.length,
313
+ inp_ms: round(duration),
314
+ worst: {
315
+ event: main.name,
316
+ events: [...new Set(group.map((e) => e.name))],
317
+ selector: stableSelector(main.target),
318
+ input_delay_ms: round(procStart - start),
319
+ processing_ms: round(procEnd - procStart),
320
+ presentation_delay_ms: round(Math.max(0, start + duration - procEnd)),
321
+ },
322
+ };
323
+ }
324
+
325
+ function stop({ threshold_ms = 50, limit = 10 } = {}) {
326
+ const rec = globalThis.__cbMotionRec;
327
+ if (!rec) return { error: 'No recording in this page: it was never started, or the page navigated and lost it.' };
328
+ stopObservers(rec);
329
+ delete globalThis.__cbMotionRec;
330
+ const t0 = rec.t0;
331
+ const animations = [...rec.seen.entries()].map(([a, at]) => ({ first_seen_ms: at, ...describe(a) }));
332
+ const long = rec.loaf.filter((e) => e.duration >= threshold_ms).sort((x, y) => y.duration - x.duration);
333
+ return {
334
+ window_ms: round(performance.now() - t0),
335
+ prefers_reduced_motion: reducedMotion(),
336
+ ...(rec.hiddenDuring ? { page_hidden_during: true } : {}),
337
+ support: rec.support,
338
+ frames: frameStats(rec.frames),
339
+ long_frames: {
340
+ threshold_ms,
341
+ count: long.length,
342
+ total_blocking_ms: round(long.reduce((s, e) => s + (e.blockingDuration || 0), 0)),
343
+ worst: long.slice(0, limit).map((e) => ({ ...loafEntry(e), at_ms: round(e.startTime - t0) })),
344
+ },
345
+ layout_shifts: shiftsSummary(rec.shifts),
346
+ interactions: interactionsSummary(rec.events),
347
+ animations: {
348
+ summary: summarize(animations),
349
+ list: animations.slice(0, limit * 3),
350
+ ...(animations.length > limit * 3 ? { truncated: animations.length - limit * 3 } : {}),
351
+ },
352
+ };
353
+ }
354
+
355
+ globalThis.__cbMotion = { snapshot, start, stop, _internals: { describe, summarize, frameStats, shiftsSummary, interactionsSummary, loafEntry, shortEasing } };
356
+ })();
@@ -5,7 +5,10 @@
5
5
  * scorciatoie di tastiera gestite dal browser.
6
6
  *
7
7
  * Il debugger si aggancia solo per la durata dell'azione e si stacca subito:
8
- * la barra «sta eseguendo il debug» compare solo in quel momento.
8
+ * la barra «sta eseguendo il debug» compare solo in quel momento. Eccezione:
9
+ * una scheda «tenuta» (holdDebugger, per l'emulazione di emulate_media, che
10
+ * vive solo finché il debugger è agganciato) resta agganciata fino al
11
+ * rilascio, e withDebugger usa quell'aggancio senza staccarlo.
9
12
  */
10
13
 
11
14
  // Tasti con nome: code e keyCode di Windows che Input.dispatchKeyEvent vuole
@@ -73,20 +76,76 @@ export function mouseClickEvents(x, y, { button = 'left', count = 1 } = {}) {
73
76
  return events;
74
77
  }
75
78
 
79
+ // Schede tenute agganciate: tabId → insieme dei motivi ('emulation', …).
80
+ // Chrome stacca il debugger da solo quando l'utente preme «Annulla» sulla
81
+ // barra o la scheda si chiude: onDetach toglie la voce, e chi la teneva lo
82
+ // scopre con debuggerHeld.
83
+ const held = new Map();
84
+ let detachListener = false;
85
+
86
+ function listenDetach() {
87
+ if (detachListener || !globalThis.chrome?.debugger?.onDetach) return;
88
+ detachListener = true;
89
+ chrome.debugger.onDetach.addListener((source, reason) => {
90
+ if (source.tabId == null || !held.has(source.tabId)) return;
91
+ held.delete(source.tabId);
92
+ lastDetach.set(source.tabId, reason);
93
+ });
94
+ }
95
+ const lastDetach = new Map();
96
+
97
+ async function attach(tabId, purpose) {
98
+ try {
99
+ await chrome.debugger.attach({ tabId }, '1.3');
100
+ } catch (e) {
101
+ const msg = String(e?.message ?? e);
102
+ if (/already attached/i.test(msg)) throw new Error(`${purpose} unavailable: another debugger (DevTools?) is attached to this tab — close it, or retry without it`);
103
+ throw new Error(`${purpose} unavailable: ${msg}`);
104
+ }
105
+ }
106
+
107
+ /** Tiene il debugger agganciato alla scheda per un motivo, finché releaseDebugger. */
108
+ export async function holdDebugger(tabId, why, purpose = why) {
109
+ listenDetach();
110
+ let reasons = held.get(tabId);
111
+ if (!reasons) {
112
+ await attach(tabId, purpose);
113
+ reasons = new Set();
114
+ held.set(tabId, reasons);
115
+ lastDetach.delete(tabId);
116
+ }
117
+ reasons.add(why);
118
+ return (method, params) => chrome.debugger.sendCommand({ tabId }, method, params);
119
+ }
120
+
121
+ /** Toglie un motivo; senza più motivi il debugger si stacca e la barra sparisce. */
122
+ export async function releaseDebugger(tabId, why) {
123
+ const reasons = held.get(tabId);
124
+ if (!reasons) return false;
125
+ reasons.delete(why);
126
+ if (reasons.size) return true;
127
+ held.delete(tabId);
128
+ await chrome.debugger.detach({ tabId }).catch(() => {});
129
+ return true;
130
+ }
131
+
132
+ /** Stato della tenuta: held, i motivi, o il motivo dello stacco (canceled_by_user, target_closed). */
133
+ export function debuggerHeld(tabId) {
134
+ const reasons = held.get(tabId);
135
+ if (reasons) return { held: true, reasons: [...reasons] };
136
+ return { held: false, ...(lastDetach.has(tabId) && { detached: lastDetach.get(tabId) }) };
137
+ }
138
+
76
139
  /**
77
140
  * Aggancia il debugger alla scheda, esegue fn(send) e si stacca sempre, anche
78
141
  * su errore. Un DevTools già aperto sulla scheda impedisce l'aggancio: lo dice.
142
+ * Su una scheda tenuta usa l'aggancio esistente e non lo stacca.
79
143
  */
80
144
  export async function withDebugger(tabId, fn) {
81
145
  const target = { tabId };
82
- try {
83
- await chrome.debugger.attach(target, '1.3');
84
- } catch (e) {
85
- const msg = String(e?.message ?? e);
86
- if (/already attached/i.test(msg)) throw new Error('trusted input unavailable: another debugger (DevTools?) is attached to this tab — close it, or retry without trusted');
87
- throw new Error(`trusted input unavailable: ${msg}`);
88
- }
89
146
  const send = (method, params) => chrome.debugger.sendCommand(target, method, params);
147
+ if (held.has(tabId)) return fn(send);
148
+ await attach(tabId, 'trusted input');
90
149
  try {
91
150
  return await fn(send);
92
151
  } finally {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.26.0",
5
- "description": "Bridge your browser to Claude Code: 60 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.28.0",
5
+ "description": "Bridge your browser to Claude Code: 66 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": [
@@ -58,6 +58,7 @@ body {
58
58
  .dot.connected { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
59
59
  .dot.connecting { background: #f39c12; }
60
60
  .dot.disconnected { background: var(--err); }
61
+ .dot.refused { background: #f39c12; }
61
62
  #status-text { font-weight: 600; }
62
63
  .status-row .ws-url {
63
64
  margin-left: auto;
@@ -4,6 +4,7 @@ const stateLabels = {
4
4
  connected: 'Connesso',
5
5
  connecting: 'Connessione…',
6
6
  disconnected: 'Disconnesso',
7
+ refused: 'In attesa: un altro browser è collegato',
7
8
  };
8
9
 
9
10
  function renderState(state) {