@mehmoodqureshi/chrome-mcp 0.9.2 → 0.9.4

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.
@@ -10,30 +10,47 @@
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.collectSnapshot = collectSnapshot;
13
- /** Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with fresh refs. */
14
- function collectSnapshot(interactiveOnly = true, max = 200) {
13
+ /**
14
+ * Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with
15
+ * fresh refs.
16
+ *
17
+ * With a `locator`, the walk is the same but the SCORING happens here: only the
18
+ * strongest-tier matches come back (and only they get a ref), so resolving
19
+ * "the Sign in button" ships a handful of nodes instead of the whole tree and
20
+ * touches one or two DOM attributes instead of hundreds. The tiers mirror
21
+ * `src/mcp/locate.ts` exactly (exact, case-insensitive, prefix, contains), and
22
+ * the server re-scores what it receives, so both ends always agree.
23
+ *
24
+ * Reads (visibility, names, values) all happen BEFORE the ref attributes are
25
+ * written: interleaving them made every `innerText` after a `setAttribute`
26
+ * re-run style, which on a big page is most of the snapshot's cost.
27
+ */
28
+ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
15
29
  const INTERACTIVE = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=tab],[role=checkbox],[role=radio],[role=menuitem],[role=option],[role=switch],[contenteditable=true],[onclick]';
16
30
  const LANDMARK = 'h1,h2,h3,[role=heading],nav,main,header,footer,[role=navigation]';
17
31
  const sel = interactiveOnly ? INTERACTIVE : `${INTERACTIVE},${LANDMARK}`;
18
32
  const visible = (el) => {
19
- const r = el.getBoundingClientRect();
20
- if (r.width === 0 && r.height === 0)
21
- return false;
22
- const s = window.getComputedStyle(el);
23
- if (s.visibility === 'hidden' || s.display === 'none')
24
- return false;
25
- // Prefer the native check (accounts for ancestors, content-visibility, etc.).
26
- const cv = el.checkVisibility;
27
- if (typeof cv === 'function') {
33
+ const h = el;
34
+ // The native check covers display:none / visibility:hidden on the element
35
+ // AND its ancestors plus content-visibility, in one call and without a
36
+ // getComputedStyle. Only a zero-size box needs the (cheap) rect on top.
37
+ if (typeof h.checkVisibility === 'function') {
28
38
  try {
29
- return cv.call(el, { checkOpacity: false, checkVisibilityCSS: true });
39
+ if (!h.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }))
40
+ return false;
41
+ const r = h.getBoundingClientRect();
42
+ return r.width !== 0 || r.height !== 0;
30
43
  }
31
44
  catch {
32
- /* fall through to manual ancestor walk */
45
+ /* fall through to the manual walk */
33
46
  }
34
47
  }
35
- // Fallback: walk ancestors for display:none / visibility:hidden. An element
36
- // with no offsetParent (and not position:fixed) is detached/hidden.
48
+ const r = h.getBoundingClientRect();
49
+ if (r.width === 0 && r.height === 0)
50
+ return false;
51
+ const s = window.getComputedStyle(h);
52
+ if (s.visibility === 'hidden' || s.display === 'none')
53
+ return false;
37
54
  let p = el.parentElement;
38
55
  while (p) {
39
56
  const ps = window.getComputedStyle(p);
@@ -41,7 +58,7 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
41
58
  return false;
42
59
  p = p.parentElement;
43
60
  }
44
- if (el.offsetParent === null && s.position !== 'fixed')
61
+ if (h.offsetParent === null && s.position !== 'fixed')
45
62
  return false;
46
63
  return true;
47
64
  };
@@ -101,45 +118,40 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
101
118
  return;
102
119
  let matched;
103
120
  try {
104
- matched = Array.from(root.querySelectorAll(sel));
121
+ matched = root.querySelectorAll(sel);
105
122
  }
106
123
  catch {
107
124
  matched = [];
108
125
  }
109
- for (const el of matched) {
126
+ for (let i = 0; i < matched.length; i++) {
110
127
  if (candidates.length >= max)
111
128
  break;
129
+ const el = matched[i];
112
130
  if (seen.has(el))
113
131
  continue;
114
132
  seen.add(el);
115
133
  candidates.push(el);
116
134
  }
117
- // Descend into any open shadow roots hosted under this root.
135
+ // Descend into any open shadow roots hosted under this root. A plain index
136
+ // loop over the live NodeList: no Array.from copy of every element on the page.
118
137
  let hosts;
119
138
  try {
120
- hosts = Array.from(root.querySelectorAll('*'));
139
+ hosts = root.querySelectorAll('*');
121
140
  }
122
141
  catch {
123
142
  hosts = [];
124
143
  }
125
- for (const host of hosts) {
144
+ for (let i = 0; i < hosts.length; i++) {
126
145
  if (candidates.length >= max)
127
146
  break;
128
- const sr = host.shadowRoot;
147
+ const sr = hosts[i].shadowRoot;
129
148
  if (sr)
130
149
  collect(sr);
131
150
  }
132
151
  };
133
152
  collect(document);
134
- const els = candidates.filter(visible);
135
- const nodes = [];
136
- let n = 0;
137
- for (const el of els) {
138
- if (nodes.length >= max)
139
- break;
140
- const ref = `e${++n}`;
141
- el.setAttribute('data-mcp-ref', ref);
142
- const node = { ref, role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
153
+ const read = (el) => {
154
+ const node = { role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
143
155
  // A password field's characters never leave the page. The node still
144
156
  // appears (so the model can target it) and is flagged `secret`, but the
145
157
  // value is not something a caller has a use for and every caller would
@@ -154,8 +166,80 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
154
166
  node.disabled = true;
155
167
  if (el.checked)
156
168
  node.checked = true;
157
- nodes.push(node);
169
+ return { el, node };
170
+ };
171
+ const els = candidates.filter(visible);
172
+ let picked;
173
+ let truncated = false;
174
+ let nearby;
175
+ let refPrefix = 'e';
176
+ if (locator && (locator.role !== undefined || locator.name !== undefined)) {
177
+ // Locator mode: score every visible candidate, keep the strongest tier.
178
+ const norm = (x) => x.replace(/\s+/g, ' ').trim().toLowerCase();
179
+ const wantRole = locator.role !== undefined ? norm(locator.role) : null;
180
+ const wantName = locator.name;
181
+ const score = (role, name) => {
182
+ if (wantRole !== null && norm(role) !== wantRole)
183
+ return 0;
184
+ if (!wantName)
185
+ return 1;
186
+ const have = norm(name);
187
+ const need = norm(wantName);
188
+ if (!have)
189
+ return 0;
190
+ if (name.trim() === wantName.trim())
191
+ return 4;
192
+ if (have === need)
193
+ return 3;
194
+ if (have.startsWith(need))
195
+ return 2;
196
+ if (have.includes(need))
197
+ return 1;
198
+ return 0;
199
+ };
200
+ let best = 0;
201
+ picked = [];
202
+ const sameRole = [];
203
+ for (const el of els) {
204
+ const s = read(el);
205
+ const sc = score(s.node.role, s.node.name);
206
+ if (sc === 0) {
207
+ if ((wantRole === null || norm(s.node.role) === wantRole) && sameRole.length < 8) {
208
+ sameRole.push(`${s.node.role} "${s.node.name}"`);
209
+ }
210
+ continue;
211
+ }
212
+ if (sc > best) {
213
+ best = sc;
214
+ picked = [s];
215
+ }
216
+ else if (sc === best) {
217
+ picked.push(s);
218
+ }
219
+ }
220
+ if (picked.length === 0)
221
+ nearby = sameRole;
222
+ // Refs from a locate must not collide with a prior snapshot's e1..eN, which
223
+ // may still be stamped on OTHER elements: use a distinct, per-call prefix.
224
+ refPrefix = `l${Date.now().toString(36).slice(-4)}-`;
225
+ }
226
+ else {
227
+ picked = [];
228
+ for (const el of els) {
229
+ if (picked.length >= max)
230
+ break;
231
+ picked.push(read(el));
232
+ }
233
+ truncated = els.length > picked.length;
234
+ }
235
+ // -- phase 2: writes (one attribute per returned node) ----------------------
236
+ const nodes = [];
237
+ let n = 0;
238
+ for (const { el, node } of picked) {
239
+ const ref = `${refPrefix}${++n}`;
240
+ el.setAttribute('data-mcp-ref', ref);
241
+ nodes.push({ ref, ...node });
158
242
  }
159
- return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
243
+ return { url: location.href, title: document.title, nodes, truncated, ...(nearby ? { nearby } : {}) };
160
244
  }
161
245
  //# sourceMappingURL=snapshot.js.map
@@ -36,6 +36,10 @@ export declare class ExtensionConnection {
36
36
  private readonly reportsTabUrl;
37
37
  /** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
38
38
  private activeUrl;
39
+ /** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
40
+ * `tabs_list` result fills this for EVERY tab at once, which is what lets a
41
+ * parallel batch over N tabs gate on one round-trip instead of N. */
42
+ private readonly tabUrls;
39
43
  private readonly onEvent?;
40
44
  private readonly onClose?;
41
45
  private readonly onLog?;
@@ -49,6 +53,15 @@ export declare class ExtensionConnection {
49
53
  isOpen(): boolean;
50
54
  private handleMessage;
51
55
  private settle;
56
+ /**
57
+ * Per-tab cache. Two feeds: a result for an explicitly-targeted tab carries
58
+ * that tab's landing URL; a `tabs_list` result carries every tab's URL, so one
59
+ * listing primes the gate for every op of a batch that follows it. A closed
60
+ * tab is forgotten; a blank URL (Chrome hiding it) is forgotten too, never kept.
61
+ */
62
+ private rememberTabUrls;
63
+ /** A specific tab's last reported URL if younger than `maxAgeMs`, else null. */
64
+ lastTabUrl(tabId: string, maxAgeMs: number): string | null;
52
65
  /**
53
66
  * Cache the URL a result rode home with — but ONLY when it describes the active
54
67
  * tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
@@ -38,6 +38,8 @@ function mapWireErrorCode(code) {
38
38
  }
39
39
  /** Commands that can change WHICH tab is active, invalidating a cached URL. */
40
40
  const ACTIVE_TAB_CHANGERS = new Set(['tab_select', 'tab_new', 'tab_close']);
41
+ /** Bound on the per-tab URL cache; entries beyond it are evicted oldest-first. */
42
+ const MAX_TAB_URL_ENTRIES = 256;
41
43
  class ExtensionConnection {
42
44
  extId;
43
45
  sessionId;
@@ -51,6 +53,10 @@ class ExtensionConnection {
51
53
  reportsTabUrl;
52
54
  /** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
53
55
  activeUrl = null;
56
+ /** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
57
+ * `tabs_list` result fills this for EVERY tab at once, which is what lets a
58
+ * parallel batch over N tabs gate on one round-trip instead of N. */
59
+ tabUrls = new Map();
54
60
  onEvent;
55
61
  onClose;
56
62
  onLog;
@@ -98,7 +104,7 @@ class ExtensionConnection {
98
104
  reject(new types_1.ExecutorError('TIMEOUT', `"${method}" timed out after ${timeoutMs}ms`));
99
105
  }, timeoutMs);
100
106
  timer.unref?.();
101
- this.pending.set(id, { resolve, reject, timer, method, activeTab: opts?.tabId === undefined });
107
+ this.pending.set(id, { resolve, reject, timer, method, activeTab: opts?.tabId === undefined, tabId: opts?.tabId });
102
108
  try {
103
109
  this.ws.send(JSON.stringify(frame));
104
110
  }
@@ -161,15 +167,63 @@ class ExtensionConnection {
161
167
  this.pending.delete(id);
162
168
  if (frame.type === 'result') {
163
169
  this.rememberActiveUrl(p, frame);
170
+ this.rememberTabUrls(p, frame);
164
171
  p.resolve(frame.data);
165
172
  }
166
173
  else {
167
174
  // A failed command tells us nothing reliable about where the tab ended up.
168
175
  if (p.activeTab)
169
176
  this.activeUrl = null;
177
+ if (p.tabId)
178
+ this.tabUrls.delete(p.tabId);
170
179
  p.reject(new types_1.ExecutorError(mapWireErrorCode(frame.error.code), frame.error.message));
171
180
  }
172
181
  }
182
+ /**
183
+ * Per-tab cache. Two feeds: a result for an explicitly-targeted tab carries
184
+ * that tab's landing URL; a `tabs_list` result carries every tab's URL, so one
185
+ * listing primes the gate for every op of a batch that follows it. A closed
186
+ * tab is forgotten; a blank URL (Chrome hiding it) is forgotten too, never kept.
187
+ */
188
+ rememberTabUrls(p, frame) {
189
+ if (!this.reportsTabUrl)
190
+ return;
191
+ const now = Date.now();
192
+ if (p.method === 'tab_close' && p.tabId) {
193
+ this.tabUrls.delete(p.tabId);
194
+ return;
195
+ }
196
+ if (p.method === 'tabs_list' && Array.isArray(frame.data)) {
197
+ for (const t of frame.data) {
198
+ if (typeof t.tabId !== 'string')
199
+ continue;
200
+ if (typeof t.url === 'string' && t.url)
201
+ this.tabUrls.set(t.tabId, { url: t.url, at: now });
202
+ else
203
+ this.tabUrls.delete(t.tabId);
204
+ }
205
+ }
206
+ else if (p.tabId) {
207
+ if (frame.tabUrl)
208
+ this.tabUrls.set(p.tabId, { url: frame.tabUrl, at: now });
209
+ else
210
+ this.tabUrls.delete(p.tabId);
211
+ }
212
+ // Map iteration is insertion-ordered; drop the oldest until bounded.
213
+ while (this.tabUrls.size > MAX_TAB_URL_ENTRIES) {
214
+ const oldest = this.tabUrls.keys().next().value;
215
+ if (oldest === undefined)
216
+ break;
217
+ this.tabUrls.delete(oldest);
218
+ }
219
+ }
220
+ /** A specific tab's last reported URL if younger than `maxAgeMs`, else null. */
221
+ lastTabUrl(tabId, maxAgeMs) {
222
+ const hit = this.tabUrls.get(tabId);
223
+ if (!hit)
224
+ return null;
225
+ return Date.now() - hit.at <= maxAgeMs ? hit.url : null;
226
+ }
173
227
  /**
174
228
  * Cache the URL a result rode home with — but ONLY when it describes the active
175
229
  * tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
@@ -71,6 +71,8 @@ export declare class BridgeServer {
71
71
  * to report URLs, a tab shuffle since, or simply nothing recent enough.
72
72
  */
73
73
  lastActiveUrl(profile: string | undefined, maxAgeMs: number): string | null;
74
+ /** Same, for an explicitly-targeted tab (wire id) — see ExtensionConnection.lastTabUrl. */
75
+ lastTabUrl(profile: string | undefined, tabId: string, maxAgeMs: number): string | null;
74
76
  private noPairMessage;
75
77
  status(): {
76
78
  extensionConnected: boolean;
@@ -187,6 +187,11 @@ class BridgeServer {
187
187
  const conn = this.conns.get(routeKey(profile));
188
188
  return conn?.isOpen() ? conn.lastActiveUrl(maxAgeMs) : null;
189
189
  }
190
+ /** Same, for an explicitly-targeted tab (wire id) — see ExtensionConnection.lastTabUrl. */
191
+ lastTabUrl(profile, tabId, maxAgeMs) {
192
+ const conn = this.conns.get(routeKey(profile));
193
+ return conn?.isOpen() ? conn.lastTabUrl(tabId, maxAgeMs) : null;
194
+ }
190
195
  noPairMessage(profile) {
191
196
  return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
192
197
  `extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
@@ -44,7 +44,7 @@ export declare function saveResult(tool: string, ext: string, body: string): str
44
44
  */
45
45
  export declare function saveBinary(tool: string, ext: string, bytes: Buffer): string | null;
46
46
  /** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
47
- export declare function saveScreenshot(dataBase64: string): string | null;
47
+ export declare function saveScreenshot(dataBase64: string, ext?: 'png' | 'jpg'): string | null;
48
48
  /**
49
49
  * Move a file Chrome saved to the user's Downloads dir into the active task's
50
50
  * `downloads/`. The name is re-hardened via {@link sanitizeDownloadName} and the
@@ -123,12 +123,12 @@ function saveBinary(tool, ext, bytes) {
123
123
  }
124
124
  }
125
125
  /** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
126
- function saveScreenshot(dataBase64) {
126
+ function saveScreenshot(dataBase64, ext = 'png') {
127
127
  const w = peekActiveWorkspace();
128
128
  if (!w)
129
129
  return null;
130
130
  try {
131
- const path = (0, node_path_1.join)(w.screenshotsDir, `${stem('screenshot')}.png`);
131
+ const path = (0, node_path_1.join)(w.screenshotsDir, `${stem('screenshot')}.${ext}`);
132
132
  (0, node_fs_1.writeFileSync)(path, Buffer.from(dataBase64, 'base64'), { mode: 0o600 });
133
133
  return path;
134
134
  }
package/dist/src/cli.js CHANGED
@@ -21,6 +21,7 @@ const datadir_1 = require("./bridge/datadir");
21
21
  const workspace_1 = require("./bridge/workspace");
22
22
  const auth_1 = require("./bridge/auth");
23
23
  const server_2 = require("./mcp/server");
24
+ const tools_1 = require("./mcp/tools");
24
25
  const extension_install_1 = require("./extension-install");
25
26
  /** Hard deadline for clean shutdown before we force-exit (a stuck socket must not hang us). */
26
27
  const SHUTDOWN_DEADLINE_MS = 3000;
@@ -158,6 +159,21 @@ function runTasksCommand(argv) {
158
159
  process.stdout.write(TASKS_HELP);
159
160
  return true;
160
161
  }
162
+ /** The tool names, wrapped into indented lines for `--help`. */
163
+ function toolList() {
164
+ const lines = [];
165
+ let line = ' ';
166
+ for (const name of tools_1.TOOL_NAMES) {
167
+ if (line.length + name.length + 2 > 78) {
168
+ lines.push(line);
169
+ line = ' ';
170
+ }
171
+ line += ` ${name}`;
172
+ }
173
+ if (line.trim())
174
+ lines.push(line);
175
+ return lines.join('\n');
176
+ }
161
177
  async function main() {
162
178
  if (runTasksCommand(process.argv.slice(2)))
163
179
  return;
@@ -168,6 +184,8 @@ async function main() {
168
184
  (0, server_2.setLogLevel)(cfg.logLevel);
169
185
  if (cfg.showHelp) {
170
186
  process.stdout.write(config_1.HELP_TEXT);
187
+ // The catalog is what `--tools` takes, so `--help` has to name it.
188
+ process.stdout.write(`\nTools (${tools_1.TOOL_NAMES.length}) — any of these for --tools:\n${toolList()}\n`);
171
189
  return;
172
190
  }
173
191
  if (cfg.showVersion) {
@@ -178,6 +196,9 @@ async function main() {
178
196
  process.stdout.write(`${installExtension()}\n`);
179
197
  return;
180
198
  }
199
+ // Before the bridge binds a port or writes a handshake: a typo'd `--tools`
200
+ // name should fail as a plain startup error, not leave a half-started server.
201
+ (0, tools_1.setToolAllowlist)(cfg.tools);
181
202
  const dataDir = (0, datadir_1.ensureDataDir)(cfg.dataDir);
182
203
  const token = (0, auth_1.resolveToken)(dataDir, { persist: cfg.persistToken });
183
204
  const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations } = cfg.policy;
@@ -285,9 +306,9 @@ async function main() {
285
306
  await (0, server_2.startMcpServer)(version());
286
307
  }
287
308
  main().catch((err) => {
288
- // Port-busy and similar startup failures carry a plain-English message already;
289
- // show that to the user without a noisy stack trace.
290
- const friendly = err instanceof Error && /Couldn't start:/.test(err.message);
309
+ // Port-busy and bad-flag failures carry a plain-English message already
310
+ // (a flag error starts with the flag); show it without a noisy stack trace.
311
+ const friendly = err instanceof Error && /^(Couldn't start:|--|unknown argument:)/.test(err.message);
291
312
  (0, server_2.logErr)(friendly ? err.message : `fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
292
313
  process.exit(1);
293
314
  });
@@ -34,6 +34,12 @@ export interface CliConfig {
34
34
  showExtensionPath: boolean;
35
35
  /** `--persist-token`: reuse a stable on-disk token so the extension never re-pairs. */
36
36
  persistToken: boolean;
37
+ /**
38
+ * `--tools`: advertise only these tools. `undefined` = the whole catalog.
39
+ * Names are validated against the catalog by `setToolAllowlist` at startup —
40
+ * config.ts deliberately does not import the tool surface.
41
+ */
42
+ tools?: string[];
37
43
  showHelp: boolean;
38
44
  showVersion: boolean;
39
45
  logLevel: LogLevel;
@@ -82,6 +82,10 @@ function parseArgs(argv) {
82
82
  let showVersion = false;
83
83
  let showExtensionPath = false;
84
84
  let logLevel = 'info';
85
+ // Insertion-ordered so `--tools` keeps the order the operator wrote, and a
86
+ // name repeated across two flags is listed once.
87
+ const tools = new Set();
88
+ let toolsFlagSeen = false;
85
89
  // Policy assembled from flags, layered over an optional file.
86
90
  let policyFile;
87
91
  const policyFlags = {};
@@ -174,6 +178,11 @@ function parseArgs(argv) {
174
178
  case '--persist-token':
175
179
  persistToken = true;
176
180
  break;
181
+ case '--tools':
182
+ toolsFlagSeen = true;
183
+ for (const name of splitList(requireValue(argv[++i], '--tools')))
184
+ tools.add(name);
185
+ break;
177
186
  case '--log-level':
178
187
  logLevel = requireLogLevel(argv[++i]);
179
188
  break;
@@ -196,6 +205,12 @@ function parseArgs(argv) {
196
205
  if (policy.allowUploads && !policy.uploadsDir) {
197
206
  throw new Error('--enable-uploads requires --uploads-dir <path> (uploads must be confined to a directory)');
198
207
  }
208
+ // `--tools ""` (or `--tools ,,`) means the operator asked for a restriction and
209
+ // got none — an empty surface is never what they wanted, and silently serving
210
+ // all 39 tools is the opposite of what they asked for.
211
+ if (toolsFlagSeen && tools.size === 0) {
212
+ throw new Error('--tools requires at least one tool name (comma-separated, e.g. --tools navigate,get_text)');
213
+ }
199
214
  // Fail at startup, not on the first read, if a redaction pattern is malformed:
200
215
  // a pattern the user believes is scrubbing secrets but that never compiled is
201
216
  // the worst of both worlds.
@@ -223,6 +238,7 @@ function parseArgs(argv) {
223
238
  showVersion,
224
239
  showExtensionPath,
225
240
  logLevel,
241
+ tools: tools.size > 0 ? [...tools] : undefined,
226
242
  };
227
243
  }
228
244
  // ---------------------------------------------------------------------------
@@ -248,6 +264,13 @@ function requireInt(value, flag) {
248
264
  throw new Error(`${flag} must be a non-negative integer`);
249
265
  return n;
250
266
  }
267
+ /** Split a repeatable comma/space-separated list flag, dropping empty entries. */
268
+ function splitList(value) {
269
+ return value
270
+ .split(/[,\s]+/)
271
+ .map((part) => part.trim())
272
+ .filter((part) => part.length > 0);
273
+ }
251
274
  function requirePreference(value) {
252
275
  if (value === 'extension' || value === 'cdp')
253
276
  return value;
@@ -318,6 +341,15 @@ Security (default: deny-all safe mode):
318
341
  high-confidence sign-in wall (expired session), so an
319
342
  eval harness never scores it as some other failure.
320
343
 
344
+ Tool surface:
345
+ --tools <list> Advertise ONLY these tools (comma-separated, repeatable).
346
+ Everything else is hidden from tools/list and refused if
347
+ called — including from inside a batch op. The catalog is
348
+ re-sent to the model on every turn, so trimming it to the
349
+ tools a run actually needs is the cheapest context saving
350
+ there is. Unknown names fail at startup.
351
+ e.g. --tools tabs_list,navigate,get_text,click,type
352
+
321
353
  Misc:
322
354
  --log-level <lvl> silent | info | debug (default info)
323
355
  -h, --help Show this help
@@ -12,7 +12,7 @@
12
12
  * stealth init on the launch path only, and tab resolution. `tabId`s are stamped
13
13
  * `cdp:<sessionId>:<n>` so a handle never mis-routes across a backend switch.
14
14
  */
15
- import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
15
+ import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
16
16
  export interface CdpOptions {
17
17
  mode: 'connect' | 'launch';
18
18
  cdpEndpoint?: string;
@@ -124,6 +124,7 @@ export declare class CdpExecutor implements Executor {
124
124
  tabId?: TabId;
125
125
  interactiveOnly?: boolean;
126
126
  max?: number;
127
+ locator?: SnapshotLocator;
127
128
  }): Promise<SnapshotResult>;
128
129
  getCookies(opts?: {
129
130
  tabId?: TabId;
@@ -142,7 +143,7 @@ export declare class CdpExecutor implements Executor {
142
143
  tabId?: TabId;
143
144
  fullPage?: boolean;
144
145
  target?: Target;
145
- }): Promise<ScreenshotResult>;
146
+ } & ScreenshotEncoding): Promise<ScreenshotResult>;
146
147
  eval(expression: string, opts?: {
147
148
  tabId?: TabId;
148
149
  awaitPromise?: boolean;
@@ -22,6 +22,7 @@ const node_crypto_1 = require("node:crypto");
22
22
  const playwright_1 = require("playwright");
23
23
  const download_1 = require("../../shared/download");
24
24
  const snapshot_1 = require("../../shared/snapshot");
25
+ const screenshot_1 = require("../../shared/screenshot");
25
26
  const types_1 = require("./types");
26
27
  const SINGLETON_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
27
28
  const STEALTH = `Object.defineProperty(navigator,'webdriver',{get:()=>undefined});`;
@@ -436,11 +437,11 @@ class CdpExecutor {
436
437
  return this.guard(async () => {
437
438
  const p = await this.resolveTab(opts?.tabId);
438
439
  // Inject collectSnapshot's source and run it in the page (it can't close over module scope).
439
- const raw = await p.evaluate(([fnSrc, interactiveOnly, max]) => {
440
+ const raw = await p.evaluate(([fnSrc, interactiveOnly, max, locator]) => {
440
441
  // eslint-disable-next-line no-eval
441
442
  const fn = (0, eval)(`(${fnSrc})`);
442
- return fn(interactiveOnly, max);
443
- }, [snapshot_1.collectSnapshot.toString(), opts?.interactiveOnly ?? true, opts?.max ?? 200]);
443
+ return fn(interactiveOnly, max, locator ?? null);
444
+ }, [snapshot_1.collectSnapshot.toString(), opts?.interactiveOnly ?? true, opts?.max ?? 200, opts?.locator ?? null]);
444
445
  return raw;
445
446
  });
446
447
  }
@@ -488,11 +489,25 @@ class CdpExecutor {
488
489
  async screenshot(opts) {
489
490
  return this.guard(async () => {
490
491
  const p = await this.resolveTab(opts?.tabId);
492
+ const type = opts?.format ?? screenshot_1.DEFAULT_SCREENSHOT_FORMAT;
493
+ // Playwright's `scale: 'css'` = one output pixel per CSS pixel (the same
494
+ // default the extension path uses); 'device' = the display's native DPR.
495
+ const enc = {
496
+ type,
497
+ ...(type === 'jpeg' ? { quality: opts?.quality ?? screenshot_1.DEFAULT_JPEG_QUALITY } : {}),
498
+ scale: (opts?.scale ?? screenshot_1.DEFAULT_SCREENSHOT_SCALE) >= 2 ? 'device' : 'css',
499
+ };
491
500
  const buf = opts?.target
492
- ? await this.locator(p, opts.target).screenshot()
493
- : await p.screenshot({ fullPage: opts?.fullPage });
501
+ ? await this.locator(p, opts.target).screenshot(enc)
502
+ : await p.screenshot({ fullPage: opts?.fullPage, ...enc });
494
503
  const size = p.viewportSize() ?? { width: 0, height: 0 };
495
- return { dataBase64: buf.toString('base64'), mimeType: 'image/png', width: size.width, height: size.height, truncated: false };
504
+ return {
505
+ dataBase64: buf.toString('base64'),
506
+ mimeType: type === 'jpeg' ? 'image/jpeg' : 'image/png',
507
+ width: size.width,
508
+ height: size.height,
509
+ truncated: false,
510
+ };
496
511
  });
497
512
  }
498
513
  async eval(expression, opts) {
@@ -7,7 +7,7 @@
7
7
  * method-specific arguments travel in `params`. Results are trusted shapes
8
8
  * produced by the extension router (validated there).
9
9
  */
10
- import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
10
+ import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
11
11
  import type { BridgeServer } from '../bridge/server';
12
12
  export declare class ExtensionExecutor implements Executor {
13
13
  private readonly bridge;
@@ -24,6 +24,9 @@ export declare class ExtensionExecutor implements Executor {
24
24
  /** The active tab's URL as reported by the last command on this profile, if it
25
25
  * is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
26
26
  cachedActiveUrl(): string | null;
27
+ /** A specific tab's URL as last reported (by a result for that tab, or by a
28
+ * `tabs_list`), if fresh enough to gate against. */
29
+ cachedTabUrl(tabId: TabId): string | null;
27
30
  tabsList(): Promise<TabInfo[]>;
28
31
  tabSelect(tabId: TabId): Promise<TabInfo>;
29
32
  tabNew(url?: string, opts?: {
@@ -94,6 +97,7 @@ export declare class ExtensionExecutor implements Executor {
94
97
  tabId?: TabId;
95
98
  interactiveOnly?: boolean;
96
99
  max?: number;
100
+ locator?: SnapshotLocator;
97
101
  } & FrameOpts): Promise<SnapshotResult>;
98
102
  getCookies(opts?: {
99
103
  tabId?: TabId;
@@ -112,7 +116,7 @@ export declare class ExtensionExecutor implements Executor {
112
116
  tabId?: TabId;
113
117
  fullPage?: boolean;
114
118
  target?: Target;
115
- } & FrameOpts): Promise<ScreenshotResult>;
119
+ } & ScreenshotEncoding & FrameOpts): Promise<ScreenshotResult>;
116
120
  eval(expression: string, opts?: {
117
121
  tabId?: TabId;
118
122
  awaitPromise?: boolean;