@vibebrowser/mcp 0.2.5 → 0.2.7

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.
@@ -1,11 +1,31 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, extname, resolve } from 'node:path';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { ExtensionConnection } from './connection.js';
5
+ import { DevtoolsFallbackConnection } from './devtools-fallback.js';
5
6
  import { DEFAULT_WS_PORT } from './types.js';
6
7
  const DEFAULT_TIMEOUT_MS = 30_000;
8
+ /** Short timeout for the `status` command — it should never block for 30 s. */
9
+ const STATUS_TOOLS_TIMEOUT_MS = 2_000;
10
+ const MIME_TYPE_BY_EXTENSION = {
11
+ '.css': 'text/css',
12
+ '.csv': 'text/csv',
13
+ '.gif': 'image/gif',
14
+ '.html': 'text/html',
15
+ '.jpeg': 'image/jpeg',
16
+ '.jpg': 'image/jpeg',
17
+ '.js': 'text/javascript',
18
+ '.json': 'application/json',
19
+ '.md': 'text/markdown',
20
+ '.pdf': 'application/pdf',
21
+ '.png': 'image/png',
22
+ '.svg': 'image/svg+xml',
23
+ '.txt': 'text/plain',
24
+ '.webp': 'image/webp',
25
+ '.xml': 'application/xml',
26
+ };
7
27
  const DEFAULT_BROWSER_PROFILE = process.env.VIBE_BROWSER_PROFILE || 'user';
8
- const DEFAULT_REMOTE_UUID = process.env.VIBE_EXTENSION_UUID || process.env.VIBE_RELAY_UUID;
28
+ const DEFAULT_REMOTE_UUID = process.env.VIBE_REMOTE_URL || process.env.VIBE_EXTENSION_UUID || process.env.VIBE_RELAY_UUID;
9
29
  const DEFAULT_REMOTE_RELAY_URL = process.env.VIBE_REMOTE_RELAY_URL || process.env.VIBE_RELAY_URL;
10
30
  export function registerBrowserCommand(program) {
11
31
  const browser = buildBrowserCommand(program.command('browser'));
@@ -22,17 +42,32 @@ function buildBrowserCommand(command) {
22
42
  .option('--target <target>', 'OpenClaw compatibility target selector (accepted, not used by the Vibe browser CLI)')
23
43
  .option('-p, --port <number>', 'WebSocket port for local relay (agent) connection', String(DEFAULT_WS_PORT))
24
44
  .option('-d, --debug', 'Enable debug logging', false)
45
+ .option('--devtools', 'Use only chrome-devtools backend (bypasses extension relay)', false)
25
46
  .option('-r, --remote <uuid>', 'Connect to a remote extension via public relay (provide the extension UUID)', DEFAULT_REMOTE_UUID)
47
+ .option('-s, --session <id>', 'Target a specific local browser session ID; defaults to the first connected session')
26
48
  .option('--relay-url <url>', 'Custom relay server URL', DEFAULT_REMOTE_RELAY_URL)
27
49
  .option('--json', 'Emit machine-readable JSON output', false)
28
- .option('--timeout <ms>', 'Command timeout in milliseconds', String(DEFAULT_TIMEOUT_MS));
50
+ .option('--timeout <ms>', 'Command timeout in milliseconds', String(DEFAULT_TIMEOUT_MS))
51
+ .option('--page-id <id>', 'Target a specific page/tab by its numeric ID (avoids switching the user\'s active tab)')
52
+ .option('--pageId <id>', 'Alias for --page-id');
29
53
  }
30
54
  function registerBrowserSubcommands(browser) {
31
55
  browser
32
56
  .command('status')
33
57
  .description('Show browser bridge status')
58
+ .option('--wait-for-extension', 'Wait for extension connection before returning status', false)
59
+ .option('--wait-timeout <ms>', 'Maximum wait time when --wait-for-extension is enabled')
60
+ .option('--poll-interval <ms>', 'Polling interval while waiting for extension')
34
61
  .action(async function () {
35
- await runBrowserCommand(this, 'status', false, async (ctx) => ctx.status());
62
+ await runBrowserCommand(this, 'status', false, async (ctx, options) => ctx.status({
63
+ waitForExtension: Boolean(options.waitForExtension),
64
+ waitTimeoutMs: options.waitTimeout
65
+ ? parsePositiveInteger(String(options.waitTimeout), '--wait-timeout')
66
+ : undefined,
67
+ pollIntervalMs: options.pollInterval
68
+ ? parsePositiveInteger(String(options.pollInterval), '--poll-interval')
69
+ : undefined,
70
+ }));
36
71
  });
37
72
  browser
38
73
  .command('start')
@@ -61,6 +96,12 @@ function registerBrowserSubcommands(browser) {
61
96
  note: 'The Vibe browser bridge does not own the browser process, so stop only disconnects this CLI session',
62
97
  }));
63
98
  });
99
+ browser
100
+ .command('sessions')
101
+ .description('List connected browser sessions')
102
+ .action(async function () {
103
+ await runBrowserCommand(this, 'sessions', false, async (ctx) => ctx.listSessions());
104
+ });
64
105
  browser
65
106
  .command('tabs')
66
107
  .description('List browser tabs/pages')
@@ -80,7 +121,7 @@ function registerBrowserSubcommands(browser) {
80
121
  });
81
122
  tab
82
123
  .command('select <id>')
83
- .description('Focus a tab/page')
124
+ .description('Switch to a tab/page by its ID (rarely needed — most commands accept a tab id argument instead, prefer that to avoid disrupting the user\'s browser)')
84
125
  .action(async function (id) {
85
126
  await runBrowserCommand(this, 'tab select', true, async (ctx) => ctx.focus(id));
86
127
  });
@@ -104,7 +145,7 @@ function registerBrowserSubcommands(browser) {
104
145
  });
105
146
  browser
106
147
  .command('focus <id>')
107
- .description('Focus a tab/page')
148
+ .description('Switch focus to a tab/page by its ID (rarely needed — most commands accept a tab id argument instead, prefer that to avoid disrupting the user\'s browser)')
108
149
  .action(async function (id) {
109
150
  await runBrowserCommand(this, 'focus', true, async (ctx) => ctx.focus(id));
110
151
  });
@@ -117,7 +158,7 @@ function registerBrowserSubcommands(browser) {
117
158
  browser
118
159
  .command('snapshot')
119
160
  .description('Capture a textual browser snapshot')
120
- .option('--format <format>', 'Snapshot format (ai or aria)', 'ai')
161
+ .option('--format <format>', 'Snapshot format: "ai" (default, content-script markdown — may fail on background tabs or complex SPAs) or "aria" (CDP accessibility tree — reliable for all tabs)', 'ai')
121
162
  .option('--limit <count>', 'Max visible lines/items to print in human mode')
122
163
  .option('--interactive', 'Prefer interactive/ARIA-flavored snapshot output', false)
123
164
  .option('--selector <selector>', 'Selector to scope the snapshot to')
@@ -156,31 +197,7 @@ function registerBrowserSubcommands(browser) {
156
197
  grayscale: Boolean(options.grayscale),
157
198
  }));
158
199
  });
159
- browser
160
- .command('pdf')
161
- .description('Render the current page as PDF when supported')
162
- .option('--output <path>', 'Write PDF bytes to a file')
163
- .action(async function () {
164
- await runBrowserCommand(this, 'pdf', true, async (ctx, options) => ctx.pdf(options.output ? String(options.output) : undefined));
165
- });
166
- browser
167
- .command('console')
168
- .description('List console messages')
169
- .option('--level <level>', 'Console level filter')
170
- .option('--preserve', 'Include preserved messages when supported', false)
171
- .action(async function () {
172
- await runBrowserCommand(this, 'console', true, async (ctx, options) => ctx.consoleMessages({
173
- level: options.level ? String(options.level) : undefined,
174
- preserve: Boolean(options.preserve),
175
- }));
176
- });
177
- browser
178
- .command('errors')
179
- .description('List console errors')
180
- .option('--clear', 'Compatibility flag', false)
181
- .action(async function () {
182
- await runBrowserCommand(this, 'errors', true, async (ctx) => ctx.consoleMessages({ level: 'error' }));
183
- });
200
+ // NOTE: pdf, console, errors commands removed — no matching browser tools.
184
201
  browser
185
202
  .command('requests')
186
203
  .description('List network requests')
@@ -207,9 +224,9 @@ function registerBrowserSubcommands(browser) {
207
224
  });
208
225
  browser
209
226
  .command('resize <width> <height>')
210
- .description('Resize the browser viewport')
227
+ .description('Resize the current page viewport/window')
211
228
  .action(async function (width, height) {
212
- await runBrowserCommand(this, 'resize', true, async (ctx) => ctx.resize(parsePositiveInteger(width, 'width'), parsePositiveInteger(height, 'height')));
229
+ await runBrowserCommand(this, 'resize', true, async (ctx) => ctx.resize(parsePositiveInteger(width, '<width>'), parsePositiveInteger(height, '<height>')));
213
230
  });
214
231
  browser
215
232
  .command('click <ref>')
@@ -238,11 +255,12 @@ function registerBrowserSubcommands(browser) {
238
255
  await runBrowserCommand(this, 'hover', true, async (ctx) => ctx.hover(ref));
239
256
  });
240
257
  browser
241
- .command('scrollintoview <ref>')
242
- .description('Scroll an element into view when supported')
243
- .action(async function (ref) {
244
- await runBrowserCommand(this, 'scrollintoview', true, async (ctx) => ctx.scrollIntoView(ref));
258
+ .command('upload <ref> <path>')
259
+ .description('Upload a local file via an input element reference')
260
+ .action(async function (ref, path) {
261
+ await runBrowserCommand(this, 'upload', true, async (ctx) => ctx.upload(ref, path));
245
262
  });
263
+ // NOTE: scrollintoview, download, waitfordownload commands removed — no matching tools.
246
264
  browser
247
265
  .command('drag <source> <target>')
248
266
  .description('Drag one element to another')
@@ -255,25 +273,6 @@ function registerBrowserSubcommands(browser) {
255
273
  .action(async function (ref, values) {
256
274
  await runBrowserCommand(this, 'select', true, async (ctx) => ctx.select(ref, values));
257
275
  });
258
- browser
259
- .command('download <ref> [filename]')
260
- .description('Trigger/download an element when supported')
261
- .action(async function (ref, filename) {
262
- await runBrowserCommand(this, 'download', true, async (ctx) => ctx.download(ref, filename));
263
- });
264
- browser
265
- .command('waitfordownload [filename]')
266
- .description('Wait for a download when supported')
267
- .action(async function (filename) {
268
- await runBrowserCommand(this, 'waitfordownload', true, async (ctx) => ctx.waitForDownload(filename));
269
- });
270
- browser
271
- .command('upload <path>')
272
- .description('Upload a file when supported')
273
- .option('--ref <ref>', 'Element ref/index')
274
- .action(async function (path) {
275
- await runBrowserCommand(this, 'upload', true, async (ctx, options) => ctx.upload(path, options.ref ? String(options.ref) : undefined));
276
- });
277
276
  browser
278
277
  .command('fill')
279
278
  .description('Fill a form using JSON field descriptors')
@@ -283,15 +282,15 @@ function registerBrowserSubcommands(browser) {
283
282
  });
284
283
  browser
285
284
  .command('dialog')
286
- .description('Accept or dismiss a browser dialog')
287
- .option('--accept', 'Accept the dialog', false)
288
- .option('--dismiss', 'Dismiss the dialog', false)
289
- .option('--prompt <text>', 'Prompt text to enter')
285
+ .description('Handle browser dialog (accept/dismiss prompt/confirm/alert)')
286
+ .option('--accept', 'Accept dialog', false)
287
+ .option('--dismiss', 'Dismiss dialog', false)
288
+ .option('--prompt <text>', 'Optional prompt text when accepting')
290
289
  .action(async function () {
291
290
  await runBrowserCommand(this, 'dialog', true, async (ctx, options) => ctx.dialog({
292
291
  accept: Boolean(options.accept),
293
292
  dismiss: Boolean(options.dismiss),
294
- prompt: options.prompt ? String(options.prompt) : undefined,
293
+ promptText: options.prompt ? String(options.prompt) : undefined,
295
294
  }));
296
295
  });
297
296
  browser
@@ -320,31 +319,8 @@ function registerBrowserSubcommands(browser) {
320
319
  argsJson: options.args ? String(options.args) : undefined,
321
320
  }));
322
321
  });
323
- browser
324
- .command('highlight <ref>')
325
- .description('Highlight an element when supported')
326
- .action(async function (ref) {
327
- await runBrowserCommand(this, 'highlight', true, async (ctx) => ctx.highlight(ref));
328
- });
329
- const trace = browser.command('trace').description('Start/stop browser tracing');
330
- trace
331
- .command('start')
332
- .description('Start a trace')
333
- .option('--reload', 'Reload the page during trace start when supported', false)
334
- .option('--output <path>', 'Trace output path')
335
- .action(async function () {
336
- await runBrowserCommand(this, 'trace start', true, async (ctx, options) => ctx.traceStart({
337
- reload: Boolean(options.reload),
338
- outputPath: options.output ? String(options.output) : undefined,
339
- }));
340
- });
341
- trace
342
- .command('stop')
343
- .description('Stop an active trace')
344
- .option('--output <path>', 'Trace output path')
345
- .action(async function () {
346
- await runBrowserCommand(this, 'trace stop', true, async (ctx, options) => ctx.traceStop(options.output ? String(options.output) : undefined));
347
- });
322
+ // NOTE: highlight command removed — no highlight tool; users can use 'hover' directly.
323
+ // NOTE: trace commands removed — no performance_start_trace/performance_stop_trace tools.
348
324
  }
349
325
  async function runBrowserCommand(command, commandName, requireExtension, handler) {
350
326
  const globalOptions = command.optsWithGlobals();
@@ -352,12 +328,15 @@ async function runBrowserCommand(command, commandName, requireExtension, handler
352
328
  const ctx = new BrowserCliContext({
353
329
  port: parsePositiveInteger(globalOptions.port, '--port'),
354
330
  debug: Boolean(globalOptions.debug),
331
+ devtools: Boolean(globalOptions.devtools),
355
332
  remoteUuid: globalOptions.remote,
333
+ sessionId: globalOptions.session,
356
334
  relayUrl: globalOptions.relayUrl,
357
335
  profile: globalOptions.browserProfile || DEFAULT_BROWSER_PROFILE,
358
336
  json: Boolean(globalOptions.json),
359
337
  timeoutMs: parsePositiveInteger(globalOptions.timeout, '--timeout'),
360
338
  target: globalOptions.target,
339
+ pageId: globalOptions.pageId ? parsePositiveInteger(globalOptions.pageId, '--page-id') : undefined,
361
340
  });
362
341
  try {
363
342
  await ctx.connect();
@@ -380,18 +359,27 @@ class BrowserCliContext {
380
359
  profile;
381
360
  json;
382
361
  timeoutMs;
362
+ devtoolsOnly;
383
363
  remoteUuid;
364
+ requestedSessionId;
384
365
  target;
366
+ pageId;
385
367
  toolsLoaded = false;
386
368
  tools = [];
369
+ sessions = [];
387
370
  ignoredCompatibilityOptions;
388
371
  constructor(init) {
389
- this.connection = new ExtensionConnection(init.port, init.debug, init.remoteUuid ? { uuid: init.remoteUuid, relayUrl: init.relayUrl } : undefined);
372
+ this.devtoolsOnly = init.devtools;
373
+ this.connection = init.devtools
374
+ ? new DevtoolsFallbackConnection(init.debug)
375
+ : new ExtensionConnection(init.port, init.debug, init.remoteUuid ? { uuid: init.remoteUuid, relayUrl: init.relayUrl } : undefined, init.remoteUuid ? undefined : { sessionId: init.sessionId });
390
376
  this.profile = init.profile;
391
377
  this.json = init.json;
392
378
  this.timeoutMs = init.timeoutMs;
393
379
  this.remoteUuid = init.remoteUuid;
380
+ this.requestedSessionId = init.sessionId;
394
381
  this.target = init.target;
382
+ this.pageId = init.pageId;
395
383
  this.ignoredCompatibilityOptions = [];
396
384
  if (this.target) {
397
385
  this.ignoredCompatibilityOptions.push(`target=${this.target}`);
@@ -399,37 +387,104 @@ class BrowserCliContext {
399
387
  }
400
388
  async connect() {
401
389
  await this.connection.start();
402
- await delay(100);
403
- await this.connection.waitForToolsUpdate(500);
390
+ if (this.connection instanceof ExtensionConnection) {
391
+ const extension = this.connection;
392
+ await delay(100);
393
+ this.sessions = await extension.listSessions(1_500).catch(() => extension.getSessions());
394
+ await extension.waitForToolsUpdate(500);
395
+ }
404
396
  }
405
397
  async shutdown() {
406
398
  await this.connection.stop();
407
399
  }
408
400
  async ensureExtensionConnected() {
409
- if (this.connection.isExtensionConnected()) {
401
+ if (this.isBackendConnected()) {
410
402
  return;
411
403
  }
404
+ if (this.connection instanceof ExtensionConnection) {
405
+ const extension = this.connection;
406
+ this.sessions = await extension.listSessions(1_500).catch(() => extension.getSessions());
407
+ }
408
+ this.toolsLoaded = false;
412
409
  await this.ensureToolsLoaded();
413
- if (!this.connection.isExtensionConnected()) {
414
- throw new Error('No browser extension is connected to the Vibe relay');
410
+ if (!this.isBackendConnected() && this.tools.length === 0) {
411
+ throw new Error(this.getConnectionErrorMessage());
415
412
  }
416
413
  }
417
- async status() {
418
- if (this.connection.isExtensionConnected()) {
419
- await this.ensureToolsLoaded();
414
+ async listSessions() {
415
+ if (this.connection instanceof ExtensionConnection) {
416
+ this.sessions = await this.connection.listSessions(this.timeoutMs);
417
+ }
418
+ else {
419
+ this.sessions = [];
420
+ }
421
+ return {
422
+ ok: true,
423
+ command: 'sessions',
424
+ profile: this.profile,
425
+ mode: this.mode(),
426
+ ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
427
+ sessionId: this.currentSessionId(),
428
+ requestedSessionId: this.requestedSessionId,
429
+ sessions: this.sessions,
430
+ };
431
+ }
432
+ async status(options = {}) {
433
+ const waitForExtension = options.waitForExtension === true;
434
+ const waitTimeoutMs = options.waitTimeoutMs ?? this.timeoutMs;
435
+ const pollIntervalMs = options.pollIntervalMs ?? 250;
436
+ const waitStartedAt = Date.now();
437
+ if (this.connection instanceof ExtensionConnection) {
438
+ const extension = this.connection;
439
+ this.sessions = await extension.listSessions(STATUS_TOOLS_TIMEOUT_MS).catch(() => extension.getSessions());
440
+ }
441
+ else {
442
+ this.sessions = [];
420
443
  }
444
+ if (waitForExtension && !this.isBackendConnected()) {
445
+ const deadline = Date.now() + waitTimeoutMs;
446
+ while (!this.isBackendConnected() && Date.now() < deadline) {
447
+ const remainingMs = deadline - Date.now();
448
+ if (remainingMs <= 0) {
449
+ break;
450
+ }
451
+ await delay(Math.min(pollIntervalMs, remainingMs));
452
+ if (this.connection instanceof ExtensionConnection) {
453
+ const extension = this.connection;
454
+ this.sessions = await extension.listSessions(STATUS_TOOLS_TIMEOUT_MS).catch(() => extension.getSessions());
455
+ }
456
+ }
457
+ }
458
+ if (this.isBackendConnected()) {
459
+ // Use a short timeout for status — this is a diagnostic command that
460
+ // should return quickly. Fall back to cached tools if the extension
461
+ // is slow to respond.
462
+ await this.ensureToolsLoaded(STATUS_TOOLS_TIMEOUT_MS);
463
+ }
464
+ await this.ensureToolsLoaded(STATUS_TOOLS_TIMEOUT_MS);
421
465
  return {
422
466
  ok: true,
423
467
  command: 'status',
424
468
  profile: this.profile,
425
- mode: this.remoteUuid ? 'remote' : 'local',
469
+ mode: this.mode(),
470
+ sessionId: this.currentSessionId(),
471
+ requestedSessionId: this.requestedSessionId,
472
+ sessions: this.sessions,
426
473
  ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
427
- relayConnected: this.connection.getStatus() === 'connected',
428
- extensionConnected: this.connection.isExtensionConnected(),
474
+ relayConnected: this.connection instanceof ExtensionConnection
475
+ ? this.connection.getStatus() === 'connected'
476
+ : false,
477
+ extensionConnected: this.isBackendConnected(),
429
478
  managedLifecycle: false,
430
479
  transport: 'vibebrowser-mcp',
431
480
  toolCount: this.tools.length,
432
481
  tools: this.tools.map((tool) => tool.name),
482
+ ...(waitForExtension
483
+ ? {
484
+ waitForExtension: true,
485
+ waitedMs: Date.now() - waitStartedAt,
486
+ }
487
+ : {}),
433
488
  };
434
489
  }
435
490
  async listPages() {
@@ -442,6 +497,8 @@ class BrowserCliContext {
442
497
  command: 'tabs',
443
498
  profile: this.profile,
444
499
  mode: this.mode(),
500
+ sessionId: this.currentSessionId(),
501
+ requestedSessionId: this.requestedSessionId,
445
502
  ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
446
503
  tool: invocation.tool,
447
504
  pages,
@@ -452,72 +509,74 @@ class BrowserCliContext {
452
509
  if (!url) {
453
510
  return this.callGenericCommand('open', [{ names: ['new_page', 'create_new_tab'] }], {});
454
511
  }
455
- const invocation = await this.callTool('open', [
456
- {
457
- names: ['new_page', 'create_new_tab'],
458
- buildArgs: (tool) => withUrlArgs(tool, url),
459
- },
460
- {
461
- names: ['navigate_page', 'navigate_to_url'],
462
- buildArgs: (tool) => withNavigateArgs(tool, url),
463
- },
464
- ], {});
465
- return this.outputFromInvocation('open', invocation);
512
+ try {
513
+ const invocation = await this.callTool('open', [
514
+ {
515
+ names: ['new_page', 'create_new_tab'],
516
+ buildArgs: (tool) => withOpenArgs(tool, url),
517
+ },
518
+ {
519
+ names: ['navigate_page', 'navigate_to_url'],
520
+ buildArgs: (tool) => withNavigateArgs(tool, url, this.timeoutMs),
521
+ },
522
+ ], {});
523
+ const output = this.outputFromInvocation('open', invocation);
524
+ return this.ensurePageContentInOutput('open', invocation, output, url);
525
+ }
526
+ catch (error) {
527
+ const recovered = await this.recoverPageContentAfterTimeout('open', url, error);
528
+ if (recovered) {
529
+ return recovered;
530
+ }
531
+ throw error;
532
+ }
466
533
  }
467
534
  async navigate(url) {
468
- const invocation = await this.callTool('navigate', [
469
- {
470
- names: ['navigate_page', 'navigate_to_url'],
471
- buildArgs: (tool) => withNavigateArgs(tool, url),
472
- },
535
+ try {
536
+ const invocation = await this.callTool('navigate', [
537
+ {
538
+ names: ['navigate_page', 'navigate_to_url'],
539
+ buildArgs: (tool) => withNavigateArgs(tool, url, this.timeoutMs),
540
+ },
541
+ {
542
+ names: ['new_page', 'create_new_tab'],
543
+ buildArgs: (tool) => withOpenArgs(tool, url),
544
+ },
545
+ ], {});
546
+ const output = this.outputFromInvocation('navigate', invocation);
547
+ return this.ensurePageContentInOutput('navigate', invocation, output, url);
548
+ }
549
+ catch (error) {
550
+ const recovered = await this.recoverPageContentAfterTimeout('navigate', url, error);
551
+ if (recovered) {
552
+ return recovered;
553
+ }
554
+ throw error;
555
+ }
556
+ }
557
+ async close(id) {
558
+ const invocation = await this.callTool('close', [
473
559
  {
474
- names: ['new_page', 'create_new_tab'],
475
- buildArgs: (tool) => withUrlArgs(tool, url),
560
+ names: ['close_page', 'close_tab'],
561
+ buildArgs: (tool) => withPageArgs(tool, id),
476
562
  },
477
563
  ], {});
478
- return this.outputFromInvocation('navigate', invocation);
564
+ return this.outputFromInvocation('close', invocation);
479
565
  }
480
566
  async focus(id) {
481
567
  const invocation = await this.callTool('focus', [
482
568
  {
483
- names: ['select_page', 'switch_to_tab', 'focus_tab'],
569
+ names: ['switch_to_page', 'switch_to_tab', 'select_page', 'focus_tab'],
484
570
  buildArgs: (tool) => withPageArgs(tool, id),
485
571
  },
486
572
  ], {});
487
573
  return this.outputFromInvocation('focus', invocation);
488
574
  }
489
- async close(id) {
490
- const invocation = await this.callTool('close', [
491
- {
492
- names: ['close_page', 'close_tab'],
493
- buildArgs: (tool) => withPageArgs(tool, id),
494
- },
495
- ], {});
496
- return this.outputFromInvocation('close', invocation);
497
- }
498
575
  async snapshot(options) {
499
576
  const wantsAria = options.format === 'aria' || options.interactive || Boolean(options.selector) || Boolean(options.frame);
500
- if (!wantsAria) {
501
- const result = await this.connection.getSnapshot();
502
- return {
503
- ok: true,
504
- command: 'snapshot',
505
- profile: this.profile,
506
- mode: this.mode(),
507
- ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
508
- format: options.format,
509
- url: result.url,
510
- title: result.title,
511
- snapshot: limitText(result.snapshot, options.limit),
512
- };
513
- }
514
577
  const invocation = await this.callTool('snapshot', [
515
578
  {
516
- names: ['take_a11y_snapshot'],
517
- buildArgs: (tool) => withSnapshotArgs(tool, options),
518
- },
519
- {
520
- names: ['take_snapshot', 'get_page_content'],
579
+ names: ['take_snapshot'],
521
580
  buildArgs: (tool) => withSnapshotArgs(tool, options),
522
581
  },
523
582
  ], {});
@@ -553,38 +612,6 @@ class BrowserCliContext {
553
612
  raw: normalizeToolResult(invocation.result),
554
613
  };
555
614
  }
556
- async pdf(outputPath) {
557
- const invocation = await this.callTool('pdf', [{ names: ['pdf', 'generate_pdf'] }], {});
558
- const savedPath = maybeWriteBinaryOutput(invocation.result, outputPath);
559
- return {
560
- ok: true,
561
- command: 'pdf',
562
- profile: this.profile,
563
- mode: this.mode(),
564
- ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
565
- tool: invocation.tool,
566
- outputPath: savedPath || outputPath,
567
- raw: normalizeToolResult(invocation.result),
568
- };
569
- }
570
- async consoleMessages(options) {
571
- const invocation = await this.callTool('console', [
572
- {
573
- names: ['list_console_messages'],
574
- buildArgs: (tool) => withConsoleArgs(tool, options),
575
- },
576
- ], {});
577
- return {
578
- ok: true,
579
- command: options.level === 'error' ? 'errors' : 'console',
580
- profile: this.profile,
581
- mode: this.mode(),
582
- ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
583
- tool: invocation.tool,
584
- messages: extractMessages(invocation.result),
585
- raw: normalizeToolResult(invocation.result),
586
- };
587
- }
588
615
  async requests(options) {
589
616
  const invocation = await this.callTool('requests', [
590
617
  {
@@ -638,15 +665,6 @@ class BrowserCliContext {
638
665
  raw: normalizeToolResult(invocation.result),
639
666
  };
640
667
  }
641
- async resize(width, height) {
642
- const invocation = await this.callTool('resize', [
643
- {
644
- names: ['resize_page'],
645
- buildArgs: (tool) => withResizeArgs(tool, width, height),
646
- },
647
- ], {});
648
- return this.outputFromInvocation('resize', invocation);
649
- }
650
668
  async click(ref, options) {
651
669
  const invocation = await this.callTool('click', [
652
670
  {
@@ -687,15 +705,6 @@ class BrowserCliContext {
687
705
  ], {});
688
706
  return this.outputFromInvocation('hover', invocation);
689
707
  }
690
- async scrollIntoView(ref) {
691
- const invocation = await this.callTool('scrollintoview', [
692
- {
693
- names: ['scroll_into_view', 'scrollintoview'],
694
- buildArgs: (tool) => withRefArgs(tool, ref),
695
- },
696
- ], {});
697
- return this.outputFromInvocation('scrollintoview', invocation);
698
- }
699
708
  async drag(source, target) {
700
709
  const invocation = await this.callTool('drag', [
701
710
  {
@@ -718,37 +727,6 @@ class BrowserCliContext {
718
727
  ], {});
719
728
  return this.outputFromInvocation('select', invocation);
720
729
  }
721
- async download(ref, filename) {
722
- const invocation = await this.callTool('download', [
723
- {
724
- names: ['download'],
725
- buildArgs: (tool) => withDownloadArgs(tool, ref, filename),
726
- },
727
- {
728
- names: ['click'],
729
- buildArgs: (tool) => withRefArgs(tool, ref),
730
- },
731
- ], {});
732
- return this.outputFromInvocation('download', invocation);
733
- }
734
- async waitForDownload(filename) {
735
- const invocation = await this.callTool('waitfordownload', [
736
- {
737
- names: ['wait_for_download', 'waitfordownload'],
738
- buildArgs: (tool) => withFilenameArgs(tool, filename),
739
- },
740
- ], {});
741
- return this.outputFromInvocation('waitfordownload', invocation);
742
- }
743
- async upload(path, ref) {
744
- const invocation = await this.callTool('upload', [
745
- {
746
- names: ['upload_file'],
747
- buildArgs: (tool) => withUploadArgs(tool, path, ref),
748
- },
749
- ], {});
750
- return this.outputFromInvocation('upload', invocation);
751
- }
752
730
  async fillForm(fieldsJson) {
753
731
  const fields = parseJsonValue(fieldsJson, '--fields');
754
732
  if (!Array.isArray(fields)) {
@@ -762,16 +740,6 @@ class BrowserCliContext {
762
740
  ], {});
763
741
  return this.outputFromInvocation('fill', invocation);
764
742
  }
765
- async dialog(options) {
766
- const action = options.accept ? 'accept' : options.dismiss ? 'dismiss' : 'accept';
767
- const invocation = await this.callTool('dialog', [
768
- {
769
- names: ['handle_dialog'],
770
- buildArgs: (tool) => withDialogArgs(tool, action, options.prompt),
771
- },
772
- ], {});
773
- return this.outputFromInvocation('dialog', invocation);
774
- }
775
743
  async wait(options) {
776
744
  if (options.text && options.text.length > 0) {
777
745
  const invocation = await this.callTool('wait', [
@@ -804,36 +772,36 @@ class BrowserCliContext {
804
772
  ], {});
805
773
  return this.outputFromInvocation('evaluate', invocation);
806
774
  }
807
- async highlight(ref) {
808
- const invocation = await this.callTool('highlight', [
809
- {
810
- names: ['highlight'],
811
- buildArgs: (tool) => withRefArgs(tool, ref),
812
- },
775
+ async resize(width, height) {
776
+ const invocation = await this.callTool('resize', [
813
777
  {
814
- names: ['hover'],
815
- buildArgs: (tool) => withRefArgs(tool, ref, { duration: 1500 }),
778
+ names: ['resize_page'],
779
+ buildArgs: (tool) => withResizeArgs(tool, width, height),
816
780
  },
817
781
  ], {});
818
- return this.outputFromInvocation('highlight', invocation);
782
+ return this.outputFromInvocation('resize', invocation);
819
783
  }
820
- async traceStart(options) {
821
- const invocation = await this.callTool('trace start', [
784
+ async upload(ref, path) {
785
+ const invocation = await this.callTool('upload', [
822
786
  {
823
- names: ['performance_start_trace'],
824
- buildArgs: (tool) => withTraceStartArgs(tool, options),
787
+ names: ['upload_file', 'file_upload'],
788
+ buildArgs: (tool) => withUploadArgs(tool, ref, path),
825
789
  },
826
790
  ], {});
827
- return this.outputFromInvocation('trace start', invocation);
791
+ return this.outputFromInvocation('upload', invocation);
828
792
  }
829
- async traceStop(outputPath) {
830
- const invocation = await this.callTool('trace stop', [
793
+ async dialog(options) {
794
+ if (options.accept && options.dismiss) {
795
+ throw new Error('dialog supports either --accept or --dismiss, not both');
796
+ }
797
+ const accept = options.dismiss ? false : true;
798
+ const invocation = await this.callTool('dialog', [
831
799
  {
832
- names: ['performance_stop_trace'],
833
- buildArgs: (tool) => withTraceStopArgs(tool, outputPath),
800
+ names: ['handle_dialog'],
801
+ buildArgs: (tool) => withDialogArgs(tool, accept, options.promptText),
834
802
  },
835
803
  ], {});
836
- return this.outputFromInvocation('trace stop', invocation);
804
+ return this.outputFromInvocation('dialog', invocation);
837
805
  }
838
806
  async callGenericCommand(commandName, candidates, canonicalArgs) {
839
807
  const invocation = await this.callTool(commandName, candidates, canonicalArgs);
@@ -842,6 +810,7 @@ class BrowserCliContext {
842
810
  async callTool(commandName, candidates, canonicalArgs) {
843
811
  await this.ensureToolsLoaded();
844
812
  const available = new Map(this.tools.map((tool) => [normalizeName(tool.name), tool]));
813
+ const compatibilityErrors = [];
845
814
  for (const candidate of candidates) {
846
815
  for (const candidateName of candidate.names) {
847
816
  const tool = available.get(normalizeName(candidateName));
@@ -851,44 +820,430 @@ class BrowserCliContext {
851
820
  const args = candidate.buildArgs
852
821
  ? candidate.buildArgs(tool)
853
822
  : withCanonicalArgs(tool, canonicalArgs);
854
- const result = await this.connection.callTool(tool.name, args);
855
- return { tool: tool.name, args, result: result };
823
+ // Inject --page-id into every tool call that accepts pageId/tabId,
824
+ // so the agent doesn't need to use focus/tab select (which disrupts
825
+ // the user's active browser tab).
826
+ if (this.pageId !== undefined) {
827
+ const pageKey = hasProperty(tool, 'pageId', 'tabId');
828
+ if (pageKey && !(pageKey in args)) {
829
+ args[pageKey] = this.pageId;
830
+ }
831
+ }
832
+ if (shouldRequestPageStateForCommand(commandName)) {
833
+ const pageStateKey = hasProperty(tool, 'pageStateFormat', 'page_state_format');
834
+ if (pageStateKey && !('pageStateFormat' in args) && !('page_state_format' in args)) {
835
+ args[pageStateKey] = 'markdown';
836
+ }
837
+ }
838
+ try {
839
+ const result = await this.connection.callTool(tool.name, args, this.timeoutMs);
840
+ return { tool: tool.name, args, result: result };
841
+ }
842
+ catch (error) {
843
+ if (!isToolArgumentCompatibilityError(error)) {
844
+ throw error;
845
+ }
846
+ const message = error instanceof Error ? error.message : String(error);
847
+ compatibilityErrors.push(`${tool.name}: ${message}`);
848
+ }
856
849
  }
857
850
  }
858
851
  const requested = candidates.flatMap((candidate) => candidate.names);
859
- throw new Error(`No compatible browser tool found for "${commandName}". Tried ${requested.join(', ')}. Available tools: ${this.tools.map((tool) => tool.name).join(', ')}`);
852
+ const compatibilityHint = compatibilityErrors.length > 0
853
+ ? ` Compatibility errors: ${compatibilityErrors.join(' | ')}`
854
+ : '';
855
+ throw new Error(`No compatible browser tool found for "${commandName}". Tried ${requested.join(', ')}. Available tools: ${this.tools.map((tool) => tool.name).join(', ')}.${compatibilityHint}`);
860
856
  }
861
- async ensureToolsLoaded() {
857
+ async ensureToolsLoaded(timeoutMs) {
862
858
  if (this.toolsLoaded && this.tools.length > 0) {
863
859
  return;
864
860
  }
865
- if (this.connection.isExtensionConnected()) {
866
- try {
867
- this.tools = await this.connection.refreshTools(this.timeoutMs);
868
- }
869
- catch {
870
- this.tools = await this.connection.waitForToolsUpdate(1_000);
871
- }
861
+ const effectiveTimeout = timeoutMs ?? this.timeoutMs;
862
+ try {
863
+ this.tools = await this.connection.refreshTools(effectiveTimeout);
872
864
  }
873
- else {
865
+ catch {
866
+ // Refresh timed out or failed — fall back to cached tools or a short
867
+ // passive wait so the status command is not blocked.
874
868
  this.tools = this.connection.getTools();
869
+ if (this.tools.length === 0) {
870
+ if (this.connection instanceof ExtensionConnection) {
871
+ this.tools = await this.connection.waitForToolsUpdate(1_000);
872
+ }
873
+ }
875
874
  }
876
875
  this.toolsLoaded = true;
877
876
  }
878
877
  outputFromInvocation(commandName, invocation) {
878
+ const pageContent = firstText(invocation.result);
879
879
  return {
880
880
  ok: !invocation.result.isError,
881
881
  command: commandName,
882
882
  profile: this.profile,
883
883
  mode: this.mode(),
884
+ sessionId: this.currentSessionId(),
885
+ requestedSessionId: this.requestedSessionId,
884
886
  ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
885
887
  tool: invocation.tool,
888
+ ...(looksLikePageContentText(pageContent) ? { pageContent } : {}),
886
889
  raw: normalizeToolResult(invocation.result),
887
890
  };
888
891
  }
892
+ async ensurePageContentInOutput(commandName, invocation, output, targetUrl) {
893
+ if (!output.ok) {
894
+ return output;
895
+ }
896
+ if (!hasExplicitPageContext(invocation.args)) {
897
+ return output;
898
+ }
899
+ const currentText = firstText(invocation.result);
900
+ if (looksLikePageContentText(currentText) && !isLikelyIncompletePageContent(currentText, targetUrl)) {
901
+ return output;
902
+ }
903
+ const targetPageId = extractPageIdFromResult(invocation.result) ?? this.pageId;
904
+ if (targetPageId === undefined) {
905
+ return output;
906
+ }
907
+ const fallback = await this.takeMarkdownSnapshotForTarget(targetPageId, targetUrl);
908
+ if (!fallback.content) {
909
+ return output;
910
+ }
911
+ return {
912
+ ...output,
913
+ pageContent: fallback.content,
914
+ pageContentSource: 'take_snapshot',
915
+ ...(fallback.pageId !== targetPageId ? { pageId: fallback.pageId } : {}),
916
+ };
917
+ }
918
+ async recoverPageContentAfterTimeout(commandName, url, error) {
919
+ if (!isTimeoutError(error)) {
920
+ return undefined;
921
+ }
922
+ if (this.pageId === undefined) {
923
+ return undefined;
924
+ }
925
+ const recoveryTimeoutMs = Math.max(this.timeoutMs, 10_000);
926
+ let targetPageId = this.pageId;
927
+ if (targetPageId === undefined) {
928
+ return undefined;
929
+ }
930
+ const fallback = await this.takeMarkdownSnapshotForTarget(targetPageId, url, recoveryTimeoutMs);
931
+ if (!fallback.content) {
932
+ return undefined;
933
+ }
934
+ return {
935
+ ok: true,
936
+ command: commandName,
937
+ profile: this.profile,
938
+ mode: this.mode(),
939
+ sessionId: this.currentSessionId(),
940
+ requestedSessionId: this.requestedSessionId,
941
+ ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
942
+ tool: 'take_snapshot',
943
+ recoveredFromTimeout: true,
944
+ pageId: fallback.pageId,
945
+ url,
946
+ pageContent: fallback.content,
947
+ raw: {
948
+ recovery: 'timeout_fallback',
949
+ },
950
+ };
951
+ }
952
+ async takeMarkdownSnapshotForTarget(initialPageId, targetUrl, timeoutMs = this.timeoutMs) {
953
+ let latest;
954
+ let pageId = initialPageId;
955
+ const recoveryTimeoutMs = Math.max(timeoutMs, 10_000);
956
+ const attempts = 8;
957
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
958
+ latest = await this.takeMarkdownSnapshotForPage(pageId, recoveryTimeoutMs);
959
+ if (latest && !isLikelyIncompletePageContent(latest, targetUrl)) {
960
+ return { content: latest, pageId };
961
+ }
962
+ if (targetUrl) {
963
+ const pages = await this.listPagesForRecovery(recoveryTimeoutMs);
964
+ const matched = findBestPageMatchByUrl(pages, targetUrl);
965
+ if (matched) {
966
+ pageId = matched.id;
967
+ }
968
+ }
969
+ if (attempt < attempts - 1) {
970
+ await delay(750);
971
+ }
972
+ }
973
+ return { content: latest, pageId };
974
+ }
975
+ async listPagesForRecovery(timeoutMs) {
976
+ try {
977
+ await this.ensureToolsLoaded(timeoutMs);
978
+ const listTool = this.tools.find((tool) => {
979
+ const normalized = normalizeName(tool.name);
980
+ return normalized === 'list_pages' || normalized === 'get_tabs';
981
+ });
982
+ if (!listTool) {
983
+ return [];
984
+ }
985
+ const result = await this.connection.callTool(listTool.name, {}, timeoutMs);
986
+ return extractPages(result);
987
+ }
988
+ catch {
989
+ return [];
990
+ }
991
+ }
992
+ async takeMarkdownSnapshotForPage(pageId, timeoutMs = this.timeoutMs) {
993
+ await this.ensureToolsLoaded();
994
+ const snapshotTool = this.tools.find((tool) => normalizeName(tool.name) === 'take_snapshot');
995
+ if (!snapshotTool) {
996
+ return undefined;
997
+ }
998
+ const args = {};
999
+ const pageKey = hasProperty(snapshotTool, 'pageId', 'tabId');
1000
+ if (pageKey) {
1001
+ args[pageKey] = pageId;
1002
+ }
1003
+ const formatKey = hasProperty(snapshotTool, 'format', 'pageStateFormat', 'page_state_format');
1004
+ if (formatKey) {
1005
+ args[formatKey] = 'markdown';
1006
+ }
1007
+ try {
1008
+ const result = await this.connection.callTool(snapshotTool.name, args, timeoutMs);
1009
+ const text = firstText(result);
1010
+ if (!text) {
1011
+ return undefined;
1012
+ }
1013
+ const trimmed = text.trim();
1014
+ if (/^Error:/i.test(trimmed) || /Failed to get valid tab/i.test(trimmed)) {
1015
+ return undefined;
1016
+ }
1017
+ return text;
1018
+ }
1019
+ catch {
1020
+ return undefined;
1021
+ }
1022
+ }
889
1023
  mode() {
1024
+ if (this.devtoolsOnly) {
1025
+ return 'devtools';
1026
+ }
890
1027
  return this.remoteUuid ? 'remote' : 'local';
891
1028
  }
1029
+ currentSessionId() {
1030
+ if (this.devtoolsOnly) {
1031
+ return undefined;
1032
+ }
1033
+ if (this.remoteUuid) {
1034
+ return this.remoteUuid;
1035
+ }
1036
+ const connectedSessionIds = new Set(this.sessions
1037
+ .filter((session) => session.connected)
1038
+ .map((session) => session.sessionId));
1039
+ if (this.requestedSessionId && connectedSessionIds.has(this.requestedSessionId)) {
1040
+ return this.requestedSessionId;
1041
+ }
1042
+ return this.sessions.find((session) => session.connected)?.sessionId;
1043
+ }
1044
+ isBackendConnected() {
1045
+ if (this.connection instanceof ExtensionConnection) {
1046
+ return this.connection.isExtensionConnected();
1047
+ }
1048
+ return this.connection.isAvailable();
1049
+ }
1050
+ getConnectionErrorMessage() {
1051
+ if (this.connection instanceof ExtensionConnection) {
1052
+ return this.connection.getConnectionErrorMessage();
1053
+ }
1054
+ return this.connection.getUnavailableReason() || 'chrome-devtools backend unavailable';
1055
+ }
1056
+ outputMode() {
1057
+ return this.mode();
1058
+ }
1059
+ outputContext() {
1060
+ return {
1061
+ profile: this.profile,
1062
+ mode: this.mode(),
1063
+ sessionId: this.currentSessionId(),
1064
+ requestedSessionId: this.requestedSessionId,
1065
+ ignoredCompatibilityOptions: this.ignoredCompatibilityOptions,
1066
+ };
1067
+ }
1068
+ }
1069
+ function shouldRequestPageStateForCommand(commandName) {
1070
+ return new Set([
1071
+ 'open',
1072
+ 'navigate',
1073
+ 'click',
1074
+ 'type',
1075
+ 'press',
1076
+ 'hover',
1077
+ 'drag',
1078
+ 'select',
1079
+ 'fill',
1080
+ ]).has(commandName);
1081
+ }
1082
+ function looksLikePageContentText(text) {
1083
+ const trimmed = text.trim();
1084
+ if (!trimmed) {
1085
+ return false;
1086
+ }
1087
+ if (/Error retrieving page content/i.test(trimmed) || /page content extraction failed/i.test(trimmed)) {
1088
+ return false;
1089
+ }
1090
+ return /```(?:markdown|text|html)/i.test(trimmed) || /Page State Format:/i.test(trimmed);
1091
+ }
1092
+ function hasExplicitPageContext(args) {
1093
+ const candidates = [args.pageId, args.tabId, args.page_id, args.tab_id];
1094
+ return candidates.some((candidate) => {
1095
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
1096
+ return true;
1097
+ }
1098
+ if (typeof candidate === 'string') {
1099
+ return /^\d+$/.test(candidate.trim());
1100
+ }
1101
+ return false;
1102
+ });
1103
+ }
1104
+ function shouldAttachImplicitPageContent(commandName, args) {
1105
+ if (!hasExplicitPageContext(args)) {
1106
+ return false;
1107
+ }
1108
+ return commandName !== 'close';
1109
+ }
1110
+ function stripImplicitPageContent(result) {
1111
+ const firstTextIndex = result.content.findIndex((entry) => entry.type === 'text');
1112
+ if (firstTextIndex < 0) {
1113
+ return result;
1114
+ }
1115
+ const firstTextEntry = result.content[firstTextIndex];
1116
+ if (!('text' in firstTextEntry) || !looksLikePageContentText(firstTextEntry.text)) {
1117
+ return result;
1118
+ }
1119
+ return {
1120
+ ...result,
1121
+ content: result.content.filter((_, index) => index !== firstTextIndex),
1122
+ };
1123
+ }
1124
+ function isLikelyIncompletePageContent(text, targetUrl) {
1125
+ const trimmed = text.trim();
1126
+ if (!trimmed) {
1127
+ return true;
1128
+ }
1129
+ const interactiveRefCount = (trimmed.match(/\[M\d+:/g) || []).length;
1130
+ // Common in early snapshots right after new_page(waitForReady=false):
1131
+ // tab exists but title/url are still empty.
1132
+ if (/Tab ID:[^\n]*\nTitle:\s*\nURL:\s*(?:\n|$)/i.test(trimmed)) {
1133
+ return true;
1134
+ }
1135
+ if (/#\s*Markdown Snapshot:\s*Untitled/i.test(trimmed) && interactiveRefCount < 5) {
1136
+ return true;
1137
+ }
1138
+ if (targetUrl) {
1139
+ const normalizedTarget = normalizeUrlForMatch(targetUrl);
1140
+ const hasAnyUrl = /\bURL:\s*\S+/i.test(trimmed);
1141
+ const textNormalized = normalizeUrlForMatch(trimmed);
1142
+ if (!hasAnyUrl) {
1143
+ return true;
1144
+ }
1145
+ if (normalizedTarget && !textNormalized.includes(normalizedTarget)) {
1146
+ return true;
1147
+ }
1148
+ }
1149
+ return false;
1150
+ }
1151
+ function extractPageIdFromResult(result) {
1152
+ const direct = firstDefined(result, ['pageId', 'tabId', 'id']);
1153
+ if (typeof direct === 'number' && Number.isFinite(direct)) {
1154
+ return direct;
1155
+ }
1156
+ const parsed = parseResultText(result);
1157
+ if (parsed && typeof parsed === 'object') {
1158
+ const parsedId = firstDefined(parsed, ['pageId', 'tabId', 'id']);
1159
+ if (typeof parsedId === 'number' && Number.isFinite(parsedId)) {
1160
+ return parsedId;
1161
+ }
1162
+ }
1163
+ const text = firstText(result);
1164
+ const createdMatch = /new background page \(ID:\s*(\d+)\)/i.exec(text);
1165
+ if (createdMatch) {
1166
+ return Number.parseInt(createdMatch[1], 10);
1167
+ }
1168
+ const tabMatch = /\bTab ID:\s*(\d+)\b/i.exec(text);
1169
+ if (tabMatch) {
1170
+ return Number.parseInt(tabMatch[1], 10);
1171
+ }
1172
+ const pageMatch = /\bPage ID:\s*(\d+)\b/i.exec(text);
1173
+ if (pageMatch) {
1174
+ return Number.parseInt(pageMatch[1], 10);
1175
+ }
1176
+ return undefined;
1177
+ }
1178
+ function isTimeoutError(error) {
1179
+ const message = error instanceof Error ? error.message : String(error);
1180
+ return /timed out|timeout/i.test(message);
1181
+ }
1182
+ function isToolArgumentCompatibilityError(error) {
1183
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
1184
+ if (!message) {
1185
+ return false;
1186
+ }
1187
+ if (/timed out|timeout|relay connection|connection lost|not connected|no connection|disconnected/.test(message)) {
1188
+ return false;
1189
+ }
1190
+ return /missing required|required (field|property|argument)|invalid argument|invalid args|invalid input|unexpected argument|unexpected property|unknown argument|does not accept|unrecognized (field|property|argument)/.test(message);
1191
+ }
1192
+ function findBestPageMatchByUrl(pages, targetUrl) {
1193
+ const normalizedTarget = normalizeUrlForMatch(targetUrl);
1194
+ let bestExact;
1195
+ let bestPartial;
1196
+ for (const page of pages) {
1197
+ const id = toFinitePageId(page.id);
1198
+ if (id === undefined || typeof page.url !== 'string') {
1199
+ continue;
1200
+ }
1201
+ const normalizedPage = normalizeUrlForMatch(page.url);
1202
+ if (normalizedPage === normalizedTarget) {
1203
+ if (!bestExact || id > bestExact.id) {
1204
+ bestExact = { id, url: page.url };
1205
+ }
1206
+ }
1207
+ if (normalizedPage.includes(normalizedTarget) || normalizedTarget.includes(normalizedPage)) {
1208
+ if (!bestPartial || id > bestPartial.id) {
1209
+ bestPartial = { id, url: page.url };
1210
+ }
1211
+ }
1212
+ }
1213
+ return bestExact ?? bestPartial;
1214
+ }
1215
+ function toFinitePageId(value) {
1216
+ if (typeof value === 'number' && Number.isFinite(value)) {
1217
+ return value;
1218
+ }
1219
+ if (typeof value === 'string') {
1220
+ const parsed = Number.parseInt(value, 10);
1221
+ if (Number.isFinite(parsed)) {
1222
+ return parsed;
1223
+ }
1224
+ }
1225
+ return undefined;
1226
+ }
1227
+ function normalizeUrlForMatch(value) {
1228
+ const trimmed = value.trim().replace(/\/$/, '');
1229
+ if (!trimmed) {
1230
+ return trimmed;
1231
+ }
1232
+ try {
1233
+ const parsed = new URL(trimmed);
1234
+ return safeDecode(`${parsed.origin}${parsed.pathname}${parsed.search}`.replace(/\/$/, ''));
1235
+ }
1236
+ catch {
1237
+ return safeDecode(trimmed);
1238
+ }
1239
+ }
1240
+ function safeDecode(value) {
1241
+ try {
1242
+ return decodeURIComponent(value);
1243
+ }
1244
+ catch {
1245
+ return value;
1246
+ }
892
1247
  }
893
1248
  function emitOutput(asJson, data, humanOutput) {
894
1249
  if (asJson) {
@@ -898,13 +1253,17 @@ function emitOutput(asJson, data, humanOutput) {
898
1253
  console.log(humanOutput);
899
1254
  }
900
1255
  function emitError(asJson, commandName, ctx, error) {
901
- const message = error instanceof Error ? error.message : String(error);
1256
+ let message = error instanceof Error ? error.message : String(error);
1257
+ // Improve guidance when the extension requires a pageId that wasn't provided
1258
+ if (/\bpageId\b/i.test(message) && /\bmissing\b|\brequired\b/i.test(message)) {
1259
+ message += '\nHint: use `tabs` to list pages, then pass --page-id <id> to target a specific tab.';
1260
+ }
902
1261
  if (asJson) {
1262
+ const context = ctx.outputContext();
903
1263
  console.log(JSON.stringify({
904
1264
  ok: false,
905
1265
  command: commandName,
906
- profile: DEFAULT_BROWSER_PROFILE,
907
- mode: 'local',
1266
+ ...context,
908
1267
  error: message,
909
1268
  }, null, 2));
910
1269
  return;
@@ -919,19 +1278,36 @@ function formatHumanOutput(commandName, output) {
919
1278
  return [
920
1279
  `Profile: ${String(output.profile)}`,
921
1280
  `Mode: ${String(output.mode)}`,
1281
+ output.sessionId ? `Session: ${String(output.sessionId)}` : null,
1282
+ output.requestedSessionId && output.requestedSessionId !== output.sessionId ? `Requested session: ${String(output.requestedSessionId)}` : null,
922
1283
  `Relay connected: ${boolText(output.relayConnected)}`,
923
1284
  `Extension connected: ${boolText(output.extensionConnected)}`,
924
1285
  `Managed lifecycle: ${boolText(output.managedLifecycle)}`,
925
1286
  output.toolCount !== undefined ? `Tools: ${String(output.toolCount)}` : null,
926
1287
  output.note ? String(output.note) : null,
927
1288
  ].filter(Boolean).join('\n');
1289
+ case 'sessions': {
1290
+ const sessions = Array.isArray(output.sessions) ? output.sessions : [];
1291
+ if (sessions.length === 0) {
1292
+ return 'No browser sessions connected';
1293
+ }
1294
+ return sessions.map((session, index) => {
1295
+ const selected = output.sessionId === session.sessionId ? ' [selected]' : '';
1296
+ const connected = session.connected ? 'connected' : 'disconnected';
1297
+ const tools = session.toolCount !== undefined ? ` tools=${session.toolCount}` : '';
1298
+ return `${index + 1}. ${session.sessionId}${selected} - ${connected}${tools}`;
1299
+ }).join('\n');
1300
+ }
928
1301
  case 'tabs':
929
1302
  case 'tab': {
930
1303
  const pages = Array.isArray(output.pages) ? output.pages : [];
931
1304
  if (pages.length === 0) {
932
1305
  return firstDefinedText(output.raw, 'No tabs reported by browser');
933
1306
  }
934
- return pages.map((page, index) => `${index + 1}. ${page.active ? '[active] ' : ''}${page.title || '(untitled)'}${page.url ? ` — ${page.url}` : ''}${page.id !== undefined ? ` [id=${page.id}]` : ''}`).join('\n');
1307
+ return [
1308
+ output.sessionId ? `Session: ${String(output.sessionId)}` : null,
1309
+ ...pages.map((page, index) => `${index + 1}. ${page.active ? '[active] ' : ''}${page.title || '(untitled)'}${page.url ? ` — ${page.url}` : ''}${page.id !== undefined ? ` [id=${page.id}]` : ''}`),
1310
+ ].filter(Boolean).join('\n');
935
1311
  }
936
1312
  case 'snapshot':
937
1313
  return [
@@ -946,14 +1322,6 @@ function formatHumanOutput(commandName, output) {
946
1322
  }
947
1323
  return requests.map((request, index) => `${index + 1}. ${request.method || 'GET'} ${request.url || '(unknown)'}${request.status !== undefined ? ` [${request.status}]` : ''}${request.requestId ? ` [id=${request.requestId}]` : ''}`).join('\n');
948
1324
  }
949
- case 'console':
950
- case 'errors': {
951
- const messages = Array.isArray(output.messages) ? output.messages : [];
952
- if (messages.length > 0) {
953
- return messages.map((message) => stringifyJson(message)).join('\n');
954
- }
955
- return firstDefinedText(output.raw, 'No console messages reported by browser');
956
- }
957
1325
  case 'responsebody':
958
1326
  return String(output.responseBody ?? firstDefinedText(output.raw, ''));
959
1327
  default:
@@ -970,8 +1338,19 @@ function firstDefinedText(value, fallback) {
970
1338
  if (typeof value === 'string' && value.length > 0) {
971
1339
  return value;
972
1340
  }
973
- if (value && typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
974
- return value.text;
1341
+ if (value && typeof value === 'object') {
1342
+ // Direct { text: "..." } wrapper
1343
+ if ('text' in value && typeof value.text === 'string') {
1344
+ return value.text;
1345
+ }
1346
+ // MCP tool result: { content: [{ type: 'text', text: '...' }] }
1347
+ const rec = value;
1348
+ if (Array.isArray(rec.content)) {
1349
+ const textItem = rec.content.find((entry) => entry && typeof entry === 'object' && entry.type === 'text');
1350
+ if (textItem && typeof textItem.text === 'string' && textItem.text.length > 0) {
1351
+ return textItem.text;
1352
+ }
1353
+ }
975
1354
  }
976
1355
  return fallback;
977
1356
  }
@@ -1026,8 +1405,34 @@ function extractPages(result) {
1026
1405
  }
1027
1406
  }
1028
1407
  }
1408
+ // Fallback: parse the plain-text format produced by ListPagesTool
1409
+ // e.g. 'Page 123 [ACTIVE]: "Google" - https://www.google.com'
1410
+ const text = firstText(result);
1411
+ if (text) {
1412
+ const textPages = parsePlainTextPages(text);
1413
+ if (textPages.length > 0) {
1414
+ return textPages;
1415
+ }
1416
+ }
1029
1417
  return [];
1030
1418
  }
1419
+ // Matches ListPagesTool output: 'Page <id>[ [ACTIVE]]: "title" - url'
1420
+ const PAGE_LINE_RE = /^Page\s+(\d+)\s*(\[ACTIVE\])?\s*:\s*"(.*)"\s*-\s*(.+)$/i;
1421
+ function parsePlainTextPages(text) {
1422
+ const pages = [];
1423
+ for (const line of text.split('\n')) {
1424
+ const match = PAGE_LINE_RE.exec(line.trim());
1425
+ if (match) {
1426
+ pages.push({
1427
+ id: Number(match[1]),
1428
+ active: match[2] !== undefined,
1429
+ title: match[3],
1430
+ url: match[4].trim(),
1431
+ });
1432
+ }
1433
+ }
1434
+ return pages;
1435
+ }
1031
1436
  function normalizePage(value) {
1032
1437
  if (!value || typeof value !== 'object') {
1033
1438
  return {};
@@ -1071,26 +1476,6 @@ function normalizeRequest(value) {
1071
1476
  status: firstNumber(record, ['status']),
1072
1477
  };
1073
1478
  }
1074
- function extractMessages(result) {
1075
- const direct = extractStructured(result, ['messages', 'consoleMessages']);
1076
- if (Array.isArray(direct)) {
1077
- return direct.map((entry) => sanitizeUnknown(entry) ?? null);
1078
- }
1079
- const parsed = parseResultText(result);
1080
- if (Array.isArray(parsed)) {
1081
- return parsed.map((entry) => sanitizeUnknown(entry) ?? null);
1082
- }
1083
- if (parsed && typeof parsed === 'object') {
1084
- for (const key of ['messages', 'consoleMessages']) {
1085
- const candidate = parsed[key];
1086
- if (Array.isArray(candidate)) {
1087
- return candidate.map((entry) => sanitizeUnknown(entry) ?? null);
1088
- }
1089
- }
1090
- }
1091
- const text = firstText(result);
1092
- return text ? [text] : [];
1093
- }
1094
1479
  function extractResponseBody(result) {
1095
1480
  const direct = extractStructured(result, ['responseBody', 'body']);
1096
1481
  if (typeof direct === 'string') {
@@ -1218,12 +1603,24 @@ function withUrlArgs(tool, url) {
1218
1603
  }
1219
1604
  return args;
1220
1605
  }
1221
- function withNavigateArgs(tool, url) {
1606
+ function withOpenArgs(tool, url) {
1607
+ const args = withUrlArgs(tool, url);
1608
+ const waitForReadyKey = hasProperty(tool, 'waitForReady');
1609
+ if (waitForReadyKey) {
1610
+ args[waitForReadyKey] = false;
1611
+ }
1612
+ return stripUndefined(args);
1613
+ }
1614
+ function withNavigateArgs(tool, url, timeoutMs) {
1222
1615
  const args = withUrlArgs(tool, url);
1223
1616
  const pageIdKey = hasProperty(tool, 'pageId', 'tabId');
1224
1617
  if (pageIdKey && !(pageIdKey in args)) {
1225
1618
  args[pageIdKey] = undefined;
1226
1619
  }
1620
+ const timeoutKey = hasProperty(tool, 'timeoutMs', 'timeout');
1621
+ if (timeoutKey && typeof timeoutMs === 'number' && Number.isFinite(timeoutMs)) {
1622
+ args[timeoutKey] = timeoutMs;
1623
+ }
1227
1624
  return stripUndefined(args);
1228
1625
  }
1229
1626
  function withPageArgs(tool, id) {
@@ -1258,21 +1655,6 @@ function withScreenshotArgs(tool, options) {
1258
1655
  maybeAssign(args, tool, 'grayscale', options.grayscale || undefined);
1259
1656
  return args;
1260
1657
  }
1261
- function withConsoleArgs(tool, options) {
1262
- const args = {};
1263
- if (options.level) {
1264
- if (hasProperty(tool, 'types')) {
1265
- args.types = [options.level];
1266
- }
1267
- else {
1268
- maybeAssign(args, tool, 'level', options.level);
1269
- }
1270
- }
1271
- if (options.preserve) {
1272
- maybeAssign(args, tool, 'includePreservedMessages', true);
1273
- }
1274
- return args;
1275
- }
1276
1658
  function withRequestListArgs(tool, options) {
1277
1659
  const args = {};
1278
1660
  if (options.limit) {
@@ -1358,36 +1740,60 @@ function withSelectArgs(tool, ref, values) {
1358
1740
  maybeAssign(args, tool, 'values', values);
1359
1741
  return args;
1360
1742
  }
1361
- function withDownloadArgs(tool, ref, filename) {
1362
- const args = withRefArgs(tool, ref);
1363
- maybeAssign(args, tool, 'filename', filename);
1364
- maybeAssign(args, tool, 'path', filename);
1365
- return args;
1366
- }
1367
- function withFilenameArgs(tool, filename) {
1368
- const args = {};
1369
- maybeAssign(args, tool, 'filename', filename);
1370
- maybeAssign(args, tool, 'path', filename);
1371
- return args;
1372
- }
1373
- function withUploadArgs(tool, path, ref) {
1374
- const args = {};
1375
- maybeAssign(args, tool, 'filePath', resolve(path));
1376
- if (ref) {
1377
- Object.assign(args, withRefArgs(tool, ref));
1378
- }
1379
- return args;
1380
- }
1381
1743
  function withFillFormArgs(tool, fields) {
1382
1744
  const args = {};
1383
1745
  maybeAssign(args, tool, 'fields', fields);
1384
1746
  maybeAssign(args, tool, 'elements', fields);
1385
1747
  return args;
1386
1748
  }
1387
- function withDialogArgs(tool, action, prompt) {
1749
+ function withUploadArgs(tool, ref, path) {
1750
+ const args = withRefArgs(tool, ref);
1751
+ maybeAssign(args, tool, 'filePath', path);
1752
+ maybeAssign(args, tool, 'path', path);
1753
+ if (hasProperty(tool, 'paths')) {
1754
+ args.paths = [path];
1755
+ }
1756
+ const filenameKey = hasProperty(tool, 'filename');
1757
+ const mimeTypeKey = hasProperty(tool, 'mimeType');
1758
+ const contentBase64Key = hasProperty(tool, 'contentBase64');
1759
+ const hasTopLevelPayload = Boolean(filenameKey && mimeTypeKey && contentBase64Key);
1760
+ const fileKey = hasProperty(tool, 'file');
1761
+ const fileSchema = fileKey ? toolProperties(tool)[fileKey] : undefined;
1762
+ const fileProperties = isRecord(fileSchema) && isRecord(fileSchema.properties) ? fileSchema.properties : undefined;
1763
+ const hasNestedPayload = Boolean(fileKey
1764
+ && fileProperties
1765
+ && Object.prototype.hasOwnProperty.call(fileProperties, 'filename')
1766
+ && Object.prototype.hasOwnProperty.call(fileProperties, 'mimeType')
1767
+ && Object.prototype.hasOwnProperty.call(fileProperties, 'contentBase64'));
1768
+ if (!hasTopLevelPayload && !hasNestedPayload) {
1769
+ return args;
1770
+ }
1771
+ const filePayload = buildUploadFilePayload(path);
1772
+ if (hasTopLevelPayload && filenameKey && mimeTypeKey && contentBase64Key) {
1773
+ args[filenameKey] = filePayload.filename;
1774
+ args[mimeTypeKey] = filePayload.mimeType;
1775
+ args[contentBase64Key] = filePayload.contentBase64;
1776
+ }
1777
+ if (hasNestedPayload && fileKey) {
1778
+ args[fileKey] = filePayload;
1779
+ }
1780
+ return args;
1781
+ }
1782
+ function withDialogArgs(tool, accept, promptText) {
1388
1783
  const args = {};
1389
- maybeAssign(args, tool, 'action', action);
1390
- maybeAssign(args, tool, 'promptText', prompt);
1784
+ if (hasProperty(tool, 'action')) {
1785
+ args.action = accept ? 'accept' : 'dismiss';
1786
+ }
1787
+ if (hasProperty(tool, 'accept')) {
1788
+ args.accept = accept;
1789
+ }
1790
+ if (promptText !== undefined) {
1791
+ maybeAssign(args, tool, 'promptText', promptText);
1792
+ maybeAssign(args, tool, 'prompt', promptText);
1793
+ if (hasProperty(tool, 'prompt_text')) {
1794
+ args.prompt_text = promptText;
1795
+ }
1796
+ }
1391
1797
  return args;
1392
1798
  }
1393
1799
  function withWaitArgs(tool, text, timeoutMs) {
@@ -1417,21 +1823,6 @@ function withEvaluateArgs(tool, fn, ref, argsJson) {
1417
1823
  }
1418
1824
  return args;
1419
1825
  }
1420
- function withTraceStartArgs(tool, options) {
1421
- const args = {};
1422
- maybeAssign(args, tool, 'reload', options.reload || undefined);
1423
- if (options.outputPath) {
1424
- maybeAssign(args, tool, 'filePath', resolve(options.outputPath));
1425
- }
1426
- return args;
1427
- }
1428
- function withTraceStopArgs(tool, outputPath) {
1429
- const args = {};
1430
- if (outputPath) {
1431
- maybeAssign(args, tool, 'filePath', resolve(outputPath));
1432
- }
1433
- return args;
1434
- }
1435
1826
  function maybeAssign(target, tool, property, value) {
1436
1827
  if (value === undefined) {
1437
1828
  return;
@@ -1443,6 +1834,24 @@ function maybeAssign(target, tool, property, value) {
1443
1834
  function stripUndefined(value) {
1444
1835
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
1445
1836
  }
1837
+ function buildUploadFilePayload(path) {
1838
+ let contentBase64;
1839
+ try {
1840
+ contentBase64 = readFileSync(path).toString('base64');
1841
+ }
1842
+ catch (error) {
1843
+ const message = error instanceof Error ? error.message : String(error);
1844
+ throw new Error(`Failed to read upload file at "${path}": ${message}`);
1845
+ }
1846
+ return {
1847
+ filename: basename(path),
1848
+ mimeType: MIME_TYPE_BY_EXTENSION[extname(path).toLowerCase()] ?? 'application/octet-stream',
1849
+ contentBase64,
1850
+ };
1851
+ }
1852
+ function isRecord(value) {
1853
+ return typeof value === 'object' && value !== null;
1854
+ }
1446
1855
  function parseRef(ref) {
1447
1856
  const numeric = Number.parseInt(ref, 10);
1448
1857
  return {