@extension.dev/mcp 4.9.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.js CHANGED
@@ -9,8 +9,9 @@ import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
10
  import { execFile, execFileSync } from "node:child_process";
11
11
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
12
- import ws from "ws";
12
+ import ws_0 from "ws";
13
13
  import node_http from "node:http";
14
+ import node_crypto from "node:crypto";
14
15
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
15
16
  import { promisify } from "node:util";
16
17
  var add_feature_namespaceObject = {};
@@ -53,6 +54,7 @@ var doctor_namespaceObject = {};
53
54
  __webpack_require__.r(doctor_namespaceObject);
54
55
  __webpack_require__.d(doctor_namespaceObject, {
55
56
  handler: ()=>doctor_handler,
57
+ recentErrorLogs: ()=>recentErrorLogs,
56
58
  schema: ()=>doctor_schema
57
59
  });
58
60
  var dom_inspect_namespaceObject = {};
@@ -130,6 +132,7 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
130
132
  var open_namespaceObject = {};
131
133
  __webpack_require__.r(open_namespaceObject);
132
134
  __webpack_require__.d(open_namespaceObject, {
135
+ clampPopupBounds: ()=>clampPopupBounds,
133
136
  handler: ()=>open_handler,
134
137
  schema: ()=>open_schema
135
138
  });
@@ -199,7 +202,16 @@ __webpack_require__.d(whoami_namespaceObject, {
199
202
  handler: ()=>whoami_handler,
200
203
  schema: ()=>whoami_schema
201
204
  });
202
- var package_namespaceObject = JSON.parse('{"rE":"4.8.0","El":{"OP":"^4.0.11"}}');
205
+ var package_namespaceObject = JSON.parse('{"rE":"4.9.0","El":{"OP":"^4.0.13"}}');
206
+ function scaffoldEnginePin(projectPath) {
207
+ try {
208
+ const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
209
+ const spec = pkg?.devDependencies?.extension ?? pkg?.dependencies?.extension ?? null;
210
+ return "string" == typeof spec ? spec : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
203
215
  function detectPackageManager(projectPath) {
204
216
  const byLockfile = [
205
217
  [
@@ -238,7 +250,7 @@ const create_schema = {
238
250
  },
239
251
  parentDir: {
240
252
  type: "string",
241
- description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect pass this explicitly when you care where the project lands."
253
+ description: "Directory to create the project inside. Defaults to the MCP server's working directory, which may not be where you expect, pass this explicitly when you care where the project lands."
242
254
  },
243
255
  template: {
244
256
  type: "string",
@@ -288,7 +300,7 @@ async function create_handler(args) {
288
300
  }
289
301
  });
290
302
  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),
303
+ error: transient ? "Template download failed (network/timeout/rate-limit). This is not a bad template name. Retry, or check connectivity/GitHub rate limits." : err instanceof Error ? err.message : String(err),
292
304
  ...transient ? {
293
305
  cause: err instanceof Error ? err.message : String(err)
294
306
  } : {},
@@ -308,10 +320,23 @@ async function create_handler(args) {
308
320
  return failure(err2, looksTransient());
309
321
  }
310
322
  }
311
- const packageManager = result.depsInstalled ? detectPackageManager(result.projectPath) : "npm";
323
+ const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
324
+ if (!hasManifest) return JSON.stringify({
325
+ ok: false,
326
+ status: "incomplete",
327
+ projectPath: result.projectPath,
328
+ error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
329
+ duration: Date.now() - start,
330
+ log: logTail(),
331
+ hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
332
+ });
333
+ const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
312
334
  const runDev = `${packageManager} run dev`;
335
+ const addDev = (spec)=>"npm" === packageManager ? `npm i -D ${spec}` : "yarn" === packageManager ? `yarn add -D ${spec}` : `${packageManager} add -D ${spec}`;
313
336
  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;
337
+ const scaffoldPin = scaffoldEnginePin(result.projectPath);
338
+ const pinMatches = null !== scaffoldPin && "" !== pin && scaffoldPin.includes(pin);
339
+ const engineWarning = pin && "latest" !== pin && !pinMatches ? `The scaffold pins "extension": "${scaffoldPin ?? "unknown"}"; the project-local engine wins over EXTENSION_MCP_CLI_VERSION=${pin}. Run \`(cd ${result.projectPath} && ${addDev(`extension@${pin}`)})\` to match the pinned engine.` : void 0;
315
340
  return JSON.stringify({
316
341
  projectPath: result.projectPath,
317
342
  projectName: result.projectName,
@@ -324,8 +349,8 @@ async function create_handler(args) {
324
349
  runDev
325
350
  ] : [
326
351
  `cd ${result.projectPath}`,
327
- "npm install",
328
- "npm run dev"
352
+ `${packageManager} install`,
353
+ runDev
329
354
  ],
330
355
  ...engineWarning ? {
331
356
  engineWarning
@@ -547,6 +572,9 @@ function runExtensionCli(args, options) {
547
572
  }
548
573
  function spawnExtensionCli(args, options) {
549
574
  const { command, prefixArgs } = resolveExtensionInvocation(options?.projectDir ?? options?.cwd);
575
+ const logDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "extension-mcp-"));
576
+ const logPath = node_path.join(logDir, "session.log");
577
+ const fd = node_fs.openSync(logPath, "a");
550
578
  const child = cross_spawn(command, [
551
579
  ...prefixArgs,
552
580
  ...args
@@ -555,8 +583,8 @@ function spawnExtensionCli(args, options) {
555
583
  detached: true,
556
584
  stdio: [
557
585
  "ignore",
558
- "pipe",
559
- "pipe"
586
+ fd,
587
+ fd
560
588
  ],
561
589
  env: {
562
590
  ...process.env,
@@ -564,8 +592,29 @@ function spawnExtensionCli(args, options) {
564
592
  NO_COLOR: "1"
565
593
  }
566
594
  });
595
+ node_fs.closeSync(fd);
567
596
  child.unref();
568
- return child;
597
+ return {
598
+ child,
599
+ logPath,
600
+ readOutput: ()=>{
601
+ try {
602
+ return node_fs.readFileSync(logPath, "utf8");
603
+ } catch {
604
+ return "";
605
+ }
606
+ }
607
+ };
608
+ }
609
+ function readBuildSummary(projectPath, browser, since) {
610
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
611
+ try {
612
+ const stat = node_fs.statSync(file);
613
+ if (stat.mtimeMs < since) return null;
614
+ const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
615
+ if (summary && "object" == typeof summary) return summary;
616
+ } catch {}
617
+ return null;
569
618
  }
570
619
  function builtEntrypoints(distDir) {
571
620
  let manifest;
@@ -585,14 +634,31 @@ function builtEntrypoints(distDir) {
585
634
  };
586
635
  const bg = manifest.background;
587
636
  if (bg?.service_worker) add("background.service_worker", bg.service_worker);
637
+ if (bg?.page) add("background.page", bg.page);
588
638
  if (Array.isArray(bg?.scripts)) bg.scripts.forEach((s)=>add("background.scripts", s));
589
639
  const action = manifest.action || manifest.browser_action;
590
640
  if (action?.default_popup) add("action.default_popup", action.default_popup);
641
+ const pageAction = manifest.page_action;
642
+ if (pageAction?.default_popup) add("page_action.default_popup", pageAction.default_popup);
591
643
  const cs = manifest.content_scripts;
592
644
  if (Array.isArray(cs)) cs.forEach((c, i)=>{
593
645
  if (Array.isArray(c.js)) c.js.forEach((j)=>add(`content_scripts[${i}].js`, j));
594
646
  if (Array.isArray(c.css)) c.css.forEach((s)=>add(`content_scripts[${i}].css`, s));
595
647
  });
648
+ add("devtools_page", manifest.devtools_page);
649
+ add("options_page", manifest.options_page);
650
+ const optionsUi = manifest.options_ui;
651
+ if (optionsUi?.page) add("options_ui.page", optionsUi.page);
652
+ const sidePanel = manifest.side_panel;
653
+ if (sidePanel?.default_path) add("side_panel.default_path", sidePanel.default_path);
654
+ const sidebarAction = manifest.sidebar_action;
655
+ if (sidebarAction?.default_panel) add("sidebar_action.default_panel", sidebarAction.default_panel);
656
+ const overrides = manifest.chrome_url_overrides;
657
+ if (overrides) for (const [key, ref] of Object.entries(overrides))add(`chrome_url_overrides.${key}`, ref);
658
+ const dnr = manifest.declarative_net_request;
659
+ if (dnr && Array.isArray(dnr.rule_resources)) dnr.rule_resources.forEach((r, i)=>{
660
+ if (r && "object" == typeof r) add(`declarative_net_request[${i}].path`, r.path);
661
+ });
596
662
  return out;
597
663
  }
598
664
  const build_schema = {
@@ -660,6 +726,11 @@ const build_schema = {
660
726
  ],
661
727
  default: "production",
662
728
  description: "Bundler mode override (also sets NODE_ENV)"
729
+ },
730
+ skipValidation: {
731
+ type: "boolean",
732
+ default: false,
733
+ description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses, because a manifest error means the bundle it produces is broken in ways the bundler itself does not report."
663
734
  }
664
735
  },
665
736
  required: [
@@ -667,9 +738,67 @@ const build_schema = {
667
738
  ]
668
739
  }
669
740
  };
741
+ function manifestDivergence(projectPath, browser) {
742
+ const read = (p)=>{
743
+ try {
744
+ return JSON.parse(node_fs.readFileSync(p, "utf8"));
745
+ } catch {
746
+ return null;
747
+ }
748
+ };
749
+ const built = read(node_path.resolve(projectPath, "dist", browser, "manifest.json"));
750
+ const source = read(node_path.resolve(projectPath, "src", "manifest.json")) ?? read(node_path.resolve(projectPath, "manifest.json"));
751
+ if (!built || !source) return [];
752
+ const notes = [];
753
+ const listOf = (m, key)=>Array.isArray(m[key]) ? m[key].filter((x)=>"string" == typeof x) : [];
754
+ for (const key of [
755
+ "permissions",
756
+ "host_permissions",
757
+ "optional_permissions"
758
+ ]){
759
+ const lost = listOf(source, key).filter((p)=>!listOf(built, key).includes(p));
760
+ if (lost.length) notes.push(`The built manifest drops ${key}: ${lost.join(", ")}. The production build has narrower access than the source you tested in dev.`);
761
+ }
762
+ const sourceWar = source.web_accessible_resources;
763
+ const builtWar = built.web_accessible_resources;
764
+ if (Array.isArray(sourceWar) && sourceWar.length && !Array.isArray(builtWar)) notes.push("The built manifest has no web_accessible_resources although the source declares them. Anything injected into a page (scripting.insertCSS targets, injected scripts, images) will be blocked at runtime.");
765
+ return notes;
766
+ }
767
+ async function validationPreflight(projectPath, browser) {
768
+ try {
769
+ const manifestValidate = await Promise.resolve().then(()=>({
770
+ handler: manifest_validate_handler
771
+ }));
772
+ const parsed = JSON.parse(await manifestValidate.handler({
773
+ projectPath,
774
+ browsers: [
775
+ browser
776
+ ]
777
+ }));
778
+ return {
779
+ valid: Boolean(parsed.valid),
780
+ buildBlocking: Boolean(parsed.buildBlocking),
781
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
782
+ warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
783
+ };
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
670
788
  async function build_handler(args) {
671
789
  const start = Date.now();
672
790
  const browser = args.browser ?? "chrome";
791
+ const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
792
+ if (preflight?.buildBlocking) return JSON.stringify({
793
+ success: false,
794
+ status: "blocked",
795
+ browser,
796
+ error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
797
+ errors: preflight.errors,
798
+ warnings: preflight.warnings,
799
+ duration: Date.now() - start,
800
+ hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
801
+ });
673
802
  const cliArgs = [
674
803
  "build",
675
804
  args.projectPath,
@@ -692,7 +821,30 @@ async function build_handler(args) {
692
821
  if (0 === code) {
693
822
  const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
694
823
  const status = out.match(/Build Status:\s*(\w+)/)?.[1];
824
+ const engineSummary = readBuildSummary(args.projectPath, browser, start);
825
+ const buildWarnings = engineSummary?.warnings?.length ? {
826
+ buildWarnings: engineSummary.warnings,
827
+ ..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
828
+ buildWarningsTruncated: engineSummary.warnings_count
829
+ } : {}
830
+ } : {};
695
831
  const entrypoints = builtEntrypoints(node_path.resolve(args.projectPath, "dist", browser));
832
+ const missing = entrypoints.filter((e)=>!e.present);
833
+ if (missing.length) return JSON.stringify({
834
+ success: false,
835
+ status: "incomplete",
836
+ browser,
837
+ buildExitCode: 0,
838
+ error: `The build reported success but ${missing.length} declared entrypoint(s) are missing from dist/${browser}: ` + missing.map((m)=>`${m.role} -> ${m.path}`).join(", ") + ". The browser will refuse to load this build.",
839
+ entrypoints,
840
+ ...preflight?.warnings.length ? {
841
+ manifestWarnings: preflight.warnings
842
+ } : {},
843
+ ...buildWarnings,
844
+ duration,
845
+ output: lastLines(out, 12),
846
+ hint: "The bundler exited 0 but did not emit these files. Check that the manifest paths match what the build produces, and that nothing references a file outside the source tree."
847
+ });
696
848
  return JSON.stringify({
697
849
  success: true,
698
850
  browser,
@@ -705,6 +857,16 @@ async function build_handler(args) {
705
857
  ...entrypoints.length ? {
706
858
  entrypoints
707
859
  } : {},
860
+ ...preflight?.warnings.length ? {
861
+ manifestWarnings: preflight.warnings
862
+ } : {},
863
+ ...buildWarnings,
864
+ ...(()=>{
865
+ const divergence = manifestDivergence(args.projectPath, browser);
866
+ return divergence.length ? {
867
+ productionDivergence: divergence
868
+ } : {};
869
+ })(),
708
870
  zip: args.zip ?? false,
709
871
  duration,
710
872
  output: lastLines(out, 12)
@@ -833,7 +995,7 @@ const dev_schema = {
833
995
  allowEval: {
834
996
  type: "boolean",
835
997
  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."
998
+ description: "Enable extension_eval (runs code in a context; writes a 0600 session token). Implies allowControl, so a single allowEval: true also unlocks storage/reload/open/dom_inspect. You do not need to pass both."
837
999
  }
838
1000
  },
839
1001
  required: [
@@ -856,9 +1018,10 @@ async function dev_handler(args) {
856
1018
  cliArgs.push(...launchFlagArgs(args));
857
1019
  if (allowControl) cliArgs.push("--allow-control");
858
1020
  if (args.allowEval) cliArgs.push("--allow-eval");
859
- const child = spawnExtensionCli(cliArgs, {
1021
+ const spawned = spawnExtensionCli(cliArgs, {
860
1022
  projectDir: args.projectPath
861
1023
  });
1024
+ const { child, logPath } = spawned;
862
1025
  const pid = child.pid;
863
1026
  registerSession({
864
1027
  pid,
@@ -868,15 +1031,37 @@ async function dev_handler(args) {
868
1031
  command: "dev"
869
1032
  });
870
1033
  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
1034
  await new Promise((resolve)=>setTimeout(resolve, 3000));
878
- child.stdout?.off("data", collector);
879
- child.stderr?.off("data", collector);
1035
+ const earlyOutput = spawned.readOutput();
1036
+ if (null !== child.exitCode || null !== child.signalCode) {
1037
+ const code = child.exitCode;
1038
+ const signal = child.signalCode;
1039
+ return JSON.stringify({
1040
+ ok: false,
1041
+ status: "exited",
1042
+ projectPath: args.projectPath,
1043
+ browser,
1044
+ pid,
1045
+ exitCode: code,
1046
+ signal,
1047
+ error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
1048
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1049
+ logPath,
1050
+ hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
1051
+ });
1052
+ }
1053
+ const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(earlyOutput);
1054
+ if (compileFailed) return JSON.stringify({
1055
+ ok: false,
1056
+ status: "compile-failed",
1057
+ projectPath: args.projectPath,
1058
+ browser,
1059
+ pid,
1060
+ error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
1061
+ output: denoiseEarlyOutput(earlyOutput).slice(0, 2000),
1062
+ logPath,
1063
+ hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
1064
+ });
880
1065
  const controlVerbs = "storage, reload, open, dom_inspect";
881
1066
  const capabilities = {
882
1067
  allowControl,
@@ -884,6 +1069,7 @@ async function dev_handler(args) {
884
1069
  unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
885
1070
  };
886
1071
  return JSON.stringify({
1072
+ ok: true,
887
1073
  pid,
888
1074
  browser,
889
1075
  port: args.port ?? 8080,
@@ -891,7 +1077,8 @@ async function dev_handler(args) {
891
1077
  status: "started",
892
1078
  capabilities,
893
1079
  hint: "Use extension_wait to check when the extension is fully loaded, then extension_source_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_inspect need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). Restart extension_dev with the flag you need.") + " When you are done, call extension_stop to shut down the dev server and browser.",
894
- earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500)
1080
+ earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1081
+ logPath
895
1082
  });
896
1083
  }
897
1084
  function denoiseEarlyOutput(raw) {
@@ -905,6 +1092,93 @@ function denoiseEarlyOutput(raw) {
905
1092
  ];
906
1093
  return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
907
1094
  }
1095
+ function contractSightings(projectPath) {
1096
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
1097
+ let dirs;
1098
+ try {
1099
+ dirs = node_fs.readdirSync(root);
1100
+ } catch {
1101
+ return [];
1102
+ }
1103
+ const sightings = [];
1104
+ for (const dir of dirs){
1105
+ const readyPath = node_path.join(root, dir, "ready.json");
1106
+ try {
1107
+ const stat = node_fs.statSync(readyPath);
1108
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1109
+ if (contract?.status !== "ready") continue;
1110
+ sightings.push({
1111
+ browser: dir,
1112
+ mtimeMs: stat.mtimeMs,
1113
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
1114
+ });
1115
+ } catch {}
1116
+ }
1117
+ return sightings;
1118
+ }
1119
+ function pidAlive(pid) {
1120
+ try {
1121
+ process.kill(pid, 0);
1122
+ return true;
1123
+ } catch {
1124
+ return false;
1125
+ }
1126
+ }
1127
+ function knownSessionBrowsers(projectPath) {
1128
+ const resolved = node_path.resolve(projectPath);
1129
+ const browsers = [];
1130
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
1131
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
1132
+ return Array.from(new Set(browsers));
1133
+ }
1134
+ function deadReadySession(projectPath) {
1135
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1136
+ browser: sighting.browser,
1137
+ pid: sighting.pid
1138
+ };
1139
+ return null;
1140
+ }
1141
+ function browserExitStamp(projectPath, browser, since) {
1142
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1143
+ try {
1144
+ const stat = node_fs.statSync(readyPath);
1145
+ if (stat.mtimeMs < since) return null;
1146
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1147
+ const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1148
+ if (contract?.status === "error" && exited) return {
1149
+ code: contract.code,
1150
+ browserExitCode: contract.browserExitCode ?? null,
1151
+ browserExitedAt: contract.browserExitedAt
1152
+ };
1153
+ } catch {}
1154
+ return null;
1155
+ }
1156
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1157
+ if (explicit) return {
1158
+ browser: explicit,
1159
+ source: "explicit"
1160
+ };
1161
+ const resolved = node_path.resolve(projectPath);
1162
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1163
+ if (mine.length > 0) return {
1164
+ browser: mine[mine.length - 1].browser,
1165
+ source: "session"
1166
+ };
1167
+ const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1168
+ const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1169
+ if (live.length > 0) return {
1170
+ browser: live[0].browser,
1171
+ source: "contract"
1172
+ };
1173
+ if (sightings.length > 0) return {
1174
+ browser: sightings[0].browser,
1175
+ source: "stale"
1176
+ };
1177
+ return {
1178
+ browser: fallback,
1179
+ source: "fallback"
1180
+ };
1181
+ }
908
1182
  const start_schema = {
909
1183
  name: "extension_start",
910
1184
  description: "Build the extension for production and immediately preview it in a browser. Combines build + preview in one step. No hot reload.",
@@ -969,9 +1243,11 @@ async function start_handler(args) {
969
1243
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
970
1244
  if (args.noBrowser) cliArgs.push("--no-browser");
971
1245
  cliArgs.push(...launchFlagArgs(args));
972
- const child = spawnExtensionCli(cliArgs, {
1246
+ const spawnedAt = Date.now();
1247
+ const spawned = spawnExtensionCli(cliArgs, {
973
1248
  projectDir: args.projectPath
974
1249
  });
1250
+ const { child, logPath } = spawned;
975
1251
  const pid = child.pid;
976
1252
  registerSession({
977
1253
  pid,
@@ -980,22 +1256,47 @@ async function start_handler(args) {
980
1256
  command: "start"
981
1257
  });
982
1258
  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
1259
  await new Promise((resolve)=>setTimeout(resolve, 5000));
990
- child.stdout?.off("data", collector);
991
- child.stderr?.off("data", collector);
1260
+ const earlyOutput = spawned.readOutput();
1261
+ if (null !== child.exitCode || null !== child.signalCode) {
1262
+ const code = child.exitCode;
1263
+ const signal = child.signalCode;
1264
+ return JSON.stringify({
1265
+ ok: false,
1266
+ status: "exited",
1267
+ projectPath: args.projectPath,
1268
+ browser,
1269
+ pid,
1270
+ exitCode: code,
1271
+ signal,
1272
+ error: `The preview server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
1273
+ output: earlyOutput.slice(0, 2000),
1274
+ logPath,
1275
+ hint: "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own."
1276
+ });
1277
+ }
1278
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1279
+ if (exitStamp) return JSON.stringify({
1280
+ ok: false,
1281
+ status: "browser-exited",
1282
+ projectPath: args.projectPath,
1283
+ browser,
1284
+ pid,
1285
+ ...exitStamp,
1286
+ error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1287
+ output: earlyOutput.slice(0, 2000),
1288
+ logPath,
1289
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1290
+ });
992
1291
  return JSON.stringify({
1292
+ ok: true,
993
1293
  pid,
994
1294
  browser,
995
1295
  projectPath: args.projectPath,
996
1296
  status: "started",
997
1297
  hint: "Use extension_wait to check when the build and browser launch are complete. When you are done, call extension_stop to shut down the session.",
998
- earlyOutput: earlyOutput.slice(0, 500)
1298
+ earlyOutput: earlyOutput.slice(0, 500),
1299
+ logPath
999
1300
  });
1000
1301
  }
1001
1302
  const preview_schema = {
@@ -1056,9 +1357,11 @@ async function preview_handler(args) {
1056
1357
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
1057
1358
  if (args.noBrowser) cliArgs.push("--no-browser");
1058
1359
  cliArgs.push(...launchFlagArgs(args));
1059
- const child = spawnExtensionCli(cliArgs, {
1360
+ const spawnedAt = Date.now();
1361
+ const spawned = spawnExtensionCli(cliArgs, {
1060
1362
  projectDir: args.projectPath
1061
1363
  });
1364
+ const { child, logPath } = spawned;
1062
1365
  const pid = child.pid;
1063
1366
  registerSession({
1064
1367
  pid,
@@ -1067,81 +1370,49 @@ async function preview_handler(args) {
1067
1370
  command: "preview"
1068
1371
  });
1069
1372
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1373
+ await new Promise((resolve)=>setTimeout(resolve, 5000));
1374
+ const earlyOutput = spawned.readOutput();
1375
+ if (null !== child.exitCode || null !== child.signalCode) {
1376
+ const code = child.exitCode;
1377
+ const signal = child.signalCode;
1378
+ return JSON.stringify({
1379
+ ok: false,
1380
+ status: "exited",
1381
+ projectPath: args.projectPath,
1382
+ browser,
1383
+ pid,
1384
+ exitCode: code,
1385
+ signal,
1386
+ error: `The preview process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). Nothing is running.`,
1387
+ output: earlyOutput.slice(0, 2000),
1388
+ logPath,
1389
+ hint: "Read `output` above for the cause: a missing or broken dist/ (run extension_build first), or a missing browser binary are the common ones."
1390
+ });
1391
+ }
1392
+ const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
1393
+ if (exitStamp) return JSON.stringify({
1394
+ ok: false,
1395
+ status: "browser-exited",
1396
+ projectPath: args.projectPath,
1397
+ browser,
1398
+ pid,
1399
+ ...exitStamp,
1400
+ error: "The preview process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.",
1401
+ output: earlyOutput.slice(0, 2000),
1402
+ logPath,
1403
+ hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
1404
+ });
1070
1405
  return JSON.stringify({
1406
+ ok: true,
1071
1407
  pid,
1072
1408
  browser,
1073
1409
  projectPath: args.projectPath,
1074
1410
  status: "launched",
1075
- hint: "Call extension_stop when you are done to close the preview browser."
1411
+ hint: "Call extension_stop when you are done to close the preview browser.",
1412
+ earlyOutput: earlyOutput.slice(0, 500),
1413
+ logPath
1076
1414
  });
1077
1415
  }
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
1416
  const stop_schema = {
1146
1417
  name: "extension_stop",
1147
1418
  description: "Stop a running dev, start, or preview session: terminates the dev server and the browser it launched. Counterpart to extension_dev/extension_start. Call it when you are done verifying so sessions do not accumulate.",
@@ -1204,7 +1475,7 @@ function isAlive(pid) {
1204
1475
  return false;
1205
1476
  }
1206
1477
  }
1207
- function signal(pid, sig) {
1478
+ function stop_signal(pid, sig) {
1208
1479
  try {
1209
1480
  process.kill(-pid, sig);
1210
1481
  return true;
@@ -1245,10 +1516,10 @@ async function stopOne(projectPath, browser) {
1245
1516
  }
1246
1517
  let detail;
1247
1518
  if (isAlive(pid)) {
1248
- signal(pid, "SIGTERM");
1519
+ stop_signal(pid, "SIGTERM");
1249
1520
  await new Promise((resolve)=>setTimeout(resolve, 1500));
1250
1521
  if (isAlive(pid)) {
1251
- signal(pid, "SIGKILL");
1522
+ stop_signal(pid, "SIGKILL");
1252
1523
  await new Promise((resolve)=>setTimeout(resolve, 250));
1253
1524
  }
1254
1525
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
@@ -1491,7 +1762,8 @@ const manifest_validate_schema = {
1491
1762
  },
1492
1763
  default: [
1493
1764
  "chrome",
1494
- "firefox"
1765
+ "firefox",
1766
+ "edge"
1495
1767
  ],
1496
1768
  description: "Browsers to validate against"
1497
1769
  },
@@ -1535,6 +1807,19 @@ function collectPathRefs(m) {
1535
1807
  if (sa) push(sa.default_panel);
1536
1808
  const cuo = m.chrome_url_overrides;
1537
1809
  if (cuo) Object.values(cuo).forEach(push);
1810
+ const dnr = m.declarative_net_request;
1811
+ if (dnr && Array.isArray(dnr.rule_resources)) {
1812
+ for (const r of dnr.rule_resources)if (r && "object" == typeof r) push(r.path);
1813
+ }
1814
+ const storage = m.storage;
1815
+ if (storage) push(storage.managed_schema);
1816
+ push(m.devtools_page);
1817
+ const pa = m.page_action;
1818
+ if (pa) {
1819
+ push(pa.default_popup);
1820
+ if ("string" == typeof pa.default_icon) push(pa.default_icon);
1821
+ else if (pa.default_icon) Object.values(pa.default_icon).forEach(push);
1822
+ }
1538
1823
  return refs;
1539
1824
  }
1540
1825
  function fileResolvesSomewhere(ref, roots) {
@@ -1642,9 +1927,11 @@ async function manifest_validate_handler(args) {
1642
1927
  args.browser
1643
1928
  ]
1644
1929
  };
1930
+ const explicitBrowsers = Array.isArray(args.browsers) && args.browsers.length > 0;
1645
1931
  const browsers = args.browsers ?? [
1646
1932
  "chrome",
1647
- "firefox"
1933
+ "firefox",
1934
+ "edge"
1648
1935
  ];
1649
1936
  const result = {
1650
1937
  valid: true,
@@ -1689,30 +1976,46 @@ async function manifest_validate_handler(args) {
1689
1976
  node_path.dirname(manifestDir)
1690
1977
  ] : []
1691
1978
  ];
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)){
1979
+ for (const ref of new Set(collectPathRefs(chromiumManifest)))if (!fileResolvesSomewhere(ref, roots)) result.errors.push(`Referenced file "${ref}" was not found near the manifest. extension_build fails on this dangling reference.`);
1980
+ const defaultLocale = manifest.default_locale;
1981
+ if ("string" == typeof defaultLocale && defaultLocale) {
1982
+ const hasCatalog = roots.some((root)=>node_fs.existsSync(node_path.resolve(root, "_locales", defaultLocale, "messages.json")));
1983
+ if (!hasCatalog) result.errors.push(`default_locale "${defaultLocale}" is declared but _locales/${defaultLocale}/messages.json was not found. The build fails on this; add the catalog or remove default_locale.`);
1984
+ }
1985
+ const iconMap = chromiumManifest.icons;
1986
+ if (!iconMap || "string" != typeof iconMap["128"]) result.warnings.push('No 128x128 icon declared ("128" key in icons). The Chrome Web Store requires one for a store listing, and Edge Add-ons expects it too.');
1987
+ const effectiveByBrowser = new Map();
1988
+ for (const b of browsers)effectiveByBrowser.set(b, filterKeysForThisBrowser(manifest, b));
1989
+ const declaredPermSet = new Set();
1990
+ for (const view of [
1991
+ chromiumManifest,
1992
+ ...effectiveByBrowser.values()
1993
+ ])for (const p of [
1994
+ ...view.permissions ?? [],
1995
+ ...view.optional_permissions ?? []
1996
+ ])if ("string" == typeof p) declaredPermSet.add(p);
1997
+ const usedApis = scanApiUsage(roots);
1998
+ for (const api of usedApis){
1698
1999
  const perm = API_PERMISSION[api];
1699
2000
  if (declaredPermSet.has(perm)) continue;
1700
2001
  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.`);
2002
+ if (HARD_APIS.has(api)) result.errors.push(`${base}, chrome.${api} is undefined without it and will crash the context at runtime.`);
2003
+ else result.warnings.push(`${base}; it may be undefined at runtime, add "${perm}" if you use it.`);
1703
2004
  }
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.');
2005
+ if (chromiumManifest.manifest_version) {
2006
+ if (2 !== chromiumManifest.manifest_version && 3 !== chromiumManifest.manifest_version) result.errors.push(`manifest_version must be 2 or 3, got ${JSON.stringify(chromiumManifest.manifest_version)}. No browser installs this manifest.`);
2007
+ } else result.errors.push('Missing manifest_version. Use "chromium:manifest_version": 3 and "firefox:manifest_version": 2 for cross-browser support.');
1705
2008
  const declaredPerms = [
1706
2009
  ...chromiumManifest.permissions ?? [],
1707
2010
  ...chromiumManifest.optional_permissions ?? []
1708
2011
  ].filter((p)=>"string" == typeof p);
1709
2012
  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).`);
2013
+ if (!KNOWN_PERMISSIONS.has(perm)) result.warnings.push(`Unrecognized permission "${perm}", check for a typo (host/match patterns belong in host_permissions, not permissions).`);
1711
2014
  }
1712
2015
  for (const browser of browsers){
1713
2016
  const isChromium = isChromiumFamily(browser);
1714
2017
  const isFirefox = isGeckoFamily(browser);
1715
- const effective = filterKeysForThisBrowser(manifest, browser);
2018
+ const effective = effectiveByBrowser.get(browser) ?? filterKeysForThisBrowser(manifest, browser);
1716
2019
  const issues = [];
1717
2020
  if (isChromium) {
1718
2021
  const mv = effective.manifest_version;
@@ -1734,6 +2037,17 @@ async function manifest_validate_handler(args) {
1734
2037
  if (bg.service_worker && !bg.scripts) issues.push('Background service_worker declared but no firefox:scripts. Firefox uses "scripts" (array) instead of "service_worker".');
1735
2038
  }
1736
2039
  }
2040
+ const effectivePerms = new Set([
2041
+ ...effective.permissions ?? [],
2042
+ ...effective.optional_permissions ?? []
2043
+ ].filter((p)=>"string" == typeof p));
2044
+ for (const api of usedApis){
2045
+ const perm = API_PERMISSION[api];
2046
+ if (effectivePerms.has(perm)) continue;
2047
+ if (!declaredPermSet.has(perm)) continue;
2048
+ const ns = isFirefox ? "browser" : "chrome";
2049
+ issues.push(`Code calls ${ns}.${api} but the ${browser} build's permissions do not include "${perm}" (it is declared only under another target's prefixed key, e.g. chromium:permissions). This target crashes at runtime.`);
2050
+ }
1737
2051
  result.browserSupport[browser] = {
1738
2052
  supported: 0 === issues.length,
1739
2053
  issues
@@ -1759,7 +2073,13 @@ async function manifest_validate_handler(args) {
1759
2073
  surfaces: t.surfaces
1760
2074
  }));
1761
2075
  } catch {}
1762
- result.valid = 0 === result.errors.length && Object.values(result.browserSupport).every((b)=>b.supported);
2076
+ for (const [browser, support] of Object.entries(result.browserSupport)){
2077
+ if (support.supported) continue;
2078
+ const issues = support.issues?.length ? support.issues.join("; ") : `${browser} is not supported by this manifest.`;
2079
+ if (explicitBrowsers) result.errors.push(`${browser}: ${issues}`);
2080
+ else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
2081
+ }
2082
+ result.valid = 0 === result.errors.length;
1763
2083
  return JSON.stringify({
1764
2084
  ...result,
1765
2085
  buildBlocking: result.errors.length > 0
@@ -1912,8 +2232,8 @@ async function inspect_handler(args) {
1912
2232
  const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
1913
2233
  const sizeWarnings = [];
1914
2234
  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.`);
2235
+ if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package, move it out of the bundled sources so it does not inflate the store zip.`);
2236
+ else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle, unusually large for a shipped asset.`);
1917
2237
  }
1918
2238
  const result = {
1919
2239
  browser,
@@ -1960,6 +2280,7 @@ async function inspect_handler(args) {
1960
2280
  storeReadiness: {
1961
2281
  hasManifest: node_fs.existsSync(manifestPath),
1962
2282
  hasIcons: files.some((f)=>"image" === f.type && f.path.includes("icon")),
2283
+ has128Icon: "string" == typeof manifest.icons?.["128"],
1963
2284
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
1964
2285
  noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
1965
2286
  under10MB: totalSize < 10485760
@@ -1981,7 +2302,7 @@ const COMMAND_TIMEOUT_MS = 15000;
1981
2302
  class CDPConnection {
1982
2303
  async connect(wsUrl) {
1983
2304
  return new Promise((resolve, reject)=>{
1984
- this.ws = new ws(wsUrl);
2305
+ this.ws = new ws_0(wsUrl);
1985
2306
  this.ws.on("open", ()=>resolve());
1986
2307
  this.ws.on("message", (data)=>{
1987
2308
  this.handleMessage(data.toString());
@@ -2050,7 +2371,7 @@ class CDPConnection {
2050
2371
  }
2051
2372
  async sendCommand(method, params = {}, sessionId) {
2052
2373
  return new Promise((resolve, reject)=>{
2053
- if (!this.ws || this.ws.readyState !== ws.OPEN) return reject(new Error("CDP WebSocket is not connected"));
2374
+ if (!this.ws || this.ws.readyState !== ws_0.OPEN) return reject(new Error("CDP WebSocket is not connected"));
2054
2375
  const id = ++this.messageId;
2055
2376
  const message = {
2056
2377
  id,
@@ -2441,7 +2762,7 @@ const source_inspect_schema = {
2441
2762
  items: {
2442
2763
  type: "string"
2443
2764
  },
2444
- description: "CSS selectors to query returns element counts and samples for each"
2765
+ description: "CSS selectors to query, returns element counts and samples for each"
2445
2766
  },
2446
2767
  include: {
2447
2768
  type: "array",
@@ -2493,7 +2814,7 @@ async function source_inspect_handler(args) {
2493
2814
  const maxBytes = args.maxBytes ?? 262144;
2494
2815
  if (!isChromiumFamily(browser)) return JSON.stringify({
2495
2816
  error: `Source inspection reads the live DOM over Chrome DevTools Protocol, which ${browser} (Gecko) does not expose. This is a capability limit, not a missing session.`,
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.`
2817
+ hint: `For the ${browser} session, read runtime state with extension_logs, or extension_eval / extension_dom_inspect (content/page, an open surface like popup/options/sidebar, or an override page like newtab) which work against Firefox over the control channel. To get CDP DOM inspection, run a Chromium-family dev session (extension_dev with browser: "chrome") in parallel.`
2497
2818
  });
2498
2819
  const resolved = await resolveCdpPort(args.projectPath, browser);
2499
2820
  if (!resolved) return JSON.stringify({
@@ -2575,7 +2896,11 @@ async function source_inspect_handler(args) {
2575
2896
  if (include.has("dom_snapshot")) result.domSnapshot = await cdp.getDomSnapshot(sessionId);
2576
2897
  if (include.has("console")) result.console = cdp.getConsoleSummary();
2577
2898
  if (include.has("extension_roots")) result.extensionRoots = await cdp.getExtensionRootMeta(sessionId);
2578
- if (args.probe?.length) result.probes = await cdp.probeSelectors(sessionId, args.probe);
2899
+ if (args.probe?.length) {
2900
+ result.probes = await cdp.probeSelectors(sessionId, args.probe);
2901
+ const jsLooking = args.probe.filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
2902
+ if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
2903
+ }
2579
2904
  if (args.deepDom) {
2580
2905
  const closed = await cdp.getClosedShadowRoots(sessionId, maxBytes > 0 ? maxBytes : 65536);
2581
2906
  result.closedShadowRoots = closed;
@@ -2594,7 +2919,7 @@ async function source_inspect_handler(args) {
2594
2919
  }
2595
2920
  const list_extensions_schema = {
2596
2921
  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.",
2922
+ description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). Identity is read read-only via the Extensions domain, other extensions' contexts are never attached to or evaluated in. A dormant MV3 service worker with no open page may be absent until it wakes. Chromium only (Firefox uses RDP, not yet supported). Requires an active dev or start session.",
2598
2923
  inputSchema: {
2599
2924
  type: "object",
2600
2925
  properties: {
@@ -2849,10 +3174,28 @@ function capRecent(events, limit) {
2849
3174
  truncated: true
2850
3175
  };
2851
3176
  }
2852
- function summarize(events, source, browser, runId, limit, dropped) {
3177
+ function emptyReason(projectPath, browser) {
3178
+ let contract;
3179
+ try {
3180
+ contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
3181
+ } catch {
3182
+ return "No ready.json for this project/browser: no dev session has produced a build here, so there is nothing to log. Start one with extension_dev.";
3183
+ }
3184
+ if ("error" === contract.status) {
3185
+ const errs = contract.errors;
3186
+ return `The dev session recorded status:"error"${errs?.length ? ` (${errs.join("; ")})` : ""}, so the extension never ran. There are no logs because there was no working build, not because your code is silent.`;
3187
+ }
3188
+ if ("number" == typeof contract.pid) try {
3189
+ process.kill(contract.pid, 0);
3190
+ } catch {
3191
+ return `ready.json reports ready but its dev-server pid ${contract.pid} is dead: the session exited. Logs stop at the moment it died. Restart with extension_dev; extension_doctor will confirm.`;
3192
+ }
3193
+ }
3194
+ function summarize(events, source, browser, runId, limit, dropped, projectPath, staleNote) {
2853
3195
  const matched = events.length;
2854
3196
  const { events: out, truncated } = capRecent(events, limit);
2855
3197
  const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
3198
+ const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
2856
3199
  return JSON.stringify({
2857
3200
  ok: true,
2858
3201
  source,
@@ -2863,9 +3206,30 @@ function summarize(events, source, browser, runId, limit, dropped) {
2863
3206
  truncated,
2864
3207
  dropped: dropped || void 0,
2865
3208
  nextSince: lastSeq >= 0 ? lastSeq : void 0,
3209
+ ...reason ? {
3210
+ emptyReason: reason
3211
+ } : {},
3212
+ ...staleNote && matched > 0 ? {
3213
+ stale: true,
3214
+ warning: staleNote
3215
+ } : {},
2866
3216
  events: out
2867
3217
  });
2868
3218
  }
3219
+ function staleFileNote(projectPath, browser, eventsRunId) {
3220
+ let contract;
3221
+ try {
3222
+ contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
3223
+ } catch {
3224
+ return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
3225
+ }
3226
+ if ("number" == typeof contract.pid) try {
3227
+ process.kill(contract.pid, 0);
3228
+ } catch {
3229
+ return `These events are from a PAST run: the session that wrote them (pid ${contract.pid}) is dead. Nothing current is producing logs; do not read these as live output.`;
3230
+ }
3231
+ if (eventsRunId && contract.runId && String(contract.runId) !== eventsRunId) return `These events carry runId ${eventsRunId} but the current session is run ${String(contract.runId)}, which has written nothing yet. Do not read these as the current run's output.`;
3232
+ }
2869
3233
  async function readFromFile(args, browser, limit) {
2870
3234
  const file = logsFilePath(args.projectPath, browser);
2871
3235
  if (!node_fs.existsSync(file)) return JSON.stringify({
@@ -2889,13 +3253,13 @@ async function readFromFile(args, browser, limit) {
2889
3253
  }
2890
3254
  if (matches(event)) events.push(event);
2891
3255
  }
2892
- return summarize(events, "file", browser, runId, limit, 0);
3256
+ return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
2893
3257
  }
2894
3258
  async function readFromStream(args, browser, limit) {
2895
3259
  const ready = readReadyContract(args.projectPath, browser);
2896
3260
  if (!ready) {
2897
3261
  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";
3262
+ const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
2899
3263
  return JSON.stringify({
2900
3264
  error: `No active control channel found for ${browser}.`,
2901
3265
  hint: `${retarget} extension_dev (browser: ${browser}) and wait for it to be ready, then retry. For past logs without a live channel, call without follow.`
@@ -2911,7 +3275,7 @@ async function readFromStream(args, browser, limit) {
2911
3275
  const url = `ws://127.0.0.1:${ready.controlPort}${CONTROL_WS_PATH}`;
2912
3276
  let socket;
2913
3277
  try {
2914
- socket = new ws(url);
3278
+ socket = new ws_0(url);
2915
3279
  } catch (err) {
2916
3280
  resolve(JSON.stringify({
2917
3281
  error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
@@ -2925,7 +3289,7 @@ async function readFromStream(args, browser, limit) {
2925
3289
  try {
2926
3290
  socket.close();
2927
3291
  } catch {}
2928
- resolve(summarize(events, "stream", browser, runId, limit, dropped));
3292
+ resolve(summarize(events, "stream", browser, runId, limit, dropped, args.projectPath));
2929
3293
  };
2930
3294
  const timer = setTimeout(finish, followMs);
2931
3295
  socket.on("open", ()=>{
@@ -2975,10 +3339,10 @@ function withSessionContext(message, projectPath) {
2975
3339
  const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
2976
3340
  if (!isControlError) return message;
2977
3341
  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.`;
3342
+ if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
2979
3343
  const running = knownSessionBrowsers(projectPath);
2980
3344
  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.`;
3345
+ return `${message} Active session browser(s) for this project: ${running.join(", ")}, pass that as \`browser\`, or restart it via extension_dev with allowControl: true if the control channel is off.`;
2982
3346
  }
2983
3347
  function translateFrame(frame, projectPath) {
2984
3348
  if (!frame || false !== frame.ok) return frame;
@@ -3022,7 +3386,7 @@ function commonFlags(args) {
3022
3386
  }
3023
3387
  const eval_schema = {
3024
3388
  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`.",
3389
+ description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Chromium caveat: eval in the MV3 background/service_worker is blocked by CSP (use an MV2/Firefox build for that context). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
3026
3390
  inputSchema: {
3027
3391
  type: "object",
3028
3392
  properties: {
@@ -3042,6 +3406,9 @@ const eval_schema = {
3042
3406
  "options",
3043
3407
  "sidebar",
3044
3408
  "devtools",
3409
+ "newtab",
3410
+ "history",
3411
+ "bookmarks",
3045
3412
  "content",
3046
3413
  "page"
3047
3414
  ],
@@ -3050,11 +3417,11 @@ const eval_schema = {
3050
3417
  },
3051
3418
  url: {
3052
3419
  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."
3420
+ description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`. You do not need a numeric id."
3054
3421
  },
3055
3422
  tab: {
3056
3423
  type: "number",
3057
- description: "Numeric chrome.tabs id. Required for context content/page/popup on Chromium (the `url` arg does not substitute for it)."
3424
+ description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
3058
3425
  },
3059
3426
  browser: {
3060
3427
  type: "string",
@@ -3073,7 +3440,7 @@ const eval_schema = {
3073
3440
  };
3074
3441
  async function eval_handler(args) {
3075
3442
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3076
- return runActVerb([
3443
+ const raw = await runActVerb([
3077
3444
  "eval",
3078
3445
  args.expression,
3079
3446
  args.projectPath,
@@ -3082,6 +3449,14 @@ async function eval_handler(args) {
3082
3449
  browser
3083
3450
  })
3084
3451
  ], args.projectPath, args.timeout);
3452
+ if ("content" === args.context) try {
3453
+ const parsed = JSON.parse(raw);
3454
+ if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
3455
+ parsed.note = "On Extension.js >= 4.0.14 a failed injection errors explicitly, so this null is the expression's real result. On OLDER engines (bug 61) it could mean the injection never ran; if this result looks wrong, check the engine version with extension_doctor, or verify with extension_logs or context:'page'.";
3456
+ return JSON.stringify(parsed);
3457
+ }
3458
+ } catch {}
3459
+ return raw;
3085
3460
  }
3086
3461
  const storage_schema = {
3087
3462
  name: "extension_storage",
@@ -3161,7 +3536,14 @@ async function storage_handler(args) {
3161
3536
  message: "storage set requires a value"
3162
3537
  }
3163
3538
  });
3164
- cli.push("--value", JSON.stringify(args.value));
3539
+ if (void 0 === args.key) return JSON.stringify({
3540
+ ok: false,
3541
+ error: {
3542
+ name: "BadRequest",
3543
+ message: 'storage set requires `key` (string) and `value` args, one key per call. There is no bulk-object set: to seed {a: 1, b: 2}, call once with key: "a" and once with key: "b".'
3544
+ }
3545
+ });
3546
+ cli.push("--value", JSON.stringify(args.value));
3165
3547
  }
3166
3548
  if (args.context) cli.push("--context", args.context);
3167
3549
  cli.push("--browser", browser);
@@ -3216,6 +3598,27 @@ async function reload_handler(args) {
3216
3598
  })
3217
3599
  ], args.projectPath, args.timeout);
3218
3600
  }
3601
+ async function pollForTarget(port, url, budgetMs) {
3602
+ const deadline = Date.now() + budgetMs;
3603
+ const wanted = url.replace(/#.*$/, "");
3604
+ for(;;){
3605
+ try {
3606
+ const targets = await CDPClient.discoverTargets(port);
3607
+ for (const t of targets){
3608
+ const tUrl = String(t.url ?? "");
3609
+ if ("page" === t.type) {
3610
+ if (tUrl === wanted || tUrl.startsWith(wanted)) return {
3611
+ id: String(t.id),
3612
+ url: tUrl,
3613
+ title: "string" == typeof t.title ? t.title : void 0
3614
+ };
3615
+ }
3616
+ }
3617
+ } catch {}
3618
+ if (Date.now() >= deadline) return null;
3619
+ await new Promise((r)=>setTimeout(r, 250));
3620
+ }
3621
+ }
3219
3622
  async function navigateToUrl(projectPath, browser, url) {
3220
3623
  if (!isChromiumFamily(browser)) return JSON.stringify({
3221
3624
  ok: false,
@@ -3248,17 +3651,24 @@ async function navigateToUrl(projectPath, browser, url) {
3248
3651
  await cdp.connect(browserWsUrl);
3249
3652
  const sessionId = await cdp.attachToTarget(String(target.id));
3250
3653
  await cdp.navigate(sessionId, url);
3251
- await new Promise((r)=>setTimeout(r, 1200));
3252
- const meta = await cdp.getPageMeta(sessionId).catch(()=>({}));
3654
+ const settled = await pollForTarget(resolved.port, url, 6000);
3655
+ if (!settled) return JSON.stringify({
3656
+ ok: false,
3657
+ error: {
3658
+ name: "NavigateFailed",
3659
+ message: `Navigation to ${url} did not produce a live page target. The URL may not exist in the extension bundle, or Chrome refused the navigation.`
3660
+ },
3661
+ hint: "Confirm the path exists in the built dist (extension_build / extension_inspect list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout."
3662
+ });
3253
3663
  return JSON.stringify({
3254
3664
  ok: true,
3255
3665
  navigated: url,
3256
- tab: {
3257
- id: target.id,
3258
- title: meta.title,
3259
- url: meta.url || url
3666
+ target: {
3667
+ targetId: settled.id,
3668
+ title: settled.title,
3669
+ url: settled.url
3260
3670
  },
3261
- hint: "Inspect it with extension_dom_inspect or extension_source_inspect (context: 'page')."
3671
+ hint: "Inspect it with extension_dom_inspect or extension_source_inspect using url (context: 'page'), they resolve the tab themselves. `target.targetId` is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. If you need a numeric tab id, call extension_dom_inspect with listTabs: true."
3262
3672
  });
3263
3673
  } catch (e) {
3264
3674
  return JSON.stringify({
@@ -3274,9 +3684,180 @@ async function navigateToUrl(projectPath, browser, url) {
3274
3684
  } catch {}
3275
3685
  }
3276
3686
  }
3687
+ function unpackedExtensionId(distPath) {
3688
+ const digest = node_crypto.createHash("sha256").update(distPath).digest();
3689
+ let id = "";
3690
+ for(let i = 0; i < 16; i++){
3691
+ id += String.fromCharCode(97 + (digest[i] >> 4));
3692
+ id += String.fromCharCode(97 + (0x0f & digest[i]));
3693
+ }
3694
+ return id;
3695
+ }
3696
+ async function resolveExtensionId(projectPath, browser) {
3697
+ const distPath = readDistPath(projectPath, browser);
3698
+ const computed = distPath ? unpackedExtensionId(distPath) : null;
3699
+ const resolved = await resolveCdpPort(projectPath, browser);
3700
+ if (!resolved) return computed;
3701
+ const ids = new Set();
3702
+ try {
3703
+ for (const t of (await CDPClient.discoverTargets(resolved.port))){
3704
+ const url = String(t.url ?? "");
3705
+ if (!url.startsWith("chrome-extension://")) continue;
3706
+ const id = url.slice(19).split("/")[0];
3707
+ if (id) ids.add(id);
3708
+ }
3709
+ } catch {}
3710
+ if (computed && ids.has(computed)) return computed;
3711
+ if (computed) return computed;
3712
+ return 1 === ids.size ? [
3713
+ ...ids
3714
+ ][0] : null;
3715
+ }
3716
+ function declaredCommands(projectPath, browser) {
3717
+ const candidates = [
3718
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3719
+ node_path.join(projectPath, "dist", "manifest.json"),
3720
+ node_path.join(projectPath, "src", "manifest.json"),
3721
+ node_path.join(projectPath, "manifest.json")
3722
+ ];
3723
+ for (const file of candidates)try {
3724
+ const manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3725
+ const commands = manifest?.commands;
3726
+ if (commands && "object" == typeof commands) return Object.keys(commands);
3727
+ return [];
3728
+ } catch {
3729
+ continue;
3730
+ }
3731
+ return null;
3732
+ }
3733
+ function readDistPath(projectPath, browser) {
3734
+ try {
3735
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
3736
+ const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
3737
+ return "string" == typeof contract?.distPath ? contract.distPath : null;
3738
+ } catch {
3739
+ return null;
3740
+ }
3741
+ }
3742
+ function surfaceDocument(projectPath, browser, surface) {
3743
+ const candidates = [
3744
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3745
+ node_path.join(projectPath, "dist", "manifest.json"),
3746
+ node_path.join(projectPath, "src", "manifest.json"),
3747
+ node_path.join(projectPath, "manifest.json")
3748
+ ];
3749
+ for (const file of candidates){
3750
+ let manifest;
3751
+ try {
3752
+ manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3753
+ } catch {
3754
+ continue;
3755
+ }
3756
+ const action = manifest.action ?? manifest.browser_action;
3757
+ const ref = "popup" === surface || "action" === surface ? action?.default_popup : "options" === surface ? manifest.options_ui?.page ?? manifest.options_page : "sidebar" === surface ? manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel : "newtab" === surface || "history" === surface || "bookmarks" === surface ? manifest.chrome_url_overrides?.[surface] : null;
3758
+ if ("string" == typeof ref && ref) return ref.replace(/^\.?\//, "");
3759
+ }
3760
+ return null;
3761
+ }
3762
+ const POPUP_MIN = 25;
3763
+ const POPUP_MAX_WIDTH = 800;
3764
+ const POPUP_MAX_HEIGHT = 600;
3765
+ function clampPopupBounds(width, height) {
3766
+ const w = Math.min(Math.max(Math.ceil(width), POPUP_MIN), POPUP_MAX_WIDTH);
3767
+ const h = Math.min(Math.max(Math.ceil(height), POPUP_MIN), POPUP_MAX_HEIGHT);
3768
+ return {
3769
+ width: w,
3770
+ height: h,
3771
+ clamped: w !== Math.ceil(width) || h !== Math.ceil(height)
3772
+ };
3773
+ }
3774
+ async function applyPopupBounds(projectPath, browser, targetId) {
3775
+ const resolved = await resolveCdpPort(projectPath, browser);
3776
+ if (!resolved) return null;
3777
+ const cdp = new CDPClient();
3778
+ try {
3779
+ const ws = await CDPClient.discoverBrowserWsUrl(resolved.port);
3780
+ await cdp.connect(ws);
3781
+ const sessionId = await cdp.attachToTarget(targetId);
3782
+ const measured = await cdp.evaluate(sessionId, `(() => {
3783
+ const de = document.documentElement, b = document.body;
3784
+ if (!de || !b) return null;
3785
+ const prev = de.style.width;
3786
+ de.style.width = "fit-content";
3787
+ const w = Math.max(de.getBoundingClientRect().width, b.getBoundingClientRect().width);
3788
+ const h = Math.max(de.getBoundingClientRect().height, b.getBoundingClientRect().height, b.scrollHeight);
3789
+ de.style.width = prev;
3790
+ return { w: Math.ceil(w), h: Math.ceil(h) };
3791
+ })()`);
3792
+ if (!measured || "number" != typeof measured.w || "number" != typeof measured.h || measured.w <= 0 || measured.h <= 0) return null;
3793
+ const bounds = clampPopupBounds(measured.w, measured.h);
3794
+ const win = await cdp.sendCommand("Browser.getWindowForTarget", {
3795
+ targetId
3796
+ });
3797
+ if ("number" != typeof win?.windowId) return null;
3798
+ await cdp.sendCommand("Browser.setWindowBounds", {
3799
+ windowId: win.windowId,
3800
+ bounds: {
3801
+ width: bounds.width,
3802
+ height: bounds.height
3803
+ }
3804
+ });
3805
+ const after = await cdp.sendCommand("Browser.getWindowBounds", {
3806
+ windowId: win.windowId
3807
+ });
3808
+ if (after?.bounds?.width !== bounds.width || after?.bounds?.height !== bounds.height) return null;
3809
+ return bounds;
3810
+ } catch {
3811
+ return null;
3812
+ } finally{
3813
+ try {
3814
+ cdp.disconnect();
3815
+ } catch {}
3816
+ }
3817
+ }
3818
+ async function openSurfaceAsTab(projectPath, browser, surface) {
3819
+ const doc = surfaceDocument(projectPath, browser, surface);
3820
+ if (!doc) return JSON.stringify({
3821
+ ok: false,
3822
+ error: {
3823
+ name: "NoSurfaceDocument",
3824
+ message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
3825
+ },
3826
+ hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
3827
+ });
3828
+ const id = await resolveExtensionId(projectPath, browser);
3829
+ if (!id) return JSON.stringify({
3830
+ ok: false,
3831
+ error: {
3832
+ name: "NoExtensionId",
3833
+ message: "Could not resolve the extension id from the live session's CDP targets."
3834
+ },
3835
+ hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
3836
+ });
3837
+ const url = `chrome-extension://${id}/${doc}`;
3838
+ const raw = await navigateToUrl(projectPath, browser, url);
3839
+ try {
3840
+ const parsed = JSON.parse(raw);
3841
+ if (parsed?.ok) {
3842
+ parsed.renderedAsTab = {
3843
+ surface,
3844
+ document: doc,
3845
+ extensionId: id
3846
+ };
3847
+ let popupBounds = null;
3848
+ if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
3849
+ popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
3850
+ if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
3851
+ }
3852
+ parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_inspect context: '${surface}' (include: ['html']), or extension_source_inspect with this url. ` + "Do NOT pass this chrome-extension:// url to extension_dom_inspect or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
3853
+ return JSON.stringify(parsed);
3854
+ }
3855
+ } catch {}
3856
+ return raw;
3857
+ }
3277
3858
  const open_schema = {
3278
3859
  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`.",
3860
+ description: "Open an extension surface or replay an event in a running session. 'popup'/'options'/'sidebar' open UI surfaces; 'newtab'/'history'/'bookmarks' open the extension's chrome_url_overrides page in a tab. 'action' triggers the toolbar action: opens the action's popup, or (no popup) replays chrome.action.onClicked. 'command' replays a chrome.commands.onCommand keyboard shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result includes gesture:false and a warning when activeTab is declared). Requires the dev session to be started with allowControl: true (extension_dev). Wraps `extension open`.",
3280
3861
  inputSchema: {
3281
3862
  type: "object",
3282
3863
  properties: {
@@ -3290,10 +3871,13 @@ const open_schema = {
3290
3871
  "popup",
3291
3872
  "options",
3292
3873
  "sidebar",
3874
+ "newtab",
3875
+ "history",
3876
+ "bookmarks",
3293
3877
  "action",
3294
3878
  "command"
3295
3879
  ],
3296
- description: "Which surface to open or event to replay. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
3880
+ description: "Which surface to open or event to replay. 'newtab'/'history'/'bookmarks' open the matching chrome_url_overrides page. 'action' triggers the toolbar action; 'command' replays a keyboard-shortcut command (requires `name`)."
3297
3881
  },
3298
3882
  name: {
3299
3883
  type: "string",
@@ -3303,6 +3887,11 @@ const open_schema = {
3303
3887
  type: "string",
3304
3888
  description: "Navigate a real tab to this URL (Chromium only, via CDP) instead of opening a surface. Use for content-script/webNavigation test pages, or the popup as a page: chrome-extension://<id>/popup.html. Alternative to `surface`."
3305
3889
  },
3890
+ asTab: {
3891
+ type: "boolean",
3892
+ default: false,
3893
+ description: "For surface popup/options/sidebar: render the surface's document in a real tab (chrome-extension://<id>/<doc>) instead of opening a real popup window. This is how you inspect a surface HEADLESSLY, where no window exists to host a popup. Applied automatically as a fallback when a headless session refuses to open the surface. Same page and APIs, but no popup sizing and window.close() closes the tab."
3894
+ },
3306
3895
  browser: {
3307
3896
  type: "string",
3308
3897
  description: "Browser session to target. Defaults to the active dev session's browser for this project."
@@ -3320,6 +3909,15 @@ const open_schema = {
3320
3909
  async function open_handler(args) {
3321
3910
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3322
3911
  if (args.url) return navigateToUrl(args.projectPath, browser, args.url);
3912
+ const AS_TAB_SURFACES = [
3913
+ "popup",
3914
+ "options",
3915
+ "sidebar",
3916
+ "newtab",
3917
+ "history",
3918
+ "bookmarks"
3919
+ ];
3920
+ if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
3323
3921
  if (!args.surface) return JSON.stringify({
3324
3922
  ok: false,
3325
3923
  error: {
@@ -3327,6 +3925,18 @@ async function open_handler(args) {
3327
3925
  message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
3328
3926
  }
3329
3927
  });
3928
+ if ("command" === args.surface) {
3929
+ const declared = declaredCommands(args.projectPath, browser);
3930
+ if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
3931
+ ok: false,
3932
+ error: {
3933
+ name: "UnknownCommand",
3934
+ message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
3935
+ },
3936
+ declaredCommands: declared,
3937
+ hint: declared.length ? `Declared commands are: ${declared.join(", ")}. Check for a typo, or add "${args.name}" to the manifest.` : "This manifest declares no commands at all. Add a `commands` block, rebuild, then retry."
3938
+ });
3939
+ }
3330
3940
  const cli = [
3331
3941
  "open",
3332
3942
  args.surface,
@@ -3344,8 +3954,18 @@ async function open_handler(args) {
3344
3954
  ].includes(args.surface)) try {
3345
3955
  const parsed = JSON.parse(raw);
3346
3956
  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.";
3957
+ if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
3958
+ if (AS_TAB_SURFACES.includes(args.surface) && isChromiumFamily(browser)) {
3959
+ const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
3960
+ try {
3961
+ const parsedFallback = JSON.parse(fallback);
3962
+ if (parsedFallback?.ok) {
3963
+ parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS), which has no window to attach a popup/sidebar to, so the surface was rendered as a tab instead. Pass asTab: false and relaunch headed for a real popup window.";
3964
+ return JSON.stringify(parsedFallback);
3965
+ }
3966
+ } catch {}
3967
+ }
3968
+ if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : "The dev browser is running headless (EXTENSION_HEADLESS), which has no visible window to attach a popup/sidebar to. Retry with asTab: true to render the surface document in a tab, or relaunch a headed dev session for a real popup window.";
3349
3969
  return JSON.stringify(parsed);
3350
3970
  }
3351
3971
  } catch {}
@@ -3363,7 +3983,16 @@ const dom_inspect_schema = {
3363
3983
  },
3364
3984
  tab: {
3365
3985
  type: "number",
3366
- description: "Tab id (required for content/page; omit for surfaces)"
3986
+ description: "Numeric chrome.tabs id, for disambiguating when several tabs match. Optional: with neither `tab` nor `url`, content/page target the active tab."
3987
+ },
3988
+ url: {
3989
+ type: "string",
3990
+ description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
3991
+ },
3992
+ listTabs: {
3993
+ type: "boolean",
3994
+ default: false,
3995
+ description: "Enumerate open tabs as {tabId,url,title} and return, ignoring the other args. The discovery path when you need an explicit numeric tab id."
3367
3996
  },
3368
3997
  context: {
3369
3998
  type: "string",
@@ -3373,10 +4002,13 @@ const dom_inspect_schema = {
3373
4002
  "popup",
3374
4003
  "options",
3375
4004
  "sidebar",
3376
- "devtools"
4005
+ "devtools",
4006
+ "newtab",
4007
+ "history",
4008
+ "bookmarks"
3377
4009
  ],
3378
4010
  default: "content",
3379
- description: "content/page (needs tab) or an OPEN extension surface (popup/options/sidebar/devtools)"
4011
+ description: "content/page (targets `url`, else the active tab), an OPEN extension surface (popup/options/sidebar/devtools), or an override page (newtab/history/bookmarks)"
3380
4012
  },
3381
4013
  include: {
3382
4014
  type: "array",
@@ -3397,8 +4029,11 @@ const dom_inspect_schema = {
3397
4029
  default: 262144
3398
4030
  },
3399
4031
  withConsole: {
3400
- type: "number",
3401
- description: "Also include the last N console lines for the target (DOM + recent console in one call)"
4032
+ type: [
4033
+ "number",
4034
+ "boolean"
4035
+ ],
4036
+ description: "Also include recent console lines for the target (DOM + console in one call). A number is how many lines; true means 50."
3402
4037
  },
3403
4038
  browser: {
3404
4039
  type: "string",
@@ -3415,30 +4050,28 @@ const dom_inspect_schema = {
3415
4050
  }
3416
4051
  };
3417
4052
  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
- });
4053
+ const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
4054
+ if (args.listTabs) return runActVerb([
4055
+ "inspect",
4056
+ args.projectPath,
4057
+ "--list-tabs",
4058
+ "--browser",
4059
+ resolveSessionBrowser(args.projectPath, args.browser).browser,
4060
+ ...null != args.timeout ? [
4061
+ "--timeout",
4062
+ String(args.timeout)
4063
+ ] : []
4064
+ ], args.projectPath, args.timeout);
3433
4065
  const cli = [
3434
4066
  "inspect",
3435
4067
  args.projectPath
3436
4068
  ];
3437
4069
  if (null != args.tab) cli.push("--tab", String(args.tab));
4070
+ if (args.url) cli.push("--url", args.url);
3438
4071
  if (args.context) cli.push("--context", args.context);
3439
4072
  if (args.include?.length) cli.push("--include", args.include.join(","));
3440
4073
  if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
3441
- if (null != args.withConsole) cli.push("--with-console", String(args.withConsole));
4074
+ if (null != withConsole) cli.push("--with-console", String(withConsole));
3442
4075
  cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
3443
4076
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
3444
4077
  return runActVerb(cli, args.projectPath, args.timeout);
@@ -3932,6 +4565,188 @@ async function deploy_handler(args) {
3932
4565
  ...data
3933
4566
  });
3934
4567
  }
4568
+ function doctor_readReadyContract(projectPath, browser) {
4569
+ try {
4570
+ const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
4571
+ return JSON.parse(raw);
4572
+ } catch {
4573
+ return null;
4574
+ }
4575
+ }
4576
+ const doctor_schema = {
4577
+ name: "extension_doctor",
4578
+ description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, and browser liveness. Returns one {check, status, detail, remediation?} entry per leg in dependency order, a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Wraps `extension doctor`. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
4579
+ inputSchema: {
4580
+ type: "object",
4581
+ properties: {
4582
+ projectPath: {
4583
+ type: "string",
4584
+ description: "Path to the extension project root. Omit for a pre-flight environment check with no project."
4585
+ },
4586
+ browser: {
4587
+ type: "string",
4588
+ description: "Browser session to diagnose. Defaults to the active dev session's browser for this project."
4589
+ }
4590
+ }
4591
+ }
4592
+ };
4593
+ async function environmentPreflight() {
4594
+ const checks = [];
4595
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
4596
+ checks.push({
4597
+ check: "node",
4598
+ status: nodeMajor >= 20 ? "pass" : "fail",
4599
+ detail: `Node ${process.versions.node} on ${process.platform}/${process.arch}`,
4600
+ remediation: nodeMajor >= 20 ? void 0 : "Extension.js needs Node >= 20.18."
4601
+ });
4602
+ const { code, stdout, stderr } = await runExtensionCli([
4603
+ "--version"
4604
+ ], {
4605
+ timeoutMs: 60000
4606
+ });
4607
+ const cliVersion = stdout.trim() || stderr.trim();
4608
+ checks.push({
4609
+ check: "extension-cli",
4610
+ status: 0 === code ? "pass" : "fail",
4611
+ detail: 0 === code ? `extension CLI resolvable (${cliVersion})` : "extension CLI could not be resolved",
4612
+ remediation: 0 === code ? void 0 : "Install locally (npm i -D extension) or rely on npx; check network access to the npm registry."
4613
+ });
4614
+ const cacheFile = node_path.join(node_os.homedir(), ".cache", "extension-js", "templates-meta.json");
4615
+ const cacheExists = node_fs.existsSync(cacheFile);
4616
+ checks.push({
4617
+ check: "template-cache",
4618
+ status: cacheExists ? "pass" : "warn",
4619
+ detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (first extension_list_templates will fetch it)"
4620
+ });
4621
+ const healthy = checks.every((c)=>"fail" !== c.status);
4622
+ return JSON.stringify({
4623
+ mode: "environment",
4624
+ healthy,
4625
+ checks,
4626
+ hint: "Pass projectPath to diagnose a live dev session end-to-end."
4627
+ });
4628
+ }
4629
+ function safeStringify(value) {
4630
+ try {
4631
+ return JSON.stringify(value) ?? String(value);
4632
+ } catch {
4633
+ return String(value);
4634
+ }
4635
+ }
4636
+ function recentErrorLogs(projectPath, browser, max = 5) {
4637
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
4638
+ let lines;
4639
+ try {
4640
+ lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
4641
+ } catch {
4642
+ return [];
4643
+ }
4644
+ const errs = [];
4645
+ for (const line of lines){
4646
+ let ev;
4647
+ try {
4648
+ ev = JSON.parse(line);
4649
+ } catch {
4650
+ continue;
4651
+ }
4652
+ if (!ev || "error" !== ev.level) continue;
4653
+ const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
4654
+ let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
4655
+ if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
4656
+ msg = msg.replace(/\s+/g, " ").trim();
4657
+ if (msg) errs.push(msg.slice(0, 300));
4658
+ }
4659
+ return [
4660
+ ...new Set(errs)
4661
+ ].slice(-max);
4662
+ }
4663
+ function projectEngineVersion(projectPath) {
4664
+ try {
4665
+ const p = node_path.resolve(projectPath, "node_modules", "extension", "package.json");
4666
+ return JSON.parse(node_fs.readFileSync(p, "utf8")).version || null;
4667
+ } catch {
4668
+ return null;
4669
+ }
4670
+ }
4671
+ async function doctor_handler(args) {
4672
+ if (!args.projectPath) return environmentPreflight();
4673
+ const projectPath = args.projectPath;
4674
+ const { browser } = resolveSessionBrowser(projectPath, args.browser);
4675
+ const { code, stdout, stderr } = await runExtensionCli([
4676
+ "doctor",
4677
+ projectPath,
4678
+ "--browser",
4679
+ browser,
4680
+ "--output",
4681
+ "json"
4682
+ ], {
4683
+ cwd: projectPath
4684
+ });
4685
+ const out = stdout.trim();
4686
+ try {
4687
+ const checks = JSON.parse(out);
4688
+ if (!Array.isArray(checks)) throw new Error("not a check array");
4689
+ for (const check of checks){
4690
+ if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
4691
+ if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
4692
+ }
4693
+ let healthy = 0 === code;
4694
+ const contract = doctor_readReadyContract(projectPath, browser);
4695
+ if (contract?.status === "error") {
4696
+ healthy = false;
4697
+ const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
4698
+ const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
4699
+ checks.push({
4700
+ check: "runtime-errors",
4701
+ status: "fail",
4702
+ detail: toMcpSpeak(detail),
4703
+ remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
4704
+ });
4705
+ } else {
4706
+ const errs = recentErrorLogs(projectPath, browser);
4707
+ if (errs.length) {
4708
+ healthy = false;
4709
+ checks.push({
4710
+ check: "runtime-errors",
4711
+ status: "fail",
4712
+ detail: `Recent error-level logs: ${errs.join(" | ")}`,
4713
+ remediation: "The extension is throwing at runtime. Inspect with extension_logs. A chrome.* API called without its permission is a common cause: extension_manifest_validate catches a permission MISSING FROM permissions[], but it does not model host-permission scope (e.g. webRequest with no matching host_permissions) or gesture requirements (e.g. activeTab without a user gesture), so a valid:true there does not rule those out."
4714
+ });
4715
+ }
4716
+ }
4717
+ const engineVersion = projectEngineVersion(projectPath);
4718
+ if (engineVersion) {
4719
+ const pin = String(process.env.EXTENSION_MCP_CLI_VERSION || "").trim();
4720
+ const mismatch = "" !== pin && "latest" !== pin && !engineVersion.includes(pin);
4721
+ checks.push({
4722
+ check: "project-engine",
4723
+ status: mismatch ? "warn" : "pass",
4724
+ detail: `project-local extension@${engineVersion}${mismatch ? `, but EXTENSION_MCP_CLI_VERSION=${pin}; the dev loop uses the project bin, not the pin` : ""}`,
4725
+ ...mismatch ? {
4726
+ remediation: `Run \`(cd ${projectPath} && npm i -D extension@${pin})\` to match the pinned engine.`
4727
+ } : {}
4728
+ });
4729
+ }
4730
+ return JSON.stringify({
4731
+ browser,
4732
+ ...engineVersion ? {
4733
+ engineVersion
4734
+ } : {},
4735
+ healthy,
4736
+ checks
4737
+ });
4738
+ } catch {
4739
+ const message = stderr.trim() || `extension exited with code ${code}`;
4740
+ return JSON.stringify({
4741
+ ok: false,
4742
+ error: {
4743
+ name: "CliError",
4744
+ message: toMcpSpeak(message),
4745
+ hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
4746
+ }
4747
+ });
4748
+ }
4749
+ }
3935
4750
  const SAFE_CEILING_MS = 50000;
3936
4751
  function wait_isAlive(pid) {
3937
4752
  try {
@@ -3974,6 +4789,7 @@ async function wait_handler(args) {
3974
4789
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
3975
4790
  const start = Date.now();
3976
4791
  const pollInterval = 1000;
4792
+ let sawCompiledButUnattached = false;
3977
4793
  while(Date.now() - start < timeout){
3978
4794
  try {
3979
4795
  const raw = node_fs.readFileSync(readyPath, "utf8");
@@ -3981,11 +4797,18 @@ async function wait_handler(args) {
3981
4797
  if ("ready" === contract.status) {
3982
4798
  if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
3983
4799
  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.`,
4800
+ message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead, the session exited. Restart with extension_dev; extension_doctor will confirm.`,
3985
4801
  browser: contract.browser,
3986
4802
  pid: contract.pid,
3987
4803
  waitDuration: Date.now() - start
3988
4804
  });
4805
+ const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
4806
+ if (!attached) {
4807
+ await new Promise((r)=>setTimeout(r, pollInterval));
4808
+ sawCompiledButUnattached = true;
4809
+ continue;
4810
+ }
4811
+ const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
3989
4812
  return JSON.stringify({
3990
4813
  status: "ready",
3991
4814
  command: contract.command,
@@ -3996,7 +4819,11 @@ async function wait_handler(args) {
3996
4819
  manifestPath: contract.manifestPath,
3997
4820
  compiledAt: contract.compiledAt,
3998
4821
  startedAt: contract.startedAt,
3999
- waitDuration: Date.now() - start
4822
+ waitDuration: Date.now() - start,
4823
+ ...runtimeErrors.length ? {
4824
+ runtimeErrors,
4825
+ warning: `Compiled and attached, but the extension is throwing at runtime (${runtimeErrors.length} recent error event${1 === runtimeErrors.length ? "" : "s"} above). Check extension_logs (level: error) or extension_doctor before trusting this session.`
4826
+ } : {}
4000
4827
  });
4001
4828
  }
4002
4829
  if ("error" === contract.status) return JSON.stringify({
@@ -4010,13 +4837,20 @@ async function wait_handler(args) {
4010
4837
  } catch {}
4011
4838
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
4012
4839
  }
4840
+ if (sawCompiledButUnattached) return JSON.stringify({
4841
+ status: "compiled-not-attached",
4842
+ message: `The extension compiled, but the runtime executor never attached within ${timeout}ms. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
4843
+ readyPath,
4844
+ waitDuration: Date.now() - start,
4845
+ hint: "This is usually transient: call extension_wait again. If it persists, stop and restart the session with extension_dev (a restart reliably reattaches); extension_doctor reports the executor leg."
4846
+ });
4013
4847
  return JSON.stringify({
4014
4848
  status: "timeout",
4015
4849
  message: `Extension not ready after ${timeout}ms this call`,
4016
4850
  readyPath,
4017
4851
  waitDuration: Date.now() - start,
4018
4852
  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."
4853
+ hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
4020
4854
  });
4021
4855
  }
4022
4856
  const add_feature_schema = {
@@ -5135,193 +5969,27 @@ async function detect_browsers_handler(args) {
5135
5969
  hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_install_browser to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
5136
5970
  });
5137
5971
  }
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
5972
  function typeOf(value) {
5309
5973
  if (Array.isArray(value)) return "array";
5310
5974
  if (null === value) return "null";
5311
5975
  return typeof value;
5312
5976
  }
5313
5977
  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({
5978
+ const allowed = void 0 === schema.type ? [] : Array.isArray(schema.type) ? schema.type : [
5979
+ schema.type
5980
+ ];
5981
+ const primitives = allowed.filter((t)=>[
5982
+ "string",
5983
+ "number",
5984
+ "boolean"
5985
+ ].includes(t));
5986
+ if (primitives.length > 0 && primitives.length === allowed.length) {
5987
+ if (!primitives.includes(typeOf(value))) return void issues.push({
5320
5988
  path,
5321
- message: `expected ${schema.type}, got ${typeOf(value)}`
5989
+ message: `expected ${primitives.join(" or ")}, got ${typeOf(value)}`
5322
5990
  });
5323
5991
  }
5324
- if ("array" === schema.type) {
5992
+ if (allowed.includes("array")) {
5325
5993
  if (!Array.isArray(value)) return void issues.push({
5326
5994
  path,
5327
5995
  message: `expected array, got ${typeOf(value)}`
@@ -5366,6 +6034,26 @@ const ARG_ALIASES = {
5366
6034
  surface: [
5367
6035
  "view",
5368
6036
  "target"
6037
+ ],
6038
+ timeout: [
6039
+ "timeoutMs",
6040
+ "timeoutMillis"
6041
+ ],
6042
+ limit: [
6043
+ "lines",
6044
+ "count",
6045
+ "max",
6046
+ "maxLines"
6047
+ ],
6048
+ tab: [
6049
+ "tabId"
6050
+ ],
6051
+ url: [
6052
+ "href",
6053
+ "pageUrl"
6054
+ ],
6055
+ browser: [
6056
+ "browserName"
5369
6057
  ]
5370
6058
  };
5371
6059
  function normalizeArgAliases(inputSchema, args) {