@extension.dev/mcp 4.8.0 → 5.1.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
@@ -9,8 +9,9 @@ import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
10
  import { execFile, execFileSync } from "node:child_process";
11
11
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
- import ws from "ws";
12
+ import ws_0 from "ws";
13
13
  import node_http from "node:http";
14
+ import node_crypto from "node:crypto";
14
15
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
15
16
  import { promisify } from "node:util";
16
17
  var add_feature_namespaceObject = {};
@@ -53,6 +54,7 @@ var doctor_namespaceObject = {};
53
54
  __webpack_require__.r(doctor_namespaceObject);
54
55
  __webpack_require__.d(doctor_namespaceObject, {
55
56
  handler: ()=>doctor_handler,
57
+ recentErrorLogs: ()=>recentErrorLogs,
56
58
  schema: ()=>doctor_schema
57
59
  });
58
60
  var dom_inspect_namespaceObject = {};
@@ -130,6 +132,7 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
130
132
  var open_namespaceObject = {};
131
133
  __webpack_require__.r(open_namespaceObject);
132
134
  __webpack_require__.d(open_namespaceObject, {
135
+ clampPopupBounds: ()=>clampPopupBounds,
133
136
  handler: ()=>open_handler,
134
137
  schema: ()=>open_schema
135
138
  });
@@ -199,7 +202,16 @@ __webpack_require__.d(whoami_namespaceObject, {
199
202
  handler: ()=>whoami_handler,
200
203
  schema: ()=>whoami_schema
201
204
  });
202
- var package_namespaceObject = JSON.parse('{"rE":"4.7.0","El":{"OP":"^4.0.11"}}');
205
+ var package_namespaceObject = JSON.parse('{"rE":"4.9.0","El":{"OP":"^4.0.13"}}');
206
+ function scaffoldEnginePin(projectPath) {
207
+ try {
208
+ const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
209
+ const spec = pkg?.devDependencies?.extension ?? pkg?.dependencies?.extension ?? null;
210
+ return "string" == typeof spec ? spec : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
203
215
  function detectPackageManager(projectPath) {
204
216
  const byLockfile = [
205
217
  [
@@ -238,7 +250,7 @@ const create_schema = {
238
250
  },
239
251
  parentDir: {
240
252
  type: "string",
241
- description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect pass this explicitly when you care where the project lands."
253
+ description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect, pass this explicitly when you care where the project lands."
242
254
  },
243
255
  template: {
244
256
  type: "string",
@@ -259,14 +271,27 @@ const create_schema = {
259
271
  async function create_handler(args) {
260
272
  const start = Date.now();
261
273
  const projectInput = args.parentDir ? node_path.resolve(args.parentDir, args.projectName) : args.projectName;
274
+ process.env.GIT_TERMINAL_PROMPT = "0";
275
+ if (void 0 === process.env.GIT_ASKPASS) process.env.GIT_ASKPASS = "";
262
276
  const logLines = [];
263
277
  const capture = (stream)=>(...parts)=>{
264
278
  const line = parts.map((p)=>"string" == typeof p ? p : String(p)).join(" ").trim();
265
279
  if (line) logLines.push("error" === stream ? `[error] ${line}` : line);
266
280
  };
267
281
  const logTail = (max = 20)=>logLines.slice(-max);
268
- try {
269
- const result = await extensionCreate(projectInput, {
282
+ const looksTransient = ()=>{
283
+ const blob = logLines.join("\n").toLowerCase();
284
+ return /timed out|timeout|etimedout|econnreset|rate limit|\b429\b|network|could not resolve host|terminal prompts disabled|authentication failed|early eof|rpc failed|remote end hung up/.test(blob);
285
+ };
286
+ const cleanPartial = ()=>{
287
+ try {
288
+ if (args.parentDir && node_fs.existsSync(projectInput)) node_fs.rmSync(projectInput, {
289
+ recursive: true,
290
+ force: true
291
+ });
292
+ } catch {}
293
+ };
294
+ const attempt = ()=>extensionCreate(projectInput, {
270
295
  template: args.template ?? "typescript",
271
296
  install: args.install ?? true,
272
297
  logger: {
@@ -274,34 +299,66 @@ async function create_handler(args) {
274
299
  error: capture("error")
275
300
  }
276
301
  });
277
- const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
278
- const runDev = `${packageManager} run dev`;
279
- return JSON.stringify({
280
- projectPath: result.projectPath,
281
- projectName: result.projectName,
282
- template: result.template,
283
- depsInstalled: result.depsInstalled,
284
- packageManager: result.depsInstalled ? packageManager : null,
285
- duration: Date.now() - start,
286
- nextSteps: result.depsInstalled ? [
287
- `cd ${result.projectPath}`,
288
- runDev
289
- ] : [
290
- `cd ${result.projectPath}`,
291
- "npm install",
292
- "npm run dev"
293
- ],
294
- ...result.depsInstalled ? {} : {
295
- warnings: logTail()
296
- }
297
- });
298
- } catch (err) {
299
- return JSON.stringify({
300
- error: err instanceof Error ? err.message : String(err),
302
+ const failure = (err, transient)=>JSON.stringify({
303
+ error: transient ? "Template download failed (network/timeout/rate-limit). This is not a bad template name. Retry, or check connectivity/GitHub rate limits." : err instanceof Error ? err.message : String(err),
304
+ ...transient ? {
305
+ cause: err instanceof Error ? err.message : String(err)
306
+ } : {},
301
307
  duration: Date.now() - start,
302
308
  log: logTail()
303
309
  });
310
+ let result;
311
+ try {
312
+ result = await attempt();
313
+ } catch (err1) {
314
+ if (!looksTransient()) return failure(err1, false);
315
+ logLines.push("[retry] transient template-download failure; retrying once");
316
+ cleanPartial();
317
+ try {
318
+ result = await attempt();
319
+ } catch (err2) {
320
+ return failure(err2, looksTransient());
321
+ }
304
322
  }
323
+ const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
324
+ if (!hasManifest) return JSON.stringify({
325
+ ok: false,
326
+ status: "incomplete",
327
+ projectPath: result.projectPath,
328
+ error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
329
+ duration: Date.now() - start,
330
+ log: logTail(),
331
+ hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
332
+ });
333
+ const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
334
+ const runDev = `${packageManager} run dev`;
335
+ const addDev = (spec)=>"npm" === packageManager ? `npm i -D ${spec}` : "yarn" === packageManager ? `yarn add -D ${spec}` : `${packageManager} add -D ${spec}`;
336
+ const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
337
+ const scaffoldPin = scaffoldEnginePin(result.projectPath);
338
+ const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
339
+ const engineWarning = pin && "latest" !== pin && !pinMatches ? `The scaffold pins "extension": "${scaffoldPin ?? "unknown"}"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && ${addDev(`extension@${pin}`)})\` to match the pinned engine.` : void 0;
340
+ return JSON.stringify({
341
+ projectPath: result.projectPath,
342
+ projectName: result.projectName,
343
+ template: result.template,
344
+ depsInstalled: result.depsInstalled,
345
+ packageManager: result.depsInstalled ? packageManager : null,
346
+ duration: Date.now() - start,
347
+ nextSteps: result.depsInstalled ? [
348
+ `cd ${result.projectPath}`,
349
+ runDev
350
+ ] : [
351
+ `cd ${result.projectPath}`,
352
+ `${packageManager} install`,
353
+ runDev
354
+ ],
355
+ ...engineWarning ? {
356
+ engineWarning
357
+ } : {},
358
+ ...result.depsInstalled ? {} : {
359
+ warnings: logTail()
360
+ }
361
+ });
305
362
  }
306
363
  const CACHE_DIR = node_path.join(node_os.homedir(), ".cache", "extension-js");
307
364
  const CACHE_FILE = node_path.join(CACHE_DIR, "templates-meta.json");
@@ -515,6 +572,9 @@ function runExtensionCli(args, options) {
515
572
  }
516
573
  function spawnExtensionCli(args, options) {
517
574
  const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
575
+ const logDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-"));
576
+ const logPath = node_path.join(logDir, "session.log");
577
+ const fd = node_fs.openSync(logPath, "a");
518
578
  const child = cross_spawn(command, [
519
579
  ...prefixArgs,
520
580
  ...args
@@ -523,8 +583,8 @@ function spawnExtensionCli(args, options) {
523
583
  detached: true,
524
584
  stdio: [
525
585
  "ignore",
526
- "pipe",
527
- "pipe"
586
+ fd,
587
+ fd
528
588
  ],
529
589
  env: {
530
590
  ...process.env,
@@ -532,8 +592,74 @@ function spawnExtensionCli(args, options) {
532
592
  NO_COLOR: "1"
533
593
  }
534
594
  });
595
+ node_fs.closeSync(fd);
535
596
  child.unref();
536
- return child;
597
+ return {
598
+ child,
599
+ logPath,
600
+ readOutput: ()=>{
601
+ try {
602
+ return node_fs.readFileSync(logPath, "utf8");
603
+ } catch {
604
+ return "";
605
+ }
606
+ }
607
+ };
608
+ }
609
+ function readBuildSummary(projectPath, browser, since) {
610
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
611
+ try {
612
+ const stat = node_fs.statSync(file);
613
+ if (stat.mtimeMs < since) return null;
614
+ const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
615
+ if (summary && "object" == typeof summary) return summary;
616
+ } catch {}
617
+ return null;
618
+ }
619
+ function builtEntrypoints(distDir) {
620
+ let manifest;
621
+ try {
622
+ manifest = JSON.parse(node_fs.readFileSync(node_path.join(distDir, "manifest.json"), "utf8"));
623
+ } catch {
624
+ return [];
625
+ }
626
+ const out = [];
627
+ const add = (role, ref)=>{
628
+ if ("string" != typeof ref) return;
629
+ out.push({
630
+ role,
631
+ path: ref,
632
+ present: node_fs.existsSync(node_path.join(distDir, ref.replace(/^\.?\//, "")))
633
+ });
634
+ };
635
+ const bg = manifest.background;
636
+ if (bg?.service_worker) add("background.service_worker", bg.service_worker);
637
+ if (bg?.page) add("background.page", bg.page);
638
+ if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
639
+ const action = manifest.action || manifest.browser_action;
640
+ if (action?.default_popup) add("action.default_popup", action.default_popup);
641
+ const pageAction = manifest.page_action;
642
+ if (pageAction?.default_popup) add("page_action.default_popup", pageAction.default_popup);
643
+ const cs = manifest.content_scripts;
644
+ if (Array.isArray(cs)) cs.forEach((c, i)=>{
645
+ if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
646
+ if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
647
+ });
648
+ add("devtools_page", manifest.devtools_page);
649
+ add("options_page", manifest.options_page);
650
+ const optionsUi = manifest.options_ui;
651
+ if (optionsUi?.page) add("options_ui.page", optionsUi.page);
652
+ const sidePanel = manifest.side_panel;
653
+ if (sidePanel?.default_path) add("side_panel.default_path", sidePanel.default_path);
654
+ const sidebarAction = manifest.sidebar_action;
655
+ if (sidebarAction?.default_panel) add("sidebar_action.default_panel", sidebarAction.default_panel);
656
+ const overrides = manifest.chrome_url_overrides;
657
+ if (overrides) for (const [key, ref] of Object.entries(overrides))add(`chrome_url_overrides.${key}`, ref);
658
+ const dnr = manifest.declarative_net_request;
659
+ if (dnr && Array.isArray(dnr.rule_resources)) dnr.rule_resources.forEach((r, i)=>{
660
+ if (r && "object" == typeof r) add(`declarative_net_request[${i}].path`, r.path);
661
+ });
662
+ return out;
537
663
  }
538
664
  const build_schema = {
539
665
  name: "extension_build",
@@ -600,6 +726,11 @@ const build_schema = {
600
726
  ],
601
727
  default: "production",
602
728
  description: "Bundler mode override (also sets NODE_ENV)"
729
+ },
730
+ skipValidation: {
731
+ type: "boolean",
732
+ default: false,
733
+ description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses, because a manifest error means the bundle it produces is broken in ways the bundler itself does not report."
603
734
  }
604
735
  },
605
736
  required: [
@@ -607,9 +738,67 @@ const build_schema = {
607
738
  ]
608
739
  }
609
740
  };
741
+ function manifestDivergence(projectPath, browser) {
742
+ const read = (p)=>{
743
+ try {
744
+ return JSON.parse(node_fs.readFileSync(p, "utf8"));
745
+ } catch {
746
+ return null;
747
+ }
748
+ };
749
+ const built = read(node_path.resolve(projectPath, "dist", browser, "manifest.json"));
750
+ const source = read(node_path.resolve(projectPath, "src", "manifest.json")) ?? read(node_path.resolve(projectPath, "manifest.json"));
751
+ if (!built || !source) return [];
752
+ const notes = [];
753
+ const listOf = (m, key)=>Array.isArray(m[key]) ? m[key].filter((x)=>"string" == typeof x) : [];
754
+ for (const key of [
755
+ "permissions",
756
+ "host_permissions",
757
+ "optional_permissions"
758
+ ]){
759
+ const lost = listOf(source, key).filter((p)=>!listOf(built, key).includes(p));
760
+ if (lost.length) notes.push(`The built manifest drops ${key}: ${lost.join(", ")}. The production build has narrower access than the source you tested in dev.`);
761
+ }
762
+ const sourceWar = source.web_accessible_resources;
763
+ const builtWar = built.web_accessible_resources;
764
+ if (Array.isArray(sourceWar) && sourceWar.length && !Array.isArray(builtWar)) notes.push("The built manifest has no web_accessible_resources although the source declares them. Anything injected into a page (scripting.insertCSS targets, injected scripts, images) will be blocked at runtime.");
765
+ return notes;
766
+ }
767
+ async function validationPreflight(projectPath, browser) {
768
+ try {
769
+ const manifestValidate = await Promise.resolve().then(()=>({
770
+ handler: manifest_validate_handler
771
+ }));
772
+ const parsed = JSON.parse(await manifestValidate.handler({
773
+ projectPath,
774
+ browsers: [
775
+ browser
776
+ ]
777
+ }));
778
+ return {
779
+ valid: Boolean(parsed.valid),
780
+ buildBlocking: Boolean(parsed.buildBlocking),
781
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
782
+ warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
783
+ };
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
610
788
  async function build_handler(args) {
611
789
  const start = Date.now();
612
790
  const browser = args.browser ?? "chrome";
791
+ const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
792
+ if (preflight?.buildBlocking) return JSON.stringify({
793
+ success: false,
794
+ status: "blocked",
795
+ browser,
796
+ error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
797
+ errors: preflight.errors,
798
+ warnings: preflight.warnings,
799
+ duration: Date.now() - start,
800
+ hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
801
+ });
613
802
  const cliArgs = [
614
803
  "build",
615
804
  args.projectPath,
@@ -632,6 +821,30 @@ async function build_handler(args) {
632
821
  if (0 === code) {
633
822
  const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
634
823
  const status = out.match(/Build Status:\s*(\w+)/)?.[1];
824
+ const engineSummary = readBuildSummary(args.projectPath, browser, start);
825
+ const buildWarnings = engineSummary?.warnings?.length ? {
826
+ buildWarnings: engineSummary.warnings,
827
+ ..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
828
+ buildWarningsTruncated: engineSummary.warnings_count
829
+ } : {}
830
+ } : {};
831
+ const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
832
+ const missing = entrypoints.filter((e)=>!e.present);
833
+ if (missing.length) return JSON.stringify({
834
+ success: false,
835
+ status: "incomplete",
836
+ browser,
837
+ buildExitCode: 0,
838
+ error: `The build reported success but ${missing.length} declared entrypoint(s) are missing from dist/${browser}: ` + missing.map((m)=>`${m.role} -> ${m.path}`).join(", ") + ". The browser will refuse to load this build.",
839
+ entrypoints,
840
+ ...preflight?.warnings.length ? {
841
+ manifestWarnings: preflight.warnings
842
+ } : {},
843
+ ...buildWarnings,
844
+ duration,
845
+ output: lastLines(out, 12),
846
+ hint: "The bundler exited 0 but did not emit these files. Check that the manifest paths match what the build produces, and that nothing references a file outside the source tree."
847
+ });
635
848
  return JSON.stringify({
636
849
  success: true,
637
850
  browser,
@@ -641,6 +854,19 @@ async function build_handler(args) {
641
854
  ...status ? {
642
855
  status
643
856
  } : {},
857
+ ...entrypoints.length ? {
858
+ entrypoints
859
+ } : {},
860
+ ...preflight?.warnings.length ? {
861
+ manifestWarnings: preflight.warnings
862
+ } : {},
863
+ ...buildWarnings,
864
+ ...(()=>{
865
+ const divergence = manifestDivergence(args.projectPath, browser);
866
+ return divergence.length ? {
867
+ productionDivergence: divergence
868
+ } : {};
869
+ })(),
644
870
  zip: args.zip ?? false,
645
871
  duration,
646
872
  output: lastLines(out, 12)
@@ -769,7 +995,7 @@ const dev_schema = {
769
995
  allowEval: {
770
996
  type: "boolean",
771
997
  default: false,
772
- description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect you do not need to pass both."
998
+ description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
773
999
  }
774
1000
  },
775
1001
  required: [
@@ -792,9 +1018,10 @@ async function dev_handler(args) {
792
1018
  cliArgs.push(...launchFlagArgs(args));
793
1019
  if (allowControl) cliArgs.push("--allow-control");
794
1020
  if (args.allowEval) cliArgs.push("--allow-eval");
795
- const child = spawnExtensionCli(cliArgs, {
1021
+ const spawned = spawnExtensionCli(cliArgs, {
796
1022
  projectDir: args.projectPath
797
1023
  });
1024
+ const { child, logPath } = spawned;
798
1025
  const pid = child.pid;
799
1026
  registerSession({
800
1027
  pid,
@@ -804,15 +1031,37 @@ async function dev_handler(args) {
804
1031
  command: "dev"
805
1032
  });
806
1033
  child.on("exit", ()=>removeSession(args.projectPath, browser));
807
- let earlyOutput = "";
808
- const collector = (data)=>{
809
- earlyOutput += data.toString();
810
- };
811
- child.stdout?.on("data", collector);
812
- child.stderr?.on("data", collector);
813
1034
  await new Promise((resolve)=>setTimeout(resolve, 3000));
814
- child.stdout?.off("data", collector);
815
- child.stderr?.off("data", collector);
1035
+ const earlyOutput = spawned.readOutput();
1036
+ if (null !== child.exitCode || null !== child.signalCode) {
1037
+ const code = child.exitCode;
1038
+ const signal = child.signalCode;
1039
+ return JSON.stringify({
1040
+ ok: false,
1041
+ status: "exited",
1042
+ projectPath: args.projectPath,
1043
+ browser,
1044
+ pid,
1045
+ exitCode: code,
1046
+ signal,
1047
+ error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
1048
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1049
+ logPath,
1050
+ hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
1051
+ });
1052
+ }
1053
+ const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
1054
+ if (compileFailed) return JSON.stringify({
1055
+ ok: false,
1056
+ status: "compile-failed",
1057
+ projectPath: args.projectPath,
1058
+ browser,
1059
+ pid,
1060
+ error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
1061
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1062
+ logPath,
1063
+ hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
1064
+ });
816
1065
  const controlVerbs = "storage, reload, open, dom_inspect";
817
1066
  const capabilities = {
818
1067
  allowControl,
@@ -820,6 +1069,7 @@ async function dev_handler(args) {
820
1069
  unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
821
1070
  };
822
1071
  return JSON.stringify({
1072
+ ok: true,
823
1073
  pid,
824
1074
  browser,
825
1075
  port: args.port ?? 8080,
@@ -827,7 +1077,8 @@ async function dev_handler(args) {
827
1077
  status: "started",
828
1078
  capabilities,
829
1079
  hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
830
- earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
1080
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1081
+ logPath
831
1082
  });
832
1083
  }
833
1084
  function denoiseEarlyOutput(raw) {
@@ -841,6 +1092,93 @@ function denoiseEarlyOutput(raw) {
841
1092
  ];
842
1093
  return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
843
1094
  }
1095
+ function contractSightings(projectPath) {
1096
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
1097
+ let dirs;
1098
+ try {
1099
+ dirs = node_fs.readdirSync(root);
1100
+ } catch {
1101
+ return [];
1102
+ }
1103
+ const sightings = [];
1104
+ for (const dir of dirs){
1105
+ const readyPath = node_path.join(root, dir, "ready.json");
1106
+ try {
1107
+ const stat = node_fs.statSync(readyPath);
1108
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1109
+ if (contract?.status !== "ready") continue;
1110
+ sightings.push({
1111
+ browser: dir,
1112
+ mtimeMs: stat.mtimeMs,
1113
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
1114
+ });
1115
+ } catch {}
1116
+ }
1117
+ return sightings;
1118
+ }
1119
+ function pidAlive(pid) {
1120
+ try {
1121
+ process.kill(pid, 0);
1122
+ return true;
1123
+ } catch {
1124
+ return false;
1125
+ }
1126
+ }
1127
+ function knownSessionBrowsers(projectPath) {
1128
+ const resolved = node_path.resolve(projectPath);
1129
+ const browsers = [];
1130
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1131
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1132
+ return Array.from(new Set(browsers));
1133
+ }
1134
+ function deadReadySession(projectPath) {
1135
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1136
+ browser: sighting.browser,
1137
+ pid: sighting.pid
1138
+ };
1139
+ return null;
1140
+ }
1141
+ function browserExitStamp(projectPath, browser, since) {
1142
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1143
+ try {
1144
+ const stat = node_fs.statSync(readyPath);
1145
+ if (stat.mtimeMs < since) return null;
1146
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1147
+ const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1148
+ if (contract?.status === "error" && exited) return {
1149
+ code: contract.code,
1150
+ browserExitCode: contract.browserExitCode ?? null,
1151
+ browserExitedAt: contract.browserExitedAt
1152
+ };
1153
+ } catch {}
1154
+ return null;
1155
+ }
1156
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1157
+ if (explicit) return {
1158
+ browser: explicit,
1159
+ source: "explicit"
1160
+ };
1161
+ const resolved = node_path.resolve(projectPath);
1162
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1163
+ if (mine.length > 0) return {
1164
+ browser: mine[mine.length - 1].browser,
1165
+ source: "session"
1166
+ };
1167
+ const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1168
+ const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1169
+ if (live.length > 0) return {
1170
+ browser: live[0].browser,
1171
+ source: "contract"
1172
+ };
1173
+ if (sightings.length > 0) return {
1174
+ browser: sightings[0].browser,
1175
+ source: "stale"
1176
+ };
1177
+ return {
1178
+ browser: fallback,
1179
+ source: "fallback"
1180
+ };
1181
+ }
844
1182
  const start_schema = {
845
1183
  name: "extension_start",
846
1184
  description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
@@ -905,9 +1243,11 @@ async function start_handler(args) {
905
1243
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
906
1244
  if (args.noBrowser) cliArgs.push("--no-browser");
907
1245
  cliArgs.push(...launchFlagArgs(args));
908
- const child = spawnExtensionCli(cliArgs, {
1246
+ const spawnedAt = Date.now();
1247
+ const spawned = spawnExtensionCli(cliArgs, {
909
1248
  projectDir: args.projectPath
910
1249
  });
1250
+ const { child, logPath } = spawned;
911
1251
  const pid = child.pid;
912
1252
  registerSession({
913
1253
  pid,
@@ -916,22 +1256,47 @@ async function start_handler(args) {
916
1256
  command: "start"
917
1257
  });
918
1258
  child.on("exit", ()=>removeSession(args.projectPath, browser));
919
- let earlyOutput = "";
920
- const collector = (data)=>{
921
- earlyOutput += data.toString();
922
- };
923
- child.stdout?.on("data", collector);
924
- child.stderr?.on("data", collector);
925
1259
  await new Promise((resolve)=>setTimeout(resolve, 5000));
926
- child.stdout?.off("data", collector);
927
- child.stderr?.off("data", collector);
1260
+ const earlyOutput = spawned.readOutput();
1261
+ if (null !== child.exitCode || null !== child.signalCode) {
1262
+ const code = child.exitCode;
1263
+ const signal = child.signalCode;
1264
+ return JSON.stringify({
1265
+ ok: false,
1266
+ status: "exited",
1267
+ projectPath: args.projectPath,
1268
+ browser,
1269
+ pid,
1270
+ exitCode: code,
1271
+ signal,
1272
+ error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
1273
+ output: earlyOutput.slice(0, 2000),
1274
+ logPath,
1275
+ hint: "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own."
1276
+ });
1277
+ }
1278
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1279
+ if (exitStamp) return JSON.stringify({
1280
+ ok: false,
1281
+ status: "browser-exited",
1282
+ projectPath: args.projectPath,
1283
+ browser,
1284
+ pid,
1285
+ ...exitStamp,
1286
+ error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1287
+ output: earlyOutput.slice(0, 2000),
1288
+ logPath,
1289
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1290
+ });
928
1291
  return JSON.stringify({
1292
+ ok: true,
929
1293
  pid,
930
1294
  browser,
931
1295
  projectPath: args.projectPath,
932
1296
  status: "started",
933
1297
  hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
934
- earlyOutput: earlyOutput.slice(0, 500)
1298
+ earlyOutput: earlyOutput.slice(0, 500),
1299
+ logPath
935
1300
  });
936
1301
  }
937
1302
  const preview_schema = {
@@ -992,9 +1357,11 @@ async function preview_handler(args) {
992
1357
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
993
1358
  if (args.noBrowser) cliArgs.push("--no-browser");
994
1359
  cliArgs.push(...launchFlagArgs(args));
995
- const child = spawnExtensionCli(cliArgs, {
1360
+ const spawnedAt = Date.now();
1361
+ const spawned = spawnExtensionCli(cliArgs, {
996
1362
  projectDir: args.projectPath
997
1363
  });
1364
+ const { child, logPath } = spawned;
998
1365
  const pid = child.pid;
999
1366
  registerSession({
1000
1367
  pid,
@@ -1003,74 +1370,49 @@ async function preview_handler(args) {
1003
1370
  command: "preview"
1004
1371
  });
1005
1372
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1373
+ await new Promise((resolve)=>setTimeout(resolve, 5000));
1374
+ const earlyOutput = spawned.readOutput();
1375
+ if (null !== child.exitCode || null !== child.signalCode) {
1376
+ const code = child.exitCode;
1377
+ const signal = child.signalCode;
1378
+ return JSON.stringify({
1379
+ ok: false,
1380
+ status: "exited",
1381
+ projectPath: args.projectPath,
1382
+ browser,
1383
+ pid,
1384
+ exitCode: code,
1385
+ signal,
1386
+ error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
1387
+ output: earlyOutput.slice(0, 2000),
1388
+ logPath,
1389
+ hint: "Read `output` above for the cause: a missing or broken dist/ (run extension_build first), or a missing browser binary are the common ones."
1390
+ });
1391
+ }
1392
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1393
+ if (exitStamp) return JSON.stringify({
1394
+ ok: false,
1395
+ status: "browser-exited",
1396
+ projectPath: args.projectPath,
1397
+ browser,
1398
+ pid,
1399
+ ...exitStamp,
1400
+ error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1401
+ output: earlyOutput.slice(0, 2000),
1402
+ logPath,
1403
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1404
+ });
1006
1405
  return JSON.stringify({
1406
+ ok: true,
1007
1407
  pid,
1008
1408
  browser,
1009
1409
  projectPath: args.projectPath,
1010
1410
  status: "launched",
1011
- hint: "Call extension_stop when you are done to close the preview browser."
1411
+ hint: "Call extension_stop when you are done to close the preview browser.",
1412
+ earlyOutput: earlyOutput.slice(0, 500),
1413
+ logPath
1012
1414
  });
1013
1415
  }
1014
- function contractSightings(projectPath) {
1015
- const root = node_path.resolve(projectPath, "dist", "extension-js");
1016
- let dirs;
1017
- try {
1018
- dirs = node_fs.readdirSync(root);
1019
- } catch {
1020
- return [];
1021
- }
1022
- const sightings = [];
1023
- for (const dir of dirs){
1024
- const readyPath = node_path.join(root, dir, "ready.json");
1025
- try {
1026
- const stat = node_fs.statSync(readyPath);
1027
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1028
- if (contract?.status !== "ready") continue;
1029
- sightings.push({
1030
- browser: dir,
1031
- mtimeMs: stat.mtimeMs,
1032
- pid: "number" == typeof contract.pid ? contract.pid : void 0
1033
- });
1034
- } catch {}
1035
- }
1036
- return sightings;
1037
- }
1038
- function pidAlive(pid) {
1039
- try {
1040
- process.kill(pid, 0);
1041
- return true;
1042
- } catch {
1043
- return false;
1044
- }
1045
- }
1046
- function knownSessionBrowsers(projectPath) {
1047
- const resolved = node_path.resolve(projectPath);
1048
- const browsers = [];
1049
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1050
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1051
- return Array.from(new Set(browsers));
1052
- }
1053
- function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
1054
- if (explicit) return {
1055
- browser: explicit,
1056
- source: "explicit"
1057
- };
1058
- const resolved = node_path.resolve(projectPath);
1059
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1060
- if (mine.length > 0) return {
1061
- browser: mine[mine.length - 1].browser,
1062
- source: "session"
1063
- };
1064
- const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1065
- if (sightings.length > 0) return {
1066
- browser: sightings[0].browser,
1067
- source: "contract"
1068
- };
1069
- return {
1070
- browser: fallback,
1071
- source: "fallback"
1072
- };
1073
- }
1074
1416
  const stop_schema = {
1075
1417
  name: "extension_stop",
1076
1418
  description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
@@ -1094,12 +1436,11 @@ const stop_schema = {
1094
1436
  required: []
1095
1437
  }
1096
1438
  };
1097
- function profileProcessPids(projectPath, browser) {
1098
- const marker = node_path.resolve(projectPath, "dist", `extension-profile-${browser}`);
1439
+ function pgrepPids(pattern) {
1099
1440
  try {
1100
1441
  const out = execFileSync("pgrep", [
1101
1442
  "-f",
1102
- marker
1443
+ pattern
1103
1444
  ], {
1104
1445
  encoding: "utf8"
1105
1446
  });
@@ -1108,8 +1449,19 @@ function profileProcessPids(projectPath, browser) {
1108
1449
  return [];
1109
1450
  }
1110
1451
  }
1111
- function reapProfileProcesses(projectPath, browser) {
1112
- const pids = profileProcessPids(projectPath, browser);
1452
+ function sessionProcessPids(projectPath) {
1453
+ const resolved = node_path.resolve(projectPath);
1454
+ const pids = new Set();
1455
+ for (const marker of [
1456
+ `extension dev ${resolved}`,
1457
+ node_path.join(resolved, "dist")
1458
+ ])for (const pid of pgrepPids(marker))pids.add(pid);
1459
+ return [
1460
+ ...pids
1461
+ ];
1462
+ }
1463
+ function reapSessionProcesses(projectPath) {
1464
+ const pids = sessionProcessPids(projectPath);
1113
1465
  for (const pid of pids)try {
1114
1466
  process.kill(pid, "SIGKILL");
1115
1467
  } catch {}
@@ -1123,7 +1475,7 @@ function isAlive(pid) {
1123
1475
  return false;
1124
1476
  }
1125
1477
  }
1126
- function signal(pid, sig) {
1478
+ function stop_signal(pid, sig) {
1127
1479
  try {
1128
1480
  process.kill(-pid, sig);
1129
1481
  return true;
@@ -1152,7 +1504,7 @@ async function stopOne(projectPath, browser) {
1152
1504
  const session = getSession(projectPath, browser);
1153
1505
  const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
1154
1506
  if (null == pid) {
1155
- const reaped = reapProfileProcesses(projectPath, browser);
1507
+ const reaped = reapSessionProcesses(projectPath);
1156
1508
  return {
1157
1509
  projectPath,
1158
1510
  browser,
@@ -1164,22 +1516,22 @@ async function stopOne(projectPath, browser) {
1164
1516
  }
1165
1517
  let detail;
1166
1518
  if (isAlive(pid)) {
1167
- signal(pid, "SIGTERM");
1519
+ stop_signal(pid, "SIGTERM");
1168
1520
  await new Promise((resolve)=>setTimeout(resolve, 1500));
1169
1521
  if (isAlive(pid)) {
1170
- signal(pid, "SIGKILL");
1522
+ stop_signal(pid, "SIGKILL");
1171
1523
  await new Promise((resolve)=>setTimeout(resolve, 250));
1172
1524
  }
1173
1525
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
1174
1526
  } else detail = "Process was already gone; cleaned up session records.";
1175
- const reaped = reapProfileProcesses(projectPath, browser);
1527
+ const reaped = reapSessionProcesses(projectPath);
1176
1528
  removeSession(projectPath, browser);
1177
1529
  try {
1178
1530
  node_fs.rmSync(readyJsonPath(projectPath, browser), {
1179
1531
  force: true
1180
1532
  });
1181
1533
  } catch {}
1182
- const survivors = profileProcessPids(projectPath, browser);
1534
+ const survivors = sessionProcessPids(projectPath);
1183
1535
  const stopped = !isAlive(pid) && 0 === survivors.length;
1184
1536
  if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
1185
1537
  else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
@@ -1410,9 +1762,14 @@ const manifest_validate_schema = {
1410
1762
  },
1411
1763
  default: [
1412
1764
  "chrome",
1413
- "firefox"
1765
+ "firefox",
1766
+ "edge"
1414
1767
  ],
1415
1768
  description: "Browsers to validate against"
1769
+ },
1770
+ browser: {
1771
+ type: "string",
1772
+ description: "Single browser to validate against; alias for browsers:[browser] to match the other tools."
1416
1773
  }
1417
1774
  },
1418
1775
  required: []
@@ -1450,6 +1807,19 @@ function collectPathRefs(m) {
1450
1807
  if (sa) push(sa.default_panel);
1451
1808
  const cuo = m.chrome_url_overrides;
1452
1809
  if (cuo) Object.values(cuo).forEach(push);
1810
+ const dnr = m.declarative_net_request;
1811
+ if (dnr && Array.isArray(dnr.rule_resources)) {
1812
+ for (const r of dnr.rule_resources)if (r && "object" == typeof r) push(r.path);
1813
+ }
1814
+ const storage = m.storage;
1815
+ if (storage) push(storage.managed_schema);
1816
+ push(m.devtools_page);
1817
+ const pa = m.page_action;
1818
+ if (pa) {
1819
+ push(pa.default_popup);
1820
+ if ("string" == typeof pa.default_icon) push(pa.default_icon);
1821
+ else if (pa.default_icon) Object.values(pa.default_icon).forEach(push);
1822
+ }
1453
1823
  return refs;
1454
1824
  }
1455
1825
  function fileResolvesSomewhere(ref, roots) {
@@ -1473,10 +1843,95 @@ function findManifest(projectPath) {
1473
1843
  }
1474
1844
  return null;
1475
1845
  }
1846
+ const API_PERMISSION = {
1847
+ storage: "storage",
1848
+ webNavigation: "webNavigation",
1849
+ history: "history",
1850
+ cookies: "cookies",
1851
+ bookmarks: "bookmarks",
1852
+ alarms: "alarms",
1853
+ contextMenus: "contextMenus",
1854
+ notifications: "notifications",
1855
+ downloads: "downloads",
1856
+ webRequest: "webRequest",
1857
+ tabGroups: "tabGroups",
1858
+ topSites: "topSites",
1859
+ idle: "idle",
1860
+ management: "management",
1861
+ scripting: "scripting",
1862
+ declarativeNetRequest: "declarativeNetRequest",
1863
+ sessions: "sessions",
1864
+ proxy: "proxy",
1865
+ tts: "tts",
1866
+ pageCapture: "pageCapture",
1867
+ desktopCapture: "desktopCapture",
1868
+ debugger: "debugger",
1869
+ geolocation: "geolocation"
1870
+ };
1871
+ const HARD_APIS = new Set([
1872
+ "history",
1873
+ "cookies",
1874
+ "bookmarks",
1875
+ "webNavigation",
1876
+ "downloads",
1877
+ "webRequest",
1878
+ "topSites",
1879
+ "management",
1880
+ "tabGroups",
1881
+ "sessions",
1882
+ "proxy",
1883
+ "debugger",
1884
+ "pageCapture",
1885
+ "desktopCapture"
1886
+ ]);
1887
+ function scanApiUsage(roots) {
1888
+ const used = new Set();
1889
+ let filesRead = 0;
1890
+ const walk = (dir, depth)=>{
1891
+ if (depth > 6 || filesRead > 300) return;
1892
+ let entries;
1893
+ try {
1894
+ entries = node_fs.readdirSync(dir, {
1895
+ withFileTypes: true
1896
+ });
1897
+ } catch {
1898
+ return;
1899
+ }
1900
+ for (const e of entries){
1901
+ if ("node_modules" === e.name || "dist" === e.name || e.name.startsWith(".")) continue;
1902
+ const full = node_path.join(dir, e.name);
1903
+ if (e.isDirectory()) {
1904
+ walk(full, depth + 1);
1905
+ continue;
1906
+ }
1907
+ if (!/\.(js|mjs|cjs|ts|tsx|jsx|svelte|vue)$/.test(e.name)) continue;
1908
+ if (filesRead++ > 300) return;
1909
+ let src;
1910
+ try {
1911
+ src = node_fs.readFileSync(full, "utf8");
1912
+ } catch {
1913
+ continue;
1914
+ }
1915
+ const re = /\b(?:chrome|browser)\.(\w+)/g;
1916
+ let m;
1917
+ while(m = re.exec(src))if (API_PERMISSION[m[1]]) used.add(m[1]);
1918
+ }
1919
+ };
1920
+ for (const root of new Set(roots))walk(root, 0);
1921
+ return used;
1922
+ }
1476
1923
  async function manifest_validate_handler(args) {
1924
+ if (!args.browsers && "string" == typeof args.browser) args = {
1925
+ ...args,
1926
+ browsers: [
1927
+ args.browser
1928
+ ]
1929
+ };
1930
+ const explicitBrowsers = Array.isArray(args.browsers) && args.browsers.length > 0;
1477
1931
  const browsers = args.browsers ?? [
1478
1932
  "chrome",
1479
- "firefox"
1933
+ "firefox",
1934
+ "edge"
1480
1935
  ];
1481
1936
  const result = {
1482
1937
  valid: true,
@@ -1521,19 +1976,46 @@ async function manifest_validate_handler(args) {
1521
1976
  node_path.dirname(manifestDir)
1522
1977
  ] : []
1523
1978
  ];
1524
- for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.warnings.push(`Referenced file "${ref}" was not found near the manifest — this is the kind of dangling reference extension_build fails on. Verify with extension_build.`);
1525
- if (!chromiumManifest.manifest_version) result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1979
+ for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.errors.push(`Referenced file "${ref}" was not found near the manifest. extension_build fails on this dangling reference.`);
1980
+ const defaultLocale = manifest.default_locale;
1981
+ if ("string" == typeof defaultLocale && defaultLocale) {
1982
+ const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
1983
+ if (!hasCatalog) result.errors.push(`default_locale "${defaultLocale}" is declared but _locales/${defaultLocale}/messages.json was not found. The build fails on this; add the catalog or remove default_locale.`);
1984
+ }
1985
+ const iconMap = chromiumManifest.icons;
1986
+ if (!iconMap || "string" != typeof iconMap["128"]) result.warnings.push('No 128x128 icon declared ("128" key in icons). The Chrome Web Store requires one for a store listing, and Edge Add-ons expects it too.');
1987
+ const effectiveByBrowser = new Map();
1988
+ for (const b of browsers)effectiveByBrowser.set(b, filterKeysForThisBrowser(manifest, b));
1989
+ const declaredPermSet = new Set();
1990
+ for (const view of [
1991
+ chromiumManifest,
1992
+ ...effectiveByBrowser.values()
1993
+ ])for (const p of [
1994
+ ...view.permissions ?? [],
1995
+ ...view.optional_permissions ?? []
1996
+ ])if ("string" == typeof p) declaredPermSet.add(p);
1997
+ const usedApis = scanApiUsage(roots);
1998
+ for (const api of usedApis){
1999
+ const perm = API_PERMISSION[api];
2000
+ if (declaredPermSet.has(perm)) continue;
2001
+ const base = `Code calls chrome.${api} but "${perm}" is not in permissions`;
2002
+ if (HARD_APIS.has(api)) result.errors.push(`${base}, chrome.${api} is undefined without it and will crash the context at runtime.`);
2003
+ else result.warnings.push(`${base}; it may be undefined at runtime, add "${perm}" if you use it.`);
2004
+ }
2005
+ if (chromiumManifest.manifest_version) {
2006
+ if (2 !== chromiumManifest.manifest_version && 3 !== chromiumManifest.manifest_version) result.errors.push(`manifest_version must be 2 or 3, got ${JSON.stringify(chromiumManifest.manifest_version)}. No browser installs this manifest.`);
2007
+ } else result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1526
2008
  const declaredPerms = [
1527
2009
  ...chromiumManifest.permissions ?? [],
1528
2010
  ...chromiumManifest.optional_permissions ?? []
1529
2011
  ].filter((p)=>"string" == typeof p);
1530
2012
  for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
1531
- if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}" check for a typo (host/match patterns belong in host_permissions, not permissions).`);
2013
+ if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}", check for a typo (host/match patterns belong in host_permissions, not permissions).`);
1532
2014
  }
1533
2015
  for (const browser of browsers){
1534
2016
  const isChromium = isChromiumFamily(browser);
1535
2017
  const isFirefox = isGeckoFamily(browser);
1536
- const effective = filterKeysForThisBrowser(manifest, browser);
2018
+ const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
1537
2019
  const issues = [];
1538
2020
  if (isChromium) {
1539
2021
  const mv = effective.manifest_version;
@@ -1555,6 +2037,17 @@ async function manifest_validate_handler(args) {
1555
2037
  if (bg.service_worker && !bg.scripts) issues.push('Background service_worker declared but no firefox:scripts. Firefox uses "scripts" (array) instead of "service_worker".');
1556
2038
  }
1557
2039
  }
2040
+ const effectivePerms = new Set([
2041
+ ...effective.permissions ?? [],
2042
+ ...effective.optional_permissions ?? []
2043
+ ].filter((p)=>"string" == typeof p));
2044
+ for (const api of usedApis){
2045
+ const perm = API_PERMISSION[api];
2046
+ if (effectivePerms.has(perm)) continue;
2047
+ if (!declaredPermSet.has(perm)) continue;
2048
+ const ns = isFirefox ? "browser" : "chrome";
2049
+ issues.push(`Code calls ${ns}.${api} but the ${browser} build's permissions do not include "${perm}" (it is declared only under another target's prefixed key, e.g. chromium:permissions). This target crashes at runtime.`);
2050
+ }
1558
2051
  result.browserSupport[browser] = {
1559
2052
  supported: 0 === issues.length,
1560
2053
  issues
@@ -1580,8 +2073,17 @@ async function manifest_validate_handler(args) {
1580
2073
  surfaces: t.surfaces
1581
2074
  }));
1582
2075
  } catch {}
1583
- if (0 === result.errors.length) result.valid = Object.values(result.browserSupport).every((b)=>b.supported);
1584
- return JSON.stringify(result);
2076
+ for (const [browser, support] of Object.entries(result.browserSupport)){
2077
+ if (support.supported) continue;
2078
+ const issues = support.issues?.length ? support.issues.join("; ") : `${browser} is not supported by this manifest.`;
2079
+ if (explicitBrowsers) result.errors.push(`${browser}: ${issues}`);
2080
+ else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
2081
+ }
2082
+ result.valid = 0 === result.errors.length;
2083
+ return JSON.stringify({
2084
+ ...result,
2085
+ buildBlocking: result.errors.length > 0
2086
+ });
1585
2087
  }
1586
2088
  const inspect_schema = {
1587
2089
  name: "extension_inspect",
@@ -1730,8 +2232,8 @@ async function inspect_handler(args) {
1730
2232
  const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
1731
2233
  const sizeWarnings = [];
1732
2234
  for (const f of files)if ("sourcemap" !== f.type) {
1733
- if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package move it out of the bundled sources so it does not inflate the store zip.`);
1734
- else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle unusually large for a shipped asset.`);
2235
+ if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package, move it out of the bundled sources so it does not inflate the store zip.`);
2236
+ else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle, unusually large for a shipped asset.`);
1735
2237
  }
1736
2238
  const result = {
1737
2239
  browser,
@@ -1778,6 +2280,7 @@ async function inspect_handler(args) {
1778
2280
  storeReadiness: {
1779
2281
  hasManifest: node_fs.existsSync(manifestPath),
1780
2282
  hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
2283
+ has128Icon: "string" == typeof manifest.icons?.["128"],
1781
2284
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
1782
2285
  noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
1783
2286
  under10MB: totalSize < 10485760
@@ -1799,7 +2302,7 @@ const COMMAND_TIMEOUT_MS = 15000;
1799
2302
  class CDPConnection {
1800
2303
  async connect(wsUrl) {
1801
2304
  return new Promise((resolve, reject)=>{
1802
- this.ws = new ws(wsUrl);
2305
+ this.ws = new ws_0(wsUrl);
1803
2306
  this.ws.on("open", ()=>resolve());
1804
2307
  this.ws.on("message", (data)=>{
1805
2308
  this.handleMessage(data.toString());
@@ -1868,7 +2371,7 @@ class CDPConnection {
1868
2371
  }
1869
2372
  async sendCommand(method, params = {}, sessionId) {
1870
2373
  return new Promise((resolve, reject)=>{
1871
- if (!this.ws || this.ws.readyState !== ws.OPEN) return reject(new Error("CDP WebSocket is not connected"));
2374
+ if (!this.ws || this.ws.readyState !== ws_0.OPEN) return reject(new Error("CDP WebSocket is not connected"));
1872
2375
  const id = ++this.messageId;
1873
2376
  const message = {
1874
2377
  id,
@@ -2259,7 +2762,7 @@ const source_inspect_schema = {
2259
2762
  items: {
2260
2763
  type: "string"
2261
2764
  },
2262
- description: "CSS selectors to query returns element counts and samples for each"
2765
+ description: "CSS selectors to query, returns element counts and samples for each"
2263
2766
  },
2264
2767
  include: {
2265
2768
  type: "array",
@@ -2311,7 +2814,7 @@ async function source_inspect_handler(args) {
2311
2814
  const maxBytes = args.maxBytes ?? 262144;
2312
2815
  if (!isChromiumFamily(browser)) return JSON.stringify({
2313
2816
  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.`,
2314
- hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval (context: "content"/"page") which works against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
2817
+ 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.`
2315
2818
  });
2316
2819
  const resolved = await resolveCdpPort(args.projectPath, browser);
2317
2820
  if (!resolved) return JSON.stringify({
@@ -2393,7 +2896,11 @@ async function source_inspect_handler(args) {
2393
2896
  if (include.has("dom_snapshot")) result.domSnapshot = await cdp.getDomSnapshot(sessionId);
2394
2897
  if (include.has("console")) result.console = cdp.getConsoleSummary();
2395
2898
  if (include.has("extension_roots")) result.extensionRoots = await cdp.getExtensionRootMeta(sessionId);
2396
- if (args.probe?.length) result.probes = await cdp.probeSelectors(sessionId, args.probe);
2899
+ if (args.probe?.length) {
2900
+ result.probes = await cdp.probeSelectors(sessionId, args.probe);
2901
+ const jsLooking = args.probe.filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
2902
+ 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.`;
2903
+ }
2397
2904
  if (args.deepDom) {
2398
2905
  const closed = await cdp.getClosedShadowRoots(sessionId, maxBytes > 0 ? maxBytes : 65536);
2399
2906
  result.closedShadowRoots = closed;
@@ -2412,7 +2919,7 @@ async function source_inspect_handler(args) {
2412
2919
  }
2413
2920
  const list_extensions_schema = {
2414
2921
  name: "extension_list_extensions",
2415
- 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). Identity is read read-only via the Extensions domain other extensions' contexts are never attached to or evaluated in. 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.",
2922
+ 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). Identity is read read-only via the Extensions domain, other extensions' contexts are never attached to or evaluated in. 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.",
2416
2923
  inputSchema: {
2417
2924
  type: "object",
2418
2925
  properties: {
@@ -2667,10 +3174,28 @@ function capRecent(events, limit) {
2667
3174
  truncated: true
2668
3175
  };
2669
3176
  }
2670
- function summarize(events, source, browser, runId, limit, dropped) {
3177
+ function emptyReason(projectPath, browser) {
3178
+ let contract;
3179
+ try {
3180
+ contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
3181
+ } catch {
3182
+ return "No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev.";
3183
+ }
3184
+ if ("error" === contract.status) {
3185
+ const errs = contract.errors;
3186
+ return `The dev session recorded status:"error"${errs?.length ? ` (${errs.join("; ")})` : ""}, so the extension never ran. There are no logs because there was no working build, not because your code is silent.`;
3187
+ }
3188
+ if ("number" == typeof contract.pid) try {
3189
+ process.kill(contract.pid, 0);
3190
+ } catch {
3191
+ return `ready.json reports ready but its dev-server pid ${contract.pid} is dead: the session exited. Logs stop at the moment it died. Restart with extension_dev; extension_doctor will confirm.`;
3192
+ }
3193
+ }
3194
+ function summarize(events, source, browser, runId, limit, dropped, projectPath, staleNote) {
2671
3195
  const matched = events.length;
2672
3196
  const { events: out, truncated } = capRecent(events, limit);
2673
3197
  const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
3198
+ const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
2674
3199
  return JSON.stringify({
2675
3200
  ok: true,
2676
3201
  source,
@@ -2681,9 +3206,30 @@ function summarize(events, source, browser, runId, limit, dropped) {
2681
3206
  truncated,
2682
3207
  dropped: dropped || void 0,
2683
3208
  nextSince: lastSeq >= 0 ? lastSeq : void 0,
3209
+ ...reason ? {
3210
+ emptyReason: reason
3211
+ } : {},
3212
+ ...staleNote && matched > 0 ? {
3213
+ stale: true,
3214
+ warning: staleNote
3215
+ } : {},
2684
3216
  events: out
2685
3217
  });
2686
3218
  }
3219
+ function staleFileNote(projectPath, browser, eventsRunId) {
3220
+ let contract;
3221
+ try {
3222
+ contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
3223
+ } catch {
3224
+ return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
3225
+ }
3226
+ if ("number" == typeof contract.pid) try {
3227
+ process.kill(contract.pid, 0);
3228
+ } catch {
3229
+ return `These events are from a PAST run: the session that wrote them (pid ${contract.pid}) is dead. Nothing current is producing logs; do not read these as live output.`;
3230
+ }
3231
+ if (eventsRunId && contract.runId && String(contract.runId) !== eventsRunId) return `These events carry runId ${eventsRunId} but the current session is run ${String(contract.runId)}, which has written nothing yet. Do not read these as the current run's output.`;
3232
+ }
2687
3233
  async function readFromFile(args, browser, limit) {
2688
3234
  const file = logsFilePath(args.projectPath, browser);
2689
3235
  if (!node_fs.existsSync(file)) return JSON.stringify({
@@ -2707,13 +3253,13 @@ async function readFromFile(args, browser, limit) {
2707
3253
  }
2708
3254
  if (matches(event)) events.push(event);
2709
3255
  }
2710
- return summarize(events, "file", browser, runId, limit, 0);
3256
+ return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
2711
3257
  }
2712
3258
  async function readFromStream(args, browser, limit) {
2713
3259
  const ready = readReadyContract(args.projectPath, browser);
2714
3260
  if (!ready) {
2715
3261
  const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
2716
- const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")} pass that as \`browser\`. Otherwise run` : "Run";
3262
+ const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
2717
3263
  return JSON.stringify({
2718
3264
  error: `No active control channel found for ${browser}.`,
2719
3265
  hint: `${retarget} extension_dev (browser: ${browser}) and wait for it to be ready, then retry. For past logs without a live channel, call without follow.`
@@ -2729,7 +3275,7 @@ async function readFromStream(args, browser, limit) {
2729
3275
  const url = `ws://127.0.0.1:${ready.controlPort}${CONTROL_WS_PATH}`;
2730
3276
  let socket;
2731
3277
  try {
2732
- socket = new ws(url);
3278
+ socket = new ws_0(url);
2733
3279
  } catch (err) {
2734
3280
  resolve(JSON.stringify({
2735
3281
  error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
@@ -2743,7 +3289,7 @@ async function readFromStream(args, browser, limit) {
2743
3289
  try {
2744
3290
  socket.close();
2745
3291
  } catch {}
2746
- resolve(summarize(events, "stream", browser, runId, limit, dropped));
3292
+ resolve(summarize(events, "stream", browser, runId, limit, dropped, args.projectPath));
2747
3293
  };
2748
3294
  const timer = setTimeout(finish, followMs);
2749
3295
  socket.on("open", ()=>{
@@ -2787,13 +3333,16 @@ async function logs_handler(args) {
2787
3333
  return readFromFile(args, browser, limit);
2788
3334
  }
2789
3335
  function toMcpSpeak(text) {
2790
- 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(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
3336
+ 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(/--context[= ]([\w-]+)/g, 'context: "$1"').replace(/--tab[= ](\d+)/g, "tab: $1").replace(/--url[= ](\S+)/g, 'url: "$1"').replace(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
2791
3337
  }
2792
3338
  function withSessionContext(message, projectPath) {
2793
- if (!/no active control channel/i.test(message)) return message;
3339
+ const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3340
+ if (!isControlError) return message;
3341
+ const dead = deadReadySession(projectPath);
3342
+ 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.`;
2794
3343
  const running = knownSessionBrowsers(projectPath);
2795
3344
  if (0 === running.length) return message;
2796
- 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.`;
3345
+ 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.`;
2797
3346
  }
2798
3347
  function translateFrame(frame, projectPath) {
2799
3348
  if (!frame || false !== frame.ok) return frame;
@@ -2837,7 +3386,7 @@ function commonFlags(args) {
2837
3386
  }
2838
3387
  const eval_schema = {
2839
3388
  name: "extension_eval",
2840
- 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). Chromium caveats: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context), and context content/page/popup require a numeric `tab` id (a chrome.tabs id) the `url` arg alone does not target a tab. To read a content-script DOM without a tab id, prefer extension_source_inspect (it auto-selects the active page and can navigate by url). Wraps `extension eval`.",
3389
+ 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). 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). Chromium caveat: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
2841
3390
  inputSchema: {
2842
3391
  type: "object",
2843
3392
  properties: {
@@ -2857,6 +3406,9 @@ const eval_schema = {
2857
3406
  "options",
2858
3407
  "sidebar",
2859
3408
  "devtools",
3409
+ "newtab",
3410
+ "history",
3411
+ "bookmarks",
2860
3412
  "content",
2861
3413
  "page"
2862
3414
  ],
@@ -2865,11 +3417,11 @@ const eval_schema = {
2865
3417
  },
2866
3418
  url: {
2867
3419
  type: "string",
2868
- description: "For content/page: filters which matching document(s) to target, but does NOT by itself select a tab a numeric `tab` id is still required on Chromium."
3420
+ description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`. You do not need a numeric id."
2869
3421
  },
2870
3422
  tab: {
2871
3423
  type: "number",
2872
- description: "Numeric chrome.tabs id. Required for context content/page/popup on Chromium (the `url` arg does not substitute for it)."
3424
+ description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
2873
3425
  },
2874
3426
  browser: {
2875
3427
  type: "string",
@@ -2888,7 +3440,7 @@ const eval_schema = {
2888
3440
  };
2889
3441
  async function eval_handler(args) {
2890
3442
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
2891
- return runActVerb([
3443
+ const raw = await runActVerb([
2892
3444
  "eval",
2893
3445
  args.expression,
2894
3446
  args.projectPath,
@@ -2897,6 +3449,14 @@ async function eval_handler(args) {
2897
3449
  browser
2898
3450
  })
2899
3451
  ], args.projectPath, args.timeout);
3452
+ if ("content" === args.context) try {
3453
+ const parsed = JSON.parse(raw);
3454
+ if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
3455
+ parsed.note = "On Extension.js >= 4.0.14 a failed injection errors explicitly, so this null is the expression's real result. On OLDER engines (bug 61) it could mean the injection never ran; if this result looks wrong, check the engine version with extension_doctor, or verify with extension_logs or context:'page'.";
3456
+ return JSON.stringify(parsed);
3457
+ }
3458
+ } catch {}
3459
+ return raw;
2900
3460
  }
2901
3461
  const storage_schema = {
2902
3462
  name: "extension_storage",
@@ -2976,6 +3536,13 @@ async function storage_handler(args) {
2976
3536
  message: "storage set requires a value"
2977
3537
  }
2978
3538
  });
3539
+ if (void 0 === args.key) return JSON.stringify({
3540
+ ok: false,
3541
+ error: {
3542
+ name: "BadRequest",
3543
+ message: 'storage set requires `key` (string) and `value` args, one key per call. There is no bulk-object set: to seed {a: 1, b: 2}, call once with key: "a" and once with key: "b".'
3544
+ }
3545
+ });
2979
3546
  cli.push("--value", JSON.stringify(args.value));
2980
3547
  }
2981
3548
  if (args.context) cli.push("--context", args.context);
@@ -3031,9 +3598,266 @@ async function reload_handler(args) {
3031
3598
  })
3032
3599
  ], args.projectPath, args.timeout);
3033
3600
  }
3601
+ async function pollForTarget(port, url, budgetMs) {
3602
+ const deadline = Date.now() + budgetMs;
3603
+ const wanted = url.replace(/#.*$/, "");
3604
+ for(;;){
3605
+ try {
3606
+ const targets = await CDPClient.discoverTargets(port);
3607
+ for (const t of targets){
3608
+ const tUrl = String(t.url ?? "");
3609
+ if ("page" === t.type) {
3610
+ if (tUrl === wanted || tUrl.startsWith(wanted)) return {
3611
+ id: String(t.id),
3612
+ url: tUrl,
3613
+ title: "string" == typeof t.title ? t.title : void 0
3614
+ };
3615
+ }
3616
+ }
3617
+ } catch {}
3618
+ if (Date.now() >= deadline) return null;
3619
+ await new Promise((r)=>setTimeout(r, 250));
3620
+ }
3621
+ }
3622
+ async function navigateToUrl(projectPath, browser, url) {
3623
+ if (!isChromiumFamily(browser)) return JSON.stringify({
3624
+ ok: false,
3625
+ error: {
3626
+ name: "Unsupported",
3627
+ 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.`
3628
+ }
3629
+ });
3630
+ const resolved = await resolveCdpPort(projectPath, browser);
3631
+ if (!resolved) return JSON.stringify({
3632
+ ok: false,
3633
+ error: {
3634
+ name: "NoSession",
3635
+ message: `No active dev session / CDP port for ${browser}. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
3636
+ }
3637
+ });
3638
+ const cdp = new CDPClient();
3639
+ try {
3640
+ const targets = await CDPClient.discoverTargets(resolved.port);
3641
+ const pageTargets = targets.filter((t)=>"page" === t.type && !String(t.url || "").startsWith("devtools://"));
3642
+ if (0 === pageTargets.length) return JSON.stringify({
3643
+ ok: false,
3644
+ error: {
3645
+ name: "NoTab",
3646
+ message: "The dev browser has no open page tab to navigate. Trigger one (e.g. extension_open surface, or open the extension) first."
3647
+ }
3648
+ });
3649
+ const target = pageTargets[0];
3650
+ const browserWsUrl = await CDPClient.discoverBrowserWsUrl(resolved.port);
3651
+ await cdp.connect(browserWsUrl);
3652
+ const sessionId = await cdp.attachToTarget(String(target.id));
3653
+ await cdp.navigate(sessionId, url);
3654
+ const settled = await pollForTarget(resolved.port, url, 6000);
3655
+ if (!settled) return JSON.stringify({
3656
+ ok: false,
3657
+ error: {
3658
+ name: "NavigateFailed",
3659
+ message: `Navigation to ${url} did not produce a live page target. The URL may not exist in the extension bundle, or Chrome refused the navigation.`
3660
+ },
3661
+ hint: "Confirm the path exists in the built dist (extension_build / extension_inspect list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout."
3662
+ });
3663
+ return JSON.stringify({
3664
+ ok: true,
3665
+ navigated: url,
3666
+ target: {
3667
+ targetId: settled.id,
3668
+ title: settled.title,
3669
+ url: settled.url
3670
+ },
3671
+ hint: "Inspect it with extension_dom_inspect or extension_source_inspect using url (context: 'page'), they resolve the tab themselves. `target.targetId` is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. If you need a numeric tab id, call extension_dom_inspect with listTabs: true."
3672
+ });
3673
+ } catch (e) {
3674
+ return JSON.stringify({
3675
+ ok: false,
3676
+ error: {
3677
+ name: "NavigateError",
3678
+ message: e instanceof Error ? e.message : String(e)
3679
+ }
3680
+ });
3681
+ } finally{
3682
+ try {
3683
+ cdp.disconnect();
3684
+ } catch {}
3685
+ }
3686
+ }
3687
+ function unpackedExtensionId(distPath) {
3688
+ const digest = node_crypto.createHash("sha256").update(distPath).digest();
3689
+ let id = "";
3690
+ for(let i = 0; i < 16; i++){
3691
+ id += String.fromCharCode(97 + (digest[i] >> 4));
3692
+ id += String.fromCharCode(97 + (0x0f & digest[i]));
3693
+ }
3694
+ return id;
3695
+ }
3696
+ async function resolveExtensionId(projectPath, browser) {
3697
+ const distPath = readDistPath(projectPath, browser);
3698
+ const computed = distPath ? unpackedExtensionId(distPath) : null;
3699
+ const resolved = await resolveCdpPort(projectPath, browser);
3700
+ if (!resolved) return computed;
3701
+ const ids = new Set();
3702
+ try {
3703
+ for (const t of (await CDPClient.discoverTargets(resolved.port))){
3704
+ const url = String(t.url ?? "");
3705
+ if (!url.startsWith("chrome-extension://")) continue;
3706
+ const id = url.slice(19).split("/")[0];
3707
+ if (id) ids.add(id);
3708
+ }
3709
+ } catch {}
3710
+ if (computed && ids.has(computed)) return computed;
3711
+ if (computed) return computed;
3712
+ return 1 === ids.size ? [
3713
+ ...ids
3714
+ ][0] : null;
3715
+ }
3716
+ function declaredCommands(projectPath, browser) {
3717
+ const candidates = [
3718
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3719
+ node_path.join(projectPath, "dist", "manifest.json"),
3720
+ node_path.join(projectPath, "src", "manifest.json"),
3721
+ node_path.join(projectPath, "manifest.json")
3722
+ ];
3723
+ for (const file of candidates)try {
3724
+ const manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3725
+ const commands = manifest?.commands;
3726
+ if (commands && "object" == typeof commands) return Object.keys(commands);
3727
+ return [];
3728
+ } catch {
3729
+ continue;
3730
+ }
3731
+ return null;
3732
+ }
3733
+ function readDistPath(projectPath, browser) {
3734
+ try {
3735
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
3736
+ const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
3737
+ return "string" == typeof contract?.distPath ? contract.distPath : null;
3738
+ } catch {
3739
+ return null;
3740
+ }
3741
+ }
3742
+ function surfaceDocument(projectPath, browser, surface) {
3743
+ const candidates = [
3744
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3745
+ node_path.join(projectPath, "dist", "manifest.json"),
3746
+ node_path.join(projectPath, "src", "manifest.json"),
3747
+ node_path.join(projectPath, "manifest.json")
3748
+ ];
3749
+ for (const file of candidates){
3750
+ let manifest;
3751
+ try {
3752
+ manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3753
+ } catch {
3754
+ continue;
3755
+ }
3756
+ const action = manifest.action ?? manifest.browser_action;
3757
+ const ref = "popup" === surface || "action" === surface ? action?.default_popup : "options" === surface ? manifest.options_ui?.page ?? manifest.options_page : "sidebar" === surface ? manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel : "newtab" === surface || "history" === surface || "bookmarks" === surface ? manifest.chrome_url_overrides?.[surface] : null;
3758
+ if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
3759
+ }
3760
+ return null;
3761
+ }
3762
+ const POPUP_MIN = 25;
3763
+ const POPUP_MAX_WIDTH = 800;
3764
+ const POPUP_MAX_HEIGHT = 600;
3765
+ function clampPopupBounds(width, height) {
3766
+ const w = Math.min(Math.max(Math.ceil(width), POPUP_MIN), POPUP_MAX_WIDTH);
3767
+ const h = Math.min(Math.max(Math.ceil(height), POPUP_MIN), POPUP_MAX_HEIGHT);
3768
+ return {
3769
+ width: w,
3770
+ height: h,
3771
+ clamped: w !== Math.ceil(width) || h !== Math.ceil(height)
3772
+ };
3773
+ }
3774
+ async function applyPopupBounds(projectPath, browser, targetId) {
3775
+ const resolved = await resolveCdpPort(projectPath, browser);
3776
+ if (!resolved) return null;
3777
+ const cdp = new CDPClient();
3778
+ try {
3779
+ const ws = await CDPClient.discoverBrowserWsUrl(resolved.port);
3780
+ await cdp.connect(ws);
3781
+ const sessionId = await cdp.attachToTarget(targetId);
3782
+ const measured = await cdp.evaluate(sessionId, `(() => {
3783
+ const de = document.documentElement, b = document.body;
3784
+ if (!de || !b) return null;
3785
+ const prev = de.style.width;
3786
+ de.style.width = "fit-content";
3787
+ const w = Math.max(de.getBoundingClientRect().width, b.getBoundingClientRect().width);
3788
+ const h = Math.max(de.getBoundingClientRect().height, b.getBoundingClientRect().height, b.scrollHeight);
3789
+ de.style.width = prev;
3790
+ return { w: Math.ceil(w), h: Math.ceil(h) };
3791
+ })()`);
3792
+ if (!measured || "number" != typeof measured.w || "number" != typeof measured.h || measured.w <= 0 || measured.h <= 0) return null;
3793
+ const bounds = clampPopupBounds(measured.w, measured.h);
3794
+ const win = await cdp.sendCommand("Browser.getWindowForTarget", {
3795
+ targetId
3796
+ });
3797
+ if ("number" != typeof win?.windowId) return null;
3798
+ await cdp.sendCommand("Browser.setWindowBounds", {
3799
+ windowId: win.windowId,
3800
+ bounds: {
3801
+ width: bounds.width,
3802
+ height: bounds.height
3803
+ }
3804
+ });
3805
+ const after = await cdp.sendCommand("Browser.getWindowBounds", {
3806
+ windowId: win.windowId
3807
+ });
3808
+ if (after?.bounds?.width !== bounds.width || after?.bounds?.height !== bounds.height) return null;
3809
+ return bounds;
3810
+ } catch {
3811
+ return null;
3812
+ } finally{
3813
+ try {
3814
+ cdp.disconnect();
3815
+ } catch {}
3816
+ }
3817
+ }
3818
+ async function openSurfaceAsTab(projectPath, browser, surface) {
3819
+ const doc = surfaceDocument(projectPath, browser, surface);
3820
+ if (!doc) return JSON.stringify({
3821
+ ok: false,
3822
+ error: {
3823
+ name: "NoSurfaceDocument",
3824
+ message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
3825
+ },
3826
+ hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
3827
+ });
3828
+ const id = await resolveExtensionId(projectPath, browser);
3829
+ if (!id) return JSON.stringify({
3830
+ ok: false,
3831
+ error: {
3832
+ name: "NoExtensionId",
3833
+ message: "Could not resolve the extension id from the live session's CDP targets."
3834
+ },
3835
+ hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
3836
+ });
3837
+ const url = `chrome-extension://${id}/${doc}`;
3838
+ const raw = await navigateToUrl(projectPath, browser, url);
3839
+ try {
3840
+ const parsed = JSON.parse(raw);
3841
+ if (parsed?.ok) {
3842
+ parsed.renderedAsTab = {
3843
+ surface,
3844
+ document: doc,
3845
+ extensionId: id
3846
+ };
3847
+ let popupBounds = null;
3848
+ if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
3849
+ popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
3850
+ if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
3851
+ }
3852
+ 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.";
3853
+ return JSON.stringify(parsed);
3854
+ }
3855
+ } catch {}
3856
+ return raw;
3857
+ }
3034
3858
  const open_schema = {
3035
3859
  name: "extension_open",
3036
- description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
3860
+ description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces; 'newtab'/'history'/'bookmarks' open the extension's chrome_url_overrides page in a tab. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
3037
3861
  inputSchema: {
3038
3862
  type: "object",
3039
3863
  properties: {
@@ -3047,15 +3871,27 @@ const open_schema = {
3047
3871
  "popup",
3048
3872
  "options",
3049
3873
  "sidebar",
3874
+ "newtab",
3875
+ "history",
3876
+ "bookmarks",
3050
3877
  "action",
3051
3878
  "command"
3052
3879
  ],
3053
- description: "Which surface to open or event to replay. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
3880
+ description: "Which surface to open or event to replay. 'newtab'/'history'/'bookmarks' open the matching chrome_url_overrides page. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
3054
3881
  },
3055
3882
  name: {
3056
3883
  type: "string",
3057
3884
  description: "For surface 'command': the chrome.commands name to trigger."
3058
3885
  },
3886
+ url: {
3887
+ type: "string",
3888
+ 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`."
3889
+ },
3890
+ asTab: {
3891
+ type: "boolean",
3892
+ default: false,
3893
+ description: "For surface popup/options/sidebar: render the surface's document in a real tab (chrome-extension://<id>/<doc>) instead of opening a real popup window. This is how you inspect a surface HEADLESSLY, where no window exists to host a popup. Applied automatically as a fallback when a headless session refuses to open the surface. Same page and APIs, but no popup sizing and window.close() closes the tab."
3894
+ },
3059
3895
  browser: {
3060
3896
  type: "string",
3061
3897
  description: "Browser session to target. Defaults to the active dev session's browser for this project."
@@ -3066,13 +3902,41 @@ const open_schema = {
3066
3902
  }
3067
3903
  },
3068
3904
  required: [
3069
- "projectPath",
3070
- "surface"
3905
+ "projectPath"
3071
3906
  ]
3072
3907
  }
3073
3908
  };
3074
3909
  async function open_handler(args) {
3075
3910
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3911
+ if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
3912
+ const AS_TAB_SURFACES = [
3913
+ "popup",
3914
+ "options",
3915
+ "sidebar",
3916
+ "newtab",
3917
+ "history",
3918
+ "bookmarks"
3919
+ ];
3920
+ if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
3921
+ if (!args.surface) return JSON.stringify({
3922
+ ok: false,
3923
+ error: {
3924
+ name: "BadRequest",
3925
+ message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
3926
+ }
3927
+ });
3928
+ if ("command" === args.surface) {
3929
+ const declared = declaredCommands(args.projectPath, browser);
3930
+ if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
3931
+ ok: false,
3932
+ error: {
3933
+ name: "UnknownCommand",
3934
+ message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
3935
+ },
3936
+ declaredCommands: declared,
3937
+ hint: declared.length ? `Declared commands are: ${declared.join(", ")}. Check for a typo, or add "${args.name}" to the manifest.` : "This manifest declares no commands at all. Add a `commands` block, rebuild, then retry."
3938
+ });
3939
+ }
3076
3940
  const cli = [
3077
3941
  "open",
3078
3942
  args.surface,
@@ -3090,8 +3954,18 @@ async function open_handler(args) {
3090
3954
  ].includes(args.surface)) try {
3091
3955
  const parsed = JSON.parse(raw);
3092
3956
  const msg = String(parsed?.error?.message ?? "");
3093
- if (parsed?.ok === false && !parsed.hint && /active browser window|no active|headless/i.test(msg)) {
3094
- parsed.hint = "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Relaunch a headed dev session to open UI surfaces.";
3957
+ if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
3958
+ if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
3959
+ const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
3960
+ try {
3961
+ const parsedFallback = JSON.parse(fallback);
3962
+ if (parsedFallback?.ok) {
3963
+ parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS), which has no window to attach a popup/sidebar to, so the surface was rendered as a tab instead. Pass asTab: false and relaunch headed for a real popup window.";
3964
+ return JSON.stringify(parsedFallback);
3965
+ }
3966
+ } catch {}
3967
+ }
3968
+ if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Retry with asTab: true to render the surface document in a tab, or relaunch a headed dev session for a real popup window.";
3095
3969
  return JSON.stringify(parsed);
3096
3970
  }
3097
3971
  } catch {}
@@ -3109,7 +3983,16 @@ const dom_inspect_schema = {
3109
3983
  },
3110
3984
  tab: {
3111
3985
  type: "number",
3112
- description: "Tab id (required for content/page; omit for surfaces)"
3986
+ description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
3987
+ },
3988
+ url: {
3989
+ type: "string",
3990
+ description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
3991
+ },
3992
+ listTabs: {
3993
+ type: "boolean",
3994
+ default: false,
3995
+ description: "Enumerate open tabs as {tabId,url,title} and return, ignoring the other args. The discovery path when you need an explicit numeric tab id."
3113
3996
  },
3114
3997
  context: {
3115
3998
  type: "string",
@@ -3119,10 +4002,13 @@ const dom_inspect_schema = {
3119
4002
  "popup",
3120
4003
  "options",
3121
4004
  "sidebar",
3122
- "devtools"
4005
+ "devtools",
4006
+ "newtab",
4007
+ "history",
4008
+ "bookmarks"
3123
4009
  ],
3124
4010
  default: "content",
3125
- description: "content/page (needs tab) or an OPEN extension surface (popup/options/sidebar/devtools)"
4011
+ description: "content/page (targets `url`, else the active tab), an OPEN extension surface (popup/options/sidebar/devtools), or an override page (newtab/history/bookmarks)"
3126
4012
  },
3127
4013
  include: {
3128
4014
  type: "array",
@@ -3143,8 +4029,11 @@ const dom_inspect_schema = {
3143
4029
  default: 262144
3144
4030
  },
3145
4031
  withConsole: {
3146
- type: "number",
3147
- description: "Also include the last N console lines for the target (DOM + recent console in one call)"
4032
+ type: [
4033
+ "number",
4034
+ "boolean"
4035
+ ],
4036
+ description: "Also include recent console lines for the target (DOM + console in one call). A number is how many lines; true means 50."
3148
4037
  },
3149
4038
  browser: {
3150
4039
  type: "string",
@@ -3161,30 +4050,28 @@ const dom_inspect_schema = {
3161
4050
  }
3162
4051
  };
3163
4052
  async function dom_inspect_handler(args) {
3164
- const surfaces = [
3165
- "popup",
3166
- "options",
3167
- "sidebar",
3168
- "devtools"
3169
- ];
3170
- const isSurface = !!args.context && surfaces.includes(args.context);
3171
- if (!isSurface && null == args.tab) return JSON.stringify({
3172
- ok: false,
3173
- error: {
3174
- name: "BadRequest",
3175
- message: "content/page inspect requires a numeric tab id (chrome.tabs id, not a CDP target id)."
3176
- },
3177
- hint: "To inspect a content script's DOM without hunting for a tab id, use extension_source_inspect (it auto-selects the active page and can navigate via its url arg). Use extension_dom_inspect with context popup/options/sidebar/devtools for an open surface (no tab needed)."
3178
- });
4053
+ const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
4054
+ if (args.listTabs) return runActVerb([
4055
+ "inspect",
4056
+ args.projectPath,
4057
+ "--list-tabs",
4058
+ "--browser",
4059
+ resolveSessionBrowser(args.projectPath, args.browser).browser,
4060
+ ...null != args.timeout ? [
4061
+ "--timeout",
4062
+ String(args.timeout)
4063
+ ] : []
4064
+ ], args.projectPath, args.timeout);
3179
4065
  const cli = [
3180
4066
  "inspect",
3181
4067
  args.projectPath
3182
4068
  ];
3183
4069
  if (null != args.tab) cli.push("--tab", String(args.tab));
4070
+ if (args.url) cli.push("--url", args.url);
3184
4071
  if (args.context) cli.push("--context", args.context);
3185
4072
  if (args.include?.length) cli.push("--include", args.include.join(","));
3186
4073
  if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
3187
- if (null != args.withConsole) cli.push("--with-console", String(args.withConsole));
4074
+ if (null != withConsole) cli.push("--with-console", String(withConsole));
3188
4075
  cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
3189
4076
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
3190
4077
  return runActVerb(cli, args.projectPath, args.timeout);
@@ -3624,61 +4511,251 @@ const deploy_schema = {
3624
4511
  };
3625
4512
  function deploy_fail(name, message) {
3626
4513
  return JSON.stringify({
3627
- ok: false,
3628
- error: {
3629
- name,
3630
- message
3631
- }
4514
+ ok: false,
4515
+ error: {
4516
+ name,
4517
+ message
4518
+ }
4519
+ });
4520
+ }
4521
+ async function deploy_handler(args) {
4522
+ const token = resolveToken();
4523
+ if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens).");
4524
+ const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
4525
+ if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
4526
+ const buildSha = String(args.buildSha || "").trim();
4527
+ if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
4528
+ const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
4529
+ if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
4530
+ const url = `${apiCheck.base}/api/cli/stores/submit`;
4531
+ const dryRun = false !== args.dryRun;
4532
+ const body = {
4533
+ browsers,
4534
+ buildSha,
4535
+ dryRun
4536
+ };
4537
+ if (args.channel) body.channel = String(args.channel).trim();
4538
+ if (args.version) body.version = String(args.version).trim();
4539
+ let res;
4540
+ try {
4541
+ res = await fetch(url, {
4542
+ method: "POST",
4543
+ headers: {
4544
+ authorization: `Bearer ${token}`,
4545
+ "content-type": "application/json"
4546
+ },
4547
+ body: JSON.stringify(body)
4548
+ });
4549
+ } catch (err) {
4550
+ return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
4551
+ }
4552
+ const text = await res.text();
4553
+ let data;
4554
+ try {
4555
+ data = JSON.parse(text);
4556
+ } catch {
4557
+ data = {
4558
+ message: text
4559
+ };
4560
+ }
4561
+ if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
4562
+ return JSON.stringify({
4563
+ mode: "platform",
4564
+ dryRun,
4565
+ ...data
4566
+ });
4567
+ }
4568
+ function doctor_readReadyContract(projectPath, browser) {
4569
+ try {
4570
+ const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
4571
+ return JSON.parse(raw);
4572
+ } catch {
4573
+ return null;
4574
+ }
4575
+ }
4576
+ const doctor_schema = {
4577
+ name: "extension_doctor",
4578
+ description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order, a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
4579
+ inputSchema: {
4580
+ type: "object",
4581
+ properties: {
4582
+ projectPath: {
4583
+ type: "string",
4584
+ description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4585
+ },
4586
+ browser: {
4587
+ type: "string",
4588
+ description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4589
+ }
4590
+ }
4591
+ }
4592
+ };
4593
+ async function environmentPreflight() {
4594
+ const checks = [];
4595
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
4596
+ checks.push({
4597
+ check: "node",
4598
+ status: nodeMajor >= 20 ? "pass" : "fail",
4599
+ detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4600
+ remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4601
+ });
4602
+ const { code, stdout, stderr } = await runExtensionCli([
4603
+ "--version"
4604
+ ], {
4605
+ timeoutMs: 60000
4606
+ });
4607
+ const cliVersion = stdout.trim() || stderr.trim();
4608
+ checks.push({
4609
+ check: "extension-cli",
4610
+ status: 0 === code ? "pass" : "fail",
4611
+ detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4612
+ remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4613
+ });
4614
+ const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4615
+ const cacheExists = node_fs.existsSync(cacheFile);
4616
+ checks.push({
4617
+ check: "template-cache",
4618
+ status: cacheExists ? "pass" : "warn",
4619
+ detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4620
+ });
4621
+ const healthy = checks.every((c)=>"fail" !== c.status);
4622
+ return JSON.stringify({
4623
+ mode: "environment",
4624
+ healthy,
4625
+ checks,
4626
+ hint: "Pass projectPath to diagnose a live dev session end-to-end."
3632
4627
  });
3633
4628
  }
3634
- async function deploy_handler(args) {
3635
- const token = resolveToken();
3636
- if (!token) return deploy_fail("DeployAuthError", "No token. Run extension_login, or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens).");
3637
- const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
3638
- if (0 === browsers.length) return deploy_fail("DeployInputError", 'browsers is required (e.g. ["chrome","firefox","edge"]).');
3639
- const buildSha = String(args.buildSha || "").trim();
3640
- if (!buildSha) return deploy_fail("DeployInputError", "buildSha is required (the built commit to submit).");
3641
- const apiCheck = safeApiBase(String(args.api || process.env.EXTENSION_DEV_API_URL || deploy_DEFAULT_API));
3642
- if (!apiCheck.ok) return deploy_fail("DeployConfigError", apiCheck.message);
3643
- const url = `${apiCheck.base}/api/cli/stores/submit`;
3644
- const dryRun = false !== args.dryRun;
3645
- const body = {
3646
- browsers,
3647
- buildSha,
3648
- dryRun
3649
- };
3650
- if (args.channel) body.channel = String(args.channel).trim();
3651
- if (args.version) body.version = String(args.version).trim();
3652
- let res;
4629
+ function safeStringify(value) {
3653
4630
  try {
3654
- res = await fetch(url, {
3655
- method: "POST",
3656
- headers: {
3657
- authorization: `Bearer ${token}`,
3658
- "content-type": "application/json"
3659
- },
3660
- body: JSON.stringify(body)
3661
- });
3662
- } catch (err) {
3663
- return deploy_fail("DeployNetworkError", `Could not reach ${url}: ${err?.message || err}`);
4631
+ return JSON.stringify(value) ?? String(value);
4632
+ } catch {
4633
+ return String(value);
3664
4634
  }
3665
- const text = await res.text();
3666
- let data;
4635
+ }
4636
+ function recentErrorLogs(projectPath, browser, max = 5) {
4637
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
4638
+ let lines;
3667
4639
  try {
3668
- data = JSON.parse(text);
4640
+ lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
3669
4641
  } catch {
3670
- data = {
3671
- message: text
3672
- };
4642
+ return [];
3673
4643
  }
3674
- if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
3675
- return JSON.stringify({
3676
- mode: "platform",
3677
- dryRun,
3678
- ...data
4644
+ const errs = [];
4645
+ for (const line of lines){
4646
+ let ev;
4647
+ try {
4648
+ ev = JSON.parse(line);
4649
+ } catch {
4650
+ continue;
4651
+ }
4652
+ if (!ev || "error" !== ev.level) continue;
4653
+ const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
4654
+ let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
4655
+ if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
4656
+ msg = msg.replace(/\s+/g, " ").trim();
4657
+ if (msg) errs.push(msg.slice(0, 300));
4658
+ }
4659
+ return [
4660
+ ...new Set(errs)
4661
+ ].slice(-max);
4662
+ }
4663
+ function projectEngineVersion(projectPath) {
4664
+ try {
4665
+ const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
4666
+ return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
4667
+ } catch {
4668
+ return null;
4669
+ }
4670
+ }
4671
+ async function doctor_handler(args) {
4672
+ if (!args.projectPath) return environmentPreflight();
4673
+ const projectPath = args.projectPath;
4674
+ const { browser } = resolveSessionBrowser(projectPath, args.browser);
4675
+ const { code, stdout, stderr } = await runExtensionCli([
4676
+ "doctor",
4677
+ projectPath,
4678
+ "--browser",
4679
+ browser,
4680
+ "--output",
4681
+ "json"
4682
+ ], {
4683
+ cwd: projectPath
3679
4684
  });
4685
+ const out = stdout.trim();
4686
+ try {
4687
+ const checks = JSON.parse(out);
4688
+ if (!Array.isArray(checks)) throw new Error("not a check array");
4689
+ for (const check of checks){
4690
+ if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
4691
+ if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
4692
+ }
4693
+ let healthy = 0 === code;
4694
+ const contract = doctor_readReadyContract(projectPath, browser);
4695
+ if (contract?.status === "error") {
4696
+ healthy = false;
4697
+ const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
4698
+ const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
4699
+ checks.push({
4700
+ check: "runtime-errors",
4701
+ status: "fail",
4702
+ detail: toMcpSpeak(detail),
4703
+ remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
4704
+ });
4705
+ } else {
4706
+ const errs = recentErrorLogs(projectPath, browser);
4707
+ if (errs.length) {
4708
+ healthy = false;
4709
+ checks.push({
4710
+ check: "runtime-errors",
4711
+ status: "fail",
4712
+ detail: `Recent error-level logs: ${errs.join(" | ")}`,
4713
+ remediation: "The extension is throwing at runtime. Inspect with extension_logs. A chrome.* API called without its permission is a common cause: extension_manifest_validate catches a permission MISSING FROM permissions[], but it does not model host-permission scope (e.g. webRequest with no matching host_permissions) or gesture requirements (e.g. activeTab without a user gesture), so a valid:true there does not rule those out."
4714
+ });
4715
+ }
4716
+ }
4717
+ const engineVersion = projectEngineVersion(projectPath);
4718
+ if (engineVersion) {
4719
+ const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
4720
+ const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
4721
+ checks.push({
4722
+ check: "project-engine",
4723
+ status: mismatch ? "warn" : "pass",
4724
+ detail: `project-local extension@${engineVersion}${mismatch ? `, but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
4725
+ ...mismatch ? {
4726
+ remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
4727
+ } : {}
4728
+ });
4729
+ }
4730
+ return JSON.stringify({
4731
+ browser,
4732
+ ...engineVersion ? {
4733
+ engineVersion
4734
+ } : {},
4735
+ healthy,
4736
+ checks
4737
+ });
4738
+ } catch {
4739
+ const message = stderr.trim() || `extension exited with code ${code}`;
4740
+ return JSON.stringify({
4741
+ ok: false,
4742
+ error: {
4743
+ name: "CliError",
4744
+ message: toMcpSpeak(message),
4745
+ hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
4746
+ }
4747
+ });
4748
+ }
3680
4749
  }
3681
4750
  const SAFE_CEILING_MS = 50000;
4751
+ function wait_isAlive(pid) {
4752
+ try {
4753
+ process.kill(pid, 0);
4754
+ return true;
4755
+ } catch {
4756
+ return false;
4757
+ }
4758
+ }
3682
4759
  const wait_schema = {
3683
4760
  name: "extension_wait",
3684
4761
  description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status. A single call is bounded to ~50s to stay under the MCP client request timeout; if it returns status:'timeout', call it again to keep waiting (polling resumes on the same contract).",
@@ -3712,22 +4789,43 @@ async function wait_handler(args) {
3712
4789
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
3713
4790
  const start = Date.now();
3714
4791
  const pollInterval = 1000;
4792
+ let sawCompiledButUnattached = false;
3715
4793
  while(Date.now() - start < timeout){
3716
4794
  try {
3717
4795
  const raw = node_fs.readFileSync(readyPath, "utf8");
3718
4796
  const contract = JSON.parse(raw);
3719
- if ("ready" === contract.status) return JSON.stringify({
3720
- status: "ready",
3721
- command: contract.command,
3722
- browser: contract.browser,
3723
- port: contract.port,
3724
- pid: contract.pid,
3725
- distPath: contract.distPath,
3726
- manifestPath: contract.manifestPath,
3727
- compiledAt: contract.compiledAt,
3728
- startedAt: contract.startedAt,
3729
- waitDuration: Date.now() - start
3730
- });
4797
+ if ("ready" === contract.status) {
4798
+ if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
4799
+ status: "stale",
4800
+ message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead, the session exited. Restart with extension_dev; extension_doctor will confirm.`,
4801
+ browser: contract.browser,
4802
+ pid: contract.pid,
4803
+ waitDuration: Date.now() - start
4804
+ });
4805
+ const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
4806
+ if (!attached) {
4807
+ await new Promise((r)=>setTimeout(r, pollInterval));
4808
+ sawCompiledButUnattached = true;
4809
+ continue;
4810
+ }
4811
+ const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
4812
+ return JSON.stringify({
4813
+ status: "ready",
4814
+ command: contract.command,
4815
+ browser: contract.browser,
4816
+ port: contract.port,
4817
+ pid: contract.pid,
4818
+ distPath: contract.distPath,
4819
+ manifestPath: contract.manifestPath,
4820
+ compiledAt: contract.compiledAt,
4821
+ startedAt: contract.startedAt,
4822
+ waitDuration: Date.now() - start,
4823
+ ...runtimeErrors.length ? {
4824
+ runtimeErrors,
4825
+ warning: `Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`
4826
+ } : {}
4827
+ });
4828
+ }
3731
4829
  if ("error" === contract.status) return JSON.stringify({
3732
4830
  status: "error",
3733
4831
  message: contract.message,
@@ -3739,13 +4837,20 @@ async function wait_handler(args) {
3739
4837
  } catch {}
3740
4838
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
3741
4839
  }
4840
+ if (sawCompiledButUnattached) return JSON.stringify({
4841
+ status: "compiled-not-attached",
4842
+ message: `The extension compiled, but the runtime executor never attached within ${timeout}ms. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
4843
+ readyPath,
4844
+ waitDuration: Date.now() - start,
4845
+ hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
4846
+ });
3742
4847
  return JSON.stringify({
3743
4848
  status: "timeout",
3744
4849
  message: `Extension not ready after ${timeout}ms this call`,
3745
4850
  readyPath,
3746
4851
  waitDuration: Date.now() - start,
3747
4852
  clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
3748
- hint: "Still building call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
4853
+ hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
3749
4854
  });
3750
4855
  }
3751
4856
  const add_feature_schema = {
@@ -4864,135 +5969,27 @@ async function detect_browsers_handler(args) {
4864
5969
  hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_install_browser to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
4865
5970
  });
4866
5971
  }
4867
- function doctor_readReadyContract(projectPath, browser) {
4868
- try {
4869
- const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
4870
- return JSON.parse(raw);
4871
- } catch {
4872
- return null;
4873
- }
4874
- }
4875
- const doctor_schema = {
4876
- name: "extension_doctor",
4877
- description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order — a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
4878
- inputSchema: {
4879
- type: "object",
4880
- properties: {
4881
- projectPath: {
4882
- type: "string",
4883
- description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4884
- },
4885
- browser: {
4886
- type: "string",
4887
- description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4888
- }
4889
- }
4890
- }
4891
- };
4892
- async function environmentPreflight() {
4893
- const checks = [];
4894
- const nodeMajor = Number(process.versions.node.split(".")[0]);
4895
- checks.push({
4896
- check: "node",
4897
- status: nodeMajor >= 20 ? "pass" : "fail",
4898
- detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4899
- remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4900
- });
4901
- const { code, stdout, stderr } = await runExtensionCli([
4902
- "--version"
4903
- ], {
4904
- timeoutMs: 60000
4905
- });
4906
- const cliVersion = stdout.trim() || stderr.trim();
4907
- checks.push({
4908
- check: "extension-cli",
4909
- status: 0 === code ? "pass" : "fail",
4910
- detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4911
- remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4912
- });
4913
- const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4914
- const cacheExists = node_fs.existsSync(cacheFile);
4915
- checks.push({
4916
- check: "template-cache",
4917
- status: cacheExists ? "pass" : "warn",
4918
- detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4919
- });
4920
- const healthy = checks.every((c)=>"fail" !== c.status);
4921
- return JSON.stringify({
4922
- mode: "environment",
4923
- healthy,
4924
- checks,
4925
- hint: "Pass projectPath to diagnose a live dev session end-to-end."
4926
- });
4927
- }
4928
- async function doctor_handler(args) {
4929
- if (!args.projectPath) return environmentPreflight();
4930
- const projectPath = args.projectPath;
4931
- const { browser } = resolveSessionBrowser(projectPath, args.browser);
4932
- const { code, stdout, stderr } = await runExtensionCli([
4933
- "doctor",
4934
- projectPath,
4935
- "--browser",
4936
- browser,
4937
- "--output",
4938
- "json"
4939
- ], {
4940
- cwd: projectPath
4941
- });
4942
- const out = stdout.trim();
4943
- try {
4944
- const checks = JSON.parse(out);
4945
- if (!Array.isArray(checks)) throw new Error("not a check array");
4946
- for (const check of checks){
4947
- if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
4948
- if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
4949
- }
4950
- let healthy = 0 === code;
4951
- const contract = doctor_readReadyContract(projectPath, browser);
4952
- if (contract?.status === "error") {
4953
- healthy = false;
4954
- const detail = contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
4955
- checks.push({
4956
- check: "runtime-errors",
4957
- status: "fail",
4958
- detail: toMcpSpeak(detail),
4959
- remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
4960
- });
4961
- }
4962
- return JSON.stringify({
4963
- browser,
4964
- healthy,
4965
- checks
4966
- });
4967
- } catch {
4968
- const message = stderr.trim() || `extension exited with code ${code}`;
4969
- return JSON.stringify({
4970
- ok: false,
4971
- error: {
4972
- name: "CliError",
4973
- message: toMcpSpeak(message),
4974
- hint: "extension doctor requires a recent extension CLI — the project's local install may predate it."
4975
- }
4976
- });
4977
- }
4978
- }
4979
5972
  function typeOf(value) {
4980
5973
  if (Array.isArray(value)) return "array";
4981
5974
  if (null === value) return "null";
4982
5975
  return typeof value;
4983
5976
  }
4984
5977
  function checkPrimitive(path, value, schema, issues) {
4985
- if (schema.type && [
4986
- "string",
4987
- "number",
4988
- "boolean"
4989
- ].includes(schema.type)) {
4990
- if (typeOf(value) !== schema.type) return void issues.push({
5978
+ const allowed = void 0 === schema.type ? [] : Array.isArray(schema.type) ? schema.type : [
5979
+ schema.type
5980
+ ];
5981
+ const primitives = allowed.filter((t)=>[
5982
+ "string",
5983
+ "number",
5984
+ "boolean"
5985
+ ].includes(t));
5986
+ if (primitives.length > 0 && primitives.length === allowed.length) {
5987
+ if (!primitives.includes(typeOf(value))) return void issues.push({
4991
5988
  path,
4992
- message: `expected ${schema.type}, got ${typeOf(value)}`
5989
+ message: `expected ${primitives.join(" or ")}, got ${typeOf(value)}`
4993
5990
  });
4994
5991
  }
4995
- if ("array" === schema.type) {
5992
+ if (allowed.includes("array")) {
4996
5993
  if (!Array.isArray(value)) return void issues.push({
4997
5994
  path,
4998
5995
  message: `expected array, got ${typeOf(value)}`
@@ -5035,7 +6032,28 @@ const ARG_ALIASES = {
5035
6032
  "manifest"
5036
6033
  ],
5037
6034
  surface: [
5038
- "view"
6035
+ "view",
6036
+ "target"
6037
+ ],
6038
+ timeout: [
6039
+ "timeoutMs",
6040
+ "timeoutMillis"
6041
+ ],
6042
+ limit: [
6043
+ "lines",
6044
+ "count",
6045
+ "max",
6046
+ "maxLines"
6047
+ ],
6048
+ tab: [
6049
+ "tabId"
6050
+ ],
6051
+ url: [
6052
+ "href",
6053
+ "pageUrl"
6054
+ ],
6055
+ browser: [
6056
+ "browserName"
5039
6057
  ]
5040
6058
  };
5041
6059
  function normalizeArgAliases(inputSchema, args) {