@extension.dev/mcp 5.5.1 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.js CHANGED
@@ -12,6 +12,7 @@ import { execFile, execFileSync } from "node:child_process";
12
12
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
13
13
  import ws_0 from "ws";
14
14
  import node_http from "node:http";
15
+ import node_net from "node:net";
15
16
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
16
17
  import { promisify } from "node:util";
17
18
  var add_feature_namespaceObject = {};
@@ -219,7 +220,7 @@ __webpack_require__.d(whoami_namespaceObject, {
219
220
  handler: ()=>whoami_handler,
220
221
  schema: ()=>whoami_schema
221
222
  });
222
- var package_namespaceObject = JSON.parse('{"rE":"5.5.1","El":{"OP":"^4.0.13"}}');
223
+ var package_namespaceObject = JSON.parse('{"rE":"5.6.0","El":{"OP":"^4.0.15"}}');
223
224
  function scaffoldEnginePin(projectPath) {
224
225
  try {
225
226
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -563,6 +564,42 @@ function resolveExtensionInvocation(projectDir) {
563
564
  function runExtensionCli(args, options) {
564
565
  const { command, prefixArgs } = resolveExtensionInvocation(options?.cwd);
565
566
  return new Promise((resolve)=>{
567
+ let ioDir;
568
+ let outFd;
569
+ let errFd;
570
+ try {
571
+ ioDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-io-"));
572
+ outFd = node_fs.openSync(node_path.join(ioDir, "stdout"), "a");
573
+ errFd = node_fs.openSync(node_path.join(ioDir, "stderr"), "a");
574
+ } catch (err) {
575
+ resolve({
576
+ code: 1,
577
+ stdout: "",
578
+ stderr: String(err)
579
+ });
580
+ return;
581
+ }
582
+ const readAll = (name)=>{
583
+ try {
584
+ return node_fs.readFileSync(node_path.join(ioDir, name), "utf8");
585
+ } catch {
586
+ return "";
587
+ }
588
+ };
589
+ const cleanup = ()=>{
590
+ for (const fd of [
591
+ outFd,
592
+ errFd
593
+ ])try {
594
+ node_fs.closeSync(fd);
595
+ } catch {}
596
+ try {
597
+ node_fs.rmSync(ioDir, {
598
+ recursive: true,
599
+ force: true
600
+ });
601
+ } catch {}
602
+ };
566
603
  const child = cross_spawn(command, [
567
604
  ...prefixArgs,
568
605
  ...args
@@ -570,8 +607,8 @@ function runExtensionCli(args, options) {
570
607
  cwd: options?.cwd,
571
608
  stdio: [
572
609
  "ignore",
573
- "pipe",
574
- "pipe"
610
+ outFd,
611
+ errFd
575
612
  ],
576
613
  env: {
577
614
  ...process.env,
@@ -580,20 +617,26 @@ function runExtensionCli(args, options) {
580
617
  },
581
618
  timeout: options?.timeoutMs ?? 30000
582
619
  });
583
- let stdout = "";
584
- let stderr = "";
585
- child.stdout?.on("data", (d)=>stdout += d.toString());
586
- child.stderr?.on("data", (d)=>stderr += d.toString());
587
- child.on("close", (code)=>resolve({
620
+ child.on("close", (code)=>{
621
+ const stdout = readAll("stdout");
622
+ const stderr = readAll("stderr");
623
+ cleanup();
624
+ resolve({
588
625
  code,
589
626
  stdout,
590
627
  stderr
591
- }));
592
- child.on("error", (err)=>resolve({
628
+ });
629
+ });
630
+ child.on("error", (err)=>{
631
+ const stdout = readAll("stdout");
632
+ const stderr = readAll("stderr");
633
+ cleanup();
634
+ resolve({
593
635
  code: 1,
594
636
  stdout,
595
637
  stderr: stderr || String(err)
596
- }));
638
+ });
639
+ });
597
640
  });
598
641
  }
599
642
  function spawnExtensionCli(args, options) {
@@ -2557,6 +2600,32 @@ async function inspect_handler(args) {
2557
2600
  };
2558
2601
  return JSON.stringify(result);
2559
2602
  }
2603
+ function summarizeConsoleMessages(messages) {
2604
+ const counts = {};
2605
+ const uniqueByLevel = {};
2606
+ for (const msg of messages){
2607
+ counts[msg.level] = (counts[msg.level] ?? 0) + 1;
2608
+ if (!uniqueByLevel[msg.level]) uniqueByLevel[msg.level] = new Map();
2609
+ const key = msg.text.slice(0, 200);
2610
+ uniqueByLevel[msg.level].set(key, (uniqueByLevel[msg.level].get(key) ?? 0) + 1);
2611
+ }
2612
+ const topMessages = [];
2613
+ for (const [level, msgs] of Object.entries(uniqueByLevel)){
2614
+ const sorted = [
2615
+ ...msgs.entries()
2616
+ ].sort((a, b)=>b[1] - a[1]);
2617
+ for (const [text, count] of sorted.slice(0, 5))topMessages.push({
2618
+ level,
2619
+ text,
2620
+ count
2621
+ });
2622
+ }
2623
+ return {
2624
+ total: messages.length,
2625
+ counts,
2626
+ topMessages: topMessages.sort((a, b)=>b.count - a.count).slice(0, 10)
2627
+ };
2628
+ }
2560
2629
  function _define_property(obj, key, value) {
2561
2630
  if (key in obj) Object.defineProperty(obj, key, {
2562
2631
  value: value,
@@ -2666,30 +2735,7 @@ class CDPConnection {
2666
2735
  ];
2667
2736
  }
2668
2737
  getConsoleSummary() {
2669
- const counts = {};
2670
- const uniqueByLevel = {};
2671
- for (const msg of this.consoleMessages){
2672
- counts[msg.level] = (counts[msg.level] ?? 0) + 1;
2673
- if (!uniqueByLevel[msg.level]) uniqueByLevel[msg.level] = new Map();
2674
- const key = msg.text.slice(0, 200);
2675
- uniqueByLevel[msg.level].set(key, (uniqueByLevel[msg.level].get(key) ?? 0) + 1);
2676
- }
2677
- const topMessages = [];
2678
- for (const [level, msgs] of Object.entries(uniqueByLevel)){
2679
- const sorted = [
2680
- ...msgs.entries()
2681
- ].sort((a, b)=>b[1] - a[1]);
2682
- for (const [text, count] of sorted.slice(0, 5))topMessages.push({
2683
- level,
2684
- text,
2685
- count
2686
- });
2687
- }
2688
- return {
2689
- total: this.consoleMessages.length,
2690
- counts,
2691
- topMessages: topMessages.sort((a, b)=>b.count - a.count).slice(0, 10)
2692
- };
2738
+ return summarizeConsoleMessages(this.consoleMessages);
2693
2739
  }
2694
2740
  onEvent(handler) {
2695
2741
  this.eventListeners.add(handler);
@@ -2964,7 +3010,7 @@ class CDPClient extends CDPConnection {
2964
3010
  return result ?? null;
2965
3011
  }
2966
3012
  }
2967
- async function resolveCdpPort(projectPath, browser, options) {
3013
+ async function resolveContractPort(projectPath, browser, field, options) {
2968
3014
  const waitMs = options?.waitMs ?? 20000;
2969
3015
  const graceMs = options?.graceMs ?? 2500;
2970
3016
  const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
@@ -2976,9 +3022,9 @@ async function resolveCdpPort(projectPath, browser, options) {
2976
3022
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
2977
3023
  contractSeen = true;
2978
3024
  if (null == contractSeenAt) contractSeenAt = Date.now();
2979
- if ("number" == typeof contract.cdpPort) return {
2980
- port: contract.cdpPort,
2981
- source: "contract"
3025
+ if ("number" == typeof contract[field]) return {
3026
+ port: contract[field],
3027
+ contractSeen
2982
3028
  };
2983
3029
  } catch {
2984
3030
  if (!contractSeen) break;
@@ -2987,13 +3033,32 @@ async function resolveCdpPort(projectPath, browser, options) {
2987
3033
  if (Date.now() >= effectiveDeadline) break;
2988
3034
  await new Promise((resolve)=>setTimeout(resolve, 500));
2989
3035
  }
3036
+ return {
3037
+ port: null,
3038
+ contractSeen
3039
+ };
3040
+ }
3041
+ async function resolveCdpPort(projectPath, browser, options) {
3042
+ const { port, contractSeen } = await resolveContractPort(projectPath, browser, "cdpPort", options);
3043
+ if (null != port) return {
3044
+ port,
3045
+ source: "contract"
3046
+ };
2990
3047
  if (!contractSeen && await isCdpEndpoint(9222)) return {
2991
3048
  port: 9222,
2992
3049
  source: "default-probe"
2993
3050
  };
2994
3051
  return null;
2995
3052
  }
3053
+ async function resolveRdpPort(projectPath, browser, options) {
3054
+ const { port } = await resolveContractPort(projectPath, browser, "rdpPort", options);
3055
+ return null != port ? {
3056
+ port,
3057
+ source: "contract"
3058
+ } : null;
3059
+ }
2996
3060
  const CDP_PORT_MISSING_HINT = "The session's ready contract has no CDP port (the browser may still be binding its debug port, or was launched without one). Confirm the session with extension_wait, give it a moment, and retry.";
3061
+ const RDP_PORT_MISSING_HINT = "The session's ready contract has no rdpPort. Firefox sessions publish one from extension.js 4.0.15 on; upgrade the project's extension dependency (or remove the local install so the MCP's pinned CLI drives the session) and restart the dev session.";
2997
3062
  function isCdpEndpoint(port) {
2998
3063
  return new Promise((resolve)=>{
2999
3064
  const req = node_http.get({
@@ -3012,9 +3077,640 @@ function isCdpEndpoint(port) {
3012
3077
  });
3013
3078
  });
3014
3079
  }
3080
+ function toMcpSpeak(text) {
3081
+ return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
3082
+ }
3083
+ function withSessionContext(message, projectPath) {
3084
+ const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3085
+ if (!isControlError) return message;
3086
+ const dead = deadReadySession(projectPath);
3087
+ if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
3088
+ const running = knownSessionBrowsers(projectPath);
3089
+ if (0 === running.length) return message;
3090
+ return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
3091
+ }
3092
+ function translateFrame(frame, projectPath) {
3093
+ if (!frame || false !== frame.ok) return frame;
3094
+ if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
3095
+ if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
3096
+ if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
3097
+ return frame;
3098
+ }
3099
+ async function runActVerb(args, projectPath, timeoutMs) {
3100
+ const { code, stdout, stderr } = await runExtensionCli([
3101
+ ...args,
3102
+ "--output",
3103
+ "json"
3104
+ ], {
3105
+ cwd: projectPath,
3106
+ timeoutMs
3107
+ });
3108
+ const out = stdout.trim();
3109
+ if (out) try {
3110
+ const frame = JSON.parse(out);
3111
+ if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
3112
+ return out;
3113
+ } catch {}
3114
+ const message = stderr.trim() || `extension exited with code ${code}`;
3115
+ return JSON.stringify({
3116
+ ok: false,
3117
+ error: {
3118
+ name: "CliError",
3119
+ message: withSessionContext(toMcpSpeak(message), projectPath)
3120
+ }
3121
+ });
3122
+ }
3123
+ function commonFlags(args) {
3124
+ const flags = [];
3125
+ if (args.context) flags.push("--context", args.context);
3126
+ if (args.url) flags.push("--url", args.url);
3127
+ if (null != args.tab) flags.push("--tab", String(args.tab));
3128
+ if (args.browser) flags.push("--browser", args.browser);
3129
+ if (null != args.timeout) flags.push("--timeout", String(args.timeout));
3130
+ return flags;
3131
+ }
3132
+ async function listBridgeTabs(projectPath, browser, timeout) {
3133
+ const raw = await runActVerb([
3134
+ "inspect",
3135
+ projectPath,
3136
+ "--list-tabs",
3137
+ "--browser",
3138
+ browser,
3139
+ ...null != timeout ? [
3140
+ "--timeout",
3141
+ String(timeout)
3142
+ ] : []
3143
+ ], projectPath, timeout);
3144
+ let parsed;
3145
+ try {
3146
+ parsed = JSON.parse(raw);
3147
+ } catch {
3148
+ return {
3149
+ error: raw
3150
+ };
3151
+ }
3152
+ if (parsed?.ok === false) return {
3153
+ error: raw
3154
+ };
3155
+ const list = Array.isArray(parsed?.tabs) ? parsed.tabs : Array.isArray(parsed?.value) ? parsed.value : Array.isArray(parsed?.value?.tabs) ? parsed.value.tabs : null;
3156
+ if (!list) return {
3157
+ error: raw
3158
+ };
3159
+ return {
3160
+ tabs: list.map((t)=>({
3161
+ tabId: "number" == typeof t?.tabId ? t.tabId : "number" == typeof t?.id ? t.id : null,
3162
+ url: String(t?.url ?? ""),
3163
+ title: String(t?.title ?? "")
3164
+ }))
3165
+ };
3166
+ }
3167
+ function matchTabsByUrl(tabs, needle) {
3168
+ const wanted = needle.toLowerCase();
3169
+ const byUrl = tabs.filter((t)=>t.url.toLowerCase().includes(wanted));
3170
+ if (byUrl.length > 0) return byUrl;
3171
+ return tabs.filter((t)=>t.title.toLowerCase().includes(wanted));
3172
+ }
3173
+ async function pollForBridgeTab(projectPath, browser, url, budgetMs) {
3174
+ const deadline = Date.now() + budgetMs;
3175
+ const wanted = url.replace(/#.*$/, "");
3176
+ for(;;){
3177
+ const listed = await listBridgeTabs(projectPath, browser);
3178
+ if ("tabs" in listed) {
3179
+ for (const t of listed.tabs)if (t.url === wanted || t.url.startsWith(wanted)) return t;
3180
+ }
3181
+ if (Date.now() >= deadline) return null;
3182
+ await new Promise((r)=>setTimeout(r, 250));
3183
+ }
3184
+ }
3185
+ async function navigateToUrlViaBridge(projectPath, browser, url, timeout) {
3186
+ const expression = `(async () => { const api = typeof browser !== "undefined" ? browser : chrome; const tabs = await api.tabs.query({ active: true, currentWindow: true }); const active = tabs && tabs[0]; const tab = active && active.id != null ? await api.tabs.update(active.id, { url: ${JSON.stringify(url)} }) : await api.tabs.create({ url: ${JSON.stringify(url)} }); return { tabId: tab && tab.id != null ? tab.id : null }; })()`;
3187
+ const raw = await runActVerb([
3188
+ "eval",
3189
+ expression,
3190
+ projectPath,
3191
+ "--context",
3192
+ "background",
3193
+ "--browser",
3194
+ browser,
3195
+ ...null != timeout ? [
3196
+ "--timeout",
3197
+ String(timeout)
3198
+ ] : []
3199
+ ], projectPath, timeout);
3200
+ try {
3201
+ const parsed = JSON.parse(raw);
3202
+ if (parsed?.ok === false) {
3203
+ if (!parsed.hint) parsed.hint = "On this browser family URL navigation rides the agent bridge (a background eval of tabs.update), so the dev session must be started with allowEval: true (extension_dev).";
3204
+ return JSON.stringify(parsed);
3205
+ }
3206
+ } catch {
3207
+ return raw;
3208
+ }
3209
+ const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
3210
+ if (!settled) return JSON.stringify({
3211
+ ok: false,
3212
+ error: {
3213
+ name: "NavigateFailed",
3214
+ message: `Navigation to ${url} did not produce a tab reporting that URL. The URL may not exist, or the browser refused the navigation (Firefox rejects privileged about:/chrome: URLs and other extensions' moz-extension: pages).`
3215
+ },
3216
+ hint: "Confirm the URL, or discover open tabs with extension_dom_inspect listTabs: true. For an extension page, the path must match the BUILT manifest."
3217
+ });
3218
+ return JSON.stringify({
3219
+ ok: true,
3220
+ navigated: url,
3221
+ tab: {
3222
+ tabId: settled.tabId,
3223
+ url: settled.url,
3224
+ title: settled.title
3225
+ },
3226
+ hint: "Inspect it with extension_dom_inspect or extension_eval using url or this numeric tab id (context: 'page'/'content')."
3227
+ });
3228
+ }
3229
+ async function resolveBridgeBaseUrl(projectPath, browser, timeout) {
3230
+ const raw = await runActVerb([
3231
+ "eval",
3232
+ '(typeof browser !== "undefined" ? browser : chrome).runtime.getURL("")',
3233
+ projectPath,
3234
+ "--context",
3235
+ "background",
3236
+ "--browser",
3237
+ browser,
3238
+ ...null != timeout ? [
3239
+ "--timeout",
3240
+ String(timeout)
3241
+ ] : []
3242
+ ], projectPath, timeout);
3243
+ try {
3244
+ const parsed = JSON.parse(raw);
3245
+ if (parsed?.ok && "string" == typeof parsed.value && parsed.value) return parsed.value.endsWith("/") ? parsed.value : `${parsed.value}/`;
3246
+ } catch {}
3247
+ return null;
3248
+ }
3249
+ function rdp_define_property(obj, key, value) {
3250
+ if (key in obj) Object.defineProperty(obj, key, {
3251
+ value: value,
3252
+ enumerable: true,
3253
+ configurable: true,
3254
+ writable: true
3255
+ });
3256
+ else obj[key] = value;
3257
+ return obj;
3258
+ }
3259
+ function encodeRdpPacket(packet) {
3260
+ const json = Buffer.from(JSON.stringify(packet), "utf8");
3261
+ return Buffer.concat([
3262
+ Buffer.from(`${json.length}:`, "ascii"),
3263
+ json
3264
+ ]);
3265
+ }
3266
+ class RdpPacketDecoder {
3267
+ push(chunk) {
3268
+ this.buffer = Buffer.concat([
3269
+ this.buffer,
3270
+ chunk
3271
+ ]);
3272
+ const packets = [];
3273
+ for(;;){
3274
+ const colon = this.buffer.indexOf(0x3a);
3275
+ if (-1 === colon) {
3276
+ if (this.buffer.length > 16) throw new Error("RDP stream corrupt: no length prefix");
3277
+ break;
3278
+ }
3279
+ const prefix = this.buffer.subarray(0, colon).toString("ascii");
3280
+ if (!/^\d+$/.test(prefix)) throw new Error(`RDP stream corrupt: bad length prefix "${prefix}"`);
3281
+ const length = Number(prefix);
3282
+ if (this.buffer.length < colon + 1 + length) break;
3283
+ const json = this.buffer.subarray(colon + 1, colon + 1 + length);
3284
+ this.buffer = this.buffer.subarray(colon + 1 + length);
3285
+ packets.push(JSON.parse(json.toString("utf8")));
3286
+ }
3287
+ return packets;
3288
+ }
3289
+ constructor(){
3290
+ rdp_define_property(this, "buffer", Buffer.alloc(0));
3291
+ }
3292
+ }
3293
+ class RdpSession {
3294
+ static connect(port, timeoutMs = 10000) {
3295
+ return new Promise((resolve, reject)=>{
3296
+ const socket = node_net.connect({
3297
+ host: "127.0.0.1",
3298
+ port
3299
+ });
3300
+ const session = new RdpSession(socket);
3301
+ const decoder = new RdpPacketDecoder();
3302
+ let settledConnect = false;
3303
+ const timer = setTimeout(()=>{
3304
+ if (!settledConnect) {
3305
+ settledConnect = true;
3306
+ session.close();
3307
+ reject(new Error(`RDP connect timed out after ${timeoutMs}ms`));
3308
+ }
3309
+ }, timeoutMs);
3310
+ socket.on("data", (chunk)=>{
3311
+ let packets;
3312
+ try {
3313
+ packets = decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3314
+ } catch {
3315
+ session.close();
3316
+ return;
3317
+ }
3318
+ for (const packet of packets){
3319
+ if (!settledConnect && "root" === packet.from) {
3320
+ settledConnect = true;
3321
+ clearTimeout(timer);
3322
+ resolve(session);
3323
+ continue;
3324
+ }
3325
+ for (const tap of session.taps)tap(packet);
3326
+ for(let i = 0; i < session.waiters.length; i++)if (session.waiters[i].match(packet)) {
3327
+ session.waiters.splice(i, 1)[0].resolve(packet);
3328
+ break;
3329
+ }
3330
+ }
3331
+ });
3332
+ socket.on("error", (error)=>{
3333
+ if (!settledConnect) {
3334
+ settledConnect = true;
3335
+ clearTimeout(timer);
3336
+ reject(error);
3337
+ }
3338
+ });
3339
+ socket.on("close", ()=>{
3340
+ session.closed = true;
3341
+ for (const waiter of session.waiters.splice(0))waiter.reject(new Error("RDP connection closed before a reply arrived"));
3342
+ });
3343
+ });
3344
+ }
3345
+ request(actor, packet, timeoutMs = 10000) {
3346
+ return new Promise((resolve, reject)=>{
3347
+ if (this.closed) return void reject(new Error("RDP connection is closed"));
3348
+ const waiter = {
3349
+ match: (p)=>p.from === actor && void 0 === p.type,
3350
+ resolve: (p)=>{
3351
+ clearTimeout(timer);
3352
+ if ("string" == typeof p.error) reject(new Error(`RDP actor error: ${p.error}${"string" == typeof p.message ? ` (${p.message})` : ""}`));
3353
+ else resolve(p);
3354
+ },
3355
+ reject: (error)=>{
3356
+ clearTimeout(timer);
3357
+ reject(error);
3358
+ }
3359
+ };
3360
+ const timer = setTimeout(()=>{
3361
+ const at = this.waiters.indexOf(waiter);
3362
+ if (-1 !== at) this.waiters.splice(at, 1);
3363
+ reject(new Error(`RDP request ${String(packet.type)} to ${actor} timed out after ${timeoutMs}ms`));
3364
+ }, timeoutMs);
3365
+ this.waiters.push(waiter);
3366
+ this.socket.write(encodeRdpPacket({
3367
+ to: actor,
3368
+ ...packet
3369
+ }));
3370
+ });
3371
+ }
3372
+ tap(handler) {
3373
+ this.taps.add(handler);
3374
+ return ()=>this.taps.delete(handler);
3375
+ }
3376
+ close() {
3377
+ this.closed = true;
3378
+ this.socket.destroy();
3379
+ }
3380
+ constructor(socket){
3381
+ rdp_define_property(this, "socket", void 0);
3382
+ rdp_define_property(this, "waiters", void 0);
3383
+ rdp_define_property(this, "taps", void 0);
3384
+ rdp_define_property(this, "closed", void 0);
3385
+ this.socket = socket;
3386
+ this.waiters = [];
3387
+ this.taps = new Set();
3388
+ this.closed = false;
3389
+ }
3390
+ }
3391
+ async function withSession(port, timeoutMs, work) {
3392
+ const session = await RdpSession.connect(port, timeoutMs);
3393
+ try {
3394
+ return await work(session);
3395
+ } finally{
3396
+ session.close();
3397
+ }
3398
+ }
3399
+ async function rdpListAddons(port, options) {
3400
+ const timeoutMs = options?.timeoutMs ?? 10000;
3401
+ return withSession(port, timeoutMs, async (session)=>{
3402
+ const reply = await session.request("root", {
3403
+ type: "listAddons"
3404
+ }, timeoutMs);
3405
+ return reply.addons ?? [];
3406
+ });
3407
+ }
3408
+ async function rdpListTabs(port, options) {
3409
+ const timeoutMs = options?.timeoutMs ?? 10000;
3410
+ return withSession(port, timeoutMs, async (session)=>{
3411
+ const reply = await session.request("root", {
3412
+ type: "listTabs"
3413
+ }, timeoutMs);
3414
+ return reply.tabs ?? [];
3415
+ });
3416
+ }
3417
+ function formatConsoleArg(arg) {
3418
+ if (null == arg) return String(arg);
3419
+ if ("object" == typeof arg) {
3420
+ const cls = arg.class;
3421
+ return "string" == typeof cls ? `[${cls}]` : "[object]";
3422
+ }
3423
+ return String(arg);
3424
+ }
3425
+ async function rdpCollectConsoleMessages(port, options) {
3426
+ const timeoutMs = options?.timeoutMs ?? 10000;
3427
+ const settleMs = options?.settleMs ?? 1000;
3428
+ return withSession(port, timeoutMs, async (session)=>{
3429
+ const tabsReply = await session.request("root", {
3430
+ type: "listTabs"
3431
+ }, timeoutMs);
3432
+ const tabs = tabsReply.tabs ?? [];
3433
+ const wanted = options?.urlFilter?.toLowerCase();
3434
+ const tab = wanted ? tabs.find((t)=>String(t.url ?? "").toLowerCase().includes(wanted)) : tabs.find((t)=>true === t.selected) ?? tabs[0];
3435
+ if (!tab?.actor) throw new Error(wanted ? `no open tab matches url: ${options?.urlFilter}` : "no open tabs");
3436
+ const watcherReply = await session.request(String(tab.actor), {
3437
+ type: "getWatcher",
3438
+ isServerTargetSwitchingEnabled: true
3439
+ }, timeoutMs);
3440
+ const watcherActor = String(watcherReply.actor ?? "");
3441
+ if (!watcherActor) throw new Error("tab descriptor returned no watcher");
3442
+ const messages = [];
3443
+ const untap = session.tap((packet)=>{
3444
+ if ("resources-available-array" !== packet.type) return;
3445
+ const array = Array.isArray(packet.array) ? packet.array : [];
3446
+ for (const entry of array){
3447
+ if (!Array.isArray(entry) || entry.length < 2) continue;
3448
+ const [resourceType, resources] = entry;
3449
+ if (Array.isArray(resources)) for (const raw of resources){
3450
+ const resource = raw;
3451
+ if ("console-message" === resourceType) {
3452
+ const args = Array.isArray(resource.arguments) ? resource.arguments : [];
3453
+ messages.push({
3454
+ level: String(resource.level ?? "log"),
3455
+ text: args.map(formatConsoleArg).join(" ")
3456
+ });
3457
+ } else if ("error-message" === resourceType) {
3458
+ const pageError = resource.pageError ?? {};
3459
+ messages.push({
3460
+ level: true === pageError.warning ? "warn" : "error",
3461
+ text: String(pageError.errorMessage ?? "")
3462
+ });
3463
+ }
3464
+ }
3465
+ }
3466
+ });
3467
+ try {
3468
+ await session.request(watcherActor, {
3469
+ type: "watchTargets",
3470
+ targetType: "frame"
3471
+ }, timeoutMs);
3472
+ await session.request(watcherActor, {
3473
+ type: "watchResources",
3474
+ resourceTypes: [
3475
+ "console-message",
3476
+ "error-message"
3477
+ ]
3478
+ }, timeoutMs);
3479
+ await new Promise((resolve)=>setTimeout(resolve, settleMs));
3480
+ } finally{
3481
+ untap();
3482
+ }
3483
+ return messages;
3484
+ });
3485
+ }
3486
+ function buildBridgeInspectExpression(opts) {
3487
+ const parts = [
3488
+ "const out = {};"
3489
+ ];
3490
+ if (opts.meta) parts.push("try { out.meta = { url: location.href, title: document.title, readyState: document.readyState }; } catch (e) {}");
3491
+ if (opts.summary) parts.push(`try {
3492
+ const roots = document.querySelectorAll('#extension-root,[data-extension-root]:not([data-extension-root="extension-js-devtools"])');
3493
+ out.summary = {
3494
+ htmlLength: document.documentElement.outerHTML.length,
3495
+ scriptCount: document.querySelectorAll('script').length,
3496
+ styleCount: document.querySelectorAll('style').length,
3497
+ linkCount: document.querySelectorAll('link').length,
3498
+ extensionRootCount: roots.length,
3499
+ bodyChildCount: document.body ? document.body.children.length : 0
3500
+ };
3501
+ } catch (e) { out.summary = {}; }`);
3502
+ if (opts.html) parts.push(`try {
3503
+ const html = ${PAGE_HTML_SCRIPT};
3504
+ const cap = ${JSON.stringify(opts.maxBytes)};
3505
+ out.htmlTruncated = cap > 0 && html.length > cap;
3506
+ out.html = out.htmlTruncated ? html.slice(0, cap) : html;
3507
+ } catch (e) {}`);
3508
+ if (opts.domSnapshot) parts.push(`try { out.domSnapshot = ${domSnapshotScript(500)}; } catch (e) {}`);
3509
+ if (opts.extensionRoots) parts.push(`try { out.extensionRoots = ${EXTENSION_ROOT_META_SCRIPT}; } catch (e) {}`);
3510
+ if (opts.probes.length) parts.push(`out.probes = {};
3511
+ for (const sel of ${JSON.stringify(opts.probes)}) {
3512
+ try {
3513
+ const nodes = document.querySelectorAll(sel);
3514
+ const first = nodes[0];
3515
+ out.probes[sel] = { count: nodes.length, sample: first ? String(first.outerHTML || "").slice(0, 200) : null };
3516
+ } catch (e) { out.probes[sel] = { error: String((e && e.message) || e) }; }
3517
+ }`);
3518
+ parts.push("return out;");
3519
+ return `(() => { ${parts.join("\n")} })()`;
3520
+ }
3521
+ function closedShadowWalkerCode(cap) {
3522
+ return `
3523
+ (function() {
3524
+ var out = { api: ("openOrClosedShadowRoot" in Element.prototype), closed: [] };
3525
+ function walk(node) {
3526
+ if (!node || node.nodeType !== 1) return;
3527
+ var sr = null;
3528
+ try { sr = node.openOrClosedShadowRoot || null; } catch (e) {}
3529
+ if (sr && sr.mode !== "open") out.closed.push({ host: node.tagName.toLowerCase(), html: String(sr.innerHTML).slice(0, ${cap}) });
3530
+ var kids = node.children;
3531
+ for (var i = 0; i < kids.length; i++) walk(kids[i]);
3532
+ if (sr) { var sk = sr.children; for (var j = 0; j < sk.length; j++) walk(sk[j]); }
3533
+ }
3534
+ walk(document.documentElement);
3535
+ return out;
3536
+ })();
3537
+ `;
3538
+ }
3539
+ function executeScriptExpression(urlFilter, code) {
3540
+ const pick = urlFilter ? `tabs.find(function (t) { return String(t.url || "").toLowerCase().indexOf(${JSON.stringify(urlFilter.toLowerCase())}) !== -1; })` : "(tabs.find(function (t) { return t.active; }) || tabs[0])";
3541
+ return `browser.tabs.query({}).then(function (tabs) {
3542
+ var tab = ${pick};
3543
+ if (!tab) return { error: "no matching tab" };
3544
+ return browser.tabs.executeScript(tab.id, { code: ${JSON.stringify(code)} }).then(
3545
+ function (results) { return { frames: results }; },
3546
+ function (err) { return { error: String((err && err.message) || err) }; }
3547
+ );
3548
+ })`;
3549
+ }
3550
+ async function collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes) {
3551
+ const raw = await runActVerb([
3552
+ "eval",
3553
+ executeScriptExpression(urlFilter, closedShadowWalkerCode(cap)),
3554
+ args.projectPath,
3555
+ "--context",
3556
+ "background",
3557
+ "--browser",
3558
+ browser,
3559
+ ...null != args.timeout ? [
3560
+ "--timeout",
3561
+ String(args.timeout)
3562
+ ] : []
3563
+ ], args.projectPath, args.timeout);
3564
+ let parsed;
3565
+ try {
3566
+ parsed = JSON.parse(raw);
3567
+ } catch {
3568
+ parsed = null;
3569
+ }
3570
+ const value = parsed?.ok === true ? parsed.value : null;
3571
+ const frame = Array.isArray(value?.frames) ? value.frames[0] : null;
3572
+ if (frame && Array.isArray(frame.closed)) {
3573
+ result.closedShadowRoots = frame.closed.map((c)=>({
3574
+ host: String(c.host ?? ""),
3575
+ type: "closed",
3576
+ html: String(c.html ?? "")
3577
+ }));
3578
+ result.deepDom = true;
3579
+ return;
3580
+ }
3581
+ const reason = value?.error ?? parsed?.error?.message ?? "the content-script walk returned nothing";
3582
+ notes.push(`deepDom failed on ${browser}: ${reason}. The walk runs via tabs.executeScript (MV2) and needs the extension to hold host permissions for the target url.`);
3583
+ }
3584
+ async function collectGeckoConsole(args, browser, urlFilter, result, notes) {
3585
+ const fallbackNote = `Console capture on ${browser} rides the RDP watcher replay and needs a session whose ready contract publishes rdpPort (extension.js 4.0.15+); extension_logs streams the extension's own console either way.`;
3586
+ const resolved = await resolveRdpPort(args.projectPath, browser, {
3587
+ waitMs: 5000
3588
+ });
3589
+ if (!resolved) return void notes.push(fallbackNote);
3590
+ try {
3591
+ const messages = await rdpCollectConsoleMessages(resolved.port, {
3592
+ urlFilter
3593
+ });
3594
+ result.console = summarizeConsoleMessages(messages);
3595
+ result.rdpPort = resolved.port;
3596
+ } catch (error) {
3597
+ notes.push(`Console capture over RDP failed: ${error.message}. ${fallbackNote}`);
3598
+ }
3599
+ }
3600
+ async function inspectViaBridge(args, browser, include, maxBytes) {
3601
+ const notes = [];
3602
+ if (args.url) {
3603
+ const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
3604
+ if ("error" in listed) return listed.error;
3605
+ const already = listed.tabs.some((t)=>t.url.includes(args.url));
3606
+ if (!already) {
3607
+ const nav = await navigateToUrlViaBridge(args.projectPath, browser, args.url, args.timeout);
3608
+ try {
3609
+ if (JSON.parse(nav)?.ok !== true) return nav;
3610
+ } catch {
3611
+ return nav;
3612
+ }
3613
+ }
3614
+ }
3615
+ const expression = buildBridgeInspectExpression({
3616
+ summary: include.has("summary"),
3617
+ meta: true,
3618
+ html: include.has("html"),
3619
+ domSnapshot: include.has("dom_snapshot"),
3620
+ extensionRoots: include.has("extension_roots"),
3621
+ probes: args.probe ?? [],
3622
+ maxBytes
3623
+ });
3624
+ let raw = await runActVerb([
3625
+ "eval",
3626
+ expression,
3627
+ args.projectPath,
3628
+ "--context",
3629
+ "page",
3630
+ ...args.url ? [
3631
+ "--url",
3632
+ args.url
3633
+ ] : [],
3634
+ "--browser",
3635
+ browser,
3636
+ ...null != args.timeout ? [
3637
+ "--timeout",
3638
+ String(args.timeout)
3639
+ ] : []
3640
+ ], args.projectPath, args.timeout);
3641
+ let parsed;
3642
+ try {
3643
+ parsed = JSON.parse(raw);
3644
+ } catch {
3645
+ return raw;
3646
+ }
3647
+ let value = parsed?.ok === true ? parsed.value ?? {} : null;
3648
+ if (null === value && /scripting is not available/i.test(String(parsed?.error?.message ?? ""))) {
3649
+ raw = await runActVerb([
3650
+ "eval",
3651
+ executeScriptExpression(args.url, expression),
3652
+ args.projectPath,
3653
+ "--context",
3654
+ "background",
3655
+ "--browser",
3656
+ browser,
3657
+ ...null != args.timeout ? [
3658
+ "--timeout",
3659
+ String(args.timeout)
3660
+ ] : []
3661
+ ], args.projectPath, args.timeout);
3662
+ try {
3663
+ parsed = JSON.parse(raw);
3664
+ } catch {
3665
+ return raw;
3666
+ }
3667
+ const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
3668
+ if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
3669
+ else if (parsed?.ok === true) return JSON.stringify({
3670
+ ok: false,
3671
+ error: {
3672
+ name: "InspectFailed",
3673
+ message: String(parsed?.value?.error ?? "the content-script inspect returned nothing")
3674
+ },
3675
+ hint: "The MV2 fallback inspects via tabs.executeScript, which needs the extension to hold host permissions for the target url."
3676
+ });
3677
+ }
3678
+ if (null === value) return raw;
3679
+ const result = {
3680
+ browser,
3681
+ transport: "bridge"
3682
+ };
3683
+ if (value.meta) {
3684
+ result.target = {
3685
+ url: value.meta.url,
3686
+ title: value.meta.title
3687
+ };
3688
+ if (include.has("meta")) result.meta = value.meta;
3689
+ }
3690
+ if (include.has("summary") && value.summary) result.summary = value.summary;
3691
+ if (include.has("html") && "string" == typeof value.html) {
3692
+ result.html = value.html;
3693
+ if (value.htmlTruncated) result.htmlTruncated = true;
3694
+ }
3695
+ if (include.has("dom_snapshot") && value.domSnapshot) result.domSnapshot = value.domSnapshot;
3696
+ if (include.has("extension_roots") && void 0 !== value.extensionRoots) result.extensionRoots = value.extensionRoots;
3697
+ if (value.probes) {
3698
+ result.probes = value.probes;
3699
+ const jsLooking = (args.probe ?? []).filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
3700
+ if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
3701
+ }
3702
+ const urlFilter = args.url ?? ("string" == typeof value.meta?.url ? value.meta.url : void 0);
3703
+ if (include.has("console")) await collectGeckoConsole(args, browser, urlFilter, result, notes);
3704
+ if (args.deepDom) {
3705
+ const cap = maxBytes > 0 ? maxBytes : 65536;
3706
+ await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
3707
+ }
3708
+ if (notes.length) result.notes = notes;
3709
+ return JSON.stringify(result);
3710
+ }
3015
3711
  const source_inspect_schema = {
3016
3712
  name: "extension_source_inspect",
3017
- description: "Inspect a running extension's live state via Chrome DevTools Protocol: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Requires an active dev or start session.",
3713
+ description: "Inspect a running extension's live state: full HTML (with shadow DOM), DOM structure, content script injection, console messages, and CSS selector queries. Chromium sessions ride Chrome DevTools Protocol. Firefox sessions are fully paired: summary/meta/html/dom_snapshot/extension_roots/probes ride the agent bridge (needs allowEval: true), console rides the RDP watcher replay (engine 4.0.15+), and deepDom walks closed shadow roots via a content-script eval (MV2 sessions with host permissions for the target url; Firefox MV3 background CSP blocks bridge evals entirely). Requires an active dev or start session.",
3018
3714
  inputSchema: {
3019
3715
  type: "object",
3020
3716
  properties: {
@@ -3065,7 +3761,7 @@ const source_inspect_schema = {
3065
3761
  deepDom: {
3066
3762
  type: "boolean",
3067
3763
  default: false,
3068
- description: "Pierce CLOSED shadow roots via CDP (Chromium only). The default path reads open shadow roots; closed ones need this escape hatch."
3764
+ description: "Pierce CLOSED shadow roots. The default path reads open shadow roots; closed ones need this escape hatch. Chromium: CDP DOM pierce. Firefox: a content-script walk via tabs.executeScript (MV2 sessions; the extension needs host permissions for the target url)."
3069
3765
  }
3070
3766
  },
3071
3767
  required: [
@@ -3081,10 +3777,7 @@ async function source_inspect_handler(args) {
3081
3777
  "console"
3082
3778
  ]);
3083
3779
  const maxBytes = args.maxBytes ?? 262144;
3084
- if (!isChromiumFamily(browser)) return JSON.stringify({
3085
- error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
3086
- hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval / extension_dom_inspect (content/page, an open surface like popup/options/sidebar, or an override page like newtab) which work against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
3087
- });
3780
+ if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
3088
3781
  const resolved = await resolveCdpPort(args.projectPath, browser);
3089
3782
  if (!resolved) return JSON.stringify({
3090
3783
  error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
@@ -3188,7 +3881,7 @@ async function source_inspect_handler(args) {
3188
3881
  }
3189
3882
  const list_extensions_schema = {
3190
3883
  name: "extension_list_extensions",
3191
- description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). The entry for THIS dev session's extension (the project being served) is flagged ownExtension: true, with name and version resolved from the session's ready contract even when the browser exposes no identity. Other extensions resolve via the read-only Extensions domain when available; their contexts are never attached to or evaluated in, so an id that the domain does not describe stays id-only with a note saying why. A dormant MV3 service worker with no open page may be absent until it wakes. Chromium only (Firefox uses RDP, not yet supported). Requires an active dev or start session.",
3884
+ description: "List the extensions in the running dev browser. Returns each extension's id, name, version, and (on Chromium) live contexts. The entry for THIS dev session's extension (the project being served) is flagged ownExtension: true, with name and version resolved from the session's ready contract even when the browser exposes no identity. On Chromium this rides the Chrome DevTools Protocol: entries are extensions with at least one live context (a dormant MV3 service worker with no open page may be absent until it wakes), and other extensions resolve via the read-only Extensions domain when available. On Firefox this rides the Remote Debugging Protocol root actor (listAddons, engine 4.0.15+): entries are INSTALLED add-ons regardless of live contexts, with temporarilyInstalled marking temporary loads, and carry no contexts. Either way other extensions' contexts are never attached to or evaluated in. Requires an active dev or start session.",
3192
3885
  inputSchema: {
3193
3886
  type: "object",
3194
3887
  properties: {
@@ -3249,9 +3942,10 @@ function readOwnIdentity(projectPath, browser) {
3249
3942
  const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domain returned nothing for this id, and other extensions' contexts are never attached to or evaluated in to read a manifest.";
3250
3943
  async function list_extensions_handler(args) {
3251
3944
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
3945
+ if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
3252
3946
  if (!isChromiumFamily(browser)) return JSON.stringify({
3253
- error: `Listing extensions for ${browser} uses RDP (Remote Debug Protocol). Currently only Chromium CDP is supported.`,
3254
- hint: 'Pass browser: "chrome" (against a Chromium-family dev session).'
3947
+ error: `Listing extensions for ${browser} is not supported: no debugging-protocol pairing exists for this browser family.`,
3948
+ hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
3255
3949
  });
3256
3950
  const resolved = await resolveCdpPort(args.projectPath, browser);
3257
3951
  if (!resolved) return JSON.stringify({
@@ -3344,6 +4038,70 @@ async function list_extensions_handler(args) {
3344
4038
  cdp.disconnect();
3345
4039
  }
3346
4040
  }
4041
+ async function listGeckoExtensions(projectPath, browser) {
4042
+ const resolved = await resolveRdpPort(projectPath, browser);
4043
+ if (!resolved) return JSON.stringify({
4044
+ error: "No active dev session with a Firefox debugger server (RDP) found.",
4045
+ hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${RDP_PORT_MISSING_HINT}`
4046
+ });
4047
+ const rdpPort = resolved.port;
4048
+ try {
4049
+ let addons = null;
4050
+ let lastError = null;
4051
+ for(let attempt = 0; attempt < 3; attempt++)try {
4052
+ addons = await rdpListAddons(rdpPort);
4053
+ break;
4054
+ } catch (error) {
4055
+ lastError = error;
4056
+ if (attempt < 2) await new Promise((resolve)=>setTimeout(resolve, 800));
4057
+ }
4058
+ if (!addons) throw lastError;
4059
+ const own = readOwnIdentity(projectPath, browser);
4060
+ const extensions = addons.filter((addon)=>true === addon.isWebExtension && true !== addon.isSystem && true !== addon.hidden).map((addon)=>{
4061
+ const entry = {
4062
+ id: String(addon.id ?? addon.actor ?? ""),
4063
+ contexts: [],
4064
+ source: "rdp-root"
4065
+ };
4066
+ if ("string" == typeof addon.name) entry.name = addon.name;
4067
+ if ("string" == typeof addon.version) entry.version = addon.version;
4068
+ if (true === addon.temporarilyInstalled) entry.temporarilyInstalled = true;
4069
+ return entry;
4070
+ });
4071
+ if (own?.name) {
4072
+ const byName = extensions.filter((e)=>e.name === own.name);
4073
+ if (1 === byName.length) byName[0].ownExtension = true;
4074
+ }
4075
+ if (!extensions.some((e)=>e.ownExtension)) {
4076
+ const temporary = extensions.filter((e)=>e.temporarilyInstalled);
4077
+ if (1 === temporary.length) {
4078
+ temporary[0].ownExtension = true;
4079
+ if (void 0 === temporary[0].name && own?.name !== void 0) {
4080
+ temporary[0].name = own.name;
4081
+ if (void 0 !== own.version) temporary[0].version = own.version;
4082
+ temporary[0].source = "session-contract";
4083
+ }
4084
+ }
4085
+ }
4086
+ extensions.sort((a, b)=>{
4087
+ if ((a.ownExtension ?? false) !== (b.ownExtension ?? false)) return a.ownExtension ? -1 : 1;
4088
+ return (a.name ?? a.id).localeCompare(b.name ?? b.id);
4089
+ });
4090
+ const ownEntry = extensions.find((e)=>e.ownExtension);
4091
+ return JSON.stringify({
4092
+ rdpPort,
4093
+ browser,
4094
+ count: extensions.length,
4095
+ ownExtensionId: ownEntry?.id ?? null,
4096
+ extensions,
4097
+ note: "Lists INSTALLED add-ons via the RDP root actor (listAddons), regardless of whether a context is currently live, so entries carry no contexts. temporarilyInstalled marks temporary loads; ownExtension marks the extension this dev session serves, matched from the session's ready contract. Add-ons are never attached to or evaluated in."
4098
+ });
4099
+ } catch (error) {
4100
+ return JSON.stringify({
4101
+ error: `Failed to list extensions over RDP: ${error.message}`
4102
+ });
4103
+ }
4104
+ }
3347
4105
  const CONTROL_WS_PATH = "/extjs-control";
3348
4106
  const LEVEL_ORDER = [
3349
4107
  "error",
@@ -3665,58 +4423,6 @@ async function logs_handler(args) {
3665
4423
  if (args.follow) return readFromStream(args, browser, limit);
3666
4424
  return readFromFile(args, browser, limit);
3667
4425
  }
3668
- function toMcpSpeak(text) {
3669
- return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
3670
- }
3671
- function withSessionContext(message, projectPath) {
3672
- const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3673
- if (!isControlError) return message;
3674
- const dead = deadReadySession(projectPath);
3675
- if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
3676
- const running = knownSessionBrowsers(projectPath);
3677
- if (0 === running.length) return message;
3678
- return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
3679
- }
3680
- function translateFrame(frame, projectPath) {
3681
- if (!frame || false !== frame.ok) return frame;
3682
- if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
3683
- if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
3684
- if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
3685
- return frame;
3686
- }
3687
- async function runActVerb(args, projectPath, timeoutMs) {
3688
- const { code, stdout, stderr } = await runExtensionCli([
3689
- ...args,
3690
- "--output",
3691
- "json"
3692
- ], {
3693
- cwd: projectPath,
3694
- timeoutMs
3695
- });
3696
- const out = stdout.trim();
3697
- if (out) try {
3698
- const frame = JSON.parse(out);
3699
- if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
3700
- return out;
3701
- } catch {}
3702
- const message = stderr.trim() || `extension exited with code ${code}`;
3703
- return JSON.stringify({
3704
- ok: false,
3705
- error: {
3706
- name: "CliError",
3707
- message: withSessionContext(toMcpSpeak(message), projectPath)
3708
- }
3709
- });
3710
- }
3711
- function commonFlags(args) {
3712
- const flags = [];
3713
- if (args.context) flags.push("--context", args.context);
3714
- if (args.url) flags.push("--url", args.url);
3715
- if (null != args.tab) flags.push("--tab", String(args.tab));
3716
- if (args.browser) flags.push("--browser", args.browser);
3717
- if (null != args.timeout) flags.push("--timeout", String(args.timeout));
3718
- return flags;
3719
- }
3720
4426
  const eval_schema = {
3721
4427
  name: "extension_eval",
3722
4428
  description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Context default: on a Chromium session whose manifest is MV3 (the default template) the default is `page` (the active tab), because the MV3 background is a service worker whose CSP blocks eval, so a background default would fail on the most common path; on Firefox/MV2 sessions the default stays `background`. Pass `context: \"background\"` explicitly to target the worker anyway (on Chromium MV3 that returns the CSP explanation). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
@@ -3984,14 +4690,8 @@ async function pollForTarget(port, url, budgetMs) {
3984
4690
  await new Promise((r)=>setTimeout(r, 250));
3985
4691
  }
3986
4692
  }
3987
- async function navigateToUrl(projectPath, browser, url) {
3988
- if (!isChromiumFamily(browser)) return JSON.stringify({
3989
- ok: false,
3990
- error: {
3991
- name: "Unsupported",
3992
- message: `URL navigation drives a tab over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. On Firefox, drive the page via extension_eval (context: "page"/"content") or read extension_logs.`
3993
- }
3994
- });
4693
+ async function navigateToUrl(projectPath, browser, url, timeout) {
4694
+ if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
3995
4695
  const resolved = await resolveCdpPort(projectPath, browser);
3996
4696
  if (!resolved) return JSON.stringify({
3997
4697
  ok: false,
@@ -4234,16 +4934,32 @@ async function applyPopupBounds(projectPath, browser, targetId) {
4234
4934
  async function openSurfaceAsTab(projectPath, browser, surface) {
4235
4935
  const doc = surfaceDocument(projectPath, browser, surface);
4236
4936
  if (!doc) return missingSurfaceError(projectPath, browser, surface, "so there is no page to render as a tab");
4237
- const id = await resolveExtensionId(projectPath, browser);
4238
- if (!id) return JSON.stringify({
4239
- ok: false,
4240
- error: {
4241
- name: "NoExtensionId",
4242
- message: "Could not resolve the extension id from the live session's CDP targets."
4243
- },
4244
- hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
4245
- });
4246
- const url = `chrome-extension://${id}/${doc}`;
4937
+ let url;
4938
+ let extensionId = null;
4939
+ if (isChromiumFamily(browser)) {
4940
+ extensionId = await resolveExtensionId(projectPath, browser);
4941
+ if (!extensionId) return JSON.stringify({
4942
+ ok: false,
4943
+ error: {
4944
+ name: "NoExtensionId",
4945
+ message: "Could not resolve the extension id from the live session's CDP targets."
4946
+ },
4947
+ hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
4948
+ });
4949
+ url = `chrome-extension://${extensionId}/${doc}`;
4950
+ } else {
4951
+ const base = await resolveBridgeBaseUrl(projectPath, browser);
4952
+ if (!base) return JSON.stringify({
4953
+ ok: false,
4954
+ error: {
4955
+ name: "NoExtensionId",
4956
+ message: "Could not resolve the extension's moz-extension:// base URL from the live session (a background eval of runtime.getURL)."
4957
+ },
4958
+ hint: "Confirm the session is ready (extension_wait) and was started with allowEval: true (extension_dev)."
4959
+ });
4960
+ url = `${base}${doc}`;
4961
+ extensionId = base.replace(/^.*:\/\//, "").replace(/\/$/, "");
4962
+ }
4247
4963
  const raw = await navigateToUrl(projectPath, browser, url);
4248
4964
  try {
4249
4965
  const parsed = JSON.parse(raw);
@@ -4251,14 +4967,14 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4251
4967
  parsed.renderedAsTab = {
4252
4968
  surface,
4253
4969
  document: doc,
4254
- extensionId: id
4970
+ extensionId
4255
4971
  };
4256
4972
  let popupBounds = null;
4257
4973
  if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
4258
4974
  popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
4259
4975
  if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
4260
4976
  }
4261
- parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this chrome-extension:// url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
4977
+ parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this extension-page url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
4262
4978
  return JSON.stringify(parsed);
4263
4979
  }
4264
4980
  } catch {}
@@ -4294,7 +5010,7 @@ const open_schema = {
4294
5010
  },
4295
5011
  url: {
4296
5012
  type: "string",
4297
- description: "Navigate a real tab to this URL (Chromium only, via CDP) instead of opening a surface. Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
5013
+ description: "Navigate a real tab to this URL instead of opening a surface (Chromium via CDP; Firefox via the agent bridge, which needs allowEval: true). Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
4298
5014
  },
4299
5015
  asTab: {
4300
5016
  type: "boolean",
@@ -4317,7 +5033,7 @@ const open_schema = {
4317
5033
  };
4318
5034
  async function open_handler(args) {
4319
5035
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4320
- if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
5036
+ if (args.url) return navigateToUrl(args.projectPath, browser, args.url, args.timeout);
4321
5037
  const AS_TAB_SURFACES = [
4322
5038
  "popup",
4323
5039
  "options",
@@ -4368,7 +5084,7 @@ async function open_handler(args) {
4368
5084
  const parsed = JSON.parse(raw);
4369
5085
  const msg = String(parsed?.error?.message ?? "");
4370
5086
  if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
4371
- if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
5087
+ if (AS_TAB_SURFACES.includes(args.surface)) {
4372
5088
  const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
4373
5089
  try {
4374
5090
  const parsedFallback = JSON.parse(fallback);
@@ -4402,9 +5118,10 @@ function matchTargetsByUrl(targets, needle) {
4402
5118
  if (byUrl.length > 0) return byUrl;
4403
5119
  return targets.filter((t)=>t.title.toLowerCase().includes(wanted));
4404
5120
  }
5121
+ const RDP_ACTOR_NOTE = "actor is an RDP tab descriptor actor id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_inspect with listTabs: true.";
4405
5122
  const dom_inspect_schema = {
4406
5123
  name: "extension_dom_inspect",
4407
- description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (CDP targetIds) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
5124
+ description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (Chromium CDP targetIds; Firefox RDP tab actors) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
4408
5125
  inputSchema: {
4409
5126
  type: "object",
4410
5127
  properties: {
@@ -4422,12 +5139,12 @@ const dom_inspect_schema = {
4422
5139
  },
4423
5140
  tabUrl: {
4424
5141
  type: "string",
4425
- description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser's CDP page targets BEFORE inspecting: exactly one match proceeds; zero or several matches return the candidate targets (targetId/url/title) so you can narrow, never a guess. Chromium sessions only; on Firefox use `url`/`tab`. Alternative to `url`."
5142
+ description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser BEFORE inspecting (Chromium: CDP page targets; Firefox: the agent bridge tab list): exactly one match proceeds; zero or several matches return the candidates so you can narrow, never a guess. Alternative to `url`."
4426
5143
  },
4427
5144
  listTargets: {
4428
5145
  type: "boolean",
4429
5146
  default: false,
4430
- description: "Enumerate the browser's CDP page targets as {targetId,url,title,type} and return, ignoring the other args. The discovery path for `tabUrl`. targetId is a CDP target id, NOT a numeric chrome.tabs id (for those use listTabs)."
5147
+ description: "Enumerate the browser's live page targets and return, ignoring the other args. The discovery path for `tabUrl`. Chromium: CDP page targets as {targetId,url,title,type}. Firefox: RDP tab descriptors as {actor,url,title,type} (engine 4.0.15+). Neither id is a numeric chrome.tabs id (for those use listTabs)."
4431
5148
  },
4432
5149
  listTabs: {
4433
5150
  type: "boolean",
@@ -4517,6 +5234,43 @@ async function dom_inspect_handler(args) {
4517
5234
  const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
4518
5235
  if (args.listTargets) {
4519
5236
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
5237
+ if (isGeckoFamily(browser)) {
5238
+ const resolved = await resolveRdpPort(args.projectPath, browser);
5239
+ if (!resolved) return JSON.stringify({
5240
+ ok: false,
5241
+ error: {
5242
+ name: "NoSession",
5243
+ message: `No active dev session with a Firefox debugger server (RDP) for ${browser}, so listTargets has no browser to ask. Start extension_dev and extension_wait for ready. ${RDP_PORT_MISSING_HINT}`
5244
+ }
5245
+ });
5246
+ try {
5247
+ const tabs = await rdpListTabs(resolved.port);
5248
+ return JSON.stringify({
5249
+ ok: true,
5250
+ browser,
5251
+ transport: "rdp",
5252
+ targets: tabs.map((t)=>({
5253
+ actor: String(t.actor ?? ""),
5254
+ type: "tab",
5255
+ url: String(t.url ?? ""),
5256
+ title: String(t.title ?? ""),
5257
+ ...true === t.selected ? {
5258
+ selected: true
5259
+ } : {}
5260
+ })),
5261
+ note: RDP_ACTOR_NOTE
5262
+ });
5263
+ } catch (e) {
5264
+ return JSON.stringify({
5265
+ ok: false,
5266
+ error: {
5267
+ name: "RdpError",
5268
+ message: `Could not list tab targets over RDP: ${e instanceof Error ? e.message : String(e)}`
5269
+ },
5270
+ hint: "Confirm the session is ready (extension_wait), then retry. listTabs: true is the bridge alternative (needs allowControl)."
5271
+ });
5272
+ }
5273
+ }
4520
5274
  const cdp = await cdpPortOrError(args.projectPath, browser, "listTargets");
4521
5275
  if ("error" in cdp) return cdp.error;
4522
5276
  try {
@@ -4550,6 +5304,7 @@ async function dom_inspect_handler(args) {
4550
5304
  ] : []
4551
5305
  ], args.projectPath, args.timeout);
4552
5306
  let targetUrl = args.url;
5307
+ let targetTab = args.tab;
4553
5308
  let resolvedTarget = null;
4554
5309
  if (args.tabUrl) {
4555
5310
  if (null != args.tab || args.url) return JSON.stringify({
@@ -4560,48 +5315,79 @@ async function dom_inspect_handler(args) {
4560
5315
  }
4561
5316
  });
4562
5317
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4563
- const cdp = await cdpPortOrError(args.projectPath, browser, "tabUrl");
4564
- if ("error" in cdp) return cdp.error;
4565
- let targets;
4566
- try {
4567
- targets = await listPageTargets(cdp.port);
4568
- } catch (e) {
4569
- return JSON.stringify({
5318
+ if (isChromiumFamily(browser)) {
5319
+ const cdp = await cdpPortOrError(args.projectPath, browser, "tabUrl");
5320
+ if ("error" in cdp) return cdp.error;
5321
+ let targets;
5322
+ try {
5323
+ targets = await listPageTargets(cdp.port);
5324
+ } catch (e) {
5325
+ return JSON.stringify({
5326
+ ok: false,
5327
+ error: {
5328
+ name: "CdpError",
5329
+ message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
5330
+ },
5331
+ hint: "Confirm the session is ready (extension_wait), then retry, or target with `url`/`tab` instead."
5332
+ });
5333
+ }
5334
+ const matches = matchTargetsByUrl(targets, args.tabUrl);
5335
+ if (0 === matches.length) return JSON.stringify({
4570
5336
  ok: false,
4571
5337
  error: {
4572
- name: "CdpError",
4573
- message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
5338
+ name: "NoMatchingTarget",
5339
+ message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
5340
+ },
5341
+ availableTargets: targets,
5342
+ hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
5343
+ });
5344
+ if (matches.length > 1) return JSON.stringify({
5345
+ ok: false,
5346
+ error: {
5347
+ name: "AmbiguousTabUrl",
5348
+ message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
5349
+ },
5350
+ matchingTargets: matches,
5351
+ hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
5352
+ });
5353
+ resolvedTarget = {
5354
+ ...matches[0]
5355
+ };
5356
+ targetUrl = matches[0].url;
5357
+ } else {
5358
+ const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
5359
+ if ("error" in listed) return listed.error;
5360
+ const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
5361
+ if (0 === matches.length) return JSON.stringify({
5362
+ ok: false,
5363
+ error: {
5364
+ name: "NoMatchingTarget",
5365
+ message: `No open tab's url (or title) contains "${args.tabUrl}" (case-insensitive).`
4574
5366
  },
4575
- hint: "Confirm the session is ready (extension_wait), then retry, or target with `url`/`tab` instead."
5367
+ availableTabs: listed.tabs,
5368
+ hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
4576
5369
  });
5370
+ if (matches.length > 1) return JSON.stringify({
5371
+ ok: false,
5372
+ error: {
5373
+ name: "AmbiguousTabUrl",
5374
+ message: `${matches.length} tabs match "${args.tabUrl}"; refusing to guess which tab you mean.`
5375
+ },
5376
+ matchingTabs: matches,
5377
+ hint: "Narrow `tabUrl` to a longer substring that matches exactly one url in matchingTabs, or pass its numeric tabId as `tab`."
5378
+ });
5379
+ resolvedTarget = {
5380
+ ...matches[0]
5381
+ };
5382
+ if (null != matches[0].tabId) targetTab = matches[0].tabId;
5383
+ else targetUrl = matches[0].url;
4577
5384
  }
4578
- const matches = matchTargetsByUrl(targets, args.tabUrl);
4579
- if (0 === matches.length) return JSON.stringify({
4580
- ok: false,
4581
- error: {
4582
- name: "NoMatchingTarget",
4583
- message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
4584
- },
4585
- availableTargets: targets,
4586
- hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
4587
- });
4588
- if (matches.length > 1) return JSON.stringify({
4589
- ok: false,
4590
- error: {
4591
- name: "AmbiguousTabUrl",
4592
- message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
4593
- },
4594
- matchingTargets: matches,
4595
- hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
4596
- });
4597
- resolvedTarget = matches[0];
4598
- targetUrl = resolvedTarget.url;
4599
5385
  }
4600
5386
  const cli = [
4601
5387
  "inspect",
4602
5388
  args.projectPath
4603
5389
  ];
4604
- if (null != args.tab) cli.push("--tab", String(args.tab));
5390
+ if (null != targetTab) cli.push("--tab", String(targetTab));
4605
5391
  if (targetUrl) cli.push("--url", targetUrl);
4606
5392
  if (args.context) cli.push("--context", args.context);
4607
5393
  if (args.include?.length) cli.push("--include", args.include.join(","));