@extension.dev/mcp 4.9.0 → 5.1.1

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 = {};
@@ -35,7 +36,8 @@ var deploy_namespaceObject = {};
35
36
  __webpack_require__.r(deploy_namespaceObject);
36
37
  __webpack_require__.d(deploy_namespaceObject, {
37
38
  handler: ()=>deploy_handler,
38
- schema: ()=>deploy_schema
39
+ schema: ()=>deploy_schema,
40
+ storeMdWarnings: ()=>storeMdWarnings
39
41
  });
40
42
  var detect_browsers_namespaceObject = {};
41
43
  __webpack_require__.r(detect_browsers_namespaceObject);
@@ -53,6 +55,7 @@ var doctor_namespaceObject = {};
53
55
  __webpack_require__.r(doctor_namespaceObject);
54
56
  __webpack_require__.d(doctor_namespaceObject, {
55
57
  handler: ()=>doctor_handler,
58
+ recentErrorLogs: ()=>recentErrorLogs,
56
59
  schema: ()=>doctor_schema
57
60
  });
58
61
  var dom_inspect_namespaceObject = {};
@@ -130,6 +133,7 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
130
133
  var open_namespaceObject = {};
131
134
  __webpack_require__.r(open_namespaceObject);
132
135
  __webpack_require__.d(open_namespaceObject, {
136
+ clampPopupBounds: ()=>clampPopupBounds,
133
137
  handler: ()=>open_handler,
134
138
  schema: ()=>open_schema
135
139
  });
@@ -199,7 +203,16 @@ __webpack_require__.d(whoami_namespaceObject, {
199
203
  handler: ()=>whoami_handler,
200
204
  schema: ()=>whoami_schema
201
205
  });
202
- var package_namespaceObject = JSON.parse('{"rE":"4.8.0","El":{"OP":"^4.0.11"}}');
206
+ var package_namespaceObject = JSON.parse('{"rE":"5.1.0","El":{"OP":"^4.0.13"}}');
207
+ function scaffoldEnginePin(projectPath) {
208
+ try {
209
+ const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
210
+ const spec = pkg?.devDependencies?.extension ?? pkg?.dependencies?.extension ?? null;
211
+ return "string" == typeof spec ? spec : null;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
203
216
  function detectPackageManager(projectPath) {
204
217
  const byLockfile = [
205
218
  [
@@ -238,7 +251,7 @@ const create_schema = {
238
251
  },
239
252
  parentDir: {
240
253
  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."
254
+ 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
255
  },
243
256
  template: {
244
257
  type: "string",
@@ -288,7 +301,7 @@ async function create_handler(args) {
288
301
  }
289
302
  });
290
303
  const failure = (err, transient)=>JSON.stringify({
291
- 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
+ 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),
292
305
  ...transient ? {
293
306
  cause: err instanceof Error ? err.message : String(err)
294
307
  } : {},
@@ -308,10 +321,23 @@ async function create_handler(args) {
308
321
  return failure(err2, looksTransient());
309
322
  }
310
323
  }
311
- const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
324
+ const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
325
+ if (!hasManifest) return JSON.stringify({
326
+ ok: false,
327
+ status: "incomplete",
328
+ projectPath: result.projectPath,
329
+ error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
330
+ duration: Date.now() - start,
331
+ log: logTail(),
332
+ hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
333
+ });
334
+ const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
312
335
  const runDev = `${packageManager} run dev`;
336
+ const addDev = (spec)=>"npm" === packageManager ? `npm i -D ${spec}` : "yarn" === packageManager ? `yarn add -D ${spec}` : `${packageManager} add -D ${spec}`;
313
337
  const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
314
- const engineWarning = pin && "latest" !== pin ? `The scaffold pins "extension": "latest"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && npm i -D extension@${pin})\` to match the pinned engine.` : void 0;
338
+ const scaffoldPin = scaffoldEnginePin(result.projectPath);
339
+ const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
340
+ 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;
315
341
  return JSON.stringify({
316
342
  projectPath: result.projectPath,
317
343
  projectName: result.projectName,
@@ -324,8 +350,8 @@ async function create_handler(args) {
324
350
  runDev
325
351
  ] : [
326
352
  `cd ${result.projectPath}`,
327
- "npm install",
328
- "npm run dev"
353
+ `${packageManager} install`,
354
+ runDev
329
355
  ],
330
356
  ...engineWarning ? {
331
357
  engineWarning
@@ -547,6 +573,9 @@ function runExtensionCli(args, options) {
547
573
  }
548
574
  function spawnExtensionCli(args, options) {
549
575
  const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
576
+ const logDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-"));
577
+ const logPath = node_path.join(logDir, "session.log");
578
+ const fd = node_fs.openSync(logPath, "a");
550
579
  const child = cross_spawn(command, [
551
580
  ...prefixArgs,
552
581
  ...args
@@ -555,8 +584,8 @@ function spawnExtensionCli(args, options) {
555
584
  detached: true,
556
585
  stdio: [
557
586
  "ignore",
558
- "pipe",
559
- "pipe"
587
+ fd,
588
+ fd
560
589
  ],
561
590
  env: {
562
591
  ...process.env,
@@ -564,8 +593,29 @@ function spawnExtensionCli(args, options) {
564
593
  NO_COLOR: "1"
565
594
  }
566
595
  });
596
+ node_fs.closeSync(fd);
567
597
  child.unref();
568
- return child;
598
+ return {
599
+ child,
600
+ logPath,
601
+ readOutput: ()=>{
602
+ try {
603
+ return node_fs.readFileSync(logPath, "utf8");
604
+ } catch {
605
+ return "";
606
+ }
607
+ }
608
+ };
609
+ }
610
+ function readBuildSummary(projectPath, browser, since) {
611
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
612
+ try {
613
+ const stat = node_fs.statSync(file);
614
+ if (stat.mtimeMs < since) return null;
615
+ const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
616
+ if (summary && "object" == typeof summary) return summary;
617
+ } catch {}
618
+ return null;
569
619
  }
570
620
  function builtEntrypoints(distDir) {
571
621
  let manifest;
@@ -585,14 +635,31 @@ function builtEntrypoints(distDir) {
585
635
  };
586
636
  const bg = manifest.background;
587
637
  if (bg?.service_worker) add("background.service_worker", bg.service_worker);
638
+ if (bg?.page) add("background.page", bg.page);
588
639
  if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
589
640
  const action = manifest.action || manifest.browser_action;
590
641
  if (action?.default_popup) add("action.default_popup", action.default_popup);
642
+ const pageAction = manifest.page_action;
643
+ if (pageAction?.default_popup) add("page_action.default_popup", pageAction.default_popup);
591
644
  const cs = manifest.content_scripts;
592
645
  if (Array.isArray(cs)) cs.forEach((c, i)=>{
593
646
  if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
594
647
  if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
595
648
  });
649
+ add("devtools_page", manifest.devtools_page);
650
+ add("options_page", manifest.options_page);
651
+ const optionsUi = manifest.options_ui;
652
+ if (optionsUi?.page) add("options_ui.page", optionsUi.page);
653
+ const sidePanel = manifest.side_panel;
654
+ if (sidePanel?.default_path) add("side_panel.default_path", sidePanel.default_path);
655
+ const sidebarAction = manifest.sidebar_action;
656
+ if (sidebarAction?.default_panel) add("sidebar_action.default_panel", sidebarAction.default_panel);
657
+ const overrides = manifest.chrome_url_overrides;
658
+ if (overrides) for (const [key, ref] of Object.entries(overrides))add(`chrome_url_overrides.${key}`, ref);
659
+ const dnr = manifest.declarative_net_request;
660
+ if (dnr && Array.isArray(dnr.rule_resources)) dnr.rule_resources.forEach((r, i)=>{
661
+ if (r && "object" == typeof r) add(`declarative_net_request[${i}].path`, r.path);
662
+ });
596
663
  return out;
597
664
  }
598
665
  const build_schema = {
@@ -660,6 +727,11 @@ const build_schema = {
660
727
  ],
661
728
  default: "production",
662
729
  description: "Bundler mode override (also sets NODE_ENV)"
730
+ },
731
+ skipValidation: {
732
+ type: "boolean",
733
+ default: false,
734
+ 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."
663
735
  }
664
736
  },
665
737
  required: [
@@ -667,9 +739,67 @@ const build_schema = {
667
739
  ]
668
740
  }
669
741
  };
742
+ function manifestDivergence(projectPath, browser) {
743
+ const read = (p)=>{
744
+ try {
745
+ return JSON.parse(node_fs.readFileSync(p, "utf8"));
746
+ } catch {
747
+ return null;
748
+ }
749
+ };
750
+ const built = read(node_path.resolve(projectPath, "dist", browser, "manifest.json"));
751
+ const source = read(node_path.resolve(projectPath, "src", "manifest.json")) ?? read(node_path.resolve(projectPath, "manifest.json"));
752
+ if (!built || !source) return [];
753
+ const notes = [];
754
+ const listOf = (m, key)=>Array.isArray(m[key]) ? m[key].filter((x)=>"string" == typeof x) : [];
755
+ for (const key of [
756
+ "permissions",
757
+ "host_permissions",
758
+ "optional_permissions"
759
+ ]){
760
+ const lost = listOf(source, key).filter((p)=>!listOf(built, key).includes(p));
761
+ 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.`);
762
+ }
763
+ const sourceWar = source.web_accessible_resources;
764
+ const builtWar = built.web_accessible_resources;
765
+ 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.");
766
+ return notes;
767
+ }
768
+ async function validationPreflight(projectPath, browser) {
769
+ try {
770
+ const manifestValidate = await Promise.resolve().then(()=>({
771
+ handler: manifest_validate_handler
772
+ }));
773
+ const parsed = JSON.parse(await manifestValidate.handler({
774
+ projectPath,
775
+ browsers: [
776
+ browser
777
+ ]
778
+ }));
779
+ return {
780
+ valid: Boolean(parsed.valid),
781
+ buildBlocking: Boolean(parsed.buildBlocking),
782
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
783
+ warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
784
+ };
785
+ } catch {
786
+ return null;
787
+ }
788
+ }
670
789
  async function build_handler(args) {
671
790
  const start = Date.now();
672
791
  const browser = args.browser ?? "chrome";
792
+ const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
793
+ if (preflight?.buildBlocking) return JSON.stringify({
794
+ success: false,
795
+ status: "blocked",
796
+ browser,
797
+ error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
798
+ errors: preflight.errors,
799
+ warnings: preflight.warnings,
800
+ duration: Date.now() - start,
801
+ 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."
802
+ });
673
803
  const cliArgs = [
674
804
  "build",
675
805
  args.projectPath,
@@ -692,7 +822,30 @@ async function build_handler(args) {
692
822
  if (0 === code) {
693
823
  const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
694
824
  const status = out.match(/Build Status:\s*(\w+)/)?.[1];
825
+ const engineSummary = readBuildSummary(args.projectPath, browser, start);
826
+ const buildWarnings = engineSummary?.warnings?.length ? {
827
+ buildWarnings: engineSummary.warnings,
828
+ ..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
829
+ buildWarningsTruncated: engineSummary.warnings_count
830
+ } : {}
831
+ } : {};
695
832
  const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
833
+ const missing = entrypoints.filter((e)=>!e.present);
834
+ if (missing.length) return JSON.stringify({
835
+ success: false,
836
+ status: "incomplete",
837
+ browser,
838
+ buildExitCode: 0,
839
+ 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.",
840
+ entrypoints,
841
+ ...preflight?.warnings.length ? {
842
+ manifestWarnings: preflight.warnings
843
+ } : {},
844
+ ...buildWarnings,
845
+ duration,
846
+ output: lastLines(out, 12),
847
+ 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."
848
+ });
696
849
  return JSON.stringify({
697
850
  success: true,
698
851
  browser,
@@ -705,6 +858,16 @@ async function build_handler(args) {
705
858
  ...entrypoints.length ? {
706
859
  entrypoints
707
860
  } : {},
861
+ ...preflight?.warnings.length ? {
862
+ manifestWarnings: preflight.warnings
863
+ } : {},
864
+ ...buildWarnings,
865
+ ...(()=>{
866
+ const divergence = manifestDivergence(args.projectPath, browser);
867
+ return divergence.length ? {
868
+ productionDivergence: divergence
869
+ } : {};
870
+ })(),
708
871
  zip: args.zip ?? false,
709
872
  duration,
710
873
  output: lastLines(out, 12)
@@ -833,7 +996,7 @@ const dev_schema = {
833
996
  allowEval: {
834
997
  type: "boolean",
835
998
  default: false,
836
- 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."
999
+ 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."
837
1000
  }
838
1001
  },
839
1002
  required: [
@@ -856,9 +1019,10 @@ async function dev_handler(args) {
856
1019
  cliArgs.push(...launchFlagArgs(args));
857
1020
  if (allowControl) cliArgs.push("--allow-control");
858
1021
  if (args.allowEval) cliArgs.push("--allow-eval");
859
- const child = spawnExtensionCli(cliArgs, {
1022
+ const spawned = spawnExtensionCli(cliArgs, {
860
1023
  projectDir: args.projectPath
861
1024
  });
1025
+ const { child, logPath } = spawned;
862
1026
  const pid = child.pid;
863
1027
  registerSession({
864
1028
  pid,
@@ -868,15 +1032,37 @@ async function dev_handler(args) {
868
1032
  command: "dev"
869
1033
  });
870
1034
  child.on("exit", ()=>removeSession(args.projectPath, browser));
871
- let earlyOutput = "";
872
- const collector = (data)=>{
873
- earlyOutput += data.toString();
874
- };
875
- child.stdout?.on("data", collector);
876
- child.stderr?.on("data", collector);
877
1035
  await new Promise((resolve)=>setTimeout(resolve, 3000));
878
- child.stdout?.off("data", collector);
879
- child.stderr?.off("data", collector);
1036
+ const earlyOutput = spawned.readOutput();
1037
+ if (null !== child.exitCode || null !== child.signalCode) {
1038
+ const code = child.exitCode;
1039
+ const signal = child.signalCode;
1040
+ return JSON.stringify({
1041
+ ok: false,
1042
+ status: "exited",
1043
+ projectPath: args.projectPath,
1044
+ browser,
1045
+ pid,
1046
+ exitCode: code,
1047
+ signal,
1048
+ 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.`,
1049
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1050
+ logPath,
1051
+ 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."
1052
+ });
1053
+ }
1054
+ const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
1055
+ if (compileFailed) return JSON.stringify({
1056
+ ok: false,
1057
+ status: "compile-failed",
1058
+ projectPath: args.projectPath,
1059
+ browser,
1060
+ pid,
1061
+ 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.",
1062
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1063
+ logPath,
1064
+ 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."
1065
+ });
880
1066
  const controlVerbs = "storage, reload, open, dom_inspect";
881
1067
  const capabilities = {
882
1068
  allowControl,
@@ -884,6 +1070,7 @@ async function dev_handler(args) {
884
1070
  unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
885
1071
  };
886
1072
  return JSON.stringify({
1073
+ ok: true,
887
1074
  pid,
888
1075
  browser,
889
1076
  port: args.port ?? 8080,
@@ -891,7 +1078,8 @@ async function dev_handler(args) {
891
1078
  status: "started",
892
1079
  capabilities,
893
1080
  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.",
894
- earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
1081
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1082
+ logPath
895
1083
  });
896
1084
  }
897
1085
  function denoiseEarlyOutput(raw) {
@@ -905,6 +1093,93 @@ function denoiseEarlyOutput(raw) {
905
1093
  ];
906
1094
  return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
907
1095
  }
1096
+ function contractSightings(projectPath) {
1097
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
1098
+ let dirs;
1099
+ try {
1100
+ dirs = node_fs.readdirSync(root);
1101
+ } catch {
1102
+ return [];
1103
+ }
1104
+ const sightings = [];
1105
+ for (const dir of dirs){
1106
+ const readyPath = node_path.join(root, dir, "ready.json");
1107
+ try {
1108
+ const stat = node_fs.statSync(readyPath);
1109
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1110
+ if (contract?.status !== "ready") continue;
1111
+ sightings.push({
1112
+ browser: dir,
1113
+ mtimeMs: stat.mtimeMs,
1114
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
1115
+ });
1116
+ } catch {}
1117
+ }
1118
+ return sightings;
1119
+ }
1120
+ function pidAlive(pid) {
1121
+ try {
1122
+ process.kill(pid, 0);
1123
+ return true;
1124
+ } catch {
1125
+ return false;
1126
+ }
1127
+ }
1128
+ function knownSessionBrowsers(projectPath) {
1129
+ const resolved = node_path.resolve(projectPath);
1130
+ const browsers = [];
1131
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1132
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1133
+ return Array.from(new Set(browsers));
1134
+ }
1135
+ function deadReadySession(projectPath) {
1136
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1137
+ browser: sighting.browser,
1138
+ pid: sighting.pid
1139
+ };
1140
+ return null;
1141
+ }
1142
+ function browserExitStamp(projectPath, browser, since) {
1143
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1144
+ try {
1145
+ const stat = node_fs.statSync(readyPath);
1146
+ if (stat.mtimeMs < since) return null;
1147
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1148
+ const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1149
+ if (contract?.status === "error" && exited) return {
1150
+ code: contract.code,
1151
+ browserExitCode: contract.browserExitCode ?? null,
1152
+ browserExitedAt: contract.browserExitedAt
1153
+ };
1154
+ } catch {}
1155
+ return null;
1156
+ }
1157
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1158
+ if (explicit) return {
1159
+ browser: explicit,
1160
+ source: "explicit"
1161
+ };
1162
+ const resolved = node_path.resolve(projectPath);
1163
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1164
+ if (mine.length > 0) return {
1165
+ browser: mine[mine.length - 1].browser,
1166
+ source: "session"
1167
+ };
1168
+ const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1169
+ const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1170
+ if (live.length > 0) return {
1171
+ browser: live[0].browser,
1172
+ source: "contract"
1173
+ };
1174
+ if (sightings.length > 0) return {
1175
+ browser: sightings[0].browser,
1176
+ source: "stale"
1177
+ };
1178
+ return {
1179
+ browser: fallback,
1180
+ source: "fallback"
1181
+ };
1182
+ }
908
1183
  const start_schema = {
909
1184
  name: "extension_start",
910
1185
  description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
@@ -969,9 +1244,11 @@ async function start_handler(args) {
969
1244
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
970
1245
  if (args.noBrowser) cliArgs.push("--no-browser");
971
1246
  cliArgs.push(...launchFlagArgs(args));
972
- const child = spawnExtensionCli(cliArgs, {
1247
+ const spawnedAt = Date.now();
1248
+ const spawned = spawnExtensionCli(cliArgs, {
973
1249
  projectDir: args.projectPath
974
1250
  });
1251
+ const { child, logPath } = spawned;
975
1252
  const pid = child.pid;
976
1253
  registerSession({
977
1254
  pid,
@@ -980,22 +1257,47 @@ async function start_handler(args) {
980
1257
  command: "start"
981
1258
  });
982
1259
  child.on("exit", ()=>removeSession(args.projectPath, browser));
983
- let earlyOutput = "";
984
- const collector = (data)=>{
985
- earlyOutput += data.toString();
986
- };
987
- child.stdout?.on("data", collector);
988
- child.stderr?.on("data", collector);
989
1260
  await new Promise((resolve)=>setTimeout(resolve, 5000));
990
- child.stdout?.off("data", collector);
991
- child.stderr?.off("data", collector);
1261
+ const earlyOutput = spawned.readOutput();
1262
+ if (null !== child.exitCode || null !== child.signalCode) {
1263
+ const code = child.exitCode;
1264
+ const signal = child.signalCode;
1265
+ return JSON.stringify({
1266
+ ok: false,
1267
+ status: "exited",
1268
+ projectPath: args.projectPath,
1269
+ browser,
1270
+ pid,
1271
+ exitCode: code,
1272
+ signal,
1273
+ error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
1274
+ output: earlyOutput.slice(0, 2000),
1275
+ logPath,
1276
+ 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."
1277
+ });
1278
+ }
1279
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1280
+ if (exitStamp) return JSON.stringify({
1281
+ ok: false,
1282
+ status: "browser-exited",
1283
+ projectPath: args.projectPath,
1284
+ browser,
1285
+ pid,
1286
+ ...exitStamp,
1287
+ 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.",
1288
+ output: earlyOutput.slice(0, 2000),
1289
+ logPath,
1290
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1291
+ });
992
1292
  return JSON.stringify({
1293
+ ok: true,
993
1294
  pid,
994
1295
  browser,
995
1296
  projectPath: args.projectPath,
996
1297
  status: "started",
997
1298
  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.",
998
- earlyOutput: earlyOutput.slice(0, 500)
1299
+ earlyOutput: earlyOutput.slice(0, 500),
1300
+ logPath
999
1301
  });
1000
1302
  }
1001
1303
  const preview_schema = {
@@ -1056,9 +1358,11 @@ async function preview_handler(args) {
1056
1358
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
1057
1359
  if (args.noBrowser) cliArgs.push("--no-browser");
1058
1360
  cliArgs.push(...launchFlagArgs(args));
1059
- const child = spawnExtensionCli(cliArgs, {
1361
+ const spawnedAt = Date.now();
1362
+ const spawned = spawnExtensionCli(cliArgs, {
1060
1363
  projectDir: args.projectPath
1061
1364
  });
1365
+ const { child, logPath } = spawned;
1062
1366
  const pid = child.pid;
1063
1367
  registerSession({
1064
1368
  pid,
@@ -1067,81 +1371,49 @@ async function preview_handler(args) {
1067
1371
  command: "preview"
1068
1372
  });
1069
1373
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1374
+ await new Promise((resolve)=>setTimeout(resolve, 5000));
1375
+ const earlyOutput = spawned.readOutput();
1376
+ if (null !== child.exitCode || null !== child.signalCode) {
1377
+ const code = child.exitCode;
1378
+ const signal = child.signalCode;
1379
+ return JSON.stringify({
1380
+ ok: false,
1381
+ status: "exited",
1382
+ projectPath: args.projectPath,
1383
+ browser,
1384
+ pid,
1385
+ exitCode: code,
1386
+ signal,
1387
+ error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
1388
+ output: earlyOutput.slice(0, 2000),
1389
+ logPath,
1390
+ 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."
1391
+ });
1392
+ }
1393
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1394
+ if (exitStamp) return JSON.stringify({
1395
+ ok: false,
1396
+ status: "browser-exited",
1397
+ projectPath: args.projectPath,
1398
+ browser,
1399
+ pid,
1400
+ ...exitStamp,
1401
+ 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.",
1402
+ output: earlyOutput.slice(0, 2000),
1403
+ logPath,
1404
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1405
+ });
1070
1406
  return JSON.stringify({
1407
+ ok: true,
1071
1408
  pid,
1072
1409
  browser,
1073
1410
  projectPath: args.projectPath,
1074
1411
  status: "launched",
1075
- hint: "Call extension_stop when you are done to close the preview browser."
1412
+ hint: "Call extension_stop when you are done to close the preview browser.",
1413
+ earlyOutput: earlyOutput.slice(0, 500),
1414
+ logPath
1076
1415
  });
1077
1416
  }
1078
- function contractSightings(projectPath) {
1079
- const root = node_path.resolve(projectPath, "dist", "extension-js");
1080
- let dirs;
1081
- try {
1082
- dirs = node_fs.readdirSync(root);
1083
- } catch {
1084
- return [];
1085
- }
1086
- const sightings = [];
1087
- for (const dir of dirs){
1088
- const readyPath = node_path.join(root, dir, "ready.json");
1089
- try {
1090
- const stat = node_fs.statSync(readyPath);
1091
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1092
- if (contract?.status !== "ready") continue;
1093
- sightings.push({
1094
- browser: dir,
1095
- mtimeMs: stat.mtimeMs,
1096
- pid: "number" == typeof contract.pid ? contract.pid : void 0
1097
- });
1098
- } catch {}
1099
- }
1100
- return sightings;
1101
- }
1102
- function pidAlive(pid) {
1103
- try {
1104
- process.kill(pid, 0);
1105
- return true;
1106
- } catch {
1107
- return false;
1108
- }
1109
- }
1110
- function knownSessionBrowsers(projectPath) {
1111
- const resolved = node_path.resolve(projectPath);
1112
- const browsers = [];
1113
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1114
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1115
- return Array.from(new Set(browsers));
1116
- }
1117
- function deadReadySession(projectPath) {
1118
- for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1119
- browser: sighting.browser,
1120
- pid: sighting.pid
1121
- };
1122
- return null;
1123
- }
1124
- function resolveSessionBrowser(projectPath, explicit, fallback = "chromium") {
1125
- if (explicit) return {
1126
- browser: explicit,
1127
- source: "explicit"
1128
- };
1129
- const resolved = node_path.resolve(projectPath);
1130
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1131
- if (mine.length > 0) return {
1132
- browser: mine[mine.length - 1].browser,
1133
- source: "session"
1134
- };
1135
- const sightings = contractSightings(projectPath).filter((s)=>void 0 === s.pid || pidAlive(s.pid)).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1136
- if (sightings.length > 0) return {
1137
- browser: sightings[0].browser,
1138
- source: "contract"
1139
- };
1140
- return {
1141
- browser: fallback,
1142
- source: "fallback"
1143
- };
1144
- }
1145
1417
  const stop_schema = {
1146
1418
  name: "extension_stop",
1147
1419
  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.",
@@ -1204,7 +1476,7 @@ function isAlive(pid) {
1204
1476
  return false;
1205
1477
  }
1206
1478
  }
1207
- function signal(pid, sig) {
1479
+ function stop_signal(pid, sig) {
1208
1480
  try {
1209
1481
  process.kill(-pid, sig);
1210
1482
  return true;
@@ -1245,10 +1517,10 @@ async function stopOne(projectPath, browser) {
1245
1517
  }
1246
1518
  let detail;
1247
1519
  if (isAlive(pid)) {
1248
- signal(pid, "SIGTERM");
1520
+ stop_signal(pid, "SIGTERM");
1249
1521
  await new Promise((resolve)=>setTimeout(resolve, 1500));
1250
1522
  if (isAlive(pid)) {
1251
- signal(pid, "SIGKILL");
1523
+ stop_signal(pid, "SIGKILL");
1252
1524
  await new Promise((resolve)=>setTimeout(resolve, 250));
1253
1525
  }
1254
1526
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
@@ -1491,7 +1763,8 @@ const manifest_validate_schema = {
1491
1763
  },
1492
1764
  default: [
1493
1765
  "chrome",
1494
- "firefox"
1766
+ "firefox",
1767
+ "edge"
1495
1768
  ],
1496
1769
  description: "Browsers to validate against"
1497
1770
  },
@@ -1535,6 +1808,19 @@ function collectPathRefs(m) {
1535
1808
  if (sa) push(sa.default_panel);
1536
1809
  const cuo = m.chrome_url_overrides;
1537
1810
  if (cuo) Object.values(cuo).forEach(push);
1811
+ const dnr = m.declarative_net_request;
1812
+ if (dnr && Array.isArray(dnr.rule_resources)) {
1813
+ for (const r of dnr.rule_resources)if (r && "object" == typeof r) push(r.path);
1814
+ }
1815
+ const storage = m.storage;
1816
+ if (storage) push(storage.managed_schema);
1817
+ push(m.devtools_page);
1818
+ const pa = m.page_action;
1819
+ if (pa) {
1820
+ push(pa.default_popup);
1821
+ if ("string" == typeof pa.default_icon) push(pa.default_icon);
1822
+ else if (pa.default_icon) Object.values(pa.default_icon).forEach(push);
1823
+ }
1538
1824
  return refs;
1539
1825
  }
1540
1826
  function fileResolvesSomewhere(ref, roots) {
@@ -1642,9 +1928,11 @@ async function manifest_validate_handler(args) {
1642
1928
  args.browser
1643
1929
  ]
1644
1930
  };
1931
+ const explicitBrowsers = Array.isArray(args.browsers) && args.browsers.length > 0;
1645
1932
  const browsers = args.browsers ?? [
1646
1933
  "chrome",
1647
- "firefox"
1934
+ "firefox",
1935
+ "edge"
1648
1936
  ];
1649
1937
  const result = {
1650
1938
  valid: true,
@@ -1689,30 +1977,46 @@ async function manifest_validate_handler(args) {
1689
1977
  node_path.dirname(manifestDir)
1690
1978
  ] : []
1691
1979
  ];
1692
- 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.`);
1693
- const declaredPermSet = new Set([
1694
- ...chromiumManifest.permissions ?? [],
1695
- ...chromiumManifest.optional_permissions ?? []
1696
- ].filter((p)=>"string" == typeof p));
1697
- for (const api of scanApiUsage(roots)){
1980
+ 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.`);
1981
+ const defaultLocale = manifest.default_locale;
1982
+ if ("string" == typeof defaultLocale && defaultLocale) {
1983
+ const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
1984
+ 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.`);
1985
+ }
1986
+ const iconMap = chromiumManifest.icons;
1987
+ 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.');
1988
+ const effectiveByBrowser = new Map();
1989
+ for (const b of browsers)effectiveByBrowser.set(b, filterKeysForThisBrowser(manifest, b));
1990
+ const declaredPermSet = new Set();
1991
+ for (const view of [
1992
+ chromiumManifest,
1993
+ ...effectiveByBrowser.values()
1994
+ ])for (const p of [
1995
+ ...view.permissions ?? [],
1996
+ ...view.optional_permissions ?? []
1997
+ ])if ("string" == typeof p) declaredPermSet.add(p);
1998
+ const usedApis = scanApiUsage(roots);
1999
+ for (const api of usedApis){
1698
2000
  const perm = API_PERMISSION[api];
1699
2001
  if (declaredPermSet.has(perm)) continue;
1700
2002
  const base = `Code calls chrome.${api} but "${perm}" is not in permissions`;
1701
- if (HARD_APIS.has(api)) result.errors.push(`${base} chrome.${api} is undefined without it and will crash the context at runtime.`);
1702
- else result.warnings.push(`${base}; it may be undefined at runtime add "${perm}" if you use it.`);
2003
+ if (HARD_APIS.has(api)) result.errors.push(`${base}, chrome.${api} is undefined without it and will crash the context at runtime.`);
2004
+ else result.warnings.push(`${base}; it may be undefined at runtime, add "${perm}" if you use it.`);
1703
2005
  }
1704
- if (!chromiumManifest.manifest_version) result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
2006
+ if (chromiumManifest.manifest_version) {
2007
+ 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.`);
2008
+ } else result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1705
2009
  const declaredPerms = [
1706
2010
  ...chromiumManifest.permissions ?? [],
1707
2011
  ...chromiumManifest.optional_permissions ?? []
1708
2012
  ].filter((p)=>"string" == typeof p);
1709
2013
  for (const perm of declaredPerms)if (!(perm.includes("://") || perm.includes("*")) && "<all_urls>" !== perm) {
1710
- if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}" check for a typo (host/match patterns belong in host_permissions, not permissions).`);
2014
+ if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}", check for a typo (host/match patterns belong in host_permissions, not permissions).`);
1711
2015
  }
1712
2016
  for (const browser of browsers){
1713
2017
  const isChromium = isChromiumFamily(browser);
1714
2018
  const isFirefox = isGeckoFamily(browser);
1715
- const effective = filterKeysForThisBrowser(manifest, browser);
2019
+ const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
1716
2020
  const issues = [];
1717
2021
  if (isChromium) {
1718
2022
  const mv = effective.manifest_version;
@@ -1734,6 +2038,17 @@ async function manifest_validate_handler(args) {
1734
2038
  if (bg.service_worker && !bg.scripts) issues.push('Background service_worker declared but no firefox:scripts. Firefox uses "scripts" (array) instead of "service_worker".');
1735
2039
  }
1736
2040
  }
2041
+ const effectivePerms = new Set([
2042
+ ...effective.permissions ?? [],
2043
+ ...effective.optional_permissions ?? []
2044
+ ].filter((p)=>"string" == typeof p));
2045
+ for (const api of usedApis){
2046
+ const perm = API_PERMISSION[api];
2047
+ if (effectivePerms.has(perm)) continue;
2048
+ if (!declaredPermSet.has(perm)) continue;
2049
+ const ns = isFirefox ? "browser" : "chrome";
2050
+ 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.`);
2051
+ }
1737
2052
  result.browserSupport[browser] = {
1738
2053
  supported: 0 === issues.length,
1739
2054
  issues
@@ -1759,7 +2074,13 @@ async function manifest_validate_handler(args) {
1759
2074
  surfaces: t.surfaces
1760
2075
  }));
1761
2076
  } catch {}
1762
- result.valid = 0 === result.errors.length && Object.values(result.browserSupport).every((b)=>b.supported);
2077
+ for (const [browser, support] of Object.entries(result.browserSupport)){
2078
+ if (support.supported) continue;
2079
+ const issues = support.issues?.length ? support.issues.join("; ") : `${browser} is not supported by this manifest.`;
2080
+ if (explicitBrowsers) result.errors.push(`${browser}: ${issues}`);
2081
+ else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
2082
+ }
2083
+ result.valid = 0 === result.errors.length;
1763
2084
  return JSON.stringify({
1764
2085
  ...result,
1765
2086
  buildBlocking: result.errors.length > 0
@@ -1912,8 +2233,8 @@ async function inspect_handler(args) {
1912
2233
  const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
1913
2234
  const sizeWarnings = [];
1914
2235
  for (const f of files)if ("sourcemap" !== f.type) {
1915
- 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.`);
1916
- 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.`);
2236
+ 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.`);
2237
+ 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.`);
1917
2238
  }
1918
2239
  const result = {
1919
2240
  browser,
@@ -1960,6 +2281,7 @@ async function inspect_handler(args) {
1960
2281
  storeReadiness: {
1961
2282
  hasManifest: node_fs.existsSync(manifestPath),
1962
2283
  hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
2284
+ has128Icon: "string" == typeof manifest.icons?.["128"],
1963
2285
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
1964
2286
  noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
1965
2287
  under10MB: totalSize < 10485760
@@ -1981,7 +2303,7 @@ const COMMAND_TIMEOUT_MS = 15000;
1981
2303
  class CDPConnection {
1982
2304
  async connect(wsUrl) {
1983
2305
  return new Promise((resolve, reject)=>{
1984
- this.ws = new ws(wsUrl);
2306
+ this.ws = new ws_0(wsUrl);
1985
2307
  this.ws.on("open", ()=>resolve());
1986
2308
  this.ws.on("message", (data)=>{
1987
2309
  this.handleMessage(data.toString());
@@ -2050,7 +2372,7 @@ class CDPConnection {
2050
2372
  }
2051
2373
  async sendCommand(method, params = {}, sessionId) {
2052
2374
  return new Promise((resolve, reject)=>{
2053
- if (!this.ws || this.ws.readyState !== ws.OPEN) return reject(new Error("CDP WebSocket is not connected"));
2375
+ if (!this.ws || this.ws.readyState !== ws_0.OPEN) return reject(new Error("CDP WebSocket is not connected"));
2054
2376
  const id = ++this.messageId;
2055
2377
  const message = {
2056
2378
  id,
@@ -2441,7 +2763,7 @@ const source_inspect_schema = {
2441
2763
  items: {
2442
2764
  type: "string"
2443
2765
  },
2444
- description: "CSS selectors to query returns element counts and samples for each"
2766
+ description: "CSS selectors to query, returns element counts and samples for each"
2445
2767
  },
2446
2768
  include: {
2447
2769
  type: "array",
@@ -2493,7 +2815,7 @@ async function source_inspect_handler(args) {
2493
2815
  const maxBytes = args.maxBytes ?? 262144;
2494
2816
  if (!isChromiumFamily(browser)) return JSON.stringify({
2495
2817
  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.`,
2496
- 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.`
2818
+ 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.`
2497
2819
  });
2498
2820
  const resolved = await resolveCdpPort(args.projectPath, browser);
2499
2821
  if (!resolved) return JSON.stringify({
@@ -2575,7 +2897,11 @@ async function source_inspect_handler(args) {
2575
2897
  if (include.has("dom_snapshot")) result.domSnapshot = await cdp.getDomSnapshot(sessionId);
2576
2898
  if (include.has("console")) result.console = cdp.getConsoleSummary();
2577
2899
  if (include.has("extension_roots")) result.extensionRoots = await cdp.getExtensionRootMeta(sessionId);
2578
- if (args.probe?.length) result.probes = await cdp.probeSelectors(sessionId, args.probe);
2900
+ if (args.probe?.length) {
2901
+ result.probes = await cdp.probeSelectors(sessionId, args.probe);
2902
+ const jsLooking = args.probe.filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
2903
+ 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.`;
2904
+ }
2579
2905
  if (args.deepDom) {
2580
2906
  const closed = await cdp.getClosedShadowRoots(sessionId, maxBytes > 0 ? maxBytes : 65536);
2581
2907
  result.closedShadowRoots = closed;
@@ -2594,7 +2920,7 @@ async function source_inspect_handler(args) {
2594
2920
  }
2595
2921
  const list_extensions_schema = {
2596
2922
  name: "extension_list_extensions",
2597
- 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.",
2923
+ 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.",
2598
2924
  inputSchema: {
2599
2925
  type: "object",
2600
2926
  properties: {
@@ -2849,10 +3175,28 @@ function capRecent(events, limit) {
2849
3175
  truncated: true
2850
3176
  };
2851
3177
  }
2852
- function summarize(events, source, browser, runId, limit, dropped) {
3178
+ function emptyReason(projectPath, browser) {
3179
+ let contract;
3180
+ try {
3181
+ contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
3182
+ } catch {
3183
+ 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.";
3184
+ }
3185
+ if ("error" === contract.status) {
3186
+ const errs = contract.errors;
3187
+ 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.`;
3188
+ }
3189
+ if ("number" == typeof contract.pid) try {
3190
+ process.kill(contract.pid, 0);
3191
+ } catch {
3192
+ 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.`;
3193
+ }
3194
+ }
3195
+ function summarize(events, source, browser, runId, limit, dropped, projectPath, staleNote) {
2853
3196
  const matched = events.length;
2854
3197
  const { events: out, truncated } = capRecent(events, limit);
2855
3198
  const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
3199
+ const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
2856
3200
  return JSON.stringify({
2857
3201
  ok: true,
2858
3202
  source,
@@ -2863,9 +3207,30 @@ function summarize(events, source, browser, runId, limit, dropped) {
2863
3207
  truncated,
2864
3208
  dropped: dropped || void 0,
2865
3209
  nextSince: lastSeq >= 0 ? lastSeq : void 0,
3210
+ ...reason ? {
3211
+ emptyReason: reason
3212
+ } : {},
3213
+ ...staleNote && matched > 0 ? {
3214
+ stale: true,
3215
+ warning: staleNote
3216
+ } : {},
2866
3217
  events: out
2867
3218
  });
2868
3219
  }
3220
+ function staleFileNote(projectPath, browser, eventsRunId) {
3221
+ let contract;
3222
+ try {
3223
+ contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
3224
+ } catch {
3225
+ return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
3226
+ }
3227
+ if ("number" == typeof contract.pid) try {
3228
+ process.kill(contract.pid, 0);
3229
+ } catch {
3230
+ 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.`;
3231
+ }
3232
+ 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.`;
3233
+ }
2869
3234
  async function readFromFile(args, browser, limit) {
2870
3235
  const file = logsFilePath(args.projectPath, browser);
2871
3236
  if (!node_fs.existsSync(file)) return JSON.stringify({
@@ -2889,13 +3254,13 @@ async function readFromFile(args, browser, limit) {
2889
3254
  }
2890
3255
  if (matches(event)) events.push(event);
2891
3256
  }
2892
- return summarize(events, "file", browser, runId, limit, 0);
3257
+ return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
2893
3258
  }
2894
3259
  async function readFromStream(args, browser, limit) {
2895
3260
  const ready = readReadyContract(args.projectPath, browser);
2896
3261
  if (!ready) {
2897
3262
  const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
2898
- const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")} pass that as \`browser\`. Otherwise run` : "Run";
3263
+ const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
2899
3264
  return JSON.stringify({
2900
3265
  error: `No active control channel found for ${browser}.`,
2901
3266
  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.`
@@ -2911,7 +3276,7 @@ async function readFromStream(args, browser, limit) {
2911
3276
  const url = `ws://127.0.0.1:${ready.controlPort}${CONTROL_WS_PATH}`;
2912
3277
  let socket;
2913
3278
  try {
2914
- socket = new ws(url);
3279
+ socket = new ws_0(url);
2915
3280
  } catch (err) {
2916
3281
  resolve(JSON.stringify({
2917
3282
  error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
@@ -2925,7 +3290,7 @@ async function readFromStream(args, browser, limit) {
2925
3290
  try {
2926
3291
  socket.close();
2927
3292
  } catch {}
2928
- resolve(summarize(events, "stream", browser, runId, limit, dropped));
3293
+ resolve(summarize(events, "stream", browser, runId, limit, dropped, args.projectPath));
2929
3294
  };
2930
3295
  const timer = setTimeout(finish, followMs);
2931
3296
  socket.on("open", ()=>{
@@ -2975,10 +3340,10 @@ function withSessionContext(message, projectPath) {
2975
3340
  const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
2976
3341
  if (!isControlError) return message;
2977
3342
  const dead = deadReadySession(projectPath);
2978
- 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.`;
3343
+ 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.`;
2979
3344
  const running = knownSessionBrowsers(projectPath);
2980
3345
  if (0 === running.length) return message;
2981
- 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.`;
3346
+ 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.`;
2982
3347
  }
2983
3348
  function translateFrame(frame, projectPath) {
2984
3349
  if (!frame || false !== frame.ok) return frame;
@@ -3022,7 +3387,7 @@ function commonFlags(args) {
3022
3387
  }
3023
3388
  const eval_schema = {
3024
3389
  name: "extension_eval",
3025
- 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`.",
3390
+ 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`.",
3026
3391
  inputSchema: {
3027
3392
  type: "object",
3028
3393
  properties: {
@@ -3042,6 +3407,9 @@ const eval_schema = {
3042
3407
  "options",
3043
3408
  "sidebar",
3044
3409
  "devtools",
3410
+ "newtab",
3411
+ "history",
3412
+ "bookmarks",
3045
3413
  "content",
3046
3414
  "page"
3047
3415
  ],
@@ -3050,11 +3418,11 @@ const eval_schema = {
3050
3418
  },
3051
3419
  url: {
3052
3420
  type: "string",
3053
- 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."
3421
+ 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."
3054
3422
  },
3055
3423
  tab: {
3056
3424
  type: "number",
3057
- description: "Numeric chrome.tabs id. Required for context content/page/popup on Chromium (the `url` arg does not substitute for it)."
3425
+ description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
3058
3426
  },
3059
3427
  browser: {
3060
3428
  type: "string",
@@ -3073,7 +3441,7 @@ const eval_schema = {
3073
3441
  };
3074
3442
  async function eval_handler(args) {
3075
3443
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3076
- return runActVerb([
3444
+ const raw = await runActVerb([
3077
3445
  "eval",
3078
3446
  args.expression,
3079
3447
  args.projectPath,
@@ -3082,6 +3450,14 @@ async function eval_handler(args) {
3082
3450
  browser
3083
3451
  })
3084
3452
  ], args.projectPath, args.timeout);
3453
+ if ("content" === args.context) try {
3454
+ const parsed = JSON.parse(raw);
3455
+ if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
3456
+ 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'.";
3457
+ return JSON.stringify(parsed);
3458
+ }
3459
+ } catch {}
3460
+ return raw;
3085
3461
  }
3086
3462
  const storage_schema = {
3087
3463
  name: "extension_storage",
@@ -3161,7 +3537,14 @@ async function storage_handler(args) {
3161
3537
  message: "storage set requires a value"
3162
3538
  }
3163
3539
  });
3164
- cli.push("--value", JSON.stringify(args.value));
3540
+ if (void 0 === args.key) return JSON.stringify({
3541
+ ok: false,
3542
+ error: {
3543
+ name: "BadRequest",
3544
+ 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".'
3545
+ }
3546
+ });
3547
+ cli.push("--value", JSON.stringify(args.value));
3165
3548
  }
3166
3549
  if (args.context) cli.push("--context", args.context);
3167
3550
  cli.push("--browser", browser);
@@ -3216,6 +3599,27 @@ async function reload_handler(args) {
3216
3599
  })
3217
3600
  ], args.projectPath, args.timeout);
3218
3601
  }
3602
+ async function pollForTarget(port, url, budgetMs) {
3603
+ const deadline = Date.now() + budgetMs;
3604
+ const wanted = url.replace(/#.*$/, "");
3605
+ for(;;){
3606
+ try {
3607
+ const targets = await CDPClient.discoverTargets(port);
3608
+ for (const t of targets){
3609
+ const tUrl = String(t.url ?? "");
3610
+ if ("page" === t.type) {
3611
+ if (tUrl === wanted || tUrl.startsWith(wanted)) return {
3612
+ id: String(t.id),
3613
+ url: tUrl,
3614
+ title: "string" == typeof t.title ? t.title : void 0
3615
+ };
3616
+ }
3617
+ }
3618
+ } catch {}
3619
+ if (Date.now() >= deadline) return null;
3620
+ await new Promise((r)=>setTimeout(r, 250));
3621
+ }
3622
+ }
3219
3623
  async function navigateToUrl(projectPath, browser, url) {
3220
3624
  if (!isChromiumFamily(browser)) return JSON.stringify({
3221
3625
  ok: false,
@@ -3248,17 +3652,24 @@ async function navigateToUrl(projectPath, browser, url) {
3248
3652
  await cdp.connect(browserWsUrl);
3249
3653
  const sessionId = await cdp.attachToTarget(String(target.id));
3250
3654
  await cdp.navigate(sessionId, url);
3251
- await new Promise((r)=>setTimeout(r, 1200));
3252
- const meta = await cdp.getPageMeta(sessionId).catch(()=>({}));
3655
+ const settled = await pollForTarget(resolved.port, url, 6000);
3656
+ if (!settled) return JSON.stringify({
3657
+ ok: false,
3658
+ error: {
3659
+ name: "NavigateFailed",
3660
+ 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.`
3661
+ },
3662
+ 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."
3663
+ });
3253
3664
  return JSON.stringify({
3254
3665
  ok: true,
3255
3666
  navigated: url,
3256
- tab: {
3257
- id: target.id,
3258
- title: meta.title,
3259
- url: meta.url || url
3667
+ target: {
3668
+ targetId: settled.id,
3669
+ title: settled.title,
3670
+ url: settled.url
3260
3671
  },
3261
- hint: "Inspect it with extension_dom_inspect or extension_source_inspect (context: 'page')."
3672
+ 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."
3262
3673
  });
3263
3674
  } catch (e) {
3264
3675
  return JSON.stringify({
@@ -3274,9 +3685,180 @@ async function navigateToUrl(projectPath, browser, url) {
3274
3685
  } catch {}
3275
3686
  }
3276
3687
  }
3688
+ function unpackedExtensionId(distPath) {
3689
+ const digest = node_crypto.createHash("sha256").update(distPath).digest();
3690
+ let id = "";
3691
+ for(let i = 0; i < 16; i++){
3692
+ id += String.fromCharCode(97 + (digest[i] >> 4));
3693
+ id += String.fromCharCode(97 + (0x0f & digest[i]));
3694
+ }
3695
+ return id;
3696
+ }
3697
+ async function resolveExtensionId(projectPath, browser) {
3698
+ const distPath = readDistPath(projectPath, browser);
3699
+ const computed = distPath ? unpackedExtensionId(distPath) : null;
3700
+ const resolved = await resolveCdpPort(projectPath, browser);
3701
+ if (!resolved) return computed;
3702
+ const ids = new Set();
3703
+ try {
3704
+ for (const t of (await CDPClient.discoverTargets(resolved.port))){
3705
+ const url = String(t.url ?? "");
3706
+ if (!url.startsWith("chrome-extension://")) continue;
3707
+ const id = url.slice(19).split("/")[0];
3708
+ if (id) ids.add(id);
3709
+ }
3710
+ } catch {}
3711
+ if (computed && ids.has(computed)) return computed;
3712
+ if (computed) return computed;
3713
+ return 1 === ids.size ? [
3714
+ ...ids
3715
+ ][0] : null;
3716
+ }
3717
+ function declaredCommands(projectPath, browser) {
3718
+ const candidates = [
3719
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3720
+ node_path.join(projectPath, "dist", "manifest.json"),
3721
+ node_path.join(projectPath, "src", "manifest.json"),
3722
+ node_path.join(projectPath, "manifest.json")
3723
+ ];
3724
+ for (const file of candidates)try {
3725
+ const manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3726
+ const commands = manifest?.commands;
3727
+ if (commands && "object" == typeof commands) return Object.keys(commands);
3728
+ return [];
3729
+ } catch {
3730
+ continue;
3731
+ }
3732
+ return null;
3733
+ }
3734
+ function readDistPath(projectPath, browser) {
3735
+ try {
3736
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
3737
+ const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
3738
+ return "string" == typeof contract?.distPath ? contract.distPath : null;
3739
+ } catch {
3740
+ return null;
3741
+ }
3742
+ }
3743
+ function surfaceDocument(projectPath, browser, surface) {
3744
+ const candidates = [
3745
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3746
+ node_path.join(projectPath, "dist", "manifest.json"),
3747
+ node_path.join(projectPath, "src", "manifest.json"),
3748
+ node_path.join(projectPath, "manifest.json")
3749
+ ];
3750
+ for (const file of candidates){
3751
+ let manifest;
3752
+ try {
3753
+ manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3754
+ } catch {
3755
+ continue;
3756
+ }
3757
+ const action = manifest.action ?? manifest.browser_action;
3758
+ 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;
3759
+ if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
3760
+ }
3761
+ return null;
3762
+ }
3763
+ const POPUP_MIN = 25;
3764
+ const POPUP_MAX_WIDTH = 800;
3765
+ const POPUP_MAX_HEIGHT = 600;
3766
+ function clampPopupBounds(width, height) {
3767
+ const w = Math.min(Math.max(Math.ceil(width), POPUP_MIN), POPUP_MAX_WIDTH);
3768
+ const h = Math.min(Math.max(Math.ceil(height), POPUP_MIN), POPUP_MAX_HEIGHT);
3769
+ return {
3770
+ width: w,
3771
+ height: h,
3772
+ clamped: w !== Math.ceil(width) || h !== Math.ceil(height)
3773
+ };
3774
+ }
3775
+ async function applyPopupBounds(projectPath, browser, targetId) {
3776
+ const resolved = await resolveCdpPort(projectPath, browser);
3777
+ if (!resolved) return null;
3778
+ const cdp = new CDPClient();
3779
+ try {
3780
+ const ws = await CDPClient.discoverBrowserWsUrl(resolved.port);
3781
+ await cdp.connect(ws);
3782
+ const sessionId = await cdp.attachToTarget(targetId);
3783
+ const measured = await cdp.evaluate(sessionId, `(() => {
3784
+ const de = document.documentElement, b = document.body;
3785
+ if (!de || !b) return null;
3786
+ const prev = de.style.width;
3787
+ de.style.width = "fit-content";
3788
+ const w = Math.max(de.getBoundingClientRect().width, b.getBoundingClientRect().width);
3789
+ const h = Math.max(de.getBoundingClientRect().height, b.getBoundingClientRect().height, b.scrollHeight);
3790
+ de.style.width = prev;
3791
+ return { w: Math.ceil(w), h: Math.ceil(h) };
3792
+ })()`);
3793
+ if (!measured || "number" != typeof measured.w || "number" != typeof measured.h || measured.w <= 0 || measured.h <= 0) return null;
3794
+ const bounds = clampPopupBounds(measured.w, measured.h);
3795
+ const win = await cdp.sendCommand("Browser.getWindowForTarget", {
3796
+ targetId
3797
+ });
3798
+ if ("number" != typeof win?.windowId) return null;
3799
+ await cdp.sendCommand("Browser.setWindowBounds", {
3800
+ windowId: win.windowId,
3801
+ bounds: {
3802
+ width: bounds.width,
3803
+ height: bounds.height
3804
+ }
3805
+ });
3806
+ const after = await cdp.sendCommand("Browser.getWindowBounds", {
3807
+ windowId: win.windowId
3808
+ });
3809
+ if (after?.bounds?.width !== bounds.width || after?.bounds?.height !== bounds.height) return null;
3810
+ return bounds;
3811
+ } catch {
3812
+ return null;
3813
+ } finally{
3814
+ try {
3815
+ cdp.disconnect();
3816
+ } catch {}
3817
+ }
3818
+ }
3819
+ async function openSurfaceAsTab(projectPath, browser, surface) {
3820
+ const doc = surfaceDocument(projectPath, browser, surface);
3821
+ if (!doc) return JSON.stringify({
3822
+ ok: false,
3823
+ error: {
3824
+ name: "NoSurfaceDocument",
3825
+ message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
3826
+ },
3827
+ hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
3828
+ });
3829
+ const id = await resolveExtensionId(projectPath, browser);
3830
+ if (!id) return JSON.stringify({
3831
+ ok: false,
3832
+ error: {
3833
+ name: "NoExtensionId",
3834
+ message: "Could not resolve the extension id from the live session's CDP targets."
3835
+ },
3836
+ hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
3837
+ });
3838
+ const url = `chrome-extension://${id}/${doc}`;
3839
+ const raw = await navigateToUrl(projectPath, browser, url);
3840
+ try {
3841
+ const parsed = JSON.parse(raw);
3842
+ if (parsed?.ok) {
3843
+ parsed.renderedAsTab = {
3844
+ surface,
3845
+ document: doc,
3846
+ extensionId: id
3847
+ };
3848
+ let popupBounds = null;
3849
+ if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
3850
+ popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
3851
+ if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
3852
+ }
3853
+ 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.";
3854
+ return JSON.stringify(parsed);
3855
+ }
3856
+ } catch {}
3857
+ return raw;
3858
+ }
3277
3859
  const open_schema = {
3278
3860
  name: "extension_open",
3279
- 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`.",
3861
+ 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`.",
3280
3862
  inputSchema: {
3281
3863
  type: "object",
3282
3864
  properties: {
@@ -3290,10 +3872,13 @@ const open_schema = {
3290
3872
  "popup",
3291
3873
  "options",
3292
3874
  "sidebar",
3875
+ "newtab",
3876
+ "history",
3877
+ "bookmarks",
3293
3878
  "action",
3294
3879
  "command"
3295
3880
  ],
3296
- description: "Which surface to open or event to replay. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
3881
+ 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`)."
3297
3882
  },
3298
3883
  name: {
3299
3884
  type: "string",
@@ -3303,6 +3888,11 @@ const open_schema = {
3303
3888
  type: "string",
3304
3889
  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`."
3305
3890
  },
3891
+ asTab: {
3892
+ type: "boolean",
3893
+ default: false,
3894
+ 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."
3895
+ },
3306
3896
  browser: {
3307
3897
  type: "string",
3308
3898
  description: "Browser session to target. Defaults to the active dev session's browser for this project."
@@ -3320,6 +3910,15 @@ const open_schema = {
3320
3910
  async function open_handler(args) {
3321
3911
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3322
3912
  if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
3913
+ const AS_TAB_SURFACES = [
3914
+ "popup",
3915
+ "options",
3916
+ "sidebar",
3917
+ "newtab",
3918
+ "history",
3919
+ "bookmarks"
3920
+ ];
3921
+ if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
3323
3922
  if (!args.surface) return JSON.stringify({
3324
3923
  ok: false,
3325
3924
  error: {
@@ -3327,6 +3926,18 @@ async function open_handler(args) {
3327
3926
  message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
3328
3927
  }
3329
3928
  });
3929
+ if ("command" === args.surface) {
3930
+ const declared = declaredCommands(args.projectPath, browser);
3931
+ if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
3932
+ ok: false,
3933
+ error: {
3934
+ name: "UnknownCommand",
3935
+ message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
3936
+ },
3937
+ declaredCommands: declared,
3938
+ 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."
3939
+ });
3940
+ }
3330
3941
  const cli = [
3331
3942
  "open",
3332
3943
  args.surface,
@@ -3344,8 +3955,18 @@ async function open_handler(args) {
3344
3955
  ].includes(args.surface)) try {
3345
3956
  const parsed = JSON.parse(raw);
3346
3957
  const msg = String(parsed?.error?.message ?? "");
3347
- if (parsed?.ok === false && !parsed.hint && /active browser window|no active|headless/i.test(msg)) {
3348
- 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.";
3958
+ if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
3959
+ if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
3960
+ const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
3961
+ try {
3962
+ const parsedFallback = JSON.parse(fallback);
3963
+ if (parsedFallback?.ok) {
3964
+ 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.";
3965
+ return JSON.stringify(parsedFallback);
3966
+ }
3967
+ } catch {}
3968
+ }
3969
+ 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.";
3349
3970
  return JSON.stringify(parsed);
3350
3971
  }
3351
3972
  } catch {}
@@ -3363,7 +3984,16 @@ const dom_inspect_schema = {
3363
3984
  },
3364
3985
  tab: {
3365
3986
  type: "number",
3366
- description: "Tab id (required for content/page; omit for surfaces)"
3987
+ description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
3988
+ },
3989
+ url: {
3990
+ type: "string",
3991
+ description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
3992
+ },
3993
+ listTabs: {
3994
+ type: "boolean",
3995
+ default: false,
3996
+ 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."
3367
3997
  },
3368
3998
  context: {
3369
3999
  type: "string",
@@ -3373,10 +4003,13 @@ const dom_inspect_schema = {
3373
4003
  "popup",
3374
4004
  "options",
3375
4005
  "sidebar",
3376
- "devtools"
4006
+ "devtools",
4007
+ "newtab",
4008
+ "history",
4009
+ "bookmarks"
3377
4010
  ],
3378
4011
  default: "content",
3379
- description: "content/page (needs tab) or an OPEN extension surface (popup/options/sidebar/devtools)"
4012
+ description: "content/page (targets `url`, else the active tab), an OPEN extension surface (popup/options/sidebar/devtools), or an override page (newtab/history/bookmarks)"
3380
4013
  },
3381
4014
  include: {
3382
4015
  type: "array",
@@ -3397,8 +4030,11 @@ const dom_inspect_schema = {
3397
4030
  default: 262144
3398
4031
  },
3399
4032
  withConsole: {
3400
- type: "number",
3401
- description: "Also include the last N console lines for the target (DOM + recent console in one call)"
4033
+ type: [
4034
+ "number",
4035
+ "boolean"
4036
+ ],
4037
+ description: "Also include recent console lines for the target (DOM + console in one call). A number is how many lines; true means 50."
3402
4038
  },
3403
4039
  browser: {
3404
4040
  type: "string",
@@ -3415,30 +4051,28 @@ const dom_inspect_schema = {
3415
4051
  }
3416
4052
  };
3417
4053
  async function dom_inspect_handler(args) {
3418
- const surfaces = [
3419
- "popup",
3420
- "options",
3421
- "sidebar",
3422
- "devtools"
3423
- ];
3424
- const isSurface = !!args.context && surfaces.includes(args.context);
3425
- if (!isSurface && null == args.tab) return JSON.stringify({
3426
- ok: false,
3427
- error: {
3428
- name: "BadRequest",
3429
- message: "content/page inspect requires a numeric tab id (chrome.tabs id, not a CDP target id)."
3430
- },
3431
- 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)."
3432
- });
4054
+ const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
4055
+ if (args.listTabs) return runActVerb([
4056
+ "inspect",
4057
+ args.projectPath,
4058
+ "--list-tabs",
4059
+ "--browser",
4060
+ resolveSessionBrowser(args.projectPath, args.browser).browser,
4061
+ ...null != args.timeout ? [
4062
+ "--timeout",
4063
+ String(args.timeout)
4064
+ ] : []
4065
+ ], args.projectPath, args.timeout);
3433
4066
  const cli = [
3434
4067
  "inspect",
3435
4068
  args.projectPath
3436
4069
  ];
3437
4070
  if (null != args.tab) cli.push("--tab", String(args.tab));
4071
+ if (args.url) cli.push("--url", args.url);
3438
4072
  if (args.context) cli.push("--context", args.context);
3439
4073
  if (args.include?.length) cli.push("--include", args.include.join(","));
3440
4074
  if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
3441
- if (null != args.withConsole) cli.push("--with-console", String(args.withConsole));
4075
+ if (null != withConsole) cli.push("--with-console", String(withConsole));
3442
4076
  cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
3443
4077
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
3444
4078
  return runActVerb(cli, args.projectPath, args.timeout);
@@ -3829,6 +4463,32 @@ async function release_promote_handler(args) {
3829
4463
  return JSON.stringify(data);
3830
4464
  }
3831
4465
  const deploy_DEFAULT_API = "https://www.extension.dev";
4466
+ function storeMdWarnings(browsers, cwd) {
4467
+ const wantsFirefox = browsers.includes("firefox");
4468
+ const wantsEdge = browsers.includes("edge");
4469
+ if (!wantsFirefox && !wantsEdge) return [];
4470
+ let content;
4471
+ try {
4472
+ content = node_fs.readFileSync(node_path.join(cwd, "STORE.md"), "utf8");
4473
+ } catch {
4474
+ return [
4475
+ "No STORE.md found in the project root. Reviewer notes (Firefox) and certification notes (Edge) will not accompany the submission. See the extension-dev skill's store-md reference."
4476
+ ];
4477
+ }
4478
+ const hasField = (section, field)=>{
4479
+ const parts = content.split(/^## +/m);
4480
+ const match = parts.find((p)=>section.test(p.split("\n", 1)[0] ?? ""));
4481
+ if (!match) return false;
4482
+ const sub = match.split(/^### +/m).find((p)=>field.test(p.split("\n", 1)[0] ?? ""));
4483
+ if (!sub) return false;
4484
+ const body = sub.split("\n").slice(1).join("\n");
4485
+ return body.replace(/<!--[\s\S]*?-->/g, "").trim().length > 0;
4486
+ };
4487
+ const warnings = [];
4488
+ if (wantsFirefox && !hasField(/firefox|amo/i, /reviewer notes/i)) warnings.push("STORE.md has no Firefox reviewer notes; AMO reviews go faster with test credentials and steps.");
4489
+ if (wantsEdge && !hasField(/edge/i, /certification notes/i)) warnings.push("STORE.md has no Edge certification notes; the certification team gets no testing guidance.");
4490
+ return warnings;
4491
+ }
3832
4492
  const deploy_schema = {
3833
4493
  name: "extension_deploy",
3834
4494
  description: "Submit a built extension to the Chrome Web Store, Firefox AMO, and/or Edge Add-ons THROUGH extension.dev, which holds your store credentials and dispatches the release from your project's mirror CI. DEFAULTS TO A DRY RUN (preflight: verifies auth, the project, that the build exists, and the store workflow - dispatches nothing); pass dryRun:false to actually submit, which is irreversible and enters store review. The target project is identified by your token (extension_login or a release token in EXTENSION_DEV_TOKEN); store credentials are never tool arguments and local files are not uploaded. Pass browsers + buildSha. Posts to the platform's CLI store-submission endpoint.",
@@ -3926,11 +4586,196 @@ async function deploy_handler(args) {
3926
4586
  };
3927
4587
  }
3928
4588
  if (!res.ok) return deploy_fail("DeployError", `submit failed (${res.status}): ${data?.message || text || "unknown error"}`);
3929
- return JSON.stringify({
4589
+ const warnings = storeMdWarnings(browsers, process.cwd());
4590
+ const result = {
3930
4591
  mode: "platform",
3931
4592
  dryRun,
3932
4593
  ...data
4594
+ };
4595
+ if (warnings.length > 0) result.warnings = warnings;
4596
+ return JSON.stringify(result);
4597
+ }
4598
+ function doctor_readReadyContract(projectPath, browser) {
4599
+ try {
4600
+ const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
4601
+ return JSON.parse(raw);
4602
+ } catch {
4603
+ return null;
4604
+ }
4605
+ }
4606
+ const doctor_schema = {
4607
+ name: "extension_doctor",
4608
+ 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.",
4609
+ inputSchema: {
4610
+ type: "object",
4611
+ properties: {
4612
+ projectPath: {
4613
+ type: "string",
4614
+ description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4615
+ },
4616
+ browser: {
4617
+ type: "string",
4618
+ description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4619
+ }
4620
+ }
4621
+ }
4622
+ };
4623
+ async function environmentPreflight() {
4624
+ const checks = [];
4625
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
4626
+ checks.push({
4627
+ check: "node",
4628
+ status: nodeMajor >= 20 ? "pass" : "fail",
4629
+ detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4630
+ remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4631
+ });
4632
+ const { code, stdout, stderr } = await runExtensionCli([
4633
+ "--version"
4634
+ ], {
4635
+ timeoutMs: 60000
4636
+ });
4637
+ const cliVersion = stdout.trim() || stderr.trim();
4638
+ checks.push({
4639
+ check: "extension-cli",
4640
+ status: 0 === code ? "pass" : "fail",
4641
+ detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4642
+ remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4643
+ });
4644
+ const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4645
+ const cacheExists = node_fs.existsSync(cacheFile);
4646
+ checks.push({
4647
+ check: "template-cache",
4648
+ status: cacheExists ? "pass" : "warn",
4649
+ detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4650
+ });
4651
+ const healthy = checks.every((c)=>"fail" !== c.status);
4652
+ return JSON.stringify({
4653
+ mode: "environment",
4654
+ healthy,
4655
+ checks,
4656
+ hint: "Pass projectPath to diagnose a live dev session end-to-end."
4657
+ });
4658
+ }
4659
+ function safeStringify(value) {
4660
+ try {
4661
+ return JSON.stringify(value) ?? String(value);
4662
+ } catch {
4663
+ return String(value);
4664
+ }
4665
+ }
4666
+ function recentErrorLogs(projectPath, browser, max = 5) {
4667
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
4668
+ let lines;
4669
+ try {
4670
+ lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
4671
+ } catch {
4672
+ return [];
4673
+ }
4674
+ const errs = [];
4675
+ for (const line of lines){
4676
+ let ev;
4677
+ try {
4678
+ ev = JSON.parse(line);
4679
+ } catch {
4680
+ continue;
4681
+ }
4682
+ if (!ev || "error" !== ev.level) continue;
4683
+ const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
4684
+ let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
4685
+ if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
4686
+ msg = msg.replace(/\s+/g, " ").trim();
4687
+ if (msg) errs.push(msg.slice(0, 300));
4688
+ }
4689
+ return [
4690
+ ...new Set(errs)
4691
+ ].slice(-max);
4692
+ }
4693
+ function projectEngineVersion(projectPath) {
4694
+ try {
4695
+ const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
4696
+ return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
4697
+ } catch {
4698
+ return null;
4699
+ }
4700
+ }
4701
+ async function doctor_handler(args) {
4702
+ if (!args.projectPath) return environmentPreflight();
4703
+ const projectPath = args.projectPath;
4704
+ const { browser } = resolveSessionBrowser(projectPath, args.browser);
4705
+ const { code, stdout, stderr } = await runExtensionCli([
4706
+ "doctor",
4707
+ projectPath,
4708
+ "--browser",
4709
+ browser,
4710
+ "--output",
4711
+ "json"
4712
+ ], {
4713
+ cwd: projectPath
3933
4714
  });
4715
+ const out = stdout.trim();
4716
+ try {
4717
+ const checks = JSON.parse(out);
4718
+ if (!Array.isArray(checks)) throw new Error("not a check array");
4719
+ for (const check of checks){
4720
+ if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
4721
+ if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
4722
+ }
4723
+ let healthy = 0 === code;
4724
+ const contract = doctor_readReadyContract(projectPath, browser);
4725
+ if (contract?.status === "error") {
4726
+ healthy = false;
4727
+ const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
4728
+ 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.";
4729
+ checks.push({
4730
+ check: "runtime-errors",
4731
+ status: "fail",
4732
+ detail: toMcpSpeak(detail),
4733
+ 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."
4734
+ });
4735
+ } else {
4736
+ const errs = recentErrorLogs(projectPath, browser);
4737
+ if (errs.length) {
4738
+ healthy = false;
4739
+ checks.push({
4740
+ check: "runtime-errors",
4741
+ status: "fail",
4742
+ detail: `Recent error-level logs: ${errs.join(" | ")}`,
4743
+ 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."
4744
+ });
4745
+ }
4746
+ }
4747
+ const engineVersion = projectEngineVersion(projectPath);
4748
+ if (engineVersion) {
4749
+ const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
4750
+ const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
4751
+ checks.push({
4752
+ check: "project-engine",
4753
+ status: mismatch ? "warn" : "pass",
4754
+ detail: `project-local extension@${engineVersion}${mismatch ? `, but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
4755
+ ...mismatch ? {
4756
+ remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
4757
+ } : {}
4758
+ });
4759
+ }
4760
+ return JSON.stringify({
4761
+ browser,
4762
+ ...engineVersion ? {
4763
+ engineVersion
4764
+ } : {},
4765
+ healthy,
4766
+ checks
4767
+ });
4768
+ } catch {
4769
+ const message = stderr.trim() || `extension exited with code ${code}`;
4770
+ return JSON.stringify({
4771
+ ok: false,
4772
+ error: {
4773
+ name: "CliError",
4774
+ message: toMcpSpeak(message),
4775
+ hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
4776
+ }
4777
+ });
4778
+ }
3934
4779
  }
3935
4780
  const SAFE_CEILING_MS = 50000;
3936
4781
  function wait_isAlive(pid) {
@@ -3974,6 +4819,7 @@ async function wait_handler(args) {
3974
4819
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
3975
4820
  const start = Date.now();
3976
4821
  const pollInterval = 1000;
4822
+ let sawCompiledButUnattached = false;
3977
4823
  while(Date.now() - start < timeout){
3978
4824
  try {
3979
4825
  const raw = node_fs.readFileSync(readyPath, "utf8");
@@ -3981,11 +4827,18 @@ async function wait_handler(args) {
3981
4827
  if ("ready" === contract.status) {
3982
4828
  if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
3983
4829
  status: "stale",
3984
- 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.`,
4830
+ 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.`,
3985
4831
  browser: contract.browser,
3986
4832
  pid: contract.pid,
3987
4833
  waitDuration: Date.now() - start
3988
4834
  });
4835
+ const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
4836
+ if (!attached) {
4837
+ await new Promise((r)=>setTimeout(r, pollInterval));
4838
+ sawCompiledButUnattached = true;
4839
+ continue;
4840
+ }
4841
+ const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
3989
4842
  return JSON.stringify({
3990
4843
  status: "ready",
3991
4844
  command: contract.command,
@@ -3996,7 +4849,11 @@ async function wait_handler(args) {
3996
4849
  manifestPath: contract.manifestPath,
3997
4850
  compiledAt: contract.compiledAt,
3998
4851
  startedAt: contract.startedAt,
3999
- waitDuration: Date.now() - start
4852
+ waitDuration: Date.now() - start,
4853
+ ...runtimeErrors.length ? {
4854
+ runtimeErrors,
4855
+ 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.`
4856
+ } : {}
4000
4857
  });
4001
4858
  }
4002
4859
  if ("error" === contract.status) return JSON.stringify({
@@ -4010,13 +4867,20 @@ async function wait_handler(args) {
4010
4867
  } catch {}
4011
4868
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
4012
4869
  }
4870
+ if (sawCompiledButUnattached) return JSON.stringify({
4871
+ status: "compiled-not-attached",
4872
+ 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".`,
4873
+ readyPath,
4874
+ waitDuration: Date.now() - start,
4875
+ 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."
4876
+ });
4013
4877
  return JSON.stringify({
4014
4878
  status: "timeout",
4015
4879
  message: `Extension not ready after ${timeout}ms this call`,
4016
4880
  readyPath,
4017
4881
  waitDuration: Date.now() - start,
4018
4882
  clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
4019
- 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."
4883
+ 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."
4020
4884
  });
4021
4885
  }
4022
4886
  const add_feature_schema = {
@@ -5135,193 +5999,27 @@ async function detect_browsers_handler(args) {
5135
5999
  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."
5136
6000
  });
5137
6001
  }
5138
- function doctor_readReadyContract(projectPath, browser) {
5139
- try {
5140
- const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
5141
- return JSON.parse(raw);
5142
- } catch {
5143
- return null;
5144
- }
5145
- }
5146
- const doctor_schema = {
5147
- name: "extension_doctor",
5148
- 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.",
5149
- inputSchema: {
5150
- type: "object",
5151
- properties: {
5152
- projectPath: {
5153
- type: "string",
5154
- description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
5155
- },
5156
- browser: {
5157
- type: "string",
5158
- description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
5159
- }
5160
- }
5161
- }
5162
- };
5163
- async function environmentPreflight() {
5164
- const checks = [];
5165
- const nodeMajor = Number(process.versions.node.split(".")[0]);
5166
- checks.push({
5167
- check: "node",
5168
- status: nodeMajor >= 20 ? "pass" : "fail",
5169
- detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
5170
- remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
5171
- });
5172
- const { code, stdout, stderr } = await runExtensionCli([
5173
- "--version"
5174
- ], {
5175
- timeoutMs: 60000
5176
- });
5177
- const cliVersion = stdout.trim() || stderr.trim();
5178
- checks.push({
5179
- check: "extension-cli",
5180
- status: 0 === code ? "pass" : "fail",
5181
- detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
5182
- remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
5183
- });
5184
- const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
5185
- const cacheExists = node_fs.existsSync(cacheFile);
5186
- checks.push({
5187
- check: "template-cache",
5188
- status: cacheExists ? "pass" : "warn",
5189
- detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
5190
- });
5191
- const healthy = checks.every((c)=>"fail" !== c.status);
5192
- return JSON.stringify({
5193
- mode: "environment",
5194
- healthy,
5195
- checks,
5196
- hint: "Pass projectPath to diagnose a live dev session end-to-end."
5197
- });
5198
- }
5199
- function recentErrorLogs(projectPath, browser, max = 5) {
5200
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
5201
- let lines;
5202
- try {
5203
- lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
5204
- } catch {
5205
- return [];
5206
- }
5207
- const errs = [];
5208
- for (const line of lines){
5209
- let ev;
5210
- try {
5211
- ev = JSON.parse(line);
5212
- } catch {
5213
- continue;
5214
- }
5215
- if (ev && "error" === ev.level) {
5216
- const msg = Array.isArray(ev.args) ? ev.args.join(" ") : ev.message || ev.text || "";
5217
- if (msg) errs.push(String(msg).slice(0, 300));
5218
- }
5219
- }
5220
- return errs.slice(-max);
5221
- }
5222
- function projectEngineVersion(projectPath) {
5223
- try {
5224
- const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
5225
- return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
5226
- } catch {
5227
- return null;
5228
- }
5229
- }
5230
- async function doctor_handler(args) {
5231
- if (!args.projectPath) return environmentPreflight();
5232
- const projectPath = args.projectPath;
5233
- const { browser } = resolveSessionBrowser(projectPath, args.browser);
5234
- const { code, stdout, stderr } = await runExtensionCli([
5235
- "doctor",
5236
- projectPath,
5237
- "--browser",
5238
- browser,
5239
- "--output",
5240
- "json"
5241
- ], {
5242
- cwd: projectPath
5243
- });
5244
- const out = stdout.trim();
5245
- try {
5246
- const checks = JSON.parse(out);
5247
- if (!Array.isArray(checks)) throw new Error("not a check array");
5248
- for (const check of checks){
5249
- if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
5250
- if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
5251
- }
5252
- let healthy = 0 === code;
5253
- const contract = doctor_readReadyContract(projectPath, browser);
5254
- if (contract?.status === "error") {
5255
- healthy = false;
5256
- const detail = contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
5257
- checks.push({
5258
- check: "runtime-errors",
5259
- status: "fail",
5260
- detail: toMcpSpeak(detail),
5261
- remediation: "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
5262
- });
5263
- } else {
5264
- const errs = recentErrorLogs(projectPath, browser);
5265
- if (errs.length) {
5266
- healthy = false;
5267
- checks.push({
5268
- check: "runtime-errors",
5269
- status: "fail",
5270
- detail: `Recent error-level logs: ${errs.join(" | ")}`,
5271
- remediation: "The extension is throwing at runtime. Inspect with extension_logs; a missing permission for a called chrome.* API is a common cause (extension_manifest_validate now checks this)."
5272
- });
5273
- }
5274
- }
5275
- const engineVersion = projectEngineVersion(projectPath);
5276
- if (engineVersion) {
5277
- const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
5278
- const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
5279
- checks.push({
5280
- check: "project-engine",
5281
- status: mismatch ? "warn" : "pass",
5282
- detail: `project-local extension@${engineVersion}${mismatch ? ` — but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
5283
- ...mismatch ? {
5284
- remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
5285
- } : {}
5286
- });
5287
- }
5288
- return JSON.stringify({
5289
- browser,
5290
- ...engineVersion ? {
5291
- engineVersion
5292
- } : {},
5293
- healthy,
5294
- checks
5295
- });
5296
- } catch {
5297
- const message = stderr.trim() || `extension exited with code ${code}`;
5298
- return JSON.stringify({
5299
- ok: false,
5300
- error: {
5301
- name: "CliError",
5302
- message: toMcpSpeak(message),
5303
- hint: "extension doctor requires a recent extension CLI — the project's local install may predate it."
5304
- }
5305
- });
5306
- }
5307
- }
5308
6002
  function typeOf(value) {
5309
6003
  if (Array.isArray(value)) return "array";
5310
6004
  if (null === value) return "null";
5311
6005
  return typeof value;
5312
6006
  }
5313
6007
  function checkPrimitive(path, value, schema, issues) {
5314
- if (schema.type && [
5315
- "string",
5316
- "number",
5317
- "boolean"
5318
- ].includes(schema.type)) {
5319
- if (typeOf(value) !== schema.type) return void issues.push({
6008
+ const allowed = void 0 === schema.type ? [] : Array.isArray(schema.type) ? schema.type : [
6009
+ schema.type
6010
+ ];
6011
+ const primitives = allowed.filter((t)=>[
6012
+ "string",
6013
+ "number",
6014
+ "boolean"
6015
+ ].includes(t));
6016
+ if (primitives.length > 0 && primitives.length === allowed.length) {
6017
+ if (!primitives.includes(typeOf(value))) return void issues.push({
5320
6018
  path,
5321
- message: `expected ${schema.type}, got ${typeOf(value)}`
6019
+ message: `expected ${primitives.join(" or ")}, got ${typeOf(value)}`
5322
6020
  });
5323
6021
  }
5324
- if ("array" === schema.type) {
6022
+ if (allowed.includes("array")) {
5325
6023
  if (!Array.isArray(value)) return void issues.push({
5326
6024
  path,
5327
6025
  message: `expected array, got ${typeOf(value)}`
@@ -5366,6 +6064,26 @@ const ARG_ALIASES = {
5366
6064
  surface: [
5367
6065
  "view",
5368
6066
  "target"
6067
+ ],
6068
+ timeout: [
6069
+ "timeoutMs",
6070
+ "timeoutMillis"
6071
+ ],
6072
+ limit: [
6073
+ "lines",
6074
+ "count",
6075
+ "max",
6076
+ "maxLines"
6077
+ ],
6078
+ tab: [
6079
+ "tabId"
6080
+ ],
6081
+ url: [
6082
+ "href",
6083
+ "pageUrl"
6084
+ ],
6085
+ browser: [
6086
+ "browserName"
5369
6087
  ]
5370
6088
  };
5371
6089
  function normalizeArgAliases(inputSchema, args) {