chromex-mcp 1.0.0 → 1.2.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 +4 -4
- package/bin/chromex-cli.mjs +2 -0
- package/bin/chromex-mcp.mjs +2 -0
- package/package.json +4 -3
- package/plugins/chromex/skills/chromex/scripts/chromex.mjs +3 -1
- package/plugins/chromex/skills/chromex/scripts/lib/commands/snapshot.mjs +191 -17
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +51 -7
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +41 -16
package/README.md
CHANGED
|
@@ -289,11 +289,11 @@ Chromex also ships as an MCP server -- typed tools, auto-approve with one line,
|
|
|
289
289
|
### Setup
|
|
290
290
|
|
|
291
291
|
```bash
|
|
292
|
-
#
|
|
293
|
-
claude mcp add chromex npx chromex-mcp@latest
|
|
292
|
+
# Global (all projects)
|
|
293
|
+
claude mcp add chromex -s user npx chromex-mcp@latest
|
|
294
294
|
|
|
295
|
-
# Or
|
|
296
|
-
claude mcp add chromex
|
|
295
|
+
# Or project-only
|
|
296
|
+
claude mcp add chromex npx chromex-mcp@latest
|
|
297
297
|
```
|
|
298
298
|
|
|
299
299
|
### Auto-Approve
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chromex-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 52 typed tools, per-tab daemons, security hardened.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"chromex-mcp": "./
|
|
8
|
-
"chromex-cli": "./
|
|
7
|
+
"chromex-mcp": "./bin/chromex-mcp.mjs",
|
|
8
|
+
"chromex-cli": "./bin/chromex-cli.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"bin/",
|
|
11
12
|
"plugins/chromex/skills/chromex/scripts/",
|
|
12
13
|
"LICENSE",
|
|
13
14
|
"README.md"
|
|
@@ -244,7 +244,8 @@ async function main() {
|
|
|
244
244
|
|
|
245
245
|
const conn = await getOrStartTabDaemon(targetId, config);
|
|
246
246
|
|
|
247
|
-
const
|
|
247
|
+
const noSnap = args.includes('--no-snap');
|
|
248
|
+
const cmdArgs = args.slice(1).filter(a => a !== '--no-snap');
|
|
248
249
|
|
|
249
250
|
// Juntar argumentos para comandos que aceitam texto livre
|
|
250
251
|
if (cmd === 'eval') {
|
|
@@ -278,6 +279,7 @@ async function main() {
|
|
|
278
279
|
process.exit(1);
|
|
279
280
|
}
|
|
280
281
|
|
|
282
|
+
if (noSnap) cmdArgs.push('--no-snap');
|
|
281
283
|
const response = await sendCommand(conn, { cmd, args: cmdArgs });
|
|
282
284
|
|
|
283
285
|
if (response.ok) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Accessibility tree snapshot with
|
|
1
|
+
// Accessibility tree snapshot with incremental diff and interactive refs (@e1, @e2...)
|
|
2
2
|
|
|
3
3
|
const INTERACTIVE_ROLES = new Set([
|
|
4
4
|
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
@@ -6,7 +6,17 @@ const INTERACTIVE_ROLES = new Set([
|
|
|
6
6
|
'option', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
|
|
7
7
|
]);
|
|
8
8
|
|
|
9
|
+
// Check if AX node is marked as ignored/hidden by the browser
|
|
10
|
+
function isAxNodeHidden(node) {
|
|
11
|
+
if (node.ignored) return true;
|
|
12
|
+
// CDP includes boolean properties like 'hidden' in the properties array
|
|
13
|
+
const hidden = node.properties?.find(p => p.name === 'hidden');
|
|
14
|
+
if (hidden?.value?.value === true) return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
function shouldShowAxNode(node, compact = false) {
|
|
19
|
+
if (isAxNodeHidden(node)) return false;
|
|
10
20
|
const role = node.role?.value || '';
|
|
11
21
|
const name = node.name?.value ?? '';
|
|
12
22
|
const value = node.value?.value;
|
|
@@ -14,21 +24,36 @@ function shouldShowAxNode(node, compact = false) {
|
|
|
14
24
|
return role !== 'none' && role !== 'generic' && !(name === '' && (value === '' || value == null));
|
|
15
25
|
}
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
// Check if element is focusable/clickable (not disabled, not aria-disabled)
|
|
28
|
+
function isAxNodeInteractable(node) {
|
|
29
|
+
const disabled = node.properties?.find(p => p.name === 'disabled');
|
|
30
|
+
if (disabled?.value?.value === true) return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_NAME_LENGTH = 200;
|
|
35
|
+
|
|
36
|
+
function truncate(str, max = MAX_NAME_LENGTH) {
|
|
37
|
+
if (!str || str.length <= max) return str;
|
|
38
|
+
return str.slice(0, max) + '...';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatAxNode(node, depth, refIndex, refs, isNew = false) {
|
|
18
42
|
const role = node.role?.value || '';
|
|
19
|
-
const name = node.name?.value ?? '';
|
|
43
|
+
const name = truncate(node.name?.value ?? '');
|
|
20
44
|
const value = node.value?.value;
|
|
21
45
|
const indent = ' '.repeat(Math.min(depth, 10));
|
|
22
46
|
|
|
23
47
|
let refTag = '';
|
|
24
|
-
if (refs && INTERACTIVE_ROLES.has(role.toLowerCase())) {
|
|
48
|
+
if (refs && INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
|
|
25
49
|
refTag = `@e${refIndex.value} `;
|
|
26
50
|
refIndex.value++;
|
|
27
51
|
}
|
|
28
52
|
|
|
29
|
-
|
|
53
|
+
const newTag = isNew ? '*' : '';
|
|
54
|
+
let line = `${indent}${newTag}${refTag}[${role}]`;
|
|
30
55
|
if (name !== '') line += ` ${name}`;
|
|
31
|
-
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(value)}`;
|
|
56
|
+
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
|
|
32
57
|
return line;
|
|
33
58
|
}
|
|
34
59
|
|
|
@@ -51,9 +76,76 @@ function orderedAxChildren(node, nodesById, childrenByParent) {
|
|
|
51
76
|
return children;
|
|
52
77
|
}
|
|
53
78
|
|
|
79
|
+
// Build a fingerprint for each visible node: role + name + value + childCount
|
|
80
|
+
// Used for incremental diff to detect changes
|
|
81
|
+
function buildFingerprints(nodes, nodesById, childrenByParent, compact) {
|
|
82
|
+
const fingerprints = new Map();
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
if (!shouldShowAxNode(node, compact)) continue;
|
|
85
|
+
const role = node.role?.value || '';
|
|
86
|
+
const name = node.name?.value ?? '';
|
|
87
|
+
const value = node.value?.value ?? '';
|
|
88
|
+
const children = orderedAxChildren(node, nodesById, childrenByParent);
|
|
89
|
+
const childIds = children.filter(c => shouldShowAxNode(c, compact)).map(c => c.nodeId).join(',');
|
|
90
|
+
fingerprints.set(node.nodeId, `${role}|${name}|${value}|${childIds}`);
|
|
91
|
+
}
|
|
92
|
+
return fingerprints;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Detect scrollable containers. Returns human-readable summary lines.
|
|
96
|
+
// Only reports containers with overflow:auto|scroll and >50px hidden content.
|
|
97
|
+
async function detectScrollables(cdp, sid) {
|
|
98
|
+
try {
|
|
99
|
+
const { result } = await cdp.send('Runtime.evaluate', {
|
|
100
|
+
expression: `(() => {
|
|
101
|
+
const MIN = 50;
|
|
102
|
+
const out = [];
|
|
103
|
+
const walk = (el, path) => {
|
|
104
|
+
if (el.nodeType !== 1 || out.length >= 10) return;
|
|
105
|
+
const sh = el.scrollHeight, sw = el.scrollWidth;
|
|
106
|
+
const ch = el.clientHeight, cw = el.clientWidth;
|
|
107
|
+
const isRoot = el === document.documentElement || el === document.body;
|
|
108
|
+
const style = isRoot ? null : getComputedStyle(el);
|
|
109
|
+
const oy = isRoot ? 'auto' : style.overflowY;
|
|
110
|
+
const ox = isRoot ? 'auto' : style.overflowX;
|
|
111
|
+
const scrollableY = isRoot || oy === 'auto' || oy === 'scroll';
|
|
112
|
+
const scrollableX = isRoot || ox === 'auto' || ox === 'scroll';
|
|
113
|
+
if (((scrollableY && sh > ch + MIN) || (scrollableX && sw > cw + MIN)) && ch > 0) {
|
|
114
|
+
const dirs = [];
|
|
115
|
+
if (scrollableY && sh > ch + MIN) {
|
|
116
|
+
const down = sh - ch - el.scrollTop;
|
|
117
|
+
const up = el.scrollTop;
|
|
118
|
+
if (up > MIN) dirs.push('up:' + Math.round(up) + 'px');
|
|
119
|
+
if (down > MIN) dirs.push('down:' + Math.round(down) + 'px');
|
|
120
|
+
}
|
|
121
|
+
if (scrollableX && sw > cw + MIN) {
|
|
122
|
+
const right = sw - cw - el.scrollLeft;
|
|
123
|
+
const left = el.scrollLeft;
|
|
124
|
+
if (left > MIN) dirs.push('left:' + Math.round(left) + 'px');
|
|
125
|
+
if (right > MIN) dirs.push('right:' + Math.round(right) + 'px');
|
|
126
|
+
}
|
|
127
|
+
if (dirs.length) {
|
|
128
|
+
const label = isRoot ? 'page' : (el.getAttribute('aria-label') || el.getAttribute('role') || el.id || el.tagName.toLowerCase());
|
|
129
|
+
out.push(label + ': ' + dirs.join(', '));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const c of el.children) walk(c);
|
|
133
|
+
};
|
|
134
|
+
walk(document.documentElement);
|
|
135
|
+
return out;
|
|
136
|
+
})()`,
|
|
137
|
+
returnByValue: true,
|
|
138
|
+
}, sid);
|
|
139
|
+
return result?.value || [];
|
|
140
|
+
} catch { return []; }
|
|
141
|
+
}
|
|
142
|
+
|
|
54
143
|
// refMap is populated when refs=true: { refNumber -> { backendNodeId, role, name } }
|
|
55
144
|
// The caller (daemon) stores this map for later ref resolution.
|
|
56
|
-
|
|
145
|
+
// previousFingerprints: Map from prior snapshot for incremental diff.
|
|
146
|
+
// maxDepth: limit tree depth (0 = unlimited). Nodes at the limit render as leaves.
|
|
147
|
+
// Returns { text, refMap, fingerprints } -- caller stores fingerprints for next diff.
|
|
148
|
+
export async function snapshotStr(cdp, sid, compact = true, refs = false, previousFingerprints = null, maxDepth = 0) {
|
|
57
149
|
const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
|
|
58
150
|
const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
|
|
59
151
|
const childrenByParent = new Map();
|
|
@@ -63,38 +155,120 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false) {
|
|
|
63
155
|
childrenByParent.get(node.parentId).push(node);
|
|
64
156
|
}
|
|
65
157
|
|
|
158
|
+
const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
|
|
159
|
+
const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
|
|
160
|
+
const scrollables = await detectScrollables(cdp, sid);
|
|
161
|
+
|
|
66
162
|
const refIndex = { value: 1 };
|
|
67
163
|
const refMap = new Map();
|
|
68
164
|
const lines = [];
|
|
69
165
|
const visited = new Set();
|
|
70
166
|
|
|
167
|
+
// Track unchanged subtree roots for diff output
|
|
168
|
+
let unchangedCount = 0;
|
|
169
|
+
|
|
170
|
+
function isSubtreeUnchanged(node) {
|
|
171
|
+
if (!previousFingerprints) return false;
|
|
172
|
+
const nodeId = node.nodeId;
|
|
173
|
+
const curr = currentFingerprints.get(nodeId);
|
|
174
|
+
const prev = previousFingerprints.get(nodeId);
|
|
175
|
+
if (!curr || !prev || curr !== prev) return false;
|
|
176
|
+
// Node itself matches -- check all visible children recursively
|
|
177
|
+
const children = orderedAxChildren(node, nodesById, childrenByParent);
|
|
178
|
+
for (const child of children) {
|
|
179
|
+
if (!shouldShowAxNode(child, compact)) continue;
|
|
180
|
+
if (!isSubtreeUnchanged(child)) return false;
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
71
185
|
function visit(node, depth) {
|
|
72
186
|
if (!node || visited.has(node.nodeId)) return;
|
|
73
187
|
visited.add(node.nodeId);
|
|
74
|
-
if (shouldShowAxNode(node, compact)) {
|
|
75
|
-
const role = node.role?.value || '';
|
|
76
|
-
const currentRef = refIndex.value;
|
|
77
188
|
|
|
78
|
-
|
|
189
|
+
const show = shouldShowAxNode(node, compact);
|
|
190
|
+
|
|
191
|
+
if (!show) {
|
|
192
|
+
// Generic/none node: collapse by visiting children at SAME depth (no indentation increase)
|
|
193
|
+
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
194
|
+
visit(child, depth);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Incremental diff: if this subtree is unchanged, collapse it
|
|
200
|
+
if (isDiff && isSubtreeUnchanged(node)) {
|
|
201
|
+
unchangedCount++;
|
|
202
|
+
if (refs) {
|
|
203
|
+
advanceRefsForSubtree(node);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const role = node.role?.value || '';
|
|
209
|
+
const currentRef = refIndex.value;
|
|
210
|
+
const isNew = isDiff && !previousFingerprints.has(node.nodeId);
|
|
211
|
+
|
|
212
|
+
lines.push(formatAxNode(node, depth, refIndex, refs, isNew));
|
|
213
|
+
|
|
214
|
+
if (refs && refIndex.value > currentRef) {
|
|
215
|
+
refMap.set(currentRef, {
|
|
216
|
+
backendNodeId: node.backendDOMNodeId,
|
|
217
|
+
nodeId: node.nodeId,
|
|
218
|
+
role,
|
|
219
|
+
name: node.name?.value ?? '',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Depth limiting: at the limit, render as leaf (no children)
|
|
224
|
+
if (maxDepth > 0 && depth >= maxDepth) return;
|
|
225
|
+
|
|
226
|
+
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
227
|
+
visit(child, depth + 1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
79
230
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
231
|
+
// Advance ref counter for unchanged subtrees to keep ref numbers stable
|
|
232
|
+
// Uses its own visited set because visit() already marked nodes before calling this
|
|
233
|
+
const refAdvanced = new Set();
|
|
234
|
+
function advanceRefsForSubtree(node) {
|
|
235
|
+
if (!node || refAdvanced.has(node.nodeId)) return;
|
|
236
|
+
refAdvanced.add(node.nodeId);
|
|
237
|
+
if (shouldShowAxNode(node, compact)) {
|
|
238
|
+
const role = node.role?.value || '';
|
|
239
|
+
if (INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
|
|
240
|
+
refMap.set(refIndex.value, {
|
|
83
241
|
backendNodeId: node.backendDOMNodeId,
|
|
84
242
|
nodeId: node.nodeId,
|
|
85
243
|
role,
|
|
86
244
|
name: node.name?.value ?? '',
|
|
87
245
|
});
|
|
246
|
+
refIndex.value++;
|
|
88
247
|
}
|
|
89
248
|
}
|
|
90
249
|
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
91
|
-
|
|
250
|
+
advanceRefsForSubtree(child);
|
|
92
251
|
}
|
|
93
252
|
}
|
|
94
253
|
|
|
95
254
|
const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
|
|
96
255
|
for (const root of roots) visit(root, 0);
|
|
97
|
-
|
|
256
|
+
// Second pass: catch disconnected nodes (skip when depth-limited to avoid false depth=0)
|
|
257
|
+
if (!maxDepth) {
|
|
258
|
+
for (const node of nodes) visit(node, 0);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Add diff summary header when in incremental mode
|
|
262
|
+
let text = lines.join('\n');
|
|
263
|
+
if (isDiff && unchangedCount > 0) {
|
|
264
|
+
const totalVisible = currentFingerprints.size;
|
|
265
|
+
const changedCount = totalVisible - unchangedCount;
|
|
266
|
+
text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
|
|
267
|
+
}
|
|
268
|
+
// Append scroll info footer when scrollable containers exist
|
|
269
|
+
if (scrollables.length > 0) {
|
|
270
|
+
text += `\n[scroll: ${scrollables.join(' | ')}]`;
|
|
271
|
+
}
|
|
98
272
|
|
|
99
|
-
return { text
|
|
273
|
+
return { text, refMap, fingerprints: currentFingerprints };
|
|
100
274
|
}
|
|
@@ -46,6 +46,15 @@ import { touchStr } from './commands/touch.mjs';
|
|
|
46
46
|
import { domsnapshotStr } from './commands/domsnapshot.mjs';
|
|
47
47
|
import { parseRef, clickRefStr, hoverRefStr, fillRefStr } from './commands/refs.mjs';
|
|
48
48
|
import { highlightStr } from './commands/highlight.mjs';
|
|
49
|
+
import { sleep } from './utils.mjs';
|
|
50
|
+
|
|
51
|
+
// Commands that modify visible DOM and should trigger automatic post-action snapshot.
|
|
52
|
+
// After these commands, an incremental snapshot with refs is appended to the result,
|
|
53
|
+
// so the AI agent sees the page state without needing a separate snapshot call.
|
|
54
|
+
const AUTO_SNAP_CMDS = new Set([
|
|
55
|
+
'click', 'clickxy', 'type', 'fill', 'clear', 'select', 'check', 'form',
|
|
56
|
+
'nav', 'navigate', 'dialog', 'loadall', 'drag', 'touch', 'upload',
|
|
57
|
+
]);
|
|
49
58
|
|
|
50
59
|
export function getOrCreateToken(config) {
|
|
51
60
|
if (!config.socketAuth) return null;
|
|
@@ -106,17 +115,26 @@ export async function runDaemon(targetId, config) {
|
|
|
106
115
|
idleTimer = setTimeout(shutdown, config.idleTimeout);
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
//
|
|
118
|
+
// Per-tab state: ref map for @eN resolution + fingerprints for incremental diff
|
|
110
119
|
let currentRefMap = new Map();
|
|
120
|
+
let previousFingerprints = null;
|
|
111
121
|
|
|
112
122
|
async function handleCommand({ cmd, args }) {
|
|
113
123
|
resetIdle();
|
|
114
124
|
const auditResult = { ok: true };
|
|
115
125
|
try {
|
|
126
|
+
// Strip --no-snap before dispatch so it doesn't contaminate command args
|
|
127
|
+
// (e.g. fill would type "--no-snap" into the input field).
|
|
128
|
+
const noSnap = args.includes('--no-snap');
|
|
129
|
+
if (noSnap) args = args.filter(a => a !== '--no-snap');
|
|
130
|
+
|
|
131
|
+
let result;
|
|
132
|
+
let isRefCmd = false;
|
|
133
|
+
|
|
116
134
|
// Ref-based dispatch: click @e5, fill @e3 "value", hover @e12
|
|
117
135
|
if (args[0] && parseRef(args[0]) !== null) {
|
|
136
|
+
isRefCmd = true;
|
|
118
137
|
const refNum = parseRef(args[0]);
|
|
119
|
-
let result;
|
|
120
138
|
if (cmd === 'click') {
|
|
121
139
|
result = await clickRefStr(cdp, sessionId, currentRefMap, refNum);
|
|
122
140
|
} else if (cmd === 'fill') {
|
|
@@ -126,12 +144,9 @@ export async function runDaemon(targetId, config) {
|
|
|
126
144
|
} else {
|
|
127
145
|
throw new Error(`Ref @e${refNum} not supported for command "${cmd}". Use with: click, fill, hover.`);
|
|
128
146
|
}
|
|
129
|
-
audit(cmd, targetId, args, auditResult, config);
|
|
130
|
-
return { ok: true, result };
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
|
|
134
|
-
switch (cmd) {
|
|
149
|
+
if (!isRefCmd) switch (cmd) {
|
|
135
150
|
// --- Comandos originais ---
|
|
136
151
|
case 'list': {
|
|
137
152
|
const pages = await getPages(cdp);
|
|
@@ -145,8 +160,13 @@ export async function runDaemon(targetId, config) {
|
|
|
145
160
|
}
|
|
146
161
|
case 'snap': case 'snapshot': {
|
|
147
162
|
const useRefs = args.includes('--refs') || args.includes('-i');
|
|
148
|
-
const
|
|
163
|
+
const forceFull = args.includes('--full');
|
|
164
|
+
const depthArg = args.find(a => a.startsWith('--depth='));
|
|
165
|
+
const maxDepth = depthArg ? parseInt(depthArg.split('=')[1]) || 0 : 0;
|
|
166
|
+
const prevFp = forceFull ? null : previousFingerprints;
|
|
167
|
+
const snapResult = await snapshotStr(cdp, sessionId, true, useRefs, prevFp, maxDepth);
|
|
149
168
|
result = snapResult.text;
|
|
169
|
+
previousFingerprints = snapResult.fingerprints;
|
|
150
170
|
if (useRefs && snapResult.refMap.size > 0) {
|
|
151
171
|
currentRefMap = snapResult.refMap;
|
|
152
172
|
}
|
|
@@ -166,6 +186,7 @@ export async function runDaemon(targetId, config) {
|
|
|
166
186
|
break;
|
|
167
187
|
case 'nav': case 'navigate':
|
|
168
188
|
result = await navStr(cdp, sessionId, args[0], config);
|
|
189
|
+
previousFingerprints = null; // Reset: new page needs full snapshot
|
|
169
190
|
break;
|
|
170
191
|
case 'net': case 'network':
|
|
171
192
|
result = await netStr(cdp, sessionId);
|
|
@@ -316,6 +337,29 @@ export async function runDaemon(targetId, config) {
|
|
|
316
337
|
}
|
|
317
338
|
}
|
|
318
339
|
audit(cmd, targetId, args, auditResult, config);
|
|
340
|
+
|
|
341
|
+
// Auto-snapshot: append incremental snapshot with refs after DOM-modifying actions.
|
|
342
|
+
// This lets the AI agent see the resulting page state in a single round-trip.
|
|
343
|
+
// Opt-out with --no-snap for scripts doing rapid sequential actions.
|
|
344
|
+
const shouldSnap = isRefCmd
|
|
345
|
+
? (cmd === 'click' || cmd === 'fill') // hover doesn't change DOM
|
|
346
|
+
: AUTO_SNAP_CMDS.has(cmd);
|
|
347
|
+
|
|
348
|
+
if (shouldSnap && !noSnap) {
|
|
349
|
+
try {
|
|
350
|
+
// Navigate already waits for load+readyState; shorter settle for it.
|
|
351
|
+
// Other actions (click, fill) need more time for SPA re-renders.
|
|
352
|
+
const settleMs = (cmd === 'nav' || cmd === 'navigate') ? 100 : 300;
|
|
353
|
+
await sleep(settleMs);
|
|
354
|
+
const snapResult = await snapshotStr(cdp, sessionId, true, true, previousFingerprints);
|
|
355
|
+
previousFingerprints = snapResult.fingerprints;
|
|
356
|
+
if (snapResult.refMap.size > 0) {
|
|
357
|
+
currentRefMap = snapResult.refMap;
|
|
358
|
+
}
|
|
359
|
+
result = (result ?? '') + '\n\n' + snapResult.text;
|
|
360
|
+
} catch (e) { process.stderr.write(`[auto-snap] ${e.message}\n`); }
|
|
361
|
+
}
|
|
362
|
+
|
|
319
363
|
return { ok: true, result: result ?? '' };
|
|
320
364
|
} catch (e) {
|
|
321
365
|
auditResult.ok = false;
|
|
@@ -54,6 +54,7 @@ function tool(name, description, properties, required, annotations) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const P_TARGET = { type: 'string', description: 'Target ID prefix from chromex_list' };
|
|
57
|
+
const P_NO_SNAP = { type: 'boolean', description: 'Skip auto-snapshot after action' };
|
|
57
58
|
|
|
58
59
|
// ---- Tool definitions (52 tools) ----
|
|
59
60
|
|
|
@@ -97,10 +98,12 @@ const TOOLS = [
|
|
|
97
98
|
|
|
98
99
|
// == INSPECT (readOnly) ==
|
|
99
100
|
tool('chromex_snapshot',
|
|
100
|
-
'Accessibility tree snapshot.
|
|
101
|
+
'Accessibility tree snapshot. Returns incremental diff after first call (only changed nodes). Use refs=true to get @eN references for click/fill/hover.',
|
|
101
102
|
{
|
|
102
103
|
target: P_TARGET,
|
|
103
104
|
refs: { type: 'boolean', description: 'Assign @eN refs to interactive elements', default: false },
|
|
105
|
+
full: { type: 'boolean', description: 'Force full snapshot (skip incremental diff)', default: false },
|
|
106
|
+
depth: { type: 'number', description: 'Max tree depth (0 = unlimited)' },
|
|
104
107
|
}, ['target'], RO),
|
|
105
108
|
|
|
106
109
|
tool('chromex_html',
|
|
@@ -165,10 +168,11 @@ const TOOLS = [
|
|
|
165
168
|
|
|
166
169
|
// == NAVIGATE ==
|
|
167
170
|
tool('chromex_navigate',
|
|
168
|
-
'Navigate to URL and wait for page load.',
|
|
171
|
+
'Navigate to URL and wait for page load. Returns full snapshot with refs of the new page.',
|
|
169
172
|
{
|
|
170
173
|
target: P_TARGET,
|
|
171
174
|
url: { type: 'string', description: 'URL to navigate to' },
|
|
175
|
+
noSnap: P_NO_SNAP,
|
|
172
176
|
}, ['target', 'url'], RW),
|
|
173
177
|
|
|
174
178
|
tool('chromex_waitfor',
|
|
@@ -197,25 +201,28 @@ const TOOLS = [
|
|
|
197
201
|
|
|
198
202
|
// == INTERACT ==
|
|
199
203
|
tool('chromex_click',
|
|
200
|
-
'Click element by CSS selector or @eN ref from snapshot.',
|
|
204
|
+
'Click element by CSS selector or @eN ref from snapshot. Returns auto-snapshot with updated refs.',
|
|
201
205
|
{
|
|
202
206
|
target: P_TARGET,
|
|
203
207
|
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
208
|
+
noSnap: P_NO_SNAP,
|
|
204
209
|
}, ['target', 'selector'], RW),
|
|
205
210
|
|
|
206
211
|
tool('chromex_clickxy',
|
|
207
|
-
'Click at CSS pixel coordinates.',
|
|
212
|
+
'Click at CSS pixel coordinates. Returns auto-snapshot with updated refs.',
|
|
208
213
|
{
|
|
209
214
|
target: P_TARGET,
|
|
210
215
|
x: { type: 'number', description: 'X in CSS pixels' },
|
|
211
216
|
y: { type: 'number', description: 'Y in CSS pixels' },
|
|
217
|
+
noSnap: P_NO_SNAP,
|
|
212
218
|
}, ['target', 'x', 'y'], RW),
|
|
213
219
|
|
|
214
220
|
tool('chromex_type',
|
|
215
|
-
'Type text at currently focused element.',
|
|
221
|
+
'Type text at currently focused element. Returns auto-snapshot with updated refs.',
|
|
216
222
|
{
|
|
217
223
|
target: P_TARGET,
|
|
218
224
|
text: { type: 'string', description: 'Text to type' },
|
|
225
|
+
noSnap: P_NO_SNAP,
|
|
219
226
|
}, ['target', 'text'], RW),
|
|
220
227
|
|
|
221
228
|
tool('chromex_hover',
|
|
@@ -226,82 +233,92 @@ const TOOLS = [
|
|
|
226
233
|
}, ['target', 'ref'], RW),
|
|
227
234
|
|
|
228
235
|
tool('chromex_drag',
|
|
229
|
-
'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2).',
|
|
236
|
+
'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2). Returns auto-snapshot with updated refs.',
|
|
230
237
|
{
|
|
231
238
|
target: P_TARGET,
|
|
232
239
|
from: { type: 'string', description: 'Source selector or x,y' },
|
|
233
240
|
to: { type: 'string', description: 'Destination selector or x,y' },
|
|
241
|
+
noSnap: P_NO_SNAP,
|
|
234
242
|
}, ['target', 'from', 'to'], RW),
|
|
235
243
|
|
|
236
244
|
tool('chromex_touch',
|
|
237
|
-
'Touch gesture: tap, swipe, pinch, longpress.',
|
|
245
|
+
'Touch gesture: tap, swipe, pinch, longpress. Returns auto-snapshot with updated refs.',
|
|
238
246
|
{
|
|
239
247
|
target: P_TARGET,
|
|
240
248
|
gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
|
|
241
249
|
args: { type: 'array', items: { type: 'string' }, description: 'Gesture args: tap(x,y), swipe(x1,y1,x2,y2), pinch(x,y,scale), longpress(x,y,[ms])' },
|
|
250
|
+
noSnap: P_NO_SNAP,
|
|
242
251
|
}, ['target', 'gesture'], RW),
|
|
243
252
|
|
|
244
253
|
tool('chromex_dialog',
|
|
245
|
-
'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all.',
|
|
254
|
+
'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all. Returns auto-snapshot with updated refs.',
|
|
246
255
|
{
|
|
247
256
|
target: P_TARGET,
|
|
248
257
|
action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
|
|
249
258
|
text: { type: 'string', description: 'Text for prompt (only with accept)' },
|
|
259
|
+
noSnap: P_NO_SNAP,
|
|
250
260
|
}, ['target', 'action'], RW),
|
|
251
261
|
|
|
252
262
|
tool('chromex_loadall',
|
|
253
|
-
'Click "load more" button repeatedly until it disappears.',
|
|
263
|
+
'Click "load more" button repeatedly until it disappears. Returns auto-snapshot with updated refs.',
|
|
254
264
|
{
|
|
255
265
|
target: P_TARGET,
|
|
256
266
|
selector: { type: 'string', description: 'CSS selector of load-more button' },
|
|
257
267
|
interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
|
|
268
|
+
noSnap: P_NO_SNAP,
|
|
258
269
|
}, ['target', 'selector'], RW),
|
|
259
270
|
|
|
260
271
|
// == FORMS ==
|
|
261
272
|
tool('chromex_fill',
|
|
262
|
-
'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref.',
|
|
273
|
+
'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref. Returns auto-snapshot with updated refs.',
|
|
263
274
|
{
|
|
264
275
|
target: P_TARGET,
|
|
265
276
|
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
266
277
|
value: { type: 'string', description: 'Value to fill' },
|
|
278
|
+
noSnap: P_NO_SNAP,
|
|
267
279
|
}, ['target', 'selector', 'value'], RW),
|
|
268
280
|
|
|
269
281
|
tool('chromex_clear',
|
|
270
|
-
'Clear input field.',
|
|
282
|
+
'Clear input field. Returns auto-snapshot with updated refs.',
|
|
271
283
|
{
|
|
272
284
|
target: P_TARGET,
|
|
273
285
|
selector: { type: 'string', description: 'CSS selector' },
|
|
286
|
+
noSnap: P_NO_SNAP,
|
|
274
287
|
}, ['target', 'selector'], RW),
|
|
275
288
|
|
|
276
289
|
tool('chromex_select',
|
|
277
|
-
'Select option in dropdown.',
|
|
290
|
+
'Select option in dropdown. Returns auto-snapshot with updated refs.',
|
|
278
291
|
{
|
|
279
292
|
target: P_TARGET,
|
|
280
293
|
selector: { type: 'string', description: 'CSS selector of select element' },
|
|
281
294
|
value: { type: 'string', description: 'Option value or visible text' },
|
|
295
|
+
noSnap: P_NO_SNAP,
|
|
282
296
|
}, ['target', 'selector', 'value'], RW),
|
|
283
297
|
|
|
284
298
|
tool('chromex_check',
|
|
285
|
-
'Toggle checkbox or radio button.',
|
|
299
|
+
'Toggle checkbox or radio button. Returns auto-snapshot with updated refs.',
|
|
286
300
|
{
|
|
287
301
|
target: P_TARGET,
|
|
288
302
|
selector: { type: 'string', description: 'CSS selector' },
|
|
289
303
|
checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
|
|
304
|
+
noSnap: P_NO_SNAP,
|
|
290
305
|
}, ['target', 'selector'], RW),
|
|
291
306
|
|
|
292
307
|
tool('chromex_form',
|
|
293
|
-
'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes.',
|
|
308
|
+
'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes. Returns auto-snapshot with updated refs.',
|
|
294
309
|
{
|
|
295
310
|
target: P_TARGET,
|
|
296
311
|
fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
|
|
312
|
+
noSnap: P_NO_SNAP,
|
|
297
313
|
}, ['target', 'fields'], RW),
|
|
298
314
|
|
|
299
315
|
tool('chromex_upload',
|
|
300
|
-
'Upload file(s) to input[type=file].',
|
|
316
|
+
'Upload file(s) to input[type=file]. Returns auto-snapshot with updated refs.',
|
|
301
317
|
{
|
|
302
318
|
target: P_TARGET,
|
|
303
319
|
selector: { type: 'string', description: 'CSS selector of file input' },
|
|
304
320
|
files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
|
|
321
|
+
noSnap: P_NO_SNAP,
|
|
305
322
|
}, ['target', 'selector', 'files'], RW),
|
|
306
323
|
|
|
307
324
|
// == DATA ==
|
|
@@ -445,7 +462,13 @@ const TOOLS = [
|
|
|
445
462
|
function toolToCmd(name, p) {
|
|
446
463
|
switch (name) {
|
|
447
464
|
// Inspect
|
|
448
|
-
case 'chromex_snapshot':
|
|
465
|
+
case 'chromex_snapshot': {
|
|
466
|
+
const a = [];
|
|
467
|
+
if (p.refs) a.push('--refs');
|
|
468
|
+
if (p.full) a.push('--full');
|
|
469
|
+
if (p.depth) a.push(`--depth=${p.depth}`);
|
|
470
|
+
return { cmd: 'snap', args: a };
|
|
471
|
+
}
|
|
449
472
|
case 'chromex_html': return { cmd: 'html', args: p.selector ? [p.selector] : [] };
|
|
450
473
|
case 'chromex_screenshot': {
|
|
451
474
|
const a = [];
|
|
@@ -623,6 +646,8 @@ async function executeTool(name, params) {
|
|
|
623
646
|
const mapped = toolToCmd(name, params);
|
|
624
647
|
if (!mapped) return fail(`Unknown tool: ${name}`);
|
|
625
648
|
|
|
649
|
+
if (params.noSnap) mapped.args.push('--no-snap');
|
|
650
|
+
|
|
626
651
|
const conn = await getOrStartTabDaemon(targetId, config);
|
|
627
652
|
const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });
|
|
628
653
|
|