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 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
- # Via npx (recommended)
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 with absolute path (no npm needed)
296
- claude mcp add chromex node /path/to/plugins/chromex/skills/chromex/scripts/mcp-server.mjs
295
+ # Or project-only
296
+ claude mcp add chromex npx chromex-mcp@latest
297
297
  ```
298
298
 
299
299
  ### Auto-Approve
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../plugins/chromex/skills/chromex/scripts/chromex.mjs';
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../plugins/chromex/skills/chromex/scripts/mcp-server.mjs';
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "chromex-mcp",
3
- "version": "1.0.0",
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": "./plugins/chromex/skills/chromex/scripts/mcp-server.mjs",
8
- "chromex-cli": "./plugins/chromex/skills/chromex/scripts/chromex.mjs"
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 cmdArgs = args.slice(1);
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 optional interactive refs (@e1, @e2...)
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
- function formatAxNode(node, depth, refIndex, refs) {
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
- let line = `${indent}${refTag}[${role}]`;
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
- export async function snapshotStr(cdp, sid, compact = true, refs = false) {
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
- lines.push(formatAxNode(node, depth, refIndex, refs));
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
- // If ref was assigned (refIndex advanced), record the mapping
81
- if (refs && refIndex.value > currentRef) {
82
- refMap.set(currentRef, {
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
- visit(child, depth + 1);
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
- for (const node of nodes) visit(node, 0);
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: lines.join('\n'), refMap };
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
- // Ref-based selection state: stores mapping from @eN -> {backendNodeId, role, name}
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
- let result;
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 snapResult = await snapshotStr(cdp, sessionId, true, useRefs);
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. Prefer over HTML for page structure. Use refs=true to get @eN references for click/fill/hover.',
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': return { cmd: 'snap', args: p.refs ? ['--refs'] : [] };
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