chrome-bridge-mcp 1.18.0 → 1.20.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 +31 -1
- package/extension/lib/element-label.js +60 -0
- package/extension/lib/find-text.js +97 -0
- package/extension/manifest.json +1 -1
- package/extension/service-worker.js +135 -59
- package/package.json +1 -1
- package/server/index.js +9 -1
- package/server/observe.js +314 -0
- package/server/protocol.js +5 -2
- package/server/tools.js +110 -30
- package/skills/chrome-bridge/SKILL.md +19 -2
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ Environment variables, each with a matching CLI flag:
|
|
|
156
156
|
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
157
157
|
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
|
|
158
158
|
| `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` |
|
|
159
|
+
| `CHROME_BRIDGE_OBSERVE` | unset | `off` stops the local error log below. The plugin sets `hook`: its hooks keep the log and the server stays out of it |
|
|
159
160
|
| `CHROME_BRIDGE_WRITE_ROOT` / `--write-root` | unset | Every path the model chooses (`save_to`, `output_path`, exports) must be under this directory, checked before the browser does any work; the server's own state under `~/.config/chrome-bridge` stays writable. The CLI is your shell and is not restricted |
|
|
160
161
|
|
|
161
162
|
The bridge binds loopback, accepts extension connections only from a
|
|
@@ -171,6 +172,31 @@ exports and screenshots are **not** redacted and may carry cookies, tokens or
|
|
|
171
172
|
personal data. Don't point the automation at pages holding secrets you wouldn't
|
|
172
173
|
paste into a chat.
|
|
173
174
|
|
|
175
|
+
### Local error log
|
|
176
|
+
|
|
177
|
+
When one of its own tools fails, chrome-bridge adds a line to a **local** file,
|
|
178
|
+
`${XDG_STATE_HOME:-~/.local/state}/claude-observe/chrome-bridge.jsonl` (mode
|
|
179
|
+
0600, in a 0700 folder), in the [claude-observe](https://github.com/frsorrentino/claude-observe)
|
|
180
|
+
format (the plugin's copy is in `observe/`). With the plugin, a `PostToolUseFailure` hook writes it; with the npm
|
|
181
|
+
server alone, the server does. The same error seen again is one line with a
|
|
182
|
+
count.
|
|
183
|
+
|
|
184
|
+
- **Kept:** the tool name, the error text after scrubbing (your home folder
|
|
185
|
+
becomes `~`; emails, URL queries, secrets and typed values are removed), the
|
|
186
|
+
names of the parameters and the length of text values, the chrome-bridge
|
|
187
|
+
version, the OS, the name of the working folder and of the Claude Code config
|
|
188
|
+
folder. With the plugin, also the Claude Code and model versions, the
|
|
189
|
+
session id and the names of the last three tools called.
|
|
190
|
+
- **Never kept:** parameter values — no URLs, selectors, typed text or page
|
|
191
|
+
content.
|
|
192
|
+
- **Nothing leaves your computer** unless you say yes. With the plugin, after a
|
|
193
|
+
few errors have piled up Claude may offer, at a natural moment, to send them
|
|
194
|
+
as one GitHub issue: it shows you the anonymized text first and sends it only
|
|
195
|
+
after your confirmation. The server alone never sends or offers anything.
|
|
196
|
+
- **Turn it off:** `{"enabled": false}` in `~/.config/claude-observe/config.json`
|
|
197
|
+
(every plugin that uses claude-observe), or `CHROME_BRIDGE_OBSERVE=off` for
|
|
198
|
+
the server. Keep the log but stop the offers: `{"propose": false}`.
|
|
199
|
+
|
|
174
200
|
## Troubleshooting
|
|
175
201
|
|
|
176
202
|
| Symptom | Cause / fix |
|
|
@@ -180,7 +206,11 @@ paste into a chat.
|
|
|
180
206
|
| `Port N is held by a process that is not chrome-bridge` | Something else owns the port. Free it or change `CHROME_BRIDGE_PORT`. |
|
|
181
207
|
| `execute_js` fails | Enable **Allow user scripts** in `chrome://extensions` → Chrome Bridge → Details (Chrome 138+; on 135-137 enable Developer Mode). |
|
|
182
208
|
| `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. |
|
|
183
|
-
| Screenshot times out |
|
|
209
|
+
| 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. |
|
|
210
|
+
| `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. |
|
|
211
|
+
| `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. |
|
|
212
|
+
| `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`. |
|
|
213
|
+
| `[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`. |
|
|
184
214
|
| 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. |
|
|
185
215
|
| Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
|
|
186
216
|
| Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* labelOf: il nome con cui un utente riconosce un elemento interattivo.
|
|
3
|
+
* Prima get_interactives prendeva aria-label || value || textContent: i campi
|
|
4
|
+
* con <label for>, <label> avvolgente o aria-labelledby uscivano senza nome
|
|
5
|
+
* (httpbin.org/forms/post: Customer name, Telephone, E-mail…) e radio e
|
|
6
|
+
* checkbox mostravano il value («small», «bacon») invece del testo visibile.
|
|
7
|
+
*
|
|
8
|
+
* Ordine: aria-label, aria-labelledby, <label> (el.labels copre for e
|
|
9
|
+
* avvolgente), testo o value per bottoni e altri elementi che non sono campi,
|
|
10
|
+
* title, placeholder. Spazi normalizzati, 80 caratteri.
|
|
11
|
+
*
|
|
12
|
+
* Non è un modulo ES: chrome.scripting serializza solo la funzione iniettata,
|
|
13
|
+
* quindi get_interactives inietta prima questo file (files:, world MAIN) e poi
|
|
14
|
+
* usa globalThis.__cbLabelOf. Nei test Node si importa per side effect, come
|
|
15
|
+
* stack-detect.js.
|
|
16
|
+
*/
|
|
17
|
+
(() => {
|
|
18
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
19
|
+
// Nel testo di un <label> avvolgente non entrano il campo stesso né le
|
|
20
|
+
// opzioni di una select: «Size <select>…» deve dare «Size».
|
|
21
|
+
const SKIP = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'SCRIPT', 'STYLE', 'TEMPLATE']);
|
|
22
|
+
const textOf = (node) => {
|
|
23
|
+
if (node.nodeType === 3) return node.textContent || '';
|
|
24
|
+
if (node.nodeType !== 1 || SKIP.has(node.tagName)) return '';
|
|
25
|
+
let out = '';
|
|
26
|
+
for (const c of node.childNodes) out += textOf(c);
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const BUTTON_INPUT = /^(submit|button|reset)$/i;
|
|
30
|
+
|
|
31
|
+
function labelOf(el, doc) {
|
|
32
|
+
const d = doc || el.ownerDocument;
|
|
33
|
+
const attr = (n) => (el.getAttribute(n) || '').trim();
|
|
34
|
+
|
|
35
|
+
const aria = clean(attr('aria-label'));
|
|
36
|
+
if (aria) return aria;
|
|
37
|
+
|
|
38
|
+
const ids = attr('aria-labelledby').split(/\s+/).filter(Boolean);
|
|
39
|
+
if (ids.length && d) {
|
|
40
|
+
const t = clean(ids.map((id) => { const n = d.getElementById(id); return n ? n.textContent : ''; }).join(' '));
|
|
41
|
+
if (t) return t;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const lab of el.labels || []) {
|
|
45
|
+
const t = clean(textOf(lab));
|
|
46
|
+
if (t) return t;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tag = el.tagName;
|
|
50
|
+
const isField = tag === 'SELECT' || tag === 'TEXTAREA' || (tag === 'INPUT' && !BUTTON_INPUT.test(el.getAttribute('type') || ''));
|
|
51
|
+
if (!isField) {
|
|
52
|
+
const t = clean(tag === 'INPUT' ? el.value : el.textContent);
|
|
53
|
+
if (t) return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return clean(attr('title')) || clean(attr('placeholder'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
globalThis.__cbLabelOf = labelOf;
|
|
60
|
+
})();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* findTextInPage gira nella pagina: chrome.scripting ne serializza il
|
|
3
|
+
* sorgente, quindi è autocontenuta (niente import, niente chiusure).
|
|
4
|
+
*
|
|
5
|
+
* Cerca sul testo degli elementi, non dei singoli nodi di testo: un'etichetta
|
|
6
|
+
* spezzata in più nodi («<span>Prestazioni</span> e <b>clic</b>», o con
|
|
7
|
+
* ) prima non veniva mai trovata, ed è la forma normale dei menu di
|
|
8
|
+
* Meta. Fra le parole dell'ago vale qualunque sequenza di spazi o .
|
|
9
|
+
* Ogni occorrenza è riportata una volta, dall'elemento più interno che la
|
|
10
|
+
* contiene tutta; si entra anche negli shadow root aperti.
|
|
11
|
+
*
|
|
12
|
+
* Il costo resta basso sulle pagine grandi: si scende solo nei rami il cui
|
|
13
|
+
* testo contiene l'ago.
|
|
14
|
+
*/
|
|
15
|
+
export function findTextInPage(needleRaw, caseSensitive, maxResults, doc = document, win = window) {
|
|
16
|
+
const words = String(needleRaw || '').split(/[\s ]+/).filter(Boolean);
|
|
17
|
+
if (!words.length) return { count: 0, matches: [] };
|
|
18
|
+
const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\s\\u00a0]+');
|
|
19
|
+
const flags = caseSensitive ? 'g' : 'gi';
|
|
20
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
21
|
+
|
|
22
|
+
const textOf = (node) => {
|
|
23
|
+
if (node.nodeType === 3) return node.textContent || '';
|
|
24
|
+
if (node.nodeType !== 1 || SKIP.has(node.tagName)) return '';
|
|
25
|
+
let out = '';
|
|
26
|
+
for (const c of node.childNodes) out += textOf(c);
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const ranges = (text) => {
|
|
30
|
+
const re = new RegExp(pattern, flags);
|
|
31
|
+
const out = [];
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(text))) out.push([m.index, m.index + m[0].length]);
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
const selectorFor = (el) => {
|
|
37
|
+
if (el.id) return `#${el.id}`;
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
const cls = el.classList && el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
|
|
40
|
+
return `${tag}${cls}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const matches = [];
|
|
44
|
+
const visit = (el, inShadow) => {
|
|
45
|
+
if (matches.length >= maxResults || SKIP.has(el.tagName)) return;
|
|
46
|
+
const text = textOf(el);
|
|
47
|
+
const found = ranges(text);
|
|
48
|
+
if (!found.length) return;
|
|
49
|
+
// Intervalli dei figli elemento nel testo di el: textOf concatena i figli
|
|
50
|
+
// in ordine, quindi gli offset coincidono.
|
|
51
|
+
const kids = [];
|
|
52
|
+
let off = 0;
|
|
53
|
+
for (const c of el.childNodes) {
|
|
54
|
+
const len = textOf(c).length;
|
|
55
|
+
if (c.nodeType === 1) kids.push({ el: c, from: off, to: off + len });
|
|
56
|
+
off += len;
|
|
57
|
+
}
|
|
58
|
+
const owned = found.filter(([a, b]) => !kids.some((k) => a >= k.from && b <= k.to));
|
|
59
|
+
if (owned.length) {
|
|
60
|
+
const rect = el.getBoundingClientRect();
|
|
61
|
+
const flat = text.replace(/[\s ]/g, ' ');
|
|
62
|
+
for (const [a, b] of owned) {
|
|
63
|
+
if (matches.length >= maxResults) break;
|
|
64
|
+
matches.push({
|
|
65
|
+
selector: selectorFor(el),
|
|
66
|
+
context: flat.substring(Math.max(0, a - 40), b + 40).replace(/ +/g, ' ').trim(),
|
|
67
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
68
|
+
position: { x: Math.round(rect.x + win.scrollX), y: Math.round(rect.y + win.scrollY) },
|
|
69
|
+
...(inShadow ? { in_shadow: true } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const k of kids) {
|
|
74
|
+
if (found.some(([a, b]) => a >= k.from && b <= k.to)) visit(k.el, inShadow);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Gli shadow root non compaiono nel testo dell'ospite: si cercano a parte,
|
|
79
|
+
// anche annidati, dopo il documento.
|
|
80
|
+
const roots = [];
|
|
81
|
+
const collectShadows = (root) => {
|
|
82
|
+
for (const el of root.querySelectorAll('*')) {
|
|
83
|
+
if (el.shadowRoot) {
|
|
84
|
+
roots.push(el.shadowRoot);
|
|
85
|
+
collectShadows(el.shadowRoot);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (doc.body) {
|
|
90
|
+
visit(doc.body, false);
|
|
91
|
+
collectShadows(doc.body);
|
|
92
|
+
}
|
|
93
|
+
for (const root of roots) {
|
|
94
|
+
for (const c of root.children) visit(c, true);
|
|
95
|
+
}
|
|
96
|
+
return { count: matches.length, matches };
|
|
97
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.20.0",
|
|
5
5
|
"description": "Bridge your browser to Claude Code: 59 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",
|
|
@@ -11,6 +11,7 @@ import { pageFingerprint } from './lib/page-fingerprint.js';
|
|
|
11
11
|
import { createPacer } from './lib/capture-pacing.js';
|
|
12
12
|
import { computeTiles } from './lib/tile-layout.js';
|
|
13
13
|
import { classifyDownload } from './lib/download-state.js';
|
|
14
|
+
import { findTextInPage } from './lib/find-text.js';
|
|
14
15
|
const { pushError } = globalThis.__cbTelemetry;
|
|
15
16
|
|
|
16
17
|
const DEFAULT_PORT = 8765;
|
|
@@ -603,11 +604,60 @@ async function captureVisible(windowId, options = { format: 'png' }) {
|
|
|
603
604
|
}, 10000);
|
|
604
605
|
chrome.tabs.captureVisibleTab(windowId, options).then(
|
|
605
606
|
(dataUrl) => { clearTimeout(timer); resolve(dataUrl); },
|
|
606
|
-
(err) => {
|
|
607
|
+
(err) => {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
// «image readback failed» è lo stesso guasto della finestra che non
|
|
610
|
+
// disegna, detto dal compositor: stesso messaggio, stesso rimedio.
|
|
611
|
+
if (/readback failed/i.test(err?.message || '')) {
|
|
612
|
+
reject(new Error(`Capture failed (${err.message}): the window is not rendering frames (fully occluded, minimized, or frozen). Bring the Chrome window at least partially on screen and retry.`));
|
|
613
|
+
} else {
|
|
614
|
+
reject(err);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
607
617
|
);
|
|
608
618
|
});
|
|
609
619
|
}
|
|
610
620
|
|
|
621
|
+
// --- Utility: scadenze tenute dal service worker ---
|
|
622
|
+
// Le attese che girano nella pagina usano setTimeout, e Chrome raggruppa i
|
|
623
|
+
// timer delle pagine nascoste fino a un risveglio al minuto: il controllo
|
|
624
|
+
// della scadenza scattava tardi e vinceva il timeout di trasporto (60 s per
|
|
625
|
+
// un'attesa da 25, osservato su Meta Ads Manager il 23/09/2026). Il service
|
|
626
|
+
// worker non è rallentato: la scadenza la tiene lui.
|
|
627
|
+
const HIDDEN_HINT = 'The page is hidden (window minimized, covered or in the background): Chrome slows its timers and stops rendering. Bring the window on screen, or create_tab new_window with bounds, then retry.';
|
|
628
|
+
|
|
629
|
+
async function pageHidden(tabId) {
|
|
630
|
+
try {
|
|
631
|
+
const q = chrome.scripting.executeScript({ target: { tabId }, func: () => document.visibilityState === 'hidden' });
|
|
632
|
+
const r = await Promise.race([q, new Promise((res) => setTimeout(() => res(null), 1000))]);
|
|
633
|
+
return r?.[0]?.result === true;
|
|
634
|
+
} catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Esegue run(); se non risponde entro deadlineMs restituisce onTimeout. In
|
|
640
|
+
// entrambi i casi un esito negativo su pagina nascosta porta page_hidden e il
|
|
641
|
+
// suggerimento, così chi chiama non ritenta tre volte la stessa attesa.
|
|
642
|
+
async function withDeadline(tabId, deadlineMs, run, onTimeout, isNegative) {
|
|
643
|
+
const DEADLINE = Symbol('deadline');
|
|
644
|
+
let timer;
|
|
645
|
+
let result;
|
|
646
|
+
try {
|
|
647
|
+
result = await Promise.race([
|
|
648
|
+
run(),
|
|
649
|
+
new Promise((res) => { timer = setTimeout(() => res(DEADLINE), deadlineMs); }),
|
|
650
|
+
]);
|
|
651
|
+
} finally {
|
|
652
|
+
clearTimeout(timer);
|
|
653
|
+
}
|
|
654
|
+
if (result === DEADLINE) result = { ...onTimeout };
|
|
655
|
+
if (result && isNegative(result) && await pageHidden(tabId)) {
|
|
656
|
+
return { ...result, page_hidden: true, hint: HIDDEN_HINT };
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
|
|
611
661
|
// --- Utility: rendi visibile un tab per la cattura SENZA rubare il focus OS ---
|
|
612
662
|
// captureVisibleTab richiede solo che il tab sia attivo nella sua finestra,
|
|
613
663
|
// non che la finestra sia in primo piano. Al termine ripristina il tab che
|
|
@@ -1170,7 +1220,14 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
|
|
|
1170
1220
|
if (typeof v === 'number') winOpts[k] = v;
|
|
1171
1221
|
}
|
|
1172
1222
|
const win = await chrome.windows.create(winOpts);
|
|
1223
|
+
// La finestra nuova può nascere dietro un'altra (lo decide il window
|
|
1224
|
+
// manager): allora la pagina resta hidden e non disegna. Lo si dice qui,
|
|
1225
|
+
// non dopo tre attese scadute.
|
|
1226
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1227
|
+
const hidden = await pageHidden(tab_id);
|
|
1173
1228
|
return {
|
|
1229
|
+
visibility: hidden ? 'hidden' : 'visible',
|
|
1230
|
+
...(hidden ? { hint: HIDDEN_HINT } : {}),
|
|
1174
1231
|
moved: tab_id,
|
|
1175
1232
|
url: before.url,
|
|
1176
1233
|
from_window: before.windowId,
|
|
@@ -1344,6 +1401,9 @@ async function cmdGetPageInfo({ tab_id, frame_id }) {
|
|
|
1344
1401
|
url: location.href,
|
|
1345
1402
|
doctype: document.doctype ? document.doctype.name : null,
|
|
1346
1403
|
charset: document.characterSet,
|
|
1404
|
+
// hidden = finestra minimizzata, coperta o in secondo piano: la pagina
|
|
1405
|
+
// non disegna e i suoi timer rallentano. Va saputo prima di attendere.
|
|
1406
|
+
visibility: document.visibilityState,
|
|
1347
1407
|
metas,
|
|
1348
1408
|
scripts,
|
|
1349
1409
|
stylesheets,
|
|
@@ -1754,7 +1814,7 @@ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, vi
|
|
|
1754
1814
|
const tabId = await resolveTabId(tab_id);
|
|
1755
1815
|
const clampedInterval = Math.max(interval, 50);
|
|
1756
1816
|
|
|
1757
|
-
const
|
|
1817
|
+
const run = () => chrome.scripting.executeScript({
|
|
1758
1818
|
target: scriptTarget(tabId, frame_id),
|
|
1759
1819
|
func: (sel, tout, intv, vis) => {
|
|
1760
1820
|
function deepQuery(sel) {
|
|
@@ -1806,8 +1866,9 @@ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, vi
|
|
|
1806
1866
|
},
|
|
1807
1867
|
args: [selector, timeout, clampedInterval, visible],
|
|
1808
1868
|
world: 'MAIN',
|
|
1809
|
-
});
|
|
1810
|
-
return
|
|
1869
|
+
}).then((results) => results?.[0]?.result ?? { found: false, error: 'No result' });
|
|
1870
|
+
return withDeadline(tabId, timeout + 1000, run,
|
|
1871
|
+
{ found: false, error: `Element not found within ${timeout}ms: ${selector}` }, (r) => r.found === false);
|
|
1811
1872
|
}
|
|
1812
1873
|
|
|
1813
1874
|
// --- scroll_to ---
|
|
@@ -2058,11 +2119,16 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
|
|
|
2058
2119
|
}
|
|
2059
2120
|
const winNow = await chrome.windows.get(tab.windowId);
|
|
2060
2121
|
const geometry = { left: winNow.left, top: winNow.top, width: winNow.width, height: winNow.height, state: winNow.state };
|
|
2061
|
-
|
|
2122
|
+
// Uno zoom sotto il 100% allarga il viewport in CSS px oltre lo schermo:
|
|
2123
|
+
// senza questo numero un 2226 px su uno schermo da 1536 non si spiega.
|
|
2124
|
+
let zoom = null;
|
|
2125
|
+
try { zoom = await chrome.tabs.getZoom(tabId); } catch { /* scheda senza zoom (chrome://) */ }
|
|
2126
|
+
if (read_only) return { actual, window: geometry, zoom, viewport_error };
|
|
2062
2127
|
const win = winNow;
|
|
2063
2128
|
return {
|
|
2064
2129
|
requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
|
|
2065
2130
|
actual,
|
|
2131
|
+
zoom,
|
|
2066
2132
|
viewport_error,
|
|
2067
2133
|
// Il window manager può accettare e ignorare: senza il confronto una
|
|
2068
2134
|
// finestra rimasta ferma passerebbe per spostata.
|
|
@@ -3536,41 +3602,7 @@ async function cmdFindText({ text, case_sensitive = false, max_results = 20, tab
|
|
|
3536
3602
|
const tabId = await resolveTabId(tab_id);
|
|
3537
3603
|
const results = await chrome.scripting.executeScript({
|
|
3538
3604
|
target: { tabId },
|
|
3539
|
-
func:
|
|
3540
|
-
const needle = caseSensitive ? needleRaw : needleRaw.toLowerCase();
|
|
3541
|
-
const matches = [];
|
|
3542
|
-
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
|
3543
|
-
acceptNode: (n) => {
|
|
3544
|
-
const p = n.parentElement;
|
|
3545
|
-
if (!p || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(p.tagName)) return NodeFilter.FILTER_REJECT;
|
|
3546
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
3547
|
-
},
|
|
3548
|
-
});
|
|
3549
|
-
const selectorFor = (el) => {
|
|
3550
|
-
if (el.id) return `#${el.id}`;
|
|
3551
|
-
const tag = el.tagName.toLowerCase();
|
|
3552
|
-
const cls = el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
|
|
3553
|
-
return `${tag}${cls}`;
|
|
3554
|
-
};
|
|
3555
|
-
let node;
|
|
3556
|
-
while ((node = walker.nextNode()) && matches.length < maxResults) {
|
|
3557
|
-
const hay = caseSensitive ? node.textContent : node.textContent.toLowerCase();
|
|
3558
|
-
let idx = hay.indexOf(needle);
|
|
3559
|
-
while (idx !== -1 && matches.length < maxResults) {
|
|
3560
|
-
const parent = node.parentElement;
|
|
3561
|
-
const rect = parent.getBoundingClientRect();
|
|
3562
|
-
const ctx = node.textContent.substring(Math.max(0, idx - 40), idx + needleRaw.length + 40);
|
|
3563
|
-
matches.push({
|
|
3564
|
-
selector: selectorFor(parent),
|
|
3565
|
-
context: ctx.trim(),
|
|
3566
|
-
visible: rect.width > 0 && rect.height > 0,
|
|
3567
|
-
position: { x: Math.round(rect.x + window.scrollX), y: Math.round(rect.y + window.scrollY) },
|
|
3568
|
-
});
|
|
3569
|
-
idx = hay.indexOf(needle, idx + 1);
|
|
3570
|
-
}
|
|
3571
|
-
}
|
|
3572
|
-
return { count: matches.length, matches };
|
|
3573
|
-
},
|
|
3605
|
+
func: findTextInPage,
|
|
3574
3606
|
args: [text, case_sensitive, max_results],
|
|
3575
3607
|
world: 'MAIN',
|
|
3576
3608
|
});
|
|
@@ -4448,7 +4480,7 @@ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200,
|
|
|
4448
4480
|
if (!text) throw new Error('Missing required parameter: text');
|
|
4449
4481
|
const tabId = await resolveTabId(tab_id);
|
|
4450
4482
|
|
|
4451
|
-
const
|
|
4483
|
+
const run = () => chrome.scripting.executeScript({
|
|
4452
4484
|
target: scriptTarget(tabId, frame_id),
|
|
4453
4485
|
func: (needle, scopeSel, tout, intv) => new Promise((resolve) => {
|
|
4454
4486
|
const started = Date.now();
|
|
@@ -4477,9 +4509,10 @@ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200,
|
|
|
4477
4509
|
}),
|
|
4478
4510
|
args: [text, selector || null, timeout, Math.max(50, interval)],
|
|
4479
4511
|
world: 'MAIN',
|
|
4480
|
-
});
|
|
4512
|
+
}).then((results) => results?.[0]?.result ?? { found: false, error: 'No result' });
|
|
4481
4513
|
|
|
4482
|
-
return
|
|
4514
|
+
return withDeadline(tabId, timeout + 1000, run,
|
|
4515
|
+
{ found: false, error: `Text not found within ${timeout}ms: ${text}` }, (r) => r.found === false);
|
|
4483
4516
|
}
|
|
4484
4517
|
|
|
4485
4518
|
async function cmdSavePage({ tab_id }) {
|
|
@@ -4600,11 +4633,14 @@ chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
|
|
4600
4633
|
|
|
4601
4634
|
async function cmdGetInteractives({ scope, limit = 100, visible_only = true, tab_id, frame_id }) {
|
|
4602
4635
|
const tabId = await resolveTabId(tab_id);
|
|
4636
|
+
// labelOf vive in un file a sé (testato in test/unit): va iniettato prima
|
|
4637
|
+
await chrome.scripting.executeScript({ target: scriptTarget(tabId, frame_id), files: ['lib/element-label.js'], world: 'MAIN' });
|
|
4603
4638
|
const results = await chrome.scripting.executeScript({
|
|
4604
4639
|
target: scriptTarget(tabId, frame_id),
|
|
4605
4640
|
func: (scopeSel, lim, visibleOnly) => {
|
|
4606
4641
|
const root = scopeSel ? document.querySelector(scopeSel) : document;
|
|
4607
4642
|
if (!root) return { count: 0, elements: [], note: 'Scope element not found' };
|
|
4643
|
+
const labelOf = globalThis.__cbLabelOf;
|
|
4608
4644
|
|
|
4609
4645
|
// Selettore stabile e riusabile: id > data-testid > path nth-of-type
|
|
4610
4646
|
const stableSelector = (el) => {
|
|
@@ -4644,7 +4680,7 @@ async function cmdGetInteractives({ scope, limit = 100, visible_only = true, tab
|
|
|
4644
4680
|
const top = document.elementFromPoint(cx, cy);
|
|
4645
4681
|
occluded = !!top && top !== el && !el.contains(top) && !top.contains(el);
|
|
4646
4682
|
}
|
|
4647
|
-
const label = (el
|
|
4683
|
+
const label = labelOf(el, document);
|
|
4648
4684
|
out.push({
|
|
4649
4685
|
selector: stableSelector(el),
|
|
4650
4686
|
tag: el.tagName.toLowerCase(),
|
|
@@ -4707,52 +4743,92 @@ async function cmdWaitForFunction({ expression, timeout = 10000, polling_ms = 10
|
|
|
4707
4743
|
|
|
4708
4744
|
// --- scroll_until ---
|
|
4709
4745
|
|
|
4710
|
-
async function cmdScrollUntil({ until = 'no_new_content', selector, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
|
|
4746
|
+
async function cmdScrollUntil({ until = 'no_new_content', selector, container, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
|
|
4711
4747
|
const tabId = await resolveTabId(tab_id);
|
|
4712
4748
|
if (until === 'network_idle') await ensureNetworkHook(tabId);
|
|
4713
|
-
const
|
|
4749
|
+
const run = () => chrome.scripting.executeScript({
|
|
4714
4750
|
target: { tabId },
|
|
4715
|
-
func: (mode, sel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
|
|
4751
|
+
func: (mode, sel, containerSel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
|
|
4716
4752
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4753
|
+
const docEl = document.scrollingElement || document.documentElement;
|
|
4754
|
+
const scrollable = (el) => {
|
|
4755
|
+
if (el.scrollHeight <= el.clientHeight + 2) return false;
|
|
4756
|
+
const oy = getComputedStyle(el).overflowY;
|
|
4757
|
+
return oy === 'auto' || oy === 'scroll' || oy === 'overlay';
|
|
4758
|
+
};
|
|
4759
|
+
const describe = (el) => {
|
|
4760
|
+
if (el.id) return `#${el.id}`;
|
|
4761
|
+
const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
4762
|
+
return el.tagName.toLowerCase() + cls;
|
|
4763
|
+
};
|
|
4764
|
+
// Le app come Ads Manager bloccano il documento e scorrono in un div
|
|
4765
|
+
// interno: scorrendo solo window la condizione «bottom» era vera al
|
|
4766
|
+
// primo giro. Senza container esplicito si prende il contenitore
|
|
4767
|
+
// scorrevole più grande, ma solo se il documento non scorre.
|
|
4768
|
+
let box = null;
|
|
4769
|
+
if (containerSel) {
|
|
4770
|
+
box = document.querySelector(containerSel);
|
|
4771
|
+
if (!box) { resolve({ stopped_reason: 'error', error: `Container not found: ${containerSel}`, scrolls: 0 }); return; }
|
|
4772
|
+
} else if (docEl.scrollHeight <= innerHeight + 2
|
|
4773
|
+
// overflow hidden su html o body: l'utente non può scorrere il
|
|
4774
|
+
// documento anche se è più alto della finestra (e scrollBy sì).
|
|
4775
|
+
|| [document.documentElement, document.body].some((e) => e && getComputedStyle(e).overflowY === 'hidden')) {
|
|
4776
|
+
let best = null;
|
|
4777
|
+
let bestArea = 0;
|
|
4778
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
4779
|
+
if (el.clientHeight < 50 || !scrollable(el)) continue;
|
|
4780
|
+
const area = el.clientWidth * el.clientHeight;
|
|
4781
|
+
if (area > bestArea) { best = el; bestArea = area; }
|
|
4782
|
+
}
|
|
4783
|
+
box = best;
|
|
4784
|
+
}
|
|
4785
|
+
const s = box
|
|
4786
|
+
? { by: (d) => box.scrollBy(0, d), pos: () => box.scrollTop, height: () => box.scrollHeight, view: () => box.clientHeight, name: describe(box) }
|
|
4787
|
+
: { by: (d) => window.scrollBy(0, d), pos: () => window.scrollY, height: () => docEl.scrollHeight, view: () => innerHeight, name: 'document' };
|
|
4788
|
+
const done = (stopped_reason, scrolls) => resolve({ stopped_reason, scrolls, finalScrollY: Math.round(s.pos()), container: s.name });
|
|
4717
4789
|
(async () => {
|
|
4718
4790
|
let scrolls = 0;
|
|
4719
|
-
let lastHeight =
|
|
4791
|
+
let lastHeight = s.height();
|
|
4720
4792
|
let stableCount = 0;
|
|
4721
|
-
const step = stepPx ||
|
|
4793
|
+
const step = stepPx || s.view();
|
|
4722
4794
|
for (scrolls = 0; scrolls < maxScrolls; scrolls++) {
|
|
4723
4795
|
if (mode === 'element' && sel) {
|
|
4724
4796
|
const el = document.querySelector(sel);
|
|
4725
4797
|
if (el) {
|
|
4726
4798
|
const r = el.getBoundingClientRect();
|
|
4727
4799
|
const vis = r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
4728
|
-
if (vis) {
|
|
4800
|
+
if (vis) { done('element_found', scrolls); return; }
|
|
4729
4801
|
}
|
|
4730
4802
|
}
|
|
4731
|
-
|
|
4803
|
+
s.by(step);
|
|
4732
4804
|
await sleep(settleMs);
|
|
4733
4805
|
|
|
4734
4806
|
if (mode === 'network_idle') {
|
|
4735
4807
|
const inflight = window.__chromeBridge_inflight || 0;
|
|
4736
4808
|
const last = window.__chromeBridge_lastNetActivity || Date.now();
|
|
4737
|
-
if (inflight === 0 && Date.now() - last >= settleMs) {
|
|
4809
|
+
if (inflight === 0 && Date.now() - last >= settleMs) { done('network_idle', scrolls + 1); return; }
|
|
4738
4810
|
}
|
|
4739
4811
|
|
|
4740
|
-
const h =
|
|
4812
|
+
const h = s.height();
|
|
4741
4813
|
if (h === lastHeight) {
|
|
4742
4814
|
stableCount++;
|
|
4743
|
-
if (mode === 'no_new_content' && stableCount >= 2) {
|
|
4815
|
+
if (mode === 'no_new_content' && stableCount >= 2) { done('no_new_content', scrolls + 1); return; }
|
|
4744
4816
|
} else { stableCount = 0; lastHeight = h; }
|
|
4745
4817
|
|
|
4746
4818
|
// bottom reached
|
|
4747
|
-
if (
|
|
4819
|
+
if (s.view() + s.pos() >= h - 2) { done('bottom', scrolls + 1); return; }
|
|
4748
4820
|
}
|
|
4749
|
-
|
|
4821
|
+
done('max_scrolls', scrolls);
|
|
4750
4822
|
})();
|
|
4751
4823
|
}),
|
|
4752
|
-
args: [until, selector || null, max_scrolls, step_px || null, settle_ms],
|
|
4824
|
+
args: [until, selector || null, container || null, max_scrolls, step_px || null, settle_ms],
|
|
4753
4825
|
world: 'MAIN',
|
|
4754
|
-
});
|
|
4755
|
-
|
|
4826
|
+
}).then((results) => results?.[0]?.result ?? { stopped_reason: 'error', scrolls: 0 });
|
|
4827
|
+
// Stesse sleep in pagina delle attese: su una scheda nascosta rallentano.
|
|
4828
|
+
const deadline = Math.min(55000, max_scrolls * (settle_ms + 250) + 5000);
|
|
4829
|
+
return withDeadline(tabId, deadline, run,
|
|
4830
|
+
{ stopped_reason: 'deadline', error: `Scrolling did not finish within ${deadline}ms` },
|
|
4831
|
+
(r) => r.stopped_reason === 'deadline');
|
|
4756
4832
|
}
|
|
4757
4833
|
|
|
4758
4834
|
// --- Avvia la connessione + registra l'instrumentation ---
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
3
|
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.20.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
12
12
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
13
|
import { WSManager } from './ws-manager.js';
|
|
14
14
|
import { registerTools } from './tools.js';
|
|
15
|
+
import { createObserver } from './observe.js';
|
|
15
16
|
import { launchBrowser } from './launcher.js';
|
|
16
17
|
import { DEFAULT_PORT, VERSION } from './protocol.js';
|
|
17
18
|
|
|
@@ -64,6 +65,10 @@ async function main() {
|
|
|
64
65
|
'For more than one field use fill_form once (with submit_selector to submit in the same call) instead of repeated type_text: one turn instead of N.',
|
|
65
66
|
'For tables use extract_table (server-side where/columns filtering) or extract, never read_page: read_page on a big table costs tens of thousands of tokens for data you filter anyway.',
|
|
66
67
|
'To read detail in a screenshot, crop and zoom with element_screenshot (selector or region, scale) instead of taking another full one; to check an outcome, assert or wait_for poll for you — one call, no screenshot.',
|
|
68
|
+
// Il blocco più frequente sul campo (Meta Ads Manager, 23/09/2026): tre
|
|
69
|
+
// attese scadute e due screenshot falliti prima di capire che la
|
|
70
|
+
// finestra era dietro un'altra.
|
|
71
|
+
'A tab in a minimized, covered or background window does not render: screenshots fail and page timers slow down. get_page_info reports visibility; page_hidden in a result means the same. Bring the window on screen, or create_tab new_window with bounds.',
|
|
67
72
|
].join(' '),
|
|
68
73
|
});
|
|
69
74
|
|
|
@@ -103,7 +108,10 @@ async function main() {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
// 3. Registra i tool MCP (filtrati per capability)
|
|
106
|
-
|
|
111
|
+
// Errori dei tool annotati in locale (formato claude-observe) quando nessun
|
|
112
|
+
// hook del plugin li vede: vedi server/observe.js.
|
|
113
|
+
const observe = createObserver({ version: VERSION });
|
|
114
|
+
session.tools = registerTools(mcpServer, wsManager, parseCaps(), { ...parseSecurity(), observe });
|
|
107
115
|
|
|
108
116
|
// 4. Avvia il trasporto stdio MCP
|
|
109
117
|
const transport = new StdioServerTransport();
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errori dei tool annotati in locale quando il server gira senza il plugin
|
|
3
|
+
* (npm, cartella locale): nessun hook di Claude Code li vede, quindi li
|
|
4
|
+
* scrive il server stesso, nel formato di claude-observe (FORMAT.md, v1).
|
|
5
|
+
*
|
|
6
|
+
* Con il plugin installato gli errori li scrive l'hook PostToolUseFailure
|
|
7
|
+
* (observe/observe.py), che ha anche il contesto della sessione; il plugin
|
|
8
|
+
* passa CHROME_BRIDGE_OBSERVE=hook e qui non si scrive nulla, altrimenti ogni
|
|
9
|
+
* errore conterebbe due volte.
|
|
10
|
+
*
|
|
11
|
+
* Stesse regole della copia Python, perché i record finiscono nello stesso
|
|
12
|
+
* file e si deduplicano per id:
|
|
13
|
+
* - id = <tool[:12]>-<sha256(tool + "\0" + key)[:8]>, key = errore normalizzato;
|
|
14
|
+
* - dei parametri solo nomi dei campi e lunghezze, mai i valori;
|
|
15
|
+
* - errore ripulito (home, email, query degli URL, segreti, valori digitati);
|
|
16
|
+
* - due lock, in quest'ordine: flock su <dir>/.lock dove c'è flock(1), poi
|
|
17
|
+
* sempre la cartella <plugin>.jsonl.lock creata con mkdir; file 0600 in una
|
|
18
|
+
* cartella 0700, sostituzione atomica;
|
|
19
|
+
* - lock occupato oltre l'attesa: la voce va in <plugin>.pending.jsonl con
|
|
20
|
+
* una scrittura in append, e la incorpora il prossimo che ha il lock;
|
|
21
|
+
* - un file con record di formato più nuovo non si riscrive.
|
|
22
|
+
* scrub e normalize sono il porting di quelle di observe.py: il test
|
|
23
|
+
* observe-parity li confronta sugli stessi testi.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import {
|
|
28
|
+
appendFileSync, chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync,
|
|
29
|
+
unlinkSync, writeSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { homedir, release, type } from 'node:os';
|
|
32
|
+
import { basename, join } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
|
|
35
|
+
export const TOOL = 'chrome-bridge';
|
|
36
|
+
export const FORMAT = 1;
|
|
37
|
+
const MCP_PREFIX = 'mcp__chrome-bridge__';
|
|
38
|
+
const DEFAULTS = { enabled: true, dir: '', max_records: 2000, max_days: 90 };
|
|
39
|
+
|
|
40
|
+
// \w di Python è Unicode: qui lo stesso insieme, esplicito.
|
|
41
|
+
const W = '[\\p{L}\\p{N}_]';
|
|
42
|
+
|
|
43
|
+
export function scrub(text, limit = 300) {
|
|
44
|
+
let t = String(text ?? '');
|
|
45
|
+
const home = homedir();
|
|
46
|
+
if (home && home !== '/') t = t.split(home).join('~');
|
|
47
|
+
t = t.replace(new RegExp(`(?:${W}|[.+-])+@(?:${W}|-)+\\.(?:${W}|[.-])+`, 'gu'), '<EMAIL>');
|
|
48
|
+
t = t.replace(/(https?:\/\/[^/\s?#"'»]+)[^\s"'»]*/gu, '$1/…');
|
|
49
|
+
t = t.replace(/\b(token|password|passwd|secret|api[_-]?key|authorization|bearer)("?\s*[:=]\s*|\s+)\S+/giu, '$1$2<SECRET>');
|
|
50
|
+
t = t.replace(/\b(value_after|value|text)("?\s*[:=]?\s*)("[^"]*"|'[^']*'|«[^»]*»)/giu, '$1$2<STR>');
|
|
51
|
+
t = t.replace(/\b(?=[A-Za-z0-9_\-+/]*\d)(?=[A-Za-z0-9_\-+/]*[A-Za-z])[A-Za-z0-9_\-+/=]{24,}\b/gu, '<SECRET>');
|
|
52
|
+
return Array.from(t).slice(0, limit).join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalize(text) {
|
|
56
|
+
let t = scrub(text, 400);
|
|
57
|
+
t = t.replace(/«[^»]*»|"[^"]*"|'[^']*'/gu, '<STR>');
|
|
58
|
+
t = t.replace(new RegExp(`(~|\\.{0,2})/(?:${W}|[.\\-/~<>…])+`, 'gu'), '<PATH>');
|
|
59
|
+
t = t.replace(/\d+/gu, '<N>');
|
|
60
|
+
return Array.from(t.replace(/\s+/gu, ' ').trim()).slice(0, 300).join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Di tool_input solo la forma: nomi dei campi, lunghezza dei testi, tipo del resto.
|
|
64
|
+
export function shape(input) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(input ?? {})) {
|
|
67
|
+
if (typeof v === 'string') out[k] = Array.from(v).length;
|
|
68
|
+
else if (typeof v === 'boolean') out[k] = 'bool';
|
|
69
|
+
else if (typeof v === 'number') out[k] = 'number';
|
|
70
|
+
else if (Array.isArray(v)) out[k] = `list[${v.length}]`;
|
|
71
|
+
else if (v && typeof v === 'object') out[k] = `object[${Object.keys(v).length}]`;
|
|
72
|
+
else out[k] = 'null';
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function rid(tool, key) {
|
|
78
|
+
return `${tool.slice(0, 12)}-${createHash('sha256').update(`${tool}\0${key}`).digest('hex').slice(0, 8)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const expand = (p, env) => String(p ?? '')
|
|
82
|
+
.replace(/^~(?=$|\/)/, homedir())
|
|
83
|
+
.replace(/\$\{(\w+)\}|\$(\w+)/g, (m, a, b) => env[a ?? b] ?? m);
|
|
84
|
+
|
|
85
|
+
export function loadConfig(env = process.env) {
|
|
86
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
87
|
+
const path = env.CLAUDE_OBSERVE_CONFIG || join(base, 'claude-observe', 'config.json');
|
|
88
|
+
try {
|
|
89
|
+
return { ...DEFAULTS, ...(JSON.parse(readFileSync(path, 'utf8')) || {}) };
|
|
90
|
+
} catch {
|
|
91
|
+
return { ...DEFAULTS };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function boxDir(config, env = process.env) {
|
|
96
|
+
if (config.dir) return expand(config.dir, env);
|
|
97
|
+
return join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'claude-observe');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// L'account è la cartella di config di Claude Code: il confine fra organizzazioni.
|
|
101
|
+
function accountName(env) {
|
|
102
|
+
return basename(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Il record come lo scriverebbe record_external/record di observe.py, più la forma dei parametri. */
|
|
106
|
+
export function buildEntry({ name, args, message, durationMs, version, env = process.env, cwd = process.cwd() }) {
|
|
107
|
+
const call = name.startsWith(MCP_PREFIX) ? name : `${MCP_PREFIX}${name}`;
|
|
108
|
+
const err = String(message ?? '');
|
|
109
|
+
const key = normalize(`${call} ${err}`);
|
|
110
|
+
const context = { tool_version: version || '', os: `${type()} ${release()}` };
|
|
111
|
+
return {
|
|
112
|
+
id: rid(TOOL, key),
|
|
113
|
+
fields: {
|
|
114
|
+
context, source: 'server', kind: 'error', call, error: scrub(err), key,
|
|
115
|
+
account: accountName(env), project: basename(cwd),
|
|
116
|
+
},
|
|
117
|
+
example: { input_shape: shape(args), error_raw: scrub(err), duration_ms: durationMs ?? null, context },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readRecords(path) {
|
|
122
|
+
let text = '';
|
|
123
|
+
try { text = readFileSync(path, 'utf8'); } catch { return []; }
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const line of text.split('\n')) {
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
try { out.push(JSON.parse(line)); } catch { /* riga rotta: si salta, come observe.py */ }
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rotate(recs, config, now) {
|
|
133
|
+
const days = Number(config.max_days) || 90;
|
|
134
|
+
const cap = Number(config.max_records) || 2000;
|
|
135
|
+
let kept = recs.filter((r) => now - Number(r.last_seen || 0) <= days * 86400);
|
|
136
|
+
if (kept.length > cap) {
|
|
137
|
+
// Come observe.py: prima i record chiusi, poi i più vecchi.
|
|
138
|
+
kept.sort((a, b) => ((a.status !== 'done') - (b.status !== 'done')) || (Number(a.last_seen || 0) - Number(b.last_seen || 0)));
|
|
139
|
+
kept = kept.slice(kept.length - cap);
|
|
140
|
+
}
|
|
141
|
+
return kept;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Aggiunge o aggiorna il record nella lista: la stessa logica di apply() in
|
|
145
|
+
// observe.py (count, last_seen, fino a tre esempi, campi scritti a mano).
|
|
146
|
+
function applyRecord(recs, id, fields, example, now) {
|
|
147
|
+
let r = recs.find((x) => x.id === id);
|
|
148
|
+
if (!r) {
|
|
149
|
+
r = {
|
|
150
|
+
v: FORMAT, id, tool: TOOL, count: 0, first_seen: now, examples: [], workaround: null,
|
|
151
|
+
class: null, status: 'new', ...fields,
|
|
152
|
+
};
|
|
153
|
+
recs.push(r);
|
|
154
|
+
} else {
|
|
155
|
+
for (const k of ['workaround', 'class', 'note', 'context']) if (fields[k]) r[k] = fields[k];
|
|
156
|
+
if (r.status === 'done' && String(fields.source || '').startsWith('hook')) r.status = 'new';
|
|
157
|
+
}
|
|
158
|
+
r.count = Number(r.count || 0) + (example != null || !r.count ? 1 : 0);
|
|
159
|
+
r.last_seen = Math.max(Number(r.last_seen || 0), now);
|
|
160
|
+
if (example != null) r.examples = [...(r.examples || []).slice(-2), { ...example, at: now }];
|
|
161
|
+
return r;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pendingPath = (dir) => join(dir, `${TOOL}.pending.jsonl`);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Legge, incorpora le voci in attesa, aggiorna e riscrive il file. Va
|
|
168
|
+
* chiamata con i lock presi. null = file di formato più nuovo, non toccato.
|
|
169
|
+
*/
|
|
170
|
+
export function applyEntry(dir, entry, config, nowSec = Date.now() / 1000) {
|
|
171
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
172
|
+
chmodSync(dir, 0o700);
|
|
173
|
+
const path = join(dir, `${TOOL}.jsonl`);
|
|
174
|
+
const recs = readRecords(path);
|
|
175
|
+
// Le voci rimaste in attesa (lock occupato, FORMAT.md «Pending»): si
|
|
176
|
+
// spostano da parte e si applicano come appena registrate; il file preso si
|
|
177
|
+
// cancella solo dopo la riscrittura.
|
|
178
|
+
const pending = pendingPath(dir);
|
|
179
|
+
let taken = null;
|
|
180
|
+
try {
|
|
181
|
+
const t = `${join(dir, `${TOOL}.pending`)}.${process.pid}.taking`;
|
|
182
|
+
renameSync(pending, t);
|
|
183
|
+
taken = t;
|
|
184
|
+
} catch { /* nessuna voce in attesa */ }
|
|
185
|
+
const takenText = taken ? readFileSync(taken, 'utf8') : '';
|
|
186
|
+
// Un file scritto da una copia più nuova (formato incompatibile): non si
|
|
187
|
+
// tocca, e le voci prese tornano in attesa.
|
|
188
|
+
if (recs.some((r) => Number(r.v || 1) > FORMAT)) {
|
|
189
|
+
if (taken) { appendFileSync(pending, takenText, { mode: 0o600 }); unlinkSync(taken); }
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
for (const line of takenText.split('\n')) {
|
|
193
|
+
if (!line.trim()) continue;
|
|
194
|
+
try {
|
|
195
|
+
const e = JSON.parse(line);
|
|
196
|
+
if (Number(e.v || 1) <= FORMAT && e.id) applyRecord(recs, e.id, e.fields || {}, e.example ?? null, Number(e.at) || nowSec);
|
|
197
|
+
} catch { /* riga rotta: si salta */ }
|
|
198
|
+
}
|
|
199
|
+
const r = entry ? applyRecord(recs, entry.id, entry.fields, entry.example, Math.round(nowSec * 1000) / 1000) : null;
|
|
200
|
+
const kept = rotate(recs, config, nowSec);
|
|
201
|
+
const tmp = `${path}.tmp`;
|
|
202
|
+
const fd = openSync(tmp, 'w', 0o600);
|
|
203
|
+
try {
|
|
204
|
+
writeSync(fd, kept.map((x) => JSON.stringify(x)).join('\n') + (kept.length ? '\n' : ''));
|
|
205
|
+
} finally {
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
chmodSync(tmp, 0o600);
|
|
209
|
+
renameSync(tmp, path);
|
|
210
|
+
if (taken) unlinkSync(taken);
|
|
211
|
+
return r ?? true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Lock occupato oltre l'attesa: la voce non si perde, va in
|
|
216
|
+
* <plugin>.pending.jsonl con UNA scrittura in append (atomica senza lock per
|
|
217
|
+
* righe così piccole). La incorpora il prossimo scrittore che ha il lock.
|
|
218
|
+
*/
|
|
219
|
+
export function appendPending(dir, entry, nowSec = Date.now() / 1000) {
|
|
220
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
221
|
+
const line = JSON.stringify({
|
|
222
|
+
v: FORMAT, tool: TOOL, id: entry.id, fields: entry.fields, example: entry.example ?? null,
|
|
223
|
+
at: Math.round(nowSec * 1000) / 1000,
|
|
224
|
+
}) + '\n';
|
|
225
|
+
const fd = openSync(pendingPath(dir), 'a', 0o600);
|
|
226
|
+
try { writeSync(fd, line); } finally { closeSync(fd); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Secondo lock del protocollo (FORMAT.md, «Lock»), sempre preso: la cartella
|
|
230
|
+
// <plugin>.jsonl.lock creata con mkdir, atomica ovunque. Chi non ha flock(1)
|
|
231
|
+
// prende solo questa. Una cartella più vecchia di 10 s è di un processo
|
|
232
|
+
// morto; l'attesa complessiva si ferma a deadline (circa 3 s dall'inizio).
|
|
233
|
+
export const LOCK_WAIT_MS = 3000;
|
|
234
|
+
const LOCK_STALE_MS = 10000;
|
|
235
|
+
export const BUSY = Symbol('lock busy');
|
|
236
|
+
|
|
237
|
+
export async function withDirLock(dir, fn, deadline = Date.now() + LOCK_WAIT_MS) {
|
|
238
|
+
const lock = join(dir, `${TOOL}.jsonl.lock`);
|
|
239
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
240
|
+
for (;;) {
|
|
241
|
+
try { mkdirSync(lock); break; } catch {
|
|
242
|
+
try {
|
|
243
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { rmdirSync(lock); continue; }
|
|
244
|
+
} catch { continue; /* sparita nel frattempo: si riprova subito */ }
|
|
245
|
+
if (Date.now() > deadline) return BUSY;
|
|
246
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try { return fn(); } finally { try { rmdirSync(lock); } catch { /* già tolta */ } }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
253
|
+
// Codici d'uscita del figlio: lock occupato (flock -E o cartella) contro
|
|
254
|
+
// guasto vero. Solo il primo manda la voce in attesa.
|
|
255
|
+
const EXIT_BUSY = 75;
|
|
256
|
+
|
|
257
|
+
// Primo lock, dove c'è flock(1): la scrittura gira in un figlio sotto
|
|
258
|
+
// `flock -x <dir>/.lock`, lo stesso lock di fcntl.flock in observe.py. Le
|
|
259
|
+
// copie distribuite fino ad a787654 prendono solo quello: così nuove e vecchie
|
|
260
|
+
// convivono. Dentro, il figlio prende la cartella di lock e scrive.
|
|
261
|
+
function writeUnderFlock(dir, entry, env, deadline) {
|
|
262
|
+
const wait = String(Math.max(1, Math.ceil((deadline - Date.now()) / 1000)));
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
const child = spawn('flock', ['-x', '-w', wait, '-E', String(EXIT_BUSY), join(dir, '.lock'), process.execPath, SELF, '--write'], {
|
|
265
|
+
stdio: ['pipe', 'ignore', 'ignore'], env,
|
|
266
|
+
});
|
|
267
|
+
child.on('error', () => resolve('no-flock'));
|
|
268
|
+
child.on('exit', (code) => resolve(code === 0 ? 'ok' : code === EXIT_BUSY ? 'busy' : 'failed'));
|
|
269
|
+
child.stdin.end(JSON.stringify({ dir, entry, deadline }));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* L'osservatore del server, o null quando non deve scrivere: il plugin ha già
|
|
275
|
+
* l'hook (CHROME_BRIDGE_OBSERVE=hook) o l'utente l'ha spento (=off).
|
|
276
|
+
*/
|
|
277
|
+
export function createObserver({ env = process.env, version = '' } = {}) {
|
|
278
|
+
const mode = String(env.CHROME_BRIDGE_OBSERVE ?? '').toLowerCase();
|
|
279
|
+
if (['hook', 'off', '0', 'false', 'no'].includes(mode)) return null;
|
|
280
|
+
let chain = Promise.resolve();
|
|
281
|
+
return {
|
|
282
|
+
error(name, args, message, durationMs) {
|
|
283
|
+
chain = chain.then(async () => {
|
|
284
|
+
const config = loadConfig(env);
|
|
285
|
+
if (config.enabled === false || config.tools?.[TOOL]?.enabled === false) return;
|
|
286
|
+
const dir = boxDir(config, env);
|
|
287
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
288
|
+
const entry = buildEntry({ name, args, message, durationMs, version, env });
|
|
289
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
290
|
+
let how = await writeUnderFlock(dir, entry, env, deadline);
|
|
291
|
+
if (how === 'no-flock') {
|
|
292
|
+
how = (await withDirLock(dir, () => applyEntry(dir, entry, config), deadline)) === BUSY ? 'busy' : 'ok';
|
|
293
|
+
}
|
|
294
|
+
if (how === 'busy') appendPending(dir, entry);
|
|
295
|
+
}).catch(() => { /* annotare un errore non deve mai produrne un altro */ });
|
|
296
|
+
return chain;
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Modalità figlio: `node observe.js --write` con {dir, entry, deadline} su stdin.
|
|
302
|
+
if (process.argv[1] === SELF && process.argv[2] === '--write') {
|
|
303
|
+
let input = '';
|
|
304
|
+
process.stdin.on('data', (d) => { input += d; });
|
|
305
|
+
process.stdin.on('end', () => {
|
|
306
|
+
try {
|
|
307
|
+
const { dir, entry, deadline } = JSON.parse(input);
|
|
308
|
+
withDirLock(dir, () => applyEntry(dir, entry, loadConfig(process.env)), deadline)
|
|
309
|
+
.then((r) => process.exit(r === BUSY ? EXIT_BUSY : 0), () => process.exit(1));
|
|
310
|
+
} catch {
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
package/server/protocol.js
CHANGED
|
@@ -92,12 +92,15 @@ export const MessageType = Object.freeze({
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
// Versione
|
|
95
|
-
export const VERSION = '1.
|
|
95
|
+
export const VERSION = '1.20.0';
|
|
96
96
|
|
|
97
97
|
// Configurazione
|
|
98
98
|
export const DEFAULT_PORT = 8765;
|
|
99
99
|
export const COMMAND_TIMEOUT_MS = 30000; // 30s per comandi normali
|
|
100
|
-
|
|
100
|
+
// Più del timer interno di captureVisible (10 s, dopo il pacer da 520 ms):
|
|
101
|
+
// a parità vinceva il trasporto col messaggio generico «the tab may be busy»,
|
|
102
|
+
// e quello utile («the window is not rendering frames») non arrivava mai.
|
|
103
|
+
export const SCREENSHOT_TIMEOUT_MS = 15000;
|
|
101
104
|
export const PING_INTERVAL_MS = 15000; // 15s heartbeat
|
|
102
105
|
export const IDENT_TIMEOUT_MS = 5000; // tempo max per identificarsi
|
|
103
106
|
export const PENDING_RELAY_TTL_MS = 150000; // deve superare il timeout comando più lungo (120s full_page_screenshot)
|
package/server/tools.js
CHANGED
|
@@ -400,6 +400,25 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
400
400
|
? ['core', ...Object.keys(TOOL_CAPS)]
|
|
401
401
|
: ['core', ...String(caps).split(',').map((s) => s.trim()).filter((s) => s && s !== 'core')];
|
|
402
402
|
|
|
403
|
+
// Un errore del tool (eccezione, o risultato isError) va all'osservatore,
|
|
404
|
+
// che lo annota in locale senza i valori dei parametri (server/observe.js).
|
|
405
|
+
// L'errore arriva comunque al chiamante, uguale.
|
|
406
|
+
const observer = options.observe ?? null;
|
|
407
|
+
const observed = (name, handler) => (observer ? async (args, extra) => {
|
|
408
|
+
const t0 = Date.now();
|
|
409
|
+
try {
|
|
410
|
+
const out = await handler(args, extra);
|
|
411
|
+
if (out?.isError) {
|
|
412
|
+
const text = (out.content ?? []).find((c) => c.type === 'text')?.text ?? '';
|
|
413
|
+
observer.error(name, args, text, Date.now() - t0);
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
} catch (err) {
|
|
417
|
+
observer.error(name, args, err?.message ?? String(err), Date.now() - t0);
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
} : handler);
|
|
421
|
+
|
|
403
422
|
// Ogni registrazione passa da qui: il filtro capability scarta i tool opt-in
|
|
404
423
|
// fuori dai gruppi attivi, e le annotations vengono applicate da TOOL_ANNOTATIONS.
|
|
405
424
|
// Un solo wrapper per entrambe le cose, altrimenti con caps != 'all' le
|
|
@@ -417,10 +436,11 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
417
436
|
if (group && !enabled.has(group) && !enabled.has('all')) return;
|
|
418
437
|
}
|
|
419
438
|
const annotations = TOOL_ANNOTATIONS[name];
|
|
439
|
+
const run = observed(name, handler);
|
|
420
440
|
// Un tool senza voce resta registrato (meglio di un crash all'avvio):
|
|
421
441
|
// è il test tool-annotations a segnalarlo.
|
|
422
|
-
if (annotations) target.tool(name, desc, schema, annotations,
|
|
423
|
-
else target.tool(name, desc, schema,
|
|
442
|
+
if (annotations) target.tool(name, desc, schema, annotations, run);
|
|
443
|
+
else target.tool(name, desc, schema, run);
|
|
424
444
|
},
|
|
425
445
|
};
|
|
426
446
|
}
|
|
@@ -472,17 +492,73 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
472
492
|
}
|
|
473
493
|
};
|
|
474
494
|
|
|
475
|
-
//
|
|
476
|
-
//
|
|
495
|
+
// Registro ref → selector per tab, popolato da get_interactives, dalla
|
|
496
|
+
// preview di navigate e dai vicini di find_text. Permette click/type_text/
|
|
497
|
+
// hover per ref (n1, n2…) senza ripetere selettori lunghi.
|
|
498
|
+
// Un selettore tiene lo stesso ref finché la pagina non cambia, e un numero
|
|
499
|
+
// non viene mai riassegnato: prima ogni chiamata di scoperta ripartiva da n1
|
|
500
|
+
// sulla stessa mappa, e dopo un find_text n3 indicava un altro elemento —
|
|
501
|
+
// il click andava a segno sull'elemento sbagliato senza errori.
|
|
477
502
|
const interactivesRefs = new Map();
|
|
503
|
+
const REFS_MAX = 2000;
|
|
478
504
|
|
|
479
505
|
const refsKey = (tab_id) => tab_id ?? sessionTabId ?? 'active';
|
|
480
506
|
|
|
507
|
+
function refRegistry(tab_id) {
|
|
508
|
+
const key = refsKey(tab_id);
|
|
509
|
+
let reg = interactivesRefs.get(key);
|
|
510
|
+
if (!reg) {
|
|
511
|
+
reg = { next: 1, floor: 1, bySel: new Map(), byRef: new Map() };
|
|
512
|
+
interactivesRefs.set(key, reg);
|
|
513
|
+
}
|
|
514
|
+
return reg;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Assegna i ref agli elementi (in place). Solo gli elementi con selector
|
|
518
|
+
// ricevono un ref: senza, non ci sarebbe nulla da risolvere.
|
|
519
|
+
function assignRefs(tab_id, elements) {
|
|
520
|
+
const reg = refRegistry(tab_id);
|
|
521
|
+
for (const e of elements) {
|
|
522
|
+
if (!e.selector) continue;
|
|
523
|
+
let ref = reg.bySel.get(e.selector);
|
|
524
|
+
if (!ref) {
|
|
525
|
+
ref = `n${reg.next++}`;
|
|
526
|
+
reg.bySel.set(e.selector, ref);
|
|
527
|
+
reg.byRef.set(ref, e.selector);
|
|
528
|
+
}
|
|
529
|
+
e.ref = ref;
|
|
530
|
+
}
|
|
531
|
+
while (reg.byRef.size > REFS_MAX) {
|
|
532
|
+
const [oldRef, oldSel] = reg.byRef.entries().next().value;
|
|
533
|
+
reg.byRef.delete(oldRef);
|
|
534
|
+
reg.bySel.delete(oldSel);
|
|
535
|
+
}
|
|
536
|
+
return reg.byRef.size;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Una navigazione cambia pagina: i selettori vecchi potrebbero trovare altro.
|
|
540
|
+
// La mappa si svuota ma il contatore no, così un ref vecchio dà errore
|
|
541
|
+
// invece di indicare un elemento nuovo con lo stesso numero.
|
|
542
|
+
function resetRefs(tab_id) {
|
|
543
|
+
const reg = refRegistry(tab_id);
|
|
544
|
+
reg.bySel.clear();
|
|
545
|
+
reg.byRef.clear();
|
|
546
|
+
reg.floor = reg.next;
|
|
547
|
+
}
|
|
548
|
+
|
|
481
549
|
function resolveTarget(selector, ref, tab_id) {
|
|
482
550
|
if (selector) return selector;
|
|
483
551
|
if (ref) {
|
|
484
|
-
const
|
|
485
|
-
|
|
552
|
+
const reg = interactivesRefs.get(refsKey(tab_id));
|
|
553
|
+
const sel = reg?.byRef.get(ref);
|
|
554
|
+
if (!sel) {
|
|
555
|
+
const num = Number(String(ref).replace(/^n/, ''));
|
|
556
|
+
const why = !reg || reg.next === 1 ? 'no refs issued for this tab yet'
|
|
557
|
+
: num >= reg.next ? `refs issued so far go up to n${reg.next - 1}`
|
|
558
|
+
: num < reg.floor ? 'it was issued before the last navigate, which resets refs'
|
|
559
|
+
: 'it was evicted from the ref cache';
|
|
560
|
+
throw new Error(`Unknown ref ${ref} (${why}) — run get_interactives or find_text again`);
|
|
561
|
+
}
|
|
486
562
|
return sel;
|
|
487
563
|
}
|
|
488
564
|
throw new Error('Either selector or ref is required');
|
|
@@ -517,13 +593,9 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
517
593
|
async function interactivesPreview(tab_id, limit = 12) {
|
|
518
594
|
try {
|
|
519
595
|
const data = await send(MessageType.GET_INTERACTIVES, { limit, visible_only: true, tab_id });
|
|
520
|
-
const
|
|
521
|
-
(
|
|
522
|
-
|
|
523
|
-
if (e.selector) refMap.set(e.ref, e.selector);
|
|
524
|
-
});
|
|
525
|
-
interactivesRefs.set(refsKey(tab_id), refMap);
|
|
526
|
-
return refMap.size ? truncateText(interactivesLines(data), 1500) : null;
|
|
596
|
+
const elements = data?.elements ?? [];
|
|
597
|
+
assignRefs(tab_id, elements);
|
|
598
|
+
return elements.some((e) => e.ref) ? truncateText(interactivesLines(data), 1500) : null;
|
|
527
599
|
} catch { return null; }
|
|
528
600
|
}
|
|
529
601
|
|
|
@@ -612,6 +684,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
612
684
|
const data = await send(MessageType.NAVIGATE, { url, tab_id });
|
|
613
685
|
// Il tab navigato diventa il default di sessione per i comandi successivi
|
|
614
686
|
if (data?.tabId != null) sessionTabId = data.tabId;
|
|
687
|
+
resetRefs(data?.tabId ?? tab_id);
|
|
615
688
|
const preview = await interactivesPreview(data?.tabId ?? tab_id);
|
|
616
689
|
return {
|
|
617
690
|
content: [{
|
|
@@ -623,6 +696,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
623
696
|
);
|
|
624
697
|
|
|
625
698
|
// --- screenshot ---
|
|
699
|
+
// Larghezze dei preset, le stesse di viewport_resize nell'estensione.
|
|
700
|
+
const PRESET_WIDTHS = { mobile: 375, tablet: 768, desktop: 1440 };
|
|
626
701
|
server.tool(
|
|
627
702
|
'screenshot',
|
|
628
703
|
'Screenshot of the visible viewport only (PNG), at the current scroll position, or one per viewport preset with presets. Read-only. '
|
|
@@ -631,7 +706,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
631
706
|
{
|
|
632
707
|
tab_id: tabId,
|
|
633
708
|
save_to: saveToField('the PNG'),
|
|
634
|
-
presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('
|
|
709
|
+
presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('Resizes the window per preset, then restores it; not phone emulation. A refused width is reported, not shot. save_to = directory'),
|
|
635
710
|
},
|
|
636
711
|
async ({ tab_id, save_to, presets }) => {
|
|
637
712
|
guardWrite(save_to);
|
|
@@ -643,10 +718,24 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
643
718
|
const notes = [];
|
|
644
719
|
try {
|
|
645
720
|
for (const preset of presets) {
|
|
646
|
-
await send(MessageType.VIEWPORT_RESIZE, { preset, tab_id });
|
|
721
|
+
const resized = await send(MessageType.VIEWPORT_RESIZE, { preset, tab_id });
|
|
647
722
|
await new Promise((r) => setTimeout(r, 400));
|
|
648
723
|
const shot = await send(MessageType.SCREENSHOT, { tab_id });
|
|
649
724
|
if (!shot?.image) { notes.push(`${preset}: no image`); continue; }
|
|
725
|
+
// presets ridimensiona la finestra, non emula un dispositivo: il
|
|
726
|
+
// window manager può imporre una larghezza minima o ignorare la
|
|
727
|
+
// richiesta. Un'immagine desktop etichettata «mobile» porta a
|
|
728
|
+
// conclusioni sbagliate (2226 px su med-systems.it, 23/09/2026):
|
|
729
|
+
// meglio nessuna immagine e una nota esplicita.
|
|
730
|
+
const got = shot.viewport?.width;
|
|
731
|
+
const want = PRESET_WIDTHS[preset];
|
|
732
|
+
if (got && want && Math.abs(got - want) > Math.max(40, want * 0.15)) {
|
|
733
|
+
const zoom = resized?.zoom && resized.zoom !== 1 ? `, page zoom ${Math.round(resized.zoom * 100)}%` : '';
|
|
734
|
+
notes.push(`${preset}: NOT APPLIED — viewport stayed ${got}×${shot.viewport.height} CSS px (asked ~${want}${zoom}); `
|
|
735
|
+
+ 'the window manager refused the width. No capture saved: this would not be a ' + preset + ' rendering. '
|
|
736
|
+
+ 'Device emulation needs a headless browser.');
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
650
739
|
if (save_to) {
|
|
651
740
|
await mkdir(save_to, { recursive: true });
|
|
652
741
|
const path = join(save_to, `${preset}.png`);
|
|
@@ -1083,12 +1172,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1083
1172
|
max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps, so an infinite feed terminates'),
|
|
1084
1173
|
step_px: z.number().optional().describe('px per step, default viewport height'),
|
|
1085
1174
|
settle_ms: z.number().optional().default(400).describe('Pause ms after each step'),
|
|
1175
|
+
container: z.string().optional().describe('until: selector that scrolls; default the document, or the largest scrollable element if the document does not scroll'),
|
|
1086
1176
|
tab_id: tabId,
|
|
1087
1177
|
frame_id: frameId,
|
|
1088
1178
|
},
|
|
1089
|
-
async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, tab_id, frame_id }) => {
|
|
1179
|
+
async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, container, tab_id, frame_id }) => {
|
|
1090
1180
|
const data = (action ?? 'to') === 'until'
|
|
1091
|
-
? await send(MessageType.SCROLL_UNTIL, { until, selector, max_scrolls, step_px, settle_ms, tab_id })
|
|
1181
|
+
? await send(MessageType.SCROLL_UNTIL, { until, selector, container, max_scrolls, step_px, settle_ms, tab_id })
|
|
1092
1182
|
: await send(MessageType.SCROLL_TO, { selector, x, y, behavior, offset_y, tab_id, frame_id });
|
|
1093
1183
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1094
1184
|
}
|
|
@@ -1502,12 +1592,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1502
1592
|
.slice(0, 5)
|
|
1503
1593
|
.map((c) => c.e);
|
|
1504
1594
|
if (els.length) {
|
|
1505
|
-
|
|
1506
|
-
els.forEach((e, i) => {
|
|
1507
|
-
e.ref = `n${i + 1}`;
|
|
1508
|
-
if (e.selector) refMap.set(e.ref, e.selector);
|
|
1509
|
-
});
|
|
1510
|
-
interactivesRefs.set(refsKey(tab_id), refMap);
|
|
1595
|
+
assignRefs(tab_id, els);
|
|
1511
1596
|
near = truncateText(interactivesLines({ count: els.length, elements: els, note: 'near first match' }), 1200);
|
|
1512
1597
|
}
|
|
1513
1598
|
} catch {}
|
|
@@ -2200,13 +2285,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
2200
2285
|
},
|
|
2201
2286
|
async ({ scope, limit, visible_only, format, frame_id, tab_id }) => {
|
|
2202
2287
|
const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
|
|
2203
|
-
//
|
|
2204
|
-
|
|
2205
|
-
(data?.elements ?? []).forEach((e, i) => {
|
|
2206
|
-
e.ref = `n${i + 1}`;
|
|
2207
|
-
if (e.selector) refMap.set(e.ref, e.selector);
|
|
2208
|
-
});
|
|
2209
|
-
interactivesRefs.set(refsKey(tab_id), refMap);
|
|
2288
|
+
// Ref stabili per selettore, memorizzati per click/type_text/hover
|
|
2289
|
+
assignRefs(tab_id, data?.elements ?? []);
|
|
2210
2290
|
if ((format ?? 'lines') === 'json') {
|
|
2211
2291
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
2212
2292
|
}
|
|
@@ -150,8 +150,10 @@ highlighted diff; zoom into a region with `element_screenshot({region, scale:2})
|
|
|
150
150
|
### Three viewports
|
|
151
151
|
Triggers: "check it on mobile/tablet/desktop", «com'è su telefono?».
|
|
152
152
|
`screenshot({presets:['mobile','tablet','desktop'], save_to:'./shots'})`: one
|
|
153
|
-
call, one file per viewport, window restored.
|
|
154
|
-
|
|
153
|
+
call, one file per viewport, window restored. Presets resize the window and
|
|
154
|
+
do not emulate a phone: a width the window manager refuses comes back as
|
|
155
|
+
`NOT APPLIED`, with no image — say so instead of judging the mobile layout.
|
|
156
|
+
Report overflow, overlapping elements, hidden CTAs. Dark mode: `emulate_media({colorScheme:'dark'})`.
|
|
155
157
|
Print stylesheet: `emulate_media({printMode:true})` then `screenshot`.
|
|
156
158
|
|
|
157
159
|
### Accessibility and keyboard navigation
|
|
@@ -264,6 +266,21 @@ Triggers: "download the orders export and tell me…".
|
|
|
264
266
|
`click` the export button → `manage_downloads({action:'wait_for_complete'})` → read the file
|
|
265
267
|
with Claude Code. Spam in signups: `extract_table({where:{email:'/\\.ru$|xn--/'}})`.
|
|
266
268
|
|
|
269
|
+
### Heavy web apps (Meta Ads Manager, Google Ads, big back offices)
|
|
270
|
+
Triggers: "change the campaign budget", "read the ad set breakdown", «modifica
|
|
271
|
+
la campagna su Ads Manager», «leggi i dati dell'inserzione».
|
|
272
|
+
Keep the tab visible first: `get_page_info` must say `visibility: 'visible'`,
|
|
273
|
+
else `create_tab({url, new_window:true, left, top, width, height})` on screen.
|
|
274
|
+
Menus without ARIA roles: `find_text({text})` on the label, not
|
|
275
|
+
`get_interactives` scoped by role. The app scrolls inside a panel:
|
|
276
|
+
`scroll({action:'until', container})` (it picks the largest scrollable panel
|
|
277
|
+
when the page itself does not scroll). Virtualized lists render the next rows
|
|
278
|
+
only after a frame: scroll in one call, click in the next. Fields that save on
|
|
279
|
+
Enter: `type_text` then `press_key({key:'Enter'})`. Several clicks on a React
|
|
280
|
+
UI: one call per click, or `execute_js` with about 350 ms between them. A
|
|
281
|
+
button that opens `window.open('')` navigates the tab away: override
|
|
282
|
+
`window.open` with `execute_js` before clicking.
|
|
283
|
+
|
|
267
284
|
### Email deliverability
|
|
268
285
|
Triggers: "do the site emails land in spam?". `navigate('https://www.mail-tester.com')`
|
|
269
286
|
→ `clipboard({action:'read'})` after copying the address → send from the site
|