chromex-mcp 1.1.0 → 1.3.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
@@ -1,50 +1,87 @@
1
1
  # Chromex
2
2
 
3
- Zero-dependency Chrome DevTools Protocol CLI for AI agents. Connects directly to Chrome, Brave, Edge, or Chromium via WebSocket. No Puppeteer, no npm install, no bloat.
4
-
5
- Built as a [Claude Code](https://claude.ai/code) plugin but works standalone with any AI agent or from the terminal.
3
+ Zero-dependency Chrome DevTools Protocol toolkit for AI agents. 52 typed MCP tools + CLI. Connects directly to Chrome, Brave, Edge, or Chromium via WebSocket. No Puppeteer, no bloat.
6
4
 
7
5
  ## Features
8
6
 
9
- - **45+ commands** -- screenshots, form filling, cookies, PDF, device emulation, network interception, performance tracing, touch gestures, and more
7
+ - **52 MCP tools** -- typed JSON Schema, annotations (`readOnlyHint`, `destructiveHint`), inline screenshots (base64)
10
8
  - **Zero dependencies** -- uses only Node.js 22+ built-in modules (WebSocket, fs, net, crypto)
11
9
  - **Ref-based selection** -- `snap --refs` assigns `@e1`, `@e2`... to interactive elements, then `click @e5` or `fill @e3 "value"`. No fragile CSS selectors
10
+ - **Incremental snapshots** -- second snapshot returns only changed nodes (diff), reducing output from thousands of lines to just what changed
11
+ - **Auto-snapshot** -- interactive commands (click, fill, nav, etc.) automatically append an incremental snapshot with refs, so the agent sees the page state in a single round-trip
12
+ - **Scroll detection** -- snapshots report scrollable containers with remaining distance (`[scroll: page: down:1200px | sidebar: up:300px]`)
12
13
  - **Per-tab persistent daemons** -- each tab gets a background process connected via Unix socket. Chrome's "Allow debugging" modal fires once, not on every command
13
14
  - **Security hardened** -- domain filtering (allow/blocklist), CDP method blocklist, token-authenticated sockets, full audit log
14
15
  - **Multi-browser** -- auto-detects Brave, Chrome, Chrome Canary, Chromium, Edge, Vivaldi (macOS + Linux)
15
16
  - **Network control** -- throttle to 3G/offline, intercept & mock requests, record HAR files
16
17
  - **Form filling** -- fill inputs, select dropdowns, toggle checkboxes, upload files, batch fill entire forms. Works with React/Vue/Angular
17
18
  - **Browser launcher** -- launch browser with remote debugging pre-enabled (skips the "Allow debugging" modal entirely)
19
+ - **CLI included** -- same commands available from the terminal for scripts and CI/CD
18
20
 
19
21
  ## Requirements
20
22
 
21
23
  - Node.js 22+ (for built-in WebSocket)
22
24
  - Any Chromium-based browser
23
25
 
24
- ## Quick Start
25
-
26
- ### As a Claude Code plugin
26
+ ## Installation
27
27
 
28
28
  ```bash
29
- # Add the marketplace
30
- /plugin marketplace add github:whallysson/chromex
29
+ # Add to Claude Code (global -- all projects)
30
+ claude mcp add chromex -s user npx chromex-mcp@latest
31
31
 
32
- # Install
33
- /plugin install chromex
32
+ # Or project-only
33
+ claude mcp add chromex npx chromex-mcp@latest
34
34
  ```
35
35
 
36
- ### Standalone
36
+ ### Auto-Approve (recommended)
37
+
38
+ Add to `~/.claude/settings.json`:
39
+
40
+ ```json
41
+ {
42
+ "permissions": {
43
+ "allow": ["mcp__chromex"]
44
+ }
45
+ }
46
+ ```
47
+
48
+ This approves all 52 MCP tools at once. For granular control, approve individual tools:
49
+
50
+ ```json
51
+ {
52
+ "permissions": {
53
+ "allow": [
54
+ "mcp__chromex__chromex_list",
55
+ "mcp__chromex__chromex_snapshot",
56
+ "mcp__chromex__chromex_screenshot",
57
+ "mcp__chromex__chromex_perf"
58
+ ]
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Global install (optional)
37
64
 
38
65
  ```bash
39
- git clone https://github.com/whallysson/chromex.git
40
- cd chromex
41
- chmod +x skills/chromex/scripts/chromex.mjs
66
+ npm install -g chromex-mcp
67
+ ```
68
+
69
+ This installs three binaries:
70
+
71
+ | Binary | Purpose |
72
+ |--------|---------|
73
+ | `chromex` | CLI -- the main command for terminal usage |
74
+ | `chromex-cli` | Alias for `chromex` |
75
+ | `chromex-mcp` | MCP server (stdio JSON-RPC) -- used by `claude mcp add`, not run directly |
42
76
 
43
- # Optional: create an alias
44
- alias chromex="node $(pwd)/skills/chromex/scripts/chromex.mjs"
77
+ ```bash
78
+ chromex list # List open tabs
79
+ chromex launch --url https://example.com # Launch browser
80
+ chromex snap 6BE8 --refs # Snapshot with refs
81
+ chromex click 6BE8 @e3 # Click by ref
45
82
  ```
46
83
 
47
- ### Connect to your browser
84
+ ## Connect to Your Browser
48
85
 
49
86
  **Option A: Launch a new browser** (recommended -- no setup needed)
50
87
 
@@ -56,8 +93,6 @@ This starts Chrome/Brave/Edge with remote debugging pre-enabled. No manual confi
56
93
 
57
94
  **Option B: Connect to an already-running browser**
58
95
 
59
- You **must** enable remote debugging first:
60
-
61
96
  1. Open your browser (Chrome, Brave, Edge, etc.)
62
97
  2. Navigate to `chrome://inspect/#remote-debugging`
63
98
  3. **Toggle the switch ON** to enable remote debugging
@@ -67,11 +102,7 @@ You **must** enable remote debugging first:
67
102
 
68
103
  > **Note:** With Option B, Chrome will show an "Allow debugging" dialog the first time you access each tab. Click "Allow" once per tab -- the daemon keeps the session alive after that.
69
104
 
70
- ```bash
71
- chromex list
72
- ```
73
-
74
- ### Your first commands
105
+ ## Quick Start
75
106
 
76
107
  ```bash
77
108
  # List open tabs
@@ -118,8 +149,10 @@ chromex stop # Stop all daemons
118
149
  ### Inspect
119
150
 
120
151
  ```bash
121
- chromex snap <target> # Accessibility tree (prefer over html)
152
+ chromex snap <target> # Accessibility tree snapshot (compact)
122
153
  chromex snap <target> --refs # With interactive refs (@e1, @e2...)
154
+ chromex snap <target> --depth=3 # Limit tree depth
155
+ chromex snap <target> --full # Force full snapshot (skip diff)
123
156
  chromex html <target> "#main" # Element HTML by selector
124
157
  chromex shot <target> /tmp/page.png # Viewport screenshot
125
158
  chromex shot <target> /tmp/full.png --full # Full page screenshot
@@ -151,8 +184,10 @@ chromex wait <target> load # Wait for page load
151
184
  chromex wait <target> domready # Wait for DOMContentLoaded
152
185
  chromex wait <target> fcp # Wait for First Contentful Paint
153
186
  chromex scroll <target> down 500 # Scroll down 500px
154
- chromex scroll <target> to "#footer" # Scroll to element
187
+ chromex scroll <target> up 300 # Scroll up 300px
155
188
  chromex scroll <target> top # Scroll to top
189
+ chromex scroll <target> bottom # Scroll to bottom
190
+ chromex scroll <target> to "#footer" # Scroll to element
156
191
  ```
157
192
 
158
193
  ### Interact
@@ -207,6 +242,7 @@ chromex pdf <target> /tmp/page.pdf # Export as PDF
207
242
  ```bash
208
243
  chromex throttle <target> 3g # Throttle to 3G
209
244
  chromex throttle <target> slow-3g # Throttle to slow 3G
245
+ chromex throttle <target> 4g # Throttle to 4G
210
246
  chromex throttle <target> offline # Go offline
211
247
  chromex throttle <target> custom 200 1000 500 # Custom: latency, down, up (kbps)
212
248
  chromex throttle <target> reset # Remove throttling
@@ -224,8 +260,12 @@ chromex har <target> stop /tmp/trace.har # Save HAR file
224
260
 
225
261
  ```bash
226
262
  chromex emulate <target> iphone-14 # 390x844 @3x mobile
263
+ chromex emulate <target> iphone-15-pro # 393x852 @3x mobile
227
264
  chromex emulate <target> ipad-pro # 1024x1366 @2x tablet
228
265
  chromex emulate <target> pixel-7 # 412x915 @2.625x mobile
266
+ chromex emulate <target> galaxy-s23 # 360x780 @3x mobile
267
+ chromex emulate <target> macbook-air # 1440x900 @2x laptop
268
+ chromex emulate <target> desktop-1080p # 1920x1080 @1x
229
269
  chromex emulate <target> desktop-4k # 3840x2160 @1x
230
270
  chromex emulate <target> reset # Reset to default
231
271
  chromex geo <target> -23.55 -46.63 # Set geolocation (Sao Paulo)
@@ -236,8 +276,6 @@ chromex cpu <target> 4 # CPU 4x slower
236
276
  chromex cpu <target> reset # Reset CPU speed
237
277
  ```
238
278
 
239
- Available devices: `iphone-14`, `iphone-15-pro`, `ipad-pro`, `pixel-7`, `galaxy-s23`, `macbook-air`, `desktop-1080p`, `desktop-4k`.
240
-
241
279
  ### Advanced
242
280
 
243
281
  ```bash
@@ -278,73 +316,127 @@ chromex click <target> @e3
278
316
  chromex click <target> @e4
279
317
  ```
280
318
 
281
- Refs are assigned to all interactive elements (buttons, links, inputs, checkboxes, radios, dropdowns, tabs, switches). They persist until the next `snap --refs` call.
319
+ Refs are assigned to all interactive elements (buttons, links, inputs, checkboxes, radios, dropdowns, tabs, switches, sliders, search boxes). They persist until the next `snap --refs` call.
282
320
 
283
321
  Supported ref commands: `click @eN`, `fill @eN "value"`, `hover @eN`.
284
322
 
285
- ## MCP Server (Recommended for Claude Code)
323
+ ## Snapshot Optimizations
324
+
325
+ Chromex snapshots are designed to minimize token usage for AI agents.
286
326
 
287
- Chromex also ships as an MCP server -- typed tools, auto-approve with one line, no Bash globs.
327
+ ### Incremental Diff
288
328
 
289
- ### Setup
329
+ The first snapshot returns the full accessibility tree. Subsequent snapshots return only nodes that changed:
330
+
331
+ ```
332
+ [incremental: 2 changed, 45 unchanged]
333
+ *[textbox] Email = "user@example.com"
334
+ *[button] Submit
335
+ ```
336
+
337
+ - Changed/new nodes are marked with `*`
338
+ - Unchanged subtrees are collapsed entirely
339
+ - Use `--full` to force a full snapshot (skips diff)
340
+ - Navigation (`nav`) resets the diff baseline automatically
341
+
342
+ ### Depth Limiting
343
+
344
+ Limit tree depth for large pages:
290
345
 
291
346
  ```bash
292
- # Global (all projects)
293
- claude mcp add chromex -s user npx chromex-mcp@latest
347
+ chromex snap <target> --depth=3 # Only 3 levels deep
348
+ ```
294
349
 
295
- # Or project-only
296
- claude mcp add chromex npx chromex-mcp@latest
350
+ Nodes at the depth limit render as leaves (children are not expanded).
351
+
352
+ ### Scroll Detection
353
+
354
+ Snapshots automatically detect scrollable containers and report remaining scroll distance:
355
+
356
+ ```
357
+ [scroll: page: down:1200px | sidebar: up:300px, down:800px]
297
358
  ```
298
359
 
299
- ### Auto-Approve
360
+ ### Visibility Filtering
300
361
 
301
- Add to `~/.claude/settings.json`:
362
+ - Ignored/hidden accessibility nodes are automatically omitted
363
+ - Disabled interactive elements are shown but not assigned refs (can't be interacted with)
364
+ - Generic wrapper nodes (`div`, `span` with no semantic role) are collapsed -- their children inherit the parent's depth
365
+ - Names longer than 200 characters are truncated with `...`
302
366
 
303
- ```json
304
- {
305
- "permissions": {
306
- "allow": ["mcp__chromex"]
307
- }
308
- }
367
+ ## Auto-Snapshot
368
+
369
+ Interactive commands automatically append an incremental snapshot with refs after execution. This lets the AI agent see the updated page state without a separate `snap` call:
370
+
371
+ ```bash
372
+ chromex click <target> @e3
373
+ # Output:
374
+ # Clicked @e3 [button] "Submit"
375
+ #
376
+ # [incremental: 5 changed, 40 unchanged]
377
+ # @e1 [heading] Thank you!
378
+ # @e2 [link] Back to home
379
+ # ...
309
380
  ```
310
381
 
311
- This approves all 52 MCP tools at once. For granular control, approve individual tools:
382
+ Commands that trigger auto-snapshot: `click`, `clickxy`, `type`, `fill`, `clear`, `select`, `check`, `form`, `nav`, `dialog`, `loadall`, `drag`, `touch`, `upload`.
312
383
 
313
- ```json
314
- {
315
- "permissions": {
316
- "allow": [
317
- "mcp__chromex__chromex_list",
318
- "mcp__chromex__chromex_snapshot",
319
- "mcp__chromex__chromex_screenshot",
320
- "mcp__chromex__chromex_perf"
321
- ]
322
- }
323
- }
384
+ Suppress with `--no-snap` for scripts doing rapid sequential actions:
385
+
386
+ ```bash
387
+ chromex fill <target> @e1 "user@test.com" --no-snap
388
+ chromex fill <target> @e2 "secret123" --no-snap
389
+ chromex click <target> @e3 # Only this one triggers snapshot
324
390
  ```
325
391
 
326
- ### Why MCP over CLI?
392
+ ## MCP vs CLI
327
393
 
328
- | | CLI (Bash) | MCP Server |
394
+ Both interfaces call the same core, same daemons, same commands. The difference is how they integrate with Claude Code.
395
+
396
+ | | CLI (`chromex-cli`) | MCP Server (`chromex-mcp`) |
329
397
  |---|---|---|
330
- | Auto-approve | Fragile glob pattern | `"mcp__chromex"` -- one line |
398
+ | Token overhead | Zero schema cost | ~500 tokens per tool used (deferred loading) |
399
+ | Auto-approve | Glob pattern in settings | `"mcp__chromex"` -- one line |
331
400
  | Permissions | All-or-nothing | Per-tool granularity |
332
401
  | Parameters | Positional string args | Typed JSON Schema |
333
- | Annotations | None | `readOnlyHint`, `destructiveHint` |
334
- | Token cost | ~60-80 overhead/call | ~15-25 overhead/call |
335
- | Screenshots | Returns file path | Returns inline image (base64) |
402
+ | Screenshots | File path (needs `Read` to view) | Inline image (base64, no extra call) |
403
+ | Best for | Terminal, scripts, CI/CD, token-sensitive sessions | Plug-and-play automation, users who want zero-config |
404
+
405
+ ### Switching Between MCP and CLI
336
406
 
337
- The CLI still works and is useful for terminal, scripts, and CI/CD. The MCP server is the recommended interface for Claude Code.
407
+ You can enable and disable the MCP server at any time. The CLI always works regardless.
338
408
 
339
- ### npm
409
+ **Disable MCP** (saves ~3-5k tokens per session):
340
410
 
341
411
  ```bash
342
- npm install -g chromex-mcp # Global install
343
- chromex-mcp # Run MCP server
344
- chromex-cli list # CLI also available
412
+ claude mcp remove chromex
345
413
  ```
346
414
 
347
- ## Auto-Approve for CLI (Alternative)
415
+ The CLI (`chromex-cli`) continues working normally -- same commands, same daemons, no change.
416
+
417
+ **Re-enable MCP:**
418
+
419
+ ```bash
420
+ # Global (all projects)
421
+ claude mcp add chromex -s user npx chromex-mcp@latest
422
+
423
+ # Project-only
424
+ claude mcp add chromex npx chromex-mcp@latest
425
+ ```
426
+
427
+ **When to disable MCP:**
428
+ - Token-sensitive sessions where every token counts
429
+ - You only need occasional commands (`list`, `snap`, `shot`)
430
+ - You're already comfortable with the CLI syntax
431
+ - You're using chromex from terminal/scripts, not from Claude Code
432
+
433
+ **When to keep MCP enabled:**
434
+ - Automating multi-step browser workflows (snap -> click -> fill -> snap)
435
+ - You want per-tool auto-approve without glob patterns
436
+ - You want inline screenshots without a separate `Read` call
437
+ - First time using chromex (discovery via tool schema)
438
+
439
+ ### CLI Auto-Approve
348
440
 
349
441
  If you prefer the CLI interface, add this to `~/.claude/settings.json`:
350
442
 
@@ -352,14 +444,12 @@ If you prefer the CLI interface, add this to `~/.claude/settings.json`:
352
444
  {
353
445
  "permissions": {
354
446
  "allow": [
355
- "Bash(node *chromex/scripts/chromex.mjs *)"
447
+ "Bash(chromex-cli *)"
356
448
  ]
357
449
  }
358
450
  }
359
451
  ```
360
452
 
361
- > **Warning:** This approves all chromex commands without distinction. The security config (`~/.chromex/config.json`) still applies -- domain filtering, CDP blocklist, and audit log remain active.
362
-
363
453
  ## Security
364
454
 
365
455
  Config at `~/.chromex/config.json` (auto-created on first run):
@@ -376,11 +466,26 @@ Config at `~/.chromex/config.json` (auto-created on first run):
376
466
 
377
467
  - **Domain filtering**: block sensitive sites or restrict to a whitelist
378
468
  - **CDP blocklist**: dangerous methods blocked by default in `evalraw`
379
- - **Socket auth**: 32-byte random token per session
469
+ - **Socket auth**: 32-byte random token per session (mode 0600)
380
470
  - **Audit log**: every command logged with timestamp and status
381
471
 
382
472
  See [docs/security.md](docs/security.md) for full details.
383
473
 
474
+ ## How It Works
475
+
476
+ 1. **Browser detection** -- scans ~30 paths for `DevToolsActivePort` (or use `CDP_PORT_FILE` env var)
477
+ 2. **Daemon spawn** -- first command to a tab spawns a background Node.js process connected via CDP WebSocket
478
+ 3. **Session persistence** -- daemon holds the session open; Chrome's "Allow" modal fires once per daemon
479
+ 4. **Unix sockets** -- CLI/MCP communicates with daemon via authenticated Unix sockets
480
+ 5. **Auto-exit** -- daemons shut down after 20 minutes of inactivity (configurable)
481
+
482
+ ```
483
+ CLI Client ──Unix Socket + Auth──> Per-Tab Daemon ──CDP WebSocket──> Chrome
484
+ MCP Server ──Unix Socket + Auth──> (same) ──CDP WebSocket──> Chrome
485
+ ```
486
+
487
+ See [docs/architecture.md](docs/architecture.md) for the full deep dive.
488
+
384
489
  ## Documentation
385
490
 
386
491
  | Guide | Description |
@@ -396,16 +501,6 @@ See [docs/security.md](docs/security.md) for full details.
396
501
  | [Advanced](docs/advanced.md) | Script injection, code coverage, tracing, heap snapshots, WebAuthn |
397
502
  | [Architecture](docs/architecture.md) | How it works: daemon model, connection modes, file layout |
398
503
 
399
- ## How It Works
400
-
401
- 1. **Browser detection** -- scans ~30 paths for `DevToolsActivePort` (or use `CDP_PORT_FILE` env var)
402
- 2. **Daemon spawn** -- first command to a tab spawns a background Node.js process connected via CDP WebSocket
403
- 3. **Session persistence** -- daemon holds the session open; Chrome's "Allow" modal fires once per daemon
404
- 4. **Unix sockets** -- CLI communicates with daemon via authenticated Unix sockets
405
- 5. **Auto-exit** -- daemons shut down after 20 minutes of inactivity (configurable)
406
-
407
- See [docs/architecture.md](docs/architecture.md) for the full deep dive.
408
-
409
504
  ## License
410
505
 
411
506
  MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../plugins/chromex/skills/chromex/scripts/chromex.mjs';
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "chromex-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.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": "./bin/chromex.mjs",
7
8
  "chromex-mcp": "./bin/chromex-mcp.mjs",
8
9
  "chromex-cli": "./bin/chromex-cli.mjs"
9
10
  },
@@ -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) {
@@ -38,7 +38,7 @@ function truncate(str, max = MAX_NAME_LENGTH) {
38
38
  return str.slice(0, max) + '...';
39
39
  }
40
40
 
41
- function formatAxNode(node, depth, refIndex, refs) {
41
+ function formatAxNode(node, depth, refIndex, refs, isNew = false) {
42
42
  const role = node.role?.value || '';
43
43
  const name = truncate(node.name?.value ?? '');
44
44
  const value = node.value?.value;
@@ -50,7 +50,8 @@ function formatAxNode(node, depth, refIndex, refs) {
50
50
  refIndex.value++;
51
51
  }
52
52
 
53
- let line = `${indent}${refTag}[${role}]`;
53
+ const newTag = isNew ? '*' : '';
54
+ let line = `${indent}${newTag}${refTag}[${role}]`;
54
55
  if (name !== '') line += ` ${name}`;
55
56
  if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
56
57
  return line;
@@ -91,6 +92,54 @@ function buildFingerprints(nodes, nodesById, childrenByParent, compact) {
91
92
  return fingerprints;
92
93
  }
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
+
94
143
  // refMap is populated when refs=true: { refNumber -> { backendNodeId, role, name } }
95
144
  // The caller (daemon) stores this map for later ref resolution.
96
145
  // previousFingerprints: Map from prior snapshot for incremental diff.
@@ -108,6 +157,7 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
108
157
 
109
158
  const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
110
159
  const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
160
+ const scrollables = await detectScrollables(cdp, sid);
111
161
 
112
162
  const refIndex = { value: 1 };
113
163
  const refMap = new Map();
@@ -157,8 +207,9 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
157
207
 
158
208
  const role = node.role?.value || '';
159
209
  const currentRef = refIndex.value;
210
+ const isNew = isDiff && !previousFingerprints.has(node.nodeId);
160
211
 
161
- lines.push(formatAxNode(node, depth, refIndex, refs));
212
+ lines.push(formatAxNode(node, depth, refIndex, refs, isNew));
162
213
 
163
214
  if (refs && refIndex.value > currentRef) {
164
215
  refMap.set(currentRef, {
@@ -214,6 +265,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
214
265
  const changedCount = totalVisible - unchangedCount;
215
266
  text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
216
267
  }
268
+ // Append scroll info footer when scrollable containers exist
269
+ if (scrollables.length > 0) {
270
+ text += `\n[scroll: ${scrollables.join(' | ')}]`;
271
+ }
217
272
 
218
273
  return { text, refMap, fingerprints: currentFingerprints };
219
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;
@@ -114,10 +123,18 @@ export async function runDaemon(targetId, config) {
114
123
  resetIdle();
115
124
  const auditResult = { ok: true };
116
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
+
117
134
  // Ref-based dispatch: click @e5, fill @e3 "value", hover @e12
118
135
  if (args[0] && parseRef(args[0]) !== null) {
136
+ isRefCmd = true;
119
137
  const refNum = parseRef(args[0]);
120
- let result;
121
138
  if (cmd === 'click') {
122
139
  result = await clickRefStr(cdp, sessionId, currentRefMap, refNum);
123
140
  } else if (cmd === 'fill') {
@@ -127,12 +144,9 @@ export async function runDaemon(targetId, config) {
127
144
  } else {
128
145
  throw new Error(`Ref @e${refNum} not supported for command "${cmd}". Use with: click, fill, hover.`);
129
146
  }
130
- audit(cmd, targetId, args, auditResult, config);
131
- return { ok: true, result };
132
147
  }
133
148
 
134
- let result;
135
- switch (cmd) {
149
+ if (!isRefCmd) switch (cmd) {
136
150
  // --- Comandos originais ---
137
151
  case 'list': {
138
152
  const pages = await getPages(cdp);
@@ -172,6 +186,7 @@ export async function runDaemon(targetId, config) {
172
186
  break;
173
187
  case 'nav': case 'navigate':
174
188
  result = await navStr(cdp, sessionId, args[0], config);
189
+ previousFingerprints = null; // Reset: new page needs full snapshot
175
190
  break;
176
191
  case 'net': case 'network':
177
192
  result = await netStr(cdp, sessionId);
@@ -322,6 +337,29 @@ export async function runDaemon(targetId, config) {
322
337
  }
323
338
  }
324
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
+
325
363
  return { ok: true, result: result ?? '' };
326
364
  } catch (e) {
327
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
 
@@ -167,10 +168,11 @@ const TOOLS = [
167
168
 
168
169
  // == NAVIGATE ==
169
170
  tool('chromex_navigate',
170
- '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.',
171
172
  {
172
173
  target: P_TARGET,
173
174
  url: { type: 'string', description: 'URL to navigate to' },
175
+ noSnap: P_NO_SNAP,
174
176
  }, ['target', 'url'], RW),
175
177
 
176
178
  tool('chromex_waitfor',
@@ -199,25 +201,28 @@ const TOOLS = [
199
201
 
200
202
  // == INTERACT ==
201
203
  tool('chromex_click',
202
- '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.',
203
205
  {
204
206
  target: P_TARGET,
205
207
  selector: { type: 'string', description: 'CSS selector or @eN ref' },
208
+ noSnap: P_NO_SNAP,
206
209
  }, ['target', 'selector'], RW),
207
210
 
208
211
  tool('chromex_clickxy',
209
- 'Click at CSS pixel coordinates.',
212
+ 'Click at CSS pixel coordinates. Returns auto-snapshot with updated refs.',
210
213
  {
211
214
  target: P_TARGET,
212
215
  x: { type: 'number', description: 'X in CSS pixels' },
213
216
  y: { type: 'number', description: 'Y in CSS pixels' },
217
+ noSnap: P_NO_SNAP,
214
218
  }, ['target', 'x', 'y'], RW),
215
219
 
216
220
  tool('chromex_type',
217
- 'Type text at currently focused element.',
221
+ 'Type text at currently focused element. Returns auto-snapshot with updated refs.',
218
222
  {
219
223
  target: P_TARGET,
220
224
  text: { type: 'string', description: 'Text to type' },
225
+ noSnap: P_NO_SNAP,
221
226
  }, ['target', 'text'], RW),
222
227
 
223
228
  tool('chromex_hover',
@@ -228,82 +233,92 @@ const TOOLS = [
228
233
  }, ['target', 'ref'], RW),
229
234
 
230
235
  tool('chromex_drag',
231
- '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.',
232
237
  {
233
238
  target: P_TARGET,
234
239
  from: { type: 'string', description: 'Source selector or x,y' },
235
240
  to: { type: 'string', description: 'Destination selector or x,y' },
241
+ noSnap: P_NO_SNAP,
236
242
  }, ['target', 'from', 'to'], RW),
237
243
 
238
244
  tool('chromex_touch',
239
- 'Touch gesture: tap, swipe, pinch, longpress.',
245
+ 'Touch gesture: tap, swipe, pinch, longpress. Returns auto-snapshot with updated refs.',
240
246
  {
241
247
  target: P_TARGET,
242
248
  gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
243
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,
244
251
  }, ['target', 'gesture'], RW),
245
252
 
246
253
  tool('chromex_dialog',
247
- '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.',
248
255
  {
249
256
  target: P_TARGET,
250
257
  action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
251
258
  text: { type: 'string', description: 'Text for prompt (only with accept)' },
259
+ noSnap: P_NO_SNAP,
252
260
  }, ['target', 'action'], RW),
253
261
 
254
262
  tool('chromex_loadall',
255
- 'Click "load more" button repeatedly until it disappears.',
263
+ 'Click "load more" button repeatedly until it disappears. Returns auto-snapshot with updated refs.',
256
264
  {
257
265
  target: P_TARGET,
258
266
  selector: { type: 'string', description: 'CSS selector of load-more button' },
259
267
  interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
268
+ noSnap: P_NO_SNAP,
260
269
  }, ['target', 'selector'], RW),
261
270
 
262
271
  // == FORMS ==
263
272
  tool('chromex_fill',
264
- '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.',
265
274
  {
266
275
  target: P_TARGET,
267
276
  selector: { type: 'string', description: 'CSS selector or @eN ref' },
268
277
  value: { type: 'string', description: 'Value to fill' },
278
+ noSnap: P_NO_SNAP,
269
279
  }, ['target', 'selector', 'value'], RW),
270
280
 
271
281
  tool('chromex_clear',
272
- 'Clear input field.',
282
+ 'Clear input field. Returns auto-snapshot with updated refs.',
273
283
  {
274
284
  target: P_TARGET,
275
285
  selector: { type: 'string', description: 'CSS selector' },
286
+ noSnap: P_NO_SNAP,
276
287
  }, ['target', 'selector'], RW),
277
288
 
278
289
  tool('chromex_select',
279
- 'Select option in dropdown.',
290
+ 'Select option in dropdown. Returns auto-snapshot with updated refs.',
280
291
  {
281
292
  target: P_TARGET,
282
293
  selector: { type: 'string', description: 'CSS selector of select element' },
283
294
  value: { type: 'string', description: 'Option value or visible text' },
295
+ noSnap: P_NO_SNAP,
284
296
  }, ['target', 'selector', 'value'], RW),
285
297
 
286
298
  tool('chromex_check',
287
- 'Toggle checkbox or radio button.',
299
+ 'Toggle checkbox or radio button. Returns auto-snapshot with updated refs.',
288
300
  {
289
301
  target: P_TARGET,
290
302
  selector: { type: 'string', description: 'CSS selector' },
291
303
  checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
304
+ noSnap: P_NO_SNAP,
292
305
  }, ['target', 'selector'], RW),
293
306
 
294
307
  tool('chromex_form',
295
- '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.',
296
309
  {
297
310
  target: P_TARGET,
298
311
  fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
312
+ noSnap: P_NO_SNAP,
299
313
  }, ['target', 'fields'], RW),
300
314
 
301
315
  tool('chromex_upload',
302
- 'Upload file(s) to input[type=file].',
316
+ 'Upload file(s) to input[type=file]. Returns auto-snapshot with updated refs.',
303
317
  {
304
318
  target: P_TARGET,
305
319
  selector: { type: 'string', description: 'CSS selector of file input' },
306
320
  files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
321
+ noSnap: P_NO_SNAP,
307
322
  }, ['target', 'selector', 'files'], RW),
308
323
 
309
324
  // == DATA ==
@@ -631,6 +646,8 @@ async function executeTool(name, params) {
631
646
  const mapped = toolToCmd(name, params);
632
647
  if (!mapped) return fail(`Unknown tool: ${name}`);
633
648
 
649
+ if (params.noSnap) mapped.args.push('--no-snap');
650
+
634
651
  const conn = await getOrStartTabDaemon(targetId, config);
635
652
  const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });
636
653