chromex-mcp 1.4.0 → 1.5.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
@@ -2,13 +2,18 @@
2
2
 
3
3
  Zero-dependency Chrome DevTools Protocol toolkit for AI agents. 56 typed MCP tools + CLI. Connects directly to Chrome, Brave, Edge, or Chromium via WebSocket. No Puppeteer, no bloat.
4
4
 
5
+ Designed from the ground up for token efficiency: incremental diffs, query-filtered snapshots, ref-based selection, and a plain-text output format that consistently beats JSON and YAML-style structured alternatives by 25% to 126% in head-to-head token measurements (see [Token Efficiency](#token-efficiency) below).
6
+
5
7
  ## Features
6
8
 
7
9
  - **56 MCP tools** -- typed JSON Schema, annotations (`readOnlyHint`, `destructiveHint`), inline screenshots (base64)
8
10
  - **Zero dependencies** -- uses only Node.js 22+ built-in modules (WebSocket, fs, net, crypto)
9
11
  - **Ref-based selection** -- `snap --refs` assigns `@e1`, `@e2`... to interactive elements, then `click @e5` or `fill @e3 "value"`. No fragile CSS selectors
10
12
  - **Incremental snapshots** -- second snapshot returns only changed nodes (diff), reducing output from thousands of lines to just what changed
13
+ - **Query-filtered snapshots** -- `snap --query=login` returns only matching nodes and their ancestors, cutting output by 95% to 99% on large pages like GitHub, Jira, or Gmail
11
14
  - **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
15
+ - **Contextual hints** -- after each action, chromex appends up to 3 `help[]` next-step suggestions picked from the current ref map, eliminating the "what do I do next" turn. Opt-out with `--no-hints`
16
+ - **Pre-computed aggregates** -- `net` and `console` outputs embed counters (`network[47] errors:3 pending:0 ok:44`, `console[12] errors:2 warnings:4 info:6`) so the agent never needs a follow-up count
12
17
  - **Scroll detection** -- snapshots report scrollable containers with remaining distance (`[scroll: page: down:1200px | sidebar: up:300px]`)
13
18
  - **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
14
19
  - **Security hardened** -- domain filtering (allow/blocklist), CDP method blocklist, token-authenticated sockets, full audit log
@@ -157,6 +162,7 @@ chromex snap <target> # Accessibility tree snapshot (compa
157
162
  chromex snap <target> --refs # With interactive refs (@e1, @e2...)
158
163
  chromex snap <target> --depth=3 # Limit tree depth
159
164
  chromex snap <target> --full # Force full snapshot (skip diff)
165
+ chromex snap <target> --query=login # Filter to matching nodes + ancestors (hierarchy preserved)
160
166
  chromex html <target> "#main" # Element HTML by selector
161
167
  chromex shot <target> /tmp/page.png # Viewport screenshot
162
168
  chromex shot <target> /tmp/full.png --full # Full page screenshot
@@ -381,6 +387,30 @@ chromex snap <target> --depth=3 # Only 3 levels deep
381
387
 
382
388
  Nodes at the depth limit render as leaves (children are not expanded).
383
389
 
390
+ ### Query Filter
391
+
392
+ On large pages, a full accessibility tree can be tens of kilobytes. Use `--query` to keep only the nodes you care about, with their ancestors preserved so the hierarchy stays intact:
393
+
394
+ ```bash
395
+ chromex snap <target> --query=login # Substring match (case-insensitive)
396
+ chromex snap <target> --query=issues # role/name/value all searched
397
+ ```
398
+
399
+ Matched nodes are prefixed with `>` in the output, so the agent can spot them at a glance. Ancestor chains are included from the match up to the root, so the agent still understands the surrounding structure.
400
+
401
+ `@eN` refs stay stable across filtered and unfiltered calls because the ref map is always computed against the full tree. A filtered `snap --query=login` followed by a plain `snap --refs` returns the same ref numbering. Fingerprints for incremental diff are also computed on the full tree, so the next non-query snapshot still produces a correct diff against the previous state.
402
+
403
+ Measured reduction on a real 65 KB GitHub repo page snapshot:
404
+
405
+ | Query | Output bytes | Reduction |
406
+ |-------|-------------:|----------:|
407
+ | `snap --full` (baseline) | 65,455 | -- |
408
+ | `snap --query=issues` | 317 | **-99.5%** |
409
+ | `snap --query=star` | 1,490 | **-97.7%** |
410
+ | `snap --query=readme` | 936 | **-98.6%** |
411
+
412
+ When no node matches, chromex returns the explicit empty state `snap: no matches for query "X"` so the agent never confuses an empty filter with a silent failure.
413
+
384
414
  ### Scroll Detection
385
415
 
386
416
  Snapshots automatically detect scrollable containers and report remaining scroll distance:
@@ -421,6 +451,95 @@ chromex fill <target> @e2 "secret123" --no-snap
421
451
  chromex click <target> @e3 # Only this one triggers snapshot
422
452
  ```
423
453
 
454
+ ## Contextual Hints
455
+
456
+ After any action that produces a fresh ref map (auto-snap on interactive commands, or an explicit `snap --refs`), chromex appends a `help[N]:` block with up to 3 next-step suggestions picked heuristically from the current elements and the last command:
457
+
458
+ ```
459
+ Navigated to https://github.com/login
460
+
461
+ RootWebArea "Sign in to GitHub"
462
+ @e1 [textbox] Username or email address
463
+ @e2 [textbox] Password
464
+ @e3 [button] Sign in
465
+ @e4 [link] Forgot password?
466
+
467
+ help[3]:
468
+ chromex fill <t> @e1 "<value>" # textbox "Username or email address"
469
+ chromex click <t> @e3 # button "Sign in"
470
+ chromex click <t> @e4 # link "Forgot password?"
471
+ ```
472
+
473
+ The agent gets the most probable next commands inline, eliminating the "decide what to click" turn. Heuristic rules:
474
+
475
+ - **After `fill`** -- priority is a matching submit button (label matches `login`, `submit`, `send`, `search`, `go`, `continue`, ...) or `key Enter` fallback, then the next unfilled input.
476
+ - **After `nav`** -- first input (highest priority), then first submit button, then first link.
477
+ - **After `snap --refs`** (or any default) -- top interactive elements with non-empty names.
478
+ - **Maximum 3 hints per response.**
479
+
480
+ ### Staleness Guard
481
+
482
+ Hints are only emitted when chromex can guarantee the ref map matches the DOM that was just rendered. This prevents the agent from clicking `@eN` coordinates that no longer exist on screen:
483
+
484
+ | Command | Hints? | Why |
485
+ |---------|--------|-----|
486
+ | `click`, `fill`, `nav`, `type`, ... (default) | Yes | auto-snap just ran with refs |
487
+ | `click @e1 --no-snap`, `fill ... --no-snap` | **No** | ref map may be stale |
488
+ | `snap --refs` | Yes | ref map populated by this call |
489
+ | `snap --refs` on a page with zero interactive elements | **No** | nothing to suggest, avoids a `snap --refs` loop |
490
+ | Bare `snap` (no `--refs`) | **No** | ref map was not refreshed |
491
+
492
+ Navigation (URL, back, forward, reload) always clears the ref map before dispatching, so post-nav hints always reflect the new page.
493
+
494
+ Opt out explicitly with `--no-hints` (CLI) or `noHints: true` (MCP) for scripts that parse output strictly.
495
+
496
+ ## Token Efficiency
497
+
498
+ Chromex outputs are designed to be read by LLM agents, not humans. Every format decision -- plain text, refs over CSS selectors, incremental diffs, query filters -- was made to minimize tokens while preserving the information the agent actually needs to act.
499
+
500
+ ### Measured against common alternatives
501
+
502
+ We measured the current plain-text output against three structured format candidates often proposed for agent interfaces: minified JSON, pretty-printed JSON, and a TOON-style compact encoder (a zero-dependency 60-line implementation of the YAML-inline compact style). Five representative chromex outputs were encoded in each format and tokenized with `tiktoken` (`cl100k_base`). Lower token counts are better.
503
+
504
+ #### Case by case
505
+
506
+ | Case | text-free | json-min | json-pretty | toon-compact |
507
+ |--------------------------------|-----------:|---------------:|----------------:|----------------:|
508
+ | `snap-login-small` | **130** | 178 (+37%) | 301 (+132%) | 212 (+63%) |
509
+ | `snap-repo-large` (65 KB real) | **19,702** | 34,315 (+74%) | 63,232 (+221%) | 44,613 (+126%) |
510
+ | `net-list-50` | **454** | 574 (+26%) | 921 (+103%) | 706 (+56%) |
511
+ | `console-list-12` | **273** | 363 (+33%) | 576 (+111%) | 453 (+66%) |
512
+ | `fill-action-small` | **99** | 127 (+28%) | 221 (+123%) | 146 (+48%) |
513
+
514
+ The five cases cover the most common outputs an agent sees during a real session: a small accessibility snapshot with refs (a login form), a large accessibility snapshot of a real GitHub repository page, a network list with 50 tracked requests, a console list with a mix of `log`, `warn` and `error` entries, and a post-action result (fill + incremental diff + hints).
515
+
516
+ #### Aggregate (5 cases combined)
517
+
518
+ | Format | tokens | vs text-free |
519
+ |------------------------|----------:|-------------:|
520
+ | **text-free (current)**| **20,658**| -- |
521
+ | json-min | 35,557 | +72.1% |
522
+ | json-pretty | 65,251 | +215.9% |
523
+ | toon-compact (custom) | 46,130 | +123.3% |
524
+
525
+ The current plain-text output is the most token-efficient in every single case. Every structured alternative costs more tokens, not less, because JSON and YAML-style encodings add syntactic overhead (`{`, `}`, `"`, `:`, `,`, indentation) that the chromex plain-text format omits entirely. The chromex format is already dense: short refs (`@e5`), unquoted labels, no wrapping, no redundancy. Structured formats have nothing to optimize away.
526
+
527
+ ### Why this matters
528
+
529
+ - **No structured-output refactor is planned.** Chromex keeps the current plain-text format because the data shows it wins.
530
+ - **`@eN` refs beat CSS selectors** not just for reliability (accessibility tree is stable across SPA re-renders) but also for token count -- `@e5` is one token, `document.querySelector('#login-form > div.field input[name="email"]')` is around fifteen.
531
+ - **`--query` and incremental diff** are where the real token savings live. A filtered snapshot on a large page drops output by 95% to 99%, and an incremental diff after an action drops it by 90% or more. These are multiplicative with the already-dense base format.
532
+
533
+ ### Reproducing the benchmark
534
+
535
+ The comparison script is checked into the repo and runs standalone:
536
+
537
+ ```bash
538
+ node tests/benchmarks/token-format-comparison.mjs
539
+ ```
540
+
541
+ It uses `js-tiktoken` (devDependency) with `cl100k_base`, the GPT-4 tokenizer. Claude uses its own BPE tokenizer with a different vocabulary, so absolute numbers would shift by a few percent -- but both are byte-pair encoders over similar corpora, and the direction of the comparison (which format wins) is consistent across BPE tokenizers. Nothing in the results is a rounding error: the gaps are 25% to 220%.
542
+
424
543
  ## MCP vs CLI
425
544
 
426
545
  Both interfaces call the same core, same daemons, same commands. The difference is how they integrate with Claude Code.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "chromex-mcp",
3
- "version": "1.4.0",
4
- "description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 56 typed tools, per-tab daemons, security hardened.",
3
+ "version": "1.5.0",
4
+ "description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 56 typed tools, per-tab daemons, security hardened, contextual hints, query-filtered snapshots.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "chromex": "./bin/chromex.mjs",
8
- "chromex-mcp": "./bin/chromex-mcp.mjs",
9
- "chromex-cli": "./bin/chromex-cli.mjs"
7
+ "chromex": "bin/chromex.mjs",
8
+ "chromex-mcp": "bin/chromex-mcp.mjs",
9
+ "chromex-cli": "bin/chromex-cli.mjs"
10
10
  },
11
11
  "files": [
12
12
  "bin/",
@@ -40,13 +40,14 @@
40
40
  "license": "MIT",
41
41
  "repository": {
42
42
  "type": "git",
43
- "url": "https://github.com/whallysson/chromex"
43
+ "url": "git+https://github.com/whallysson/chromex.git"
44
44
  },
45
45
  "homepage": "https://github.com/whallysson/chromex#mcp-server-recommended-for-claude-code",
46
46
  "engines": {
47
47
  "node": ">=22.0.0"
48
48
  },
49
49
  "devDependencies": {
50
+ "js-tiktoken": "^1.0.21",
50
51
  "vitest": "^3.0.0"
51
52
  }
52
53
  }
@@ -51,7 +51,11 @@ Usage: chromex <command> [args]
51
51
  incognito [url] Create isolated browser context (no relaunch)
52
52
 
53
53
  INSPECT
54
- snap <target> Accessibility tree snapshot (compact)
54
+ snap <target> [options] Accessibility tree snapshot (compact)
55
+ --refs Assign @eN refs to interactive elements
56
+ --full Force full snapshot (skip incremental diff)
57
+ --depth=N Limit tree depth (nodes at limit render as leaves)
58
+ --query=TEXT Filter to nodes matching substring + ancestors
55
59
  html <target> [selector] Get HTML (full page or CSS selector)
56
60
  shot <target> [file] [options] Screenshot (viewport, full page, or element)
57
61
  --full Full page capture
@@ -132,6 +136,10 @@ Usage: chromex <command> [args]
132
136
 
133
137
  <target> is a unique targetId prefix from "chromex list". Ambiguous prefixes are rejected.
134
138
 
139
+ OUTPUT FLAGS
140
+ --no-snap Skip auto-snapshot after interactive commands
141
+ --no-hints Suppress contextual help[] suggestions
142
+
135
143
  SECURITY
136
144
  Config: ~/.chromex/config.json
137
145
  - blockedDomains / allowedDomains: domain filtering
@@ -265,7 +273,8 @@ async function main() {
265
273
  const conn = await getOrStartTabDaemon(targetId, config);
266
274
 
267
275
  const noSnap = args.includes('--no-snap');
268
- const cmdArgs = args.slice(1).filter(a => a !== '--no-snap');
276
+ const noHints = args.includes('--no-hints');
277
+ const cmdArgs = args.slice(1).filter(a => a !== '--no-snap' && a !== '--no-hints');
269
278
 
270
279
  // Juntar argumentos para comandos que aceitam texto livre
271
280
  if (cmd === 'eval') {
@@ -300,6 +309,7 @@ async function main() {
300
309
  }
301
310
 
302
311
  if (noSnap) cmdArgs.push('--no-snap');
312
+ if (noHints) cmdArgs.push('--no-hints');
303
313
  const response = await sendCommand(conn, { cmd, args: cmdArgs });
304
314
 
305
315
  if (response.ok) {
@@ -1,6 +1,26 @@
1
1
  // Console: live capture, stored message list, and detail with stack traces
2
2
 
3
3
  import { sleep } from '../utils.mjs';
4
+ import { emptyState, aggregate } from '../output.mjs';
5
+
6
+ // Pre-computed aggregates for a list of console entries.
7
+ // Returns { errors, warnings, info } counts; zero keys omitted by callers.
8
+ function bucketConsoleByType(entries) {
9
+ let errors = 0;
10
+ let warnings = 0;
11
+ let info = 0;
12
+ for (const e of entries) {
13
+ const type = e.type;
14
+ if (type === 'error') errors++;
15
+ else if (type === 'warning' || type === 'warn') warnings++;
16
+ else info++;
17
+ }
18
+ const meta = {};
19
+ if (errors) meta.errors = errors;
20
+ if (warnings) meta.warnings = warnings;
21
+ if (info) meta.info = info;
22
+ return meta;
23
+ }
4
24
 
5
25
  export async function consoleStr(cdp, sid, durationMs = 5000) {
6
26
  const duration = parseInt(durationMs) || 5000;
@@ -28,22 +48,30 @@ export async function consoleStr(cdp, sid, durationMs = 5000) {
28
48
  await sleep(duration);
29
49
  off();
30
50
 
31
- if (entries.length === 0) return `No console output captured in ${duration}ms.`;
51
+ if (entries.length === 0) return emptyState('console', `0 messages captured in ${duration}ms`);
32
52
 
33
- return entries.map(e => {
53
+ const header = aggregate('console', entries.length, bucketConsoleByType(entries));
54
+ const rows = entries.map(e => {
34
55
  const prefix = e.type === 'error' ? 'ERR' : e.type === 'warn' ? 'WRN' : e.type.toUpperCase().slice(0, 3);
35
56
  return `[${e.ts}] ${prefix.padEnd(3)} ${e.msg.substring(0, 200)}`;
36
- }).join('\n');
57
+ });
58
+ return `${header}\n${rows.join('\n')}`;
37
59
  }
38
60
 
39
61
  export function consoleListStr(consoleMessages) {
40
- if (consoleMessages.length === 0) return 'No console messages captured since daemon started.';
62
+ if (consoleMessages.length === 0) return emptyState('console', '0 messages captured since daemon started');
63
+
64
+ const header = aggregate('console', consoleMessages.length, bucketConsoleByType(consoleMessages));
41
65
  const msgs = consoleMessages.slice(-50);
42
- return msgs.map(e => {
66
+ const rows = msgs.map(e => {
43
67
  const ts = new Date(e.ts).toISOString().slice(11, 23);
44
68
  const prefix = e.type === 'error' ? 'ERR' : e.type === 'warn' ? 'WRN' : e.type.toUpperCase().slice(0, 3);
45
69
  return `[${e.id}] ${ts} ${prefix.padEnd(3)} ${e.args.join(' ').substring(0, 200)}`;
46
- }).join('\n');
70
+ });
71
+ const truncNote = consoleMessages.length > 50
72
+ ? `\n(showing last 50 of ${consoleMessages.length})`
73
+ : '';
74
+ return `${header}\n${rows.join('\n')}${truncNote}`;
47
75
  }
48
76
 
49
77
  export function consoleDetailStr(consoleMessages, msgId) {
@@ -1,6 +1,7 @@
1
1
  // Cookie management via CDP Network domain
2
2
 
3
3
  import { evalStr } from './evaluate.mjs';
4
+ import { emptyState } from '../output.mjs';
4
5
 
5
6
  export async function cookiesStr(cdp, sid, action, arg) {
6
7
  switch (action) {
@@ -12,7 +13,7 @@ export async function cookiesStr(cdp, sid, action, arg) {
12
13
  const { cookies } = await cdp.send('Network.getCookies', { urls: [url] }, sid);
13
14
  await cdp.send('Network.disable', {}, sid);
14
15
 
15
- if (cookies.length === 0) return 'No cookies found for this page.';
16
+ if (cookies.length === 0) return emptyState('cookies', '0 cookies for this page');
16
17
 
17
18
  return cookies.map(c => {
18
19
  const flags = [
@@ -1,27 +1,56 @@
1
1
  // Network: resource timing + CDP request detail
2
2
 
3
3
  import { evalStr } from './evaluate.mjs';
4
+ import { emptyState, aggregate, formatBytes } from '../output.mjs';
4
5
 
5
6
  export async function netStr(cdp, sid) {
6
7
  const raw = await evalStr(cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
7
8
  name: e.name.substring(0, 120), type: e.initiatorType,
8
9
  duration: Math.round(e.duration), size: e.transferSize
9
10
  })))`);
10
- return JSON.parse(raw).map(e =>
11
+ const resources = JSON.parse(raw);
12
+ if (resources.length === 0) return emptyState('network', '0 resources timed (page not loaded or resources cached)');
13
+
14
+ // Pre-computed aggregates: total transfer size so agent doesn't need a follow-up sum.
15
+ const totalSize = resources.reduce((s, e) => s + (e.size || 0), 0);
16
+ const header = aggregate('network', resources.length, { size: formatBytes(totalSize) });
17
+
18
+ const rows = resources.map(e =>
11
19
  `${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}`
12
- ).join('\n');
20
+ );
21
+ return `${header}\n${rows.join('\n')}`;
13
22
  }
14
23
 
15
24
  export function netListStr(networkRequests) {
16
- if (networkRequests.size === 0) return 'No network requests captured since daemon started.';
25
+ if (networkRequests.size === 0) return emptyState('network', '0 requests captured since daemon started');
26
+
27
+ // Pre-computed aggregates: breakdown by status class.
28
+ // Agents commonly ask "are there any errors?" -- embedding the count eliminates a round-trip.
29
+ let errors = 0;
30
+ let pending = 0;
31
+ let ok = 0;
32
+ for (const [, r] of networkRequests.entries()) {
33
+ if (r.status == null) pending++;
34
+ else if (r.status >= 400) errors++;
35
+ else ok++;
36
+ }
37
+ const meta = {};
38
+ if (errors) meta.errors = errors;
39
+ if (pending) meta.pending = pending;
40
+ if (ok) meta.ok = ok;
41
+ const header = aggregate('network', networkRequests.size, meta);
42
+
17
43
  const entries = [...networkRequests.entries()].slice(-50);
18
- const header = `${'STATUS'.padStart(3)} ${'METHOD'.padEnd(6)} ${'ID'.padEnd(14)} URL`;
44
+ const tableHeader = `${'STATUS'.padStart(3)} ${'METHOD'.padEnd(6)} ${'ID'.padEnd(14)} URL`;
19
45
  const rows = entries.map(([id, r]) => {
20
46
  const status = r.status != null ? String(r.status).padStart(3) : '...';
21
47
  const method = (r.method || 'GET').padEnd(6);
22
48
  return ` ${status} ${method} ${id.substring(0, 14).padEnd(14)} ${r.url?.substring(0, 100) || '?'}`;
23
49
  });
24
- return `${header}\n${rows.join('\n')}\n\n${entries.length} requests. Use "net <target> <requestId>" for detail.`;
50
+ const truncNote = networkRequests.size > 50
51
+ ? `\n(showing last 50 of ${networkRequests.size})`
52
+ : '';
53
+ return `${header}\n${tableHeader}\n${rows.join('\n')}${truncNote}\n\nUse "net <target> <requestId>" for detail.`;
25
54
  }
26
55
 
27
56
  export async function netDetailStr(cdp, sid, requestId, networkRequests) {
@@ -1,6 +1,7 @@
1
1
  // Core Web Vitals + performance metrics
2
2
 
3
3
  import { evalStr } from './evaluate.mjs';
4
+ import { emptyState } from '../output.mjs';
4
5
 
5
6
  export async function perfStr(cdp, sid) {
6
7
  // Métricas do CDP
@@ -53,6 +54,15 @@ export async function perfStr(cdp, sid) {
53
54
  const cdpMetrics = {};
54
55
  for (const m of metrics) cdpMetrics[m.name] = m.value;
55
56
 
57
+ // Empty state: no vitals, no nav timing, no resources -- page not loaded or blank
58
+ if (
59
+ v.lcp == null && v.fcp == null && v.ttfb == null &&
60
+ v.domInteractive == null && v.load == null &&
61
+ (!v.resources || v.resources === 0)
62
+ ) {
63
+ return emptyState('perf', 'no metrics available (page not loaded?)');
64
+ }
65
+
56
66
  const lines = ['## Core Web Vitals'];
57
67
 
58
68
  if (v.lcp != null) lines.push(`LCP: ${v.lcp}ms (${v.lcpElement})${v.lcp <= 2500 ? ' [GOOD]' : v.lcp <= 4000 ? ' [NEEDS IMPROVEMENT]' : ' [POOR]'}`);
@@ -1,5 +1,7 @@
1
1
  // Accessibility tree snapshot with incremental diff and interactive refs (@e1, @e2...)
2
2
 
3
+ import { emptyState } from '../output.mjs';
4
+
3
5
  const INTERACTIVE_ROLES = new Set([
4
6
  'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
5
7
  'menuitem', 'tab', 'switch', 'searchbox', 'slider', 'spinbutton',
@@ -38,25 +40,35 @@ function truncate(str, max = MAX_NAME_LENGTH) {
38
40
  return str.slice(0, max) + '...';
39
41
  }
40
42
 
41
- function formatAxNode(node, depth, refIndex, refs, isNew = false) {
43
+ // Render a single AX node line. Ref assignment is done OUT of this function
44
+ // (via the refMap pre-pass) so that query-filtered renders still produce
45
+ // the same @eN numbering as unfiltered renders.
46
+ function formatNodeLine(node, depth, refNum, isNew = false, isMatch = false) {
42
47
  const role = node.role?.value || '';
43
48
  const name = truncate(node.name?.value ?? '');
44
49
  const value = node.value?.value;
45
50
  const indent = ' '.repeat(Math.min(depth, 10));
46
51
 
47
- let refTag = '';
48
- if (refs && INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
49
- refTag = `@e${refIndex.value} `;
50
- refIndex.value++;
51
- }
52
-
52
+ const refTag = refNum != null ? `@e${refNum} ` : '';
53
+ const matchTag = isMatch ? '> ' : '';
53
54
  const newTag = isNew ? '*' : '';
54
- let line = `${indent}${newTag}${refTag}[${role}]`;
55
+ let line = `${indent}${matchTag}${newTag}${refTag}[${role}]`;
55
56
  if (name !== '') line += ` ${name}`;
56
57
  if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
57
58
  return line;
58
59
  }
59
60
 
61
+ // Case-insensitive substring match against role, name and value.
62
+ function nodeMatchesQuery(node, query) {
63
+ if (!query) return false;
64
+ const q = query.toLowerCase();
65
+ const role = (node.role?.value || '').toLowerCase();
66
+ const name = (node.name?.value || '').toLowerCase();
67
+ const value = node.value?.value;
68
+ const valueStr = value == null ? '' : String(value).toLowerCase();
69
+ return role.includes(q) || name.includes(q) || valueStr.includes(q);
70
+ }
71
+
60
72
  function orderedAxChildren(node, nodesById, childrenByParent) {
61
73
  const children = [];
62
74
  const seen = new Set();
@@ -144,8 +156,11 @@ async function detectScrollables(cdp, sid) {
144
156
  // The caller (daemon) stores this map for later ref resolution.
145
157
  // previousFingerprints: Map from prior snapshot for incremental diff.
146
158
  // maxDepth: limit tree depth (0 = unlimited). Nodes at the limit render as leaves.
159
+ // query: when set, filter the rendered output to nodes matching (substring) the
160
+ // query plus their ancestors. Fingerprints and refMap are still computed
161
+ // against the full tree so incremental diff and @eN stability survive.
147
162
  // 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) {
163
+ export async function snapshotStr(cdp, sid, compact = true, refs = false, previousFingerprints = null, maxDepth = 0, query = null) {
149
164
  const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
150
165
  const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
151
166
  const childrenByParent = new Map();
@@ -156,11 +171,73 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
156
171
  }
157
172
 
158
173
  const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
159
- const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
174
+ const isDiff = previousFingerprints !== null && previousFingerprints.size > 0 && !query;
160
175
  const scrollables = await detectScrollables(cdp, sid);
161
176
 
162
- const refIndex = { value: 1 };
177
+ const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
178
+
179
+ // ---- Pre-pass: populate refMap on the FULL tree so @eN stays stable
180
+ // regardless of query filtering.
163
181
  const refMap = new Map();
182
+ const nodeIdToRef = new Map();
183
+ if (refs) {
184
+ let refCounter = 1;
185
+ const prepassSeen = new Set();
186
+ const prepass = (node) => {
187
+ if (!node || prepassSeen.has(node.nodeId)) return;
188
+ prepassSeen.add(node.nodeId);
189
+ if (shouldShowAxNode(node, compact)) {
190
+ const role = node.role?.value || '';
191
+ if (INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
192
+ refMap.set(refCounter, {
193
+ backendNodeId: node.backendDOMNodeId,
194
+ nodeId: node.nodeId,
195
+ role,
196
+ name: node.name?.value ?? '',
197
+ });
198
+ nodeIdToRef.set(node.nodeId, refCounter);
199
+ refCounter++;
200
+ }
201
+ }
202
+ for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
203
+ prepass(child);
204
+ }
205
+ };
206
+ for (const root of roots) prepass(root);
207
+ if (!maxDepth) {
208
+ for (const node of nodes) prepass(node);
209
+ }
210
+ }
211
+
212
+ // ---- Query filter: compute matchedIds + their ancestors (keepIds)
213
+ let matchedIds = null;
214
+ let keepIds = null;
215
+ if (query) {
216
+ matchedIds = new Set();
217
+ keepIds = new Set();
218
+ for (const node of nodes) {
219
+ if (!shouldShowAxNode(node, compact)) continue;
220
+ if (nodeMatchesQuery(node, query)) {
221
+ matchedIds.add(node.nodeId);
222
+ // Walk ancestors via parentId; include non-visible parents too so the
223
+ // visit() pass can reach the match by descending through them.
224
+ let pid = node.parentId;
225
+ while (pid && !keepIds.has(pid)) {
226
+ keepIds.add(pid);
227
+ pid = nodesById.get(pid)?.parentId;
228
+ }
229
+ keepIds.add(node.nodeId);
230
+ }
231
+ }
232
+ if (matchedIds.size === 0) {
233
+ return {
234
+ text: `snap: no matches for query "${query}"`,
235
+ refMap,
236
+ fingerprints: currentFingerprints,
237
+ };
238
+ }
239
+ }
240
+
164
241
  const lines = [];
165
242
  const visited = new Set();
166
243
 
@@ -173,7 +250,6 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
173
250
  const curr = currentFingerprints.get(nodeId);
174
251
  const prev = previousFingerprints.get(nodeId);
175
252
  if (!curr || !prev || curr !== prev) return false;
176
- // Node itself matches -- check all visible children recursively
177
253
  const children = orderedAxChildren(node, nodesById, childrenByParent);
178
254
  for (const child of children) {
179
255
  if (!shouldShowAxNode(child, compact)) continue;
@@ -186,6 +262,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
186
262
  if (!node || visited.has(node.nodeId)) return;
187
263
  visited.add(node.nodeId);
188
264
 
265
+ // Query filter: if this node isn't on the keep path, skip the whole subtree.
266
+ // All nodes with matches have their ancestors in keepIds, so pruning here is safe.
267
+ if (query && !keepIds.has(node.nodeId)) return;
268
+
189
269
  const show = shouldShowAxNode(node, compact);
190
270
 
191
271
  if (!show) {
@@ -196,29 +276,18 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
196
276
  return;
197
277
  }
198
278
 
199
- // Incremental diff: if this subtree is unchanged, collapse it
279
+ // Incremental diff: if this subtree is unchanged, collapse it.
280
+ // Disabled when a query filter is active (user wants the matched content, not a diff).
200
281
  if (isDiff && isSubtreeUnchanged(node)) {
201
282
  unchangedCount++;
202
- if (refs) {
203
- advanceRefsForSubtree(node);
204
- }
205
283
  return;
206
284
  }
207
285
 
208
- const role = node.role?.value || '';
209
- const currentRef = refIndex.value;
210
286
  const isNew = isDiff && !previousFingerprints.has(node.nodeId);
287
+ const isMatch = !!(matchedIds && matchedIds.has(node.nodeId));
288
+ const refNum = refs ? nodeIdToRef.get(node.nodeId) ?? null : null;
211
289
 
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
- }
290
+ lines.push(formatNodeLine(node, depth, refNum, isNew, isMatch));
222
291
 
223
292
  // Depth limiting: at the limit, render as leaf (no children)
224
293
  if (maxDepth > 0 && depth >= maxDepth) return;
@@ -228,30 +297,6 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
228
297
  }
229
298
  }
230
299
 
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, {
241
- backendNodeId: node.backendDOMNodeId,
242
- nodeId: node.nodeId,
243
- role,
244
- name: node.name?.value ?? '',
245
- });
246
- refIndex.value++;
247
- }
248
- }
249
- for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
250
- advanceRefsForSubtree(child);
251
- }
252
- }
253
-
254
- const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
255
300
  for (const root of roots) visit(root, 0);
256
301
  // Second pass: catch disconnected nodes (skip when depth-limited to avoid false depth=0)
257
302
  if (!maxDepth) {
@@ -265,6 +310,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
265
310
  const changedCount = totalVisible - unchangedCount;
266
311
  text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
267
312
  }
313
+ // Empty state: no visible nodes and not an incremental diff -> page is blank/not ready
314
+ if (lines.length === 0 && !isDiff) {
315
+ text = emptyState('snap', 'empty accessibility tree');
316
+ }
268
317
  // Append scroll info footer when scrollable containers exist
269
318
  if (scrollables.length > 0) {
270
319
  text += `\n[scroll: ${scrollables.join(' | ')}]`;
@@ -2,6 +2,7 @@
2
2
  // Tracks command counts, timing, errors, and session timeline
3
3
 
4
4
  import { writeFileSync } from 'fs';
5
+ import { emptyState } from '../output.mjs';
5
6
 
6
7
  export class SessionStats {
7
8
  constructor() {
@@ -26,13 +27,15 @@ export class SessionStats {
26
27
  }
27
28
 
28
29
  export function statsStr(stats, full = false, exportPath = null) {
29
- if (!stats) return 'No stats available.';
30
+ if (!stats) return emptyState('stats', 'no stats available');
30
31
 
31
32
  const lines = [];
32
33
  const uptime = ((Date.now() - stats.startTime) / 1000).toFixed(0);
33
34
  const totalCmds = [...stats.commands.values()].reduce((s, e) => s + e.count, 0);
34
35
  const totalErrors = [...stats.commands.values()].reduce((s, e) => s + e.errors, 0);
35
36
 
37
+ if (totalCmds === 0) return emptyState('stats', `no commands executed yet (uptime: ${uptime}s)`);
38
+
36
39
  lines.push(`Session Stats (uptime: ${uptime}s, commands: ${totalCmds}, errors: ${totalErrors})`);
37
40
  lines.push('');
38
41
 
@@ -1,6 +1,7 @@
1
1
  // LocalStorage / SessionStorage management
2
2
 
3
3
  import { evalStr } from './evaluate.mjs';
4
+ import { emptyState } from '../output.mjs';
4
5
 
5
6
  export async function storageStr(cdp, sid, action) {
6
7
  switch (action) {
@@ -12,7 +13,7 @@ export async function storageStr(cdp, sid, action) {
12
13
  `);
13
14
  const data = JSON.parse(raw);
14
15
  const keys = Object.keys(data);
15
- if (keys.length === 0) return 'localStorage is empty.';
16
+ if (keys.length === 0) return emptyState('storage', 'localStorage is empty');
16
17
  return keys.map(k => {
17
18
  const v = data[k];
18
19
  const val = v && v.length > 80 ? v.slice(0, 80) + '...' : v;
@@ -28,7 +29,7 @@ export async function storageStr(cdp, sid, action) {
28
29
  `);
29
30
  const data = JSON.parse(raw);
30
31
  const keys = Object.keys(data);
31
- if (keys.length === 0) return 'sessionStorage is empty.';
32
+ if (keys.length === 0) return emptyState('storage', 'sessionStorage is empty');
32
33
  return keys.map(k => {
33
34
  const v = data[k];
34
35
  const val = v && v.length > 80 ? v.slice(0, 80) + '...' : v;
@@ -49,6 +49,7 @@ import { parseRef, clickRefStr, hoverRefStr, fillRefStr } from './commands/refs.
49
49
  import { highlightStr } from './commands/highlight.mjs';
50
50
  import { auditStr } from './commands/audit.mjs';
51
51
  import { SessionStats, statsStr } from './commands/stats.mjs';
52
+ import { generateHints, renderHints, isRefMapFresh } from './hints.mjs';
52
53
  import { sleep } from './utils.mjs';
53
54
 
54
55
  // Commands that modify visible DOM and should trigger automatic post-action snapshot.
@@ -121,6 +122,9 @@ export async function runDaemon(targetId, config) {
121
122
  // Per-tab state: ref map for @eN resolution + fingerprints for incremental diff
122
123
  let currentRefMap = new Map();
123
124
  let previousFingerprints = null;
125
+ // Track the last @eN that was filled so hints can prioritize the NEXT input
126
+ // in a multi-field form instead of re-suggesting the one we just touched.
127
+ let lastFilledRef = null;
124
128
  const sessionStats = new SessionStats();
125
129
 
126
130
  // Network request tracking (CDP Network domain) for detail drill-down
@@ -177,10 +181,11 @@ export async function runDaemon(targetId, config) {
177
181
  const startMs = Date.now();
178
182
  const auditResult = { ok: true };
179
183
  try {
180
- // Strip --no-snap before dispatch so it doesn't contaminate command args
181
- // (e.g. fill would type "--no-snap" into the input field).
184
+ // Strip --no-snap / --no-hints before dispatch so they don't contaminate
185
+ // command args (e.g. fill would type "--no-snap" into the input field).
182
186
  const noSnap = args.includes('--no-snap');
183
- if (noSnap) args = args.filter(a => a !== '--no-snap');
187
+ const noHints = args.includes('--no-hints');
188
+ if (noSnap || noHints) args = args.filter(a => a !== '--no-snap' && a !== '--no-hints');
184
189
 
185
190
  let result;
186
191
  let isRefCmd = false;
@@ -194,6 +199,7 @@ export async function runDaemon(targetId, config) {
194
199
  result = await clickRefStr(cdp, sessionId, currentRefMap, refNum, dbl);
195
200
  } else if (cmd === 'fill') {
196
201
  result = await fillRefStr(cdp, sessionId, currentRefMap, refNum, args.slice(1).join(' '));
202
+ lastFilledRef = refNum;
197
203
  } else if (cmd === 'hover') {
198
204
  result = await hoverRefStr(cdp, sessionId, currentRefMap, refNum);
199
205
  } else {
@@ -218,11 +224,17 @@ export async function runDaemon(targetId, config) {
218
224
  const forceFull = args.includes('--full');
219
225
  const depthArg = args.find(a => a.startsWith('--depth='));
220
226
  const maxDepth = depthArg ? parseInt(depthArg.split('=')[1]) || 0 : 0;
227
+ const queryArg = args.find(a => a.startsWith('--query='));
228
+ const query = queryArg ? queryArg.slice('--query='.length) : null;
221
229
  const prevFp = forceFull ? null : previousFingerprints;
222
- const snapResult = await snapshotStr(cdp, sessionId, true, useRefs, prevFp, maxDepth);
230
+ const snapResult = await snapshotStr(cdp, sessionId, true, useRefs, prevFp, maxDepth, query);
223
231
  result = snapResult.text;
232
+ // Always track fingerprints on the full tree -- this survives query filtering
233
+ // because snapshotStr computes them before applying the filter.
224
234
  previousFingerprints = snapResult.fingerprints;
225
- if (useRefs && snapResult.refMap.size > 0) {
235
+ // Replace the ref map even when it is empty: a fresh snapshot that found
236
+ // zero interactive refs must clear any stale @eN left from an older page.
237
+ if (useRefs) {
226
238
  currentRefMap = snapResult.refMap;
227
239
  }
228
240
  break;
@@ -253,12 +265,17 @@ export async function runDaemon(targetId, config) {
253
265
  result = await htmlStr(cdp, sessionId, args[0]);
254
266
  break;
255
267
  case 'nav': case 'navigate': {
256
- const navAction = args[0]?.toLowerCase();
257
268
  result = await navStr(cdp, sessionId, args[0], config);
258
- // Reset fingerprints for URL navigation and reload (new content)
259
- if (navAction !== 'back' && navAction !== 'forward') {
260
- previousFingerprints = null;
261
- }
269
+ // Any navigation changes page identity and can restore DOM via
270
+ // BFCache/hydration. Reset the diff baseline unconditionally so the
271
+ // first post-navigation snapshot is always a trustworthy full view.
272
+ previousFingerprints = null;
273
+ // ANY navigation (including back/forward) invalidates the ref map:
274
+ // the @eN numbers were assigned against a specific DOM render,
275
+ // and even back/forward can restore the page with different hydration.
276
+ // Auto-snap (if not --no-snap) will repopulate the refMap immediately below.
277
+ currentRefMap = new Map();
278
+ lastFilledRef = null;
262
279
  break;
263
280
  }
264
281
  case 'net': case 'network':
@@ -469,13 +486,33 @@ export async function runDaemon(targetId, config) {
469
486
  await sleep(settleMs);
470
487
  const snapResult = await snapshotStr(cdp, sessionId, true, true, previousFingerprints);
471
488
  previousFingerprints = snapResult.fingerprints;
472
- if (snapResult.refMap.size > 0) {
473
- currentRefMap = snapResult.refMap;
474
- }
489
+ // Same rule as explicit snap --refs: fresh empty ref maps must replace
490
+ // stale state, otherwise hints can suggest dead @eN from a prior screen.
491
+ currentRefMap = snapResult.refMap;
475
492
  result = (result ?? '') + '\n\n' + snapResult.text;
476
493
  } catch (e) { process.stderr.write(`[auto-snap] ${e.message}\n`); }
477
494
  }
478
495
 
496
+ // Contextual hints: append next-step suggestions to help the agent
497
+ // pick the next action without guessing. Opt-out via --no-hints.
498
+ // Only emit hints when the refMap is guaranteed FRESH for this command
499
+ // (either auto-snap ran, or the user explicitly asked for `snap --refs`).
500
+ // Without this guard, --no-snap or bare `snap` would emit hints pointing
501
+ // to @eN from a prior page -- stale and dangerous.
502
+ const shouldHint = !noHints && isRefMapFresh({ cmd, shouldSnap, noSnap, args });
503
+ if (shouldHint) {
504
+ try {
505
+ const hints = generateHints({
506
+ cmd,
507
+ refMap: currentRefMap,
508
+ lastFilledRef,
509
+ hasPage: true,
510
+ });
511
+ const hintsText = renderHints(hints);
512
+ if (hintsText) result = (result ?? '') + '\n\n' + hintsText;
513
+ } catch (e) { process.stderr.write(`[hints] ${e.message}\n`); }
514
+ }
515
+
479
516
  sessionStats.record(cmd, args, startMs, Date.now(), true, null);
480
517
  return { ok: true, result: result ?? '' };
481
518
  } catch (e) {
@@ -0,0 +1,203 @@
1
+ // Contextual next-step hints generator.
2
+ // Pure function, zero deps. Consumes refMap + last action context
3
+ // and produces up to MAX_HINTS suggestions of "what to do next".
4
+ //
5
+ // Design notes:
6
+ // - Hints always use a <t> placeholder for target because the daemon
7
+ // does not know the CLI prefix the agent is using.
8
+ // - Heuristic is deliberately simple: bucket refs by role, then pick
9
+ // based on the last command. More sophisticated ranking can come
10
+ // later if measurement shows it matters.
11
+ // - After `fill`, a submit button is the most likely next step, so
12
+ // it gets priority. We fall back to "key Enter" when no submit is
13
+ // visible (common on search boxes).
14
+
15
+ const SUBMIT_RX = /submit|login|log\s*in|sign\s*in|signin|send|search|go\b|continue|confirm|\bok\b|apply|save|create|next/i;
16
+
17
+ export const MAX_HINTS = 3;
18
+
19
+ const INPUT_ROLES = new Set(['textbox', 'searchbox', 'spinbutton', 'slider']);
20
+
21
+ /**
22
+ * @typedef {Object} Hint
23
+ * @property {string} cmd - CLI command with <t> placeholder
24
+ * @property {string} comment - Short human-readable context annotation
25
+ */
26
+
27
+ /**
28
+ * Classify refMap entries by interaction type.
29
+ * Returns buckets preserving original ref order (stable across calls).
30
+ *
31
+ * @param {Map<number, {role: string, name: string}>} refMap
32
+ */
33
+ function bucket(refMap) {
34
+ const submitButtons = [];
35
+ const buttons = [];
36
+ const inputs = [];
37
+ const selects = [];
38
+ const links = [];
39
+
40
+ for (const [num, ref] of refMap.entries()) {
41
+ const role = (ref.role || '').toLowerCase();
42
+ const name = ref.name || '';
43
+ const entry = { num, name, role };
44
+
45
+ if (role === 'button') {
46
+ if (SUBMIT_RX.test(name)) submitButtons.push(entry);
47
+ else buttons.push(entry);
48
+ } else if (INPUT_ROLES.has(role)) {
49
+ inputs.push(entry);
50
+ } else if (role === 'combobox') {
51
+ selects.push(entry);
52
+ } else if (role === 'link') {
53
+ links.push(entry);
54
+ }
55
+ }
56
+
57
+ return { submitButtons, buttons, inputs, selects, links };
58
+ }
59
+
60
+ function truncate(s, max = 40) {
61
+ if (!s) return '';
62
+ if (s.length <= max) return s;
63
+ return s.slice(0, max) + '...';
64
+ }
65
+
66
+ function commentFor(entry) {
67
+ if (!entry.name) return entry.role;
68
+ return `${entry.role} "${truncate(entry.name)}"`;
69
+ }
70
+
71
+ function clickHint(entry) {
72
+ return { cmd: `chromex click <t> @e${entry.num}`, comment: commentFor(entry) };
73
+ }
74
+
75
+ function fillHint(entry) {
76
+ return { cmd: `chromex fill <t> @e${entry.num} "<value>"`, comment: commentFor(entry) };
77
+ }
78
+
79
+ /**
80
+ * Generate up to MAX_HINTS next-step suggestions based on command context and refMap.
81
+ *
82
+ * @param {Object} ctx
83
+ * @param {string} ctx.cmd - Command just executed
84
+ * @param {Map<number, {role,name,backendNodeId}>} ctx.refMap
85
+ * @param {number|null} [ctx.lastFilledRef] - Ref number just filled (flow awareness)
86
+ * @param {boolean} [ctx.hasPage] - Whether a page/tab is active
87
+ * @returns {Hint[]}
88
+ */
89
+ export function generateHints({ cmd, refMap, lastFilledRef = null, hasPage = true }) {
90
+ // No page/tab -> bootstrap hints
91
+ if (!hasPage) {
92
+ return [
93
+ { cmd: 'chromex list', comment: 'see open tabs' },
94
+ { cmd: 'chromex launch', comment: 'start a browser with remote debugging' },
95
+ ];
96
+ }
97
+
98
+ // Missing refMap usually means the caller never captured refs for this page.
99
+ // In that case, teach the agent how to populate them.
100
+ if (!refMap) {
101
+ return [
102
+ { cmd: 'chromex snap <t> --refs', comment: 'assign @eN refs to interactive elements' },
103
+ ];
104
+ }
105
+
106
+ // Fresh snapshot with an EMPTY refMap means there are no interactive refs on
107
+ // the page we just rendered. Returning "snap --refs" here would create a
108
+ // pointless loop, because the agent already did that work and learned "none".
109
+ if (refMap.size === 0) return [];
110
+
111
+ const buckets = bucket(refMap);
112
+ const { submitButtons, buttons, inputs, links } = buckets;
113
+
114
+ // After fill: submit is most likely next step
115
+ if (cmd === 'fill') {
116
+ const hints = [];
117
+ if (submitButtons.length > 0) {
118
+ hints.push(clickHint(submitButtons[0]));
119
+ } else {
120
+ hints.push({ cmd: 'chromex key <t> Enter', comment: 'submit via Enter key' });
121
+ }
122
+ // Next unfilled input keeps the flow going
123
+ const unfilled = inputs.filter((i) => i.num !== lastFilledRef);
124
+ if (unfilled.length > 0 && hints.length < MAX_HINTS) {
125
+ hints.push(fillHint(unfilled[0]));
126
+ }
127
+ return hints.slice(0, MAX_HINTS);
128
+ }
129
+
130
+ // After nav: first input is usually the entry point (search, login, etc)
131
+ if (cmd === 'nav' || cmd === 'navigate') {
132
+ const hints = [];
133
+ if (inputs.length > 0) hints.push(fillHint(inputs[0]));
134
+ if (hints.length < MAX_HINTS && submitButtons.length > 0) hints.push(clickHint(submitButtons[0]));
135
+ else if (hints.length < MAX_HINTS && buttons.length > 0) hints.push(clickHint(buttons[0]));
136
+ if (hints.length < MAX_HINTS && links.length > 0) hints.push(clickHint(links[0]));
137
+ return hints.slice(0, MAX_HINTS);
138
+ }
139
+
140
+ // Default (snap, click, hover, others): top interactives with non-empty labels
141
+ return topInteractives(buckets, MAX_HINTS);
142
+ }
143
+
144
+ function topInteractives({ submitButtons, buttons, inputs, selects, links }, max) {
145
+ const ordered = [...inputs, ...submitButtons, ...buttons, ...selects, ...links];
146
+ const hints = [];
147
+ for (const entry of ordered) {
148
+ if (hints.length >= max) break;
149
+ if (!entry.name) continue; // skip nameless nodes -- they rarely help the agent
150
+ hints.push(INPUT_ROLES.has(entry.role) ? fillHint(entry) : clickHint(entry));
151
+ }
152
+ return hints;
153
+ }
154
+
155
+ /**
156
+ * Decide whether the daemon's currentRefMap is "fresh" for this command,
157
+ * i.e. safe to feed into generateHints without producing stale @eN pointers.
158
+ *
159
+ * Fresh means a snapshot with refs=true actually ran in THIS command:
160
+ * - auto-snap fired (shouldSnap && !noSnap) -- daemon always passes refs=true there
161
+ * - OR explicit `snap` with --refs/-i flag
162
+ *
163
+ * If neither happened, currentRefMap may still hold refs from a prior page
164
+ * (for example after nav --no-snap, or after a bare `snap` without --refs),
165
+ * so hints would point to coordinates that no longer exist in the DOM.
166
+ *
167
+ * This is a pure function so the daemon logic stays testable.
168
+ *
169
+ * @param {Object} ctx
170
+ * @param {string} ctx.cmd
171
+ * @param {boolean} ctx.shouldSnap - Whether this cmd is in the AUTO_SNAP_CMDS set
172
+ * @param {boolean} ctx.noSnap - Whether the caller passed --no-snap
173
+ * @param {string[]} ctx.args - Command args (to detect --refs / -i on snap)
174
+ * @returns {boolean}
175
+ */
176
+ export function isRefMapFresh({ cmd, shouldSnap, noSnap, args }) {
177
+ // Auto-snap always uses refs=true in the daemon -- if it ran, refs are fresh.
178
+ if (shouldSnap && !noSnap) return true;
179
+ // Explicit `snap --refs` (or `snap -i` alias) also populates refMap.
180
+ if ((cmd === 'snap' || cmd === 'snapshot') && Array.isArray(args)) {
181
+ if (args.includes('--refs') || args.includes('-i')) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ /**
187
+ * Render an array of hints into the chromex help[] block format.
188
+ * Format mirrors what Claude can parse unambiguously:
189
+ * help[N]:
190
+ * chromex <cmd> # comment
191
+ *
192
+ * @param {Hint[]} hints
193
+ * @returns {string} Empty string when hints is empty/missing.
194
+ */
195
+ export function renderHints(hints) {
196
+ if (!hints || hints.length === 0) return '';
197
+ const header = `help[${hints.length}]:`;
198
+ const lines = hints.map((h) => {
199
+ if (h.comment) return ` ${h.cmd} # ${h.comment}`;
200
+ return ` ${h.cmd}`;
201
+ });
202
+ return `${header}\n${lines.join('\n')}`;
203
+ }
@@ -0,0 +1,74 @@
1
+ // Standardized output helpers: empty states, aggregates, indentation.
2
+ // Pure functions, zero deps. Shared by commands and hints.
3
+
4
+ /**
5
+ * Render a standardized empty state for a given domain.
6
+ * Used to distinguish "no results" from "silent failure" so agents
7
+ * do not retry commands hoping for different output.
8
+ *
9
+ * @param {string} domain - command or data domain (e.g. 'network', 'console')
10
+ * @param {string} [msg='empty'] - human-readable reason
11
+ * @returns {string}
12
+ *
13
+ * @example
14
+ * emptyState('network', '0 requests captured')
15
+ * // => 'network: 0 requests captured'
16
+ */
17
+ export function emptyState(domain, msg = 'empty') {
18
+ return `${domain}: ${msg}`;
19
+ }
20
+
21
+ /**
22
+ * Render an aggregate header with count and optional key:value metadata.
23
+ * Embeds totals/counters into the first line of output so agents do not
24
+ * need a follow-up command just to ask "how many?".
25
+ *
26
+ * @param {string} domain
27
+ * @param {number} count
28
+ * @param {Object<string, string|number>} [meta]
29
+ * @returns {string}
30
+ *
31
+ * @example
32
+ * aggregate('network', 47, { errors: 3, pending: 0 })
33
+ * // => 'network[47] errors:3 pending:0'
34
+ */
35
+ export function aggregate(domain, count, meta) {
36
+ const header = `${domain}[${count}]`;
37
+ if (!meta) return header;
38
+ const parts = Object.entries(meta).map(([k, v]) => `${k}:${v}`);
39
+ if (parts.length === 0) return header;
40
+ return `${header} ${parts.join(' ')}`;
41
+ }
42
+
43
+ /**
44
+ * Format a byte count into a human-readable string (B / KB / MB / GB).
45
+ * Keeps one decimal place above 1KB. Returns '?' for null/undefined.
46
+ *
47
+ * @param {number|null|undefined} bytes
48
+ * @returns {string}
49
+ */
50
+ export function formatBytes(bytes) {
51
+ if (bytes == null) return '?';
52
+ if (bytes < 1024) return `${bytes}B`;
53
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
54
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
55
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
56
+ }
57
+
58
+ /**
59
+ * Indent every non-empty line of a block by `level` (2 spaces per level).
60
+ * Empty lines are preserved as empty (no trailing whitespace).
61
+ *
62
+ * @param {string} text
63
+ * @param {number} [level=1]
64
+ * @returns {string}
65
+ */
66
+ export function indent(text, level = 1) {
67
+ if (!text) return '';
68
+ if (level <= 0) return text;
69
+ const pad = ' '.repeat(level);
70
+ return text
71
+ .split('\n')
72
+ .map((line) => (line === '' ? '' : pad + line))
73
+ .join('\n');
74
+ }
@@ -14,7 +14,7 @@ import { launchBrowser, incognitoContext } from './lib/launcher.mjs';
14
14
  import { openTabStr, closeTabStr, focusTabStr } from './lib/commands/tab.mjs';
15
15
 
16
16
  const config = loadConfig();
17
- const SERVER_INFO = { name: 'chromex', version: '1.4.0' };
17
+ const SERVER_INFO = { name: 'chromex', version: '1.5.0' };
18
18
 
19
19
  // ---- JSON-RPC helpers ----
20
20
 
@@ -55,6 +55,7 @@ function tool(name, description, properties, required, annotations) {
55
55
 
56
56
  const P_TARGET = { type: 'string', description: 'Target ID prefix from chromex_list' };
57
57
  const P_NO_SNAP = { type: 'boolean', description: 'Skip auto-snapshot after action' };
58
+ const P_NO_HINTS = { type: 'boolean', description: 'Skip contextual help[] suggestions in output' };
58
59
 
59
60
  // ---- Tool definitions (56 tools) ----
60
61
 
@@ -102,12 +103,14 @@ const TOOLS = [
102
103
 
103
104
  // == INSPECT (readOnly) ==
104
105
  tool('chromex_snapshot',
105
- 'Accessibility tree snapshot. Returns incremental diff after first call (only changed nodes). Use refs=true to get @eN references for click/fill/hover.',
106
+ 'Accessibility tree snapshot. Returns incremental diff after first call (only changed nodes). Use refs=true to get @eN references for click/fill/hover. Use query to filter to matching nodes + ancestors (preserves hierarchy, slashes page output for large sites). Hints (help[]) are only emitted when refs=true because they rely on a fresh refMap.',
106
107
  {
107
108
  target: P_TARGET,
108
109
  refs: { type: 'boolean', description: 'Assign @eN refs to interactive elements', default: false },
109
110
  full: { type: 'boolean', description: 'Force full snapshot (skip incremental diff)', default: false },
110
111
  depth: { type: 'number', description: 'Max tree depth (0 = unlimited)' },
112
+ query: { type: 'string', description: 'Filter tree to nodes matching substring (case-insensitive) in role/name/value. Ancestors preserved so hierarchy is intact. @eN refs stay stable.' },
113
+ noHints: P_NO_HINTS,
111
114
  }, ['target'], RO),
112
115
 
113
116
  tool('chromex_html',
@@ -185,6 +188,7 @@ const TOOLS = [
185
188
  target: P_TARGET,
186
189
  url: { type: 'string', description: 'URL, or action: "back", "forward", "reload", "reload-hard"' },
187
190
  noSnap: P_NO_SNAP,
191
+ noHints: P_NO_HINTS,
188
192
  }, ['target', 'url'], RW),
189
193
 
190
194
  tool('chromex_waitfor',
@@ -219,6 +223,7 @@ const TOOLS = [
219
223
  selector: { type: 'string', description: 'CSS selector or @eN ref' },
220
224
  dblClick: { type: 'boolean', description: 'Double-click instead of single click', default: false },
221
225
  noSnap: P_NO_SNAP,
226
+ noHints: P_NO_HINTS,
222
227
  }, ['target', 'selector'], RW),
223
228
 
224
229
  tool('chromex_clickxy',
@@ -229,6 +234,7 @@ const TOOLS = [
229
234
  y: { type: 'number', description: 'Y in CSS pixels' },
230
235
  dblClick: { type: 'boolean', description: 'Double-click instead of single click', default: false },
231
236
  noSnap: P_NO_SNAP,
237
+ noHints: P_NO_HINTS,
232
238
  }, ['target', 'x', 'y'], RW),
233
239
 
234
240
  tool('chromex_type',
@@ -237,6 +243,7 @@ const TOOLS = [
237
243
  target: P_TARGET,
238
244
  text: { type: 'string', description: 'Text to type' },
239
245
  noSnap: P_NO_SNAP,
246
+ noHints: P_NO_HINTS,
240
247
  }, ['target', 'text'], RW),
241
248
 
242
249
  tool('chromex_hover',
@@ -253,6 +260,7 @@ const TOOLS = [
253
260
  from: { type: 'string', description: 'Source selector or x,y' },
254
261
  to: { type: 'string', description: 'Destination selector or x,y' },
255
262
  noSnap: P_NO_SNAP,
263
+ noHints: P_NO_HINTS,
256
264
  }, ['target', 'from', 'to'], RW),
257
265
 
258
266
  tool('chromex_touch',
@@ -262,6 +270,7 @@ const TOOLS = [
262
270
  gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
263
271
  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])' },
264
272
  noSnap: P_NO_SNAP,
273
+ noHints: P_NO_HINTS,
265
274
  }, ['target', 'gesture'], RW),
266
275
 
267
276
  tool('chromex_dialog',
@@ -271,6 +280,7 @@ const TOOLS = [
271
280
  action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
272
281
  text: { type: 'string', description: 'Text for prompt (only with accept)' },
273
282
  noSnap: P_NO_SNAP,
283
+ noHints: P_NO_HINTS,
274
284
  }, ['target', 'action'], RW),
275
285
 
276
286
  tool('chromex_press_key',
@@ -279,6 +289,7 @@ const TOOLS = [
279
289
  target: P_TARGET,
280
290
  key: { type: 'string', description: 'Key or combination: "Enter", "Tab", "Escape", "Control+A", "Control+Shift+R", "Meta+C"' },
281
291
  noSnap: P_NO_SNAP,
292
+ noHints: P_NO_HINTS,
282
293
  }, ['target', 'key'], RW),
283
294
 
284
295
  tool('chromex_loadall',
@@ -288,6 +299,7 @@ const TOOLS = [
288
299
  selector: { type: 'string', description: 'CSS selector of load-more button' },
289
300
  interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
290
301
  noSnap: P_NO_SNAP,
302
+ noHints: P_NO_HINTS,
291
303
  }, ['target', 'selector'], RW),
292
304
 
293
305
  // == FORMS ==
@@ -298,6 +310,7 @@ const TOOLS = [
298
310
  selector: { type: 'string', description: 'CSS selector or @eN ref' },
299
311
  value: { type: 'string', description: 'Value to fill' },
300
312
  noSnap: P_NO_SNAP,
313
+ noHints: P_NO_HINTS,
301
314
  }, ['target', 'selector', 'value'], RW),
302
315
 
303
316
  tool('chromex_clear',
@@ -306,6 +319,7 @@ const TOOLS = [
306
319
  target: P_TARGET,
307
320
  selector: { type: 'string', description: 'CSS selector' },
308
321
  noSnap: P_NO_SNAP,
322
+ noHints: P_NO_HINTS,
309
323
  }, ['target', 'selector'], RW),
310
324
 
311
325
  tool('chromex_select',
@@ -315,6 +329,7 @@ const TOOLS = [
315
329
  selector: { type: 'string', description: 'CSS selector of select element' },
316
330
  value: { type: 'string', description: 'Option value or visible text' },
317
331
  noSnap: P_NO_SNAP,
332
+ noHints: P_NO_HINTS,
318
333
  }, ['target', 'selector', 'value'], RW),
319
334
 
320
335
  tool('chromex_check',
@@ -324,6 +339,7 @@ const TOOLS = [
324
339
  selector: { type: 'string', description: 'CSS selector' },
325
340
  checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
326
341
  noSnap: P_NO_SNAP,
342
+ noHints: P_NO_HINTS,
327
343
  }, ['target', 'selector'], RW),
328
344
 
329
345
  tool('chromex_form',
@@ -332,6 +348,7 @@ const TOOLS = [
332
348
  target: P_TARGET,
333
349
  fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
334
350
  noSnap: P_NO_SNAP,
351
+ noHints: P_NO_HINTS,
335
352
  }, ['target', 'fields'], RW),
336
353
 
337
354
  tool('chromex_upload',
@@ -341,6 +358,7 @@ const TOOLS = [
341
358
  selector: { type: 'string', description: 'CSS selector of file input' },
342
359
  files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
343
360
  noSnap: P_NO_SNAP,
361
+ noHints: P_NO_HINTS,
344
362
  }, ['target', 'selector', 'files'], RW),
345
363
 
346
364
  // == DATA ==
@@ -516,6 +534,7 @@ function toolToCmd(name, p) {
516
534
  if (p.refs) a.push('--refs');
517
535
  if (p.full) a.push('--full');
518
536
  if (p.depth) a.push(`--depth=${p.depth}`);
537
+ if (p.query) a.push(`--query=${p.query}`);
519
538
  return { cmd: 'snap', args: a };
520
539
  }
521
540
  case 'chromex_html': return { cmd: 'html', args: p.selector ? [p.selector] : [] };
@@ -713,6 +732,7 @@ async function executeTool(name, params) {
713
732
  if (!mapped) return fail(`Unknown tool: ${name}`);
714
733
 
715
734
  if (params.noSnap) mapped.args.push('--no-snap');
735
+ if (params.noHints) mapped.args.push('--no-hints');
716
736
 
717
737
  const conn = await getOrStartTabDaemon(targetId, config);
718
738
  const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });