@extension.dev/mcp 5.3.1 → 5.5.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
@@ -68,6 +68,7 @@ var eval_namespaceObject = {};
68
68
  __webpack_require__.r(eval_namespaceObject);
69
69
  __webpack_require__.d(eval_namespaceObject, {
70
70
  handler: ()=>eval_handler,
71
+ resolveDefaultEvalContext: ()=>resolveDefaultEvalContext,
71
72
  schema: ()=>eval_schema
72
73
  });
73
74
  var get_template_source_namespaceObject = {};
@@ -210,7 +211,7 @@ __webpack_require__.d(whoami_namespaceObject, {
210
211
  handler: ()=>whoami_handler,
211
212
  schema: ()=>whoami_schema
212
213
  });
213
- var package_namespaceObject = JSON.parse('{"rE":"5.3.1","El":{"OP":"^4.0.13"}}');
214
+ var package_namespaceObject = JSON.parse('{"rE":"5.5.0","El":{"OP":"^4.0.13"}}');
214
215
  function scaffoldEnginePin(projectPath) {
215
216
  try {
216
217
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -623,6 +624,185 @@ function spawnExtensionCli(args, options) {
623
624
  }
624
625
  };
625
626
  }
627
+ const sessions = new Map();
628
+ function sessionKey(projectPath, browser) {
629
+ return `${node_path.resolve(projectPath)}::${browser}`;
630
+ }
631
+ function markerDir() {
632
+ return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
633
+ }
634
+ function markerPath(projectPath, browser) {
635
+ const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
636
+ return node_path.join(markerDir(), `${digest}.json`);
637
+ }
638
+ function removeSessionMarker(projectPath, browser) {
639
+ try {
640
+ node_fs.rmSync(markerPath(projectPath, browser), {
641
+ force: true
642
+ });
643
+ } catch {}
644
+ }
645
+ function listSessionMarkers() {
646
+ let files;
647
+ try {
648
+ files = node_fs.readdirSync(markerDir());
649
+ } catch {
650
+ return [];
651
+ }
652
+ const out = [];
653
+ for (const file of files)if (file.endsWith(".json")) try {
654
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
655
+ if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
656
+ } catch {}
657
+ return out;
658
+ }
659
+ function registerSession(info) {
660
+ sessions.set(sessionKey(info.projectPath, info.browser), info);
661
+ try {
662
+ node_fs.mkdirSync(markerDir(), {
663
+ recursive: true
664
+ });
665
+ node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
666
+ ...info,
667
+ projectPath: node_path.resolve(info.projectPath),
668
+ registeredAt: new Date().toISOString()
669
+ }));
670
+ } catch {}
671
+ }
672
+ function getSession(projectPath, browser) {
673
+ return sessions.get(sessionKey(projectPath, browser));
674
+ }
675
+ function findSessionInfo(projectPath, browser) {
676
+ const inMemory = sessions.get(sessionKey(projectPath, browser));
677
+ if (inMemory) return inMemory;
678
+ const resolved = node_path.resolve(projectPath);
679
+ for (const marker of listSessionMarkers())if (node_path.resolve(marker.projectPath) === resolved && marker.browser === browser) return marker;
680
+ }
681
+ function removeSession(projectPath, browser) {
682
+ sessions.delete(sessionKey(projectPath, browser));
683
+ }
684
+ function listSessions() {
685
+ return Array.from(sessions.values());
686
+ }
687
+ function contractSightings(projectPath) {
688
+ const root = node_path.resolve(projectPath, "dist", "extension-js");
689
+ let dirs;
690
+ try {
691
+ dirs = node_fs.readdirSync(root);
692
+ } catch {
693
+ return [];
694
+ }
695
+ const sightings = [];
696
+ for (const dir of dirs){
697
+ const readyPath = node_path.join(root, dir, "ready.json");
698
+ try {
699
+ const stat = node_fs.statSync(readyPath);
700
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
701
+ if (contract?.status !== "ready") continue;
702
+ sightings.push({
703
+ browser: dir,
704
+ mtimeMs: stat.mtimeMs,
705
+ pid: "number" == typeof contract.pid ? contract.pid : void 0
706
+ });
707
+ } catch {}
708
+ }
709
+ return sightings;
710
+ }
711
+ function pidAlive(pid) {
712
+ try {
713
+ process.kill(pid, 0);
714
+ return true;
715
+ } catch {
716
+ return false;
717
+ }
718
+ }
719
+ function knownSessionBrowsers(projectPath) {
720
+ const resolved = node_path.resolve(projectPath);
721
+ const browsers = [];
722
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
723
+ for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
724
+ return Array.from(new Set(browsers));
725
+ }
726
+ function liveProjectSessions(projectPath) {
727
+ const resolved = node_path.resolve(projectPath);
728
+ const out = new Map();
729
+ for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
730
+ if (pidAlive(session.pid)) out.set(session.browser, {
731
+ browser: session.browser,
732
+ pid: session.pid,
733
+ source: "registry"
734
+ });
735
+ }
736
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
737
+ if (!out.has(sighting.browser)) out.set(sighting.browser, {
738
+ browser: sighting.browser,
739
+ pid: sighting.pid,
740
+ source: "contract"
741
+ });
742
+ }
743
+ return [
744
+ ...out.values()
745
+ ];
746
+ }
747
+ function deadReadySession(projectPath) {
748
+ for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
749
+ browser: sighting.browser,
750
+ pid: sighting.pid
751
+ };
752
+ return null;
753
+ }
754
+ function browserExitStamp(projectPath, browser, since) {
755
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
756
+ try {
757
+ const stat = node_fs.statSync(readyPath);
758
+ if (stat.mtimeMs < since) return null;
759
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
760
+ const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
761
+ if (contract?.status === "error" && exited) return {
762
+ code: contract.code,
763
+ browserExitCode: contract.browserExitCode ?? null,
764
+ browserExitedAt: contract.browserExitedAt
765
+ };
766
+ } catch {}
767
+ return null;
768
+ }
769
+ function contractBoundPort(projectPath, browser, since) {
770
+ const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
771
+ try {
772
+ const stat = node_fs.statSync(readyPath);
773
+ if (stat.mtimeMs < since) return null;
774
+ const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
775
+ return "number" == typeof contract?.port && Number.isFinite(contract.port) ? contract.port : null;
776
+ } catch {
777
+ return null;
778
+ }
779
+ }
780
+ function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
781
+ if (explicit) return {
782
+ browser: explicit,
783
+ source: "explicit"
784
+ };
785
+ const resolved = node_path.resolve(projectPath);
786
+ const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
787
+ if (mine.length > 0) return {
788
+ browser: mine[mine.length - 1].browser,
789
+ source: "session"
790
+ };
791
+ const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
792
+ const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
793
+ if (live.length > 0) return {
794
+ browser: live[0].browser,
795
+ source: "contract"
796
+ };
797
+ if (sightings.length > 0) return {
798
+ browser: sightings[0].browser,
799
+ source: "stale"
800
+ };
801
+ return {
802
+ browser: fallback,
803
+ source: "fallback"
804
+ };
805
+ }
626
806
  function readBuildSummary(projectPath, browser, since) {
627
807
  const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
628
808
  try {
@@ -678,6 +858,46 @@ function builtEntrypoints(distDir) {
678
858
  });
679
859
  return out;
680
860
  }
861
+ function engineSanitize(input) {
862
+ return input.toLowerCase().replace(/[^a-z0-9 ]/gi, "").trim().replace(/\s+/g, "-");
863
+ }
864
+ function newestZip(dir, since, match) {
865
+ try {
866
+ const fresh = node_fs.readdirSync(dir).filter((name)=>name.endsWith(".zip") && (!match || match(name))).map((name)=>{
867
+ const full = node_path.join(dir, name);
868
+ return {
869
+ full,
870
+ mtimeMs: node_fs.statSync(full).mtimeMs
871
+ };
872
+ }).filter((entry)=>entry.mtimeMs >= since).sort((a, b)=>b.mtimeMs - a.mtimeMs);
873
+ return fresh[0]?.full ?? null;
874
+ } catch {
875
+ return null;
876
+ }
877
+ }
878
+ function engineZipBase(distDir, projectPath) {
879
+ let manifest = {};
880
+ try {
881
+ manifest = JSON.parse(node_fs.readFileSync(node_path.join(distDir, "manifest.json"), "utf8"));
882
+ } catch {}
883
+ const rawName = "string" != typeof manifest.name || /^__MSG_.+__$/.test(manifest.name) ? node_path.basename(node_path.resolve(projectPath)) : manifest.name;
884
+ const version = "string" == typeof manifest.version && manifest.version ? manifest.version : "0.0.0";
885
+ return `${engineSanitize(rawName)}-${version}`;
886
+ }
887
+ function locateDistZip(projectPath, browser, zipFilename, since) {
888
+ const distDir = node_path.resolve(projectPath, "dist", browser);
889
+ const base = zipFilename ? engineSanitize(zipFilename) : engineZipBase(distDir, projectPath);
890
+ const expected = node_path.join(distDir, `${base}.zip`);
891
+ if (node_fs.existsSync(expected)) return expected;
892
+ return newestZip(distDir, since);
893
+ }
894
+ function locateSourceZip(projectPath, browser, since) {
895
+ const distDir = node_path.resolve(projectPath, "dist", browser);
896
+ const distRoot = node_path.resolve(projectPath, "dist");
897
+ const expected = node_path.join(distRoot, `${engineZipBase(distDir, projectPath)}-source.zip`);
898
+ if (node_fs.existsSync(expected)) return expected;
899
+ return newestZip(distRoot, since, (name)=>name.endsWith("-source.zip"));
900
+ }
681
901
  const build_schema = {
682
902
  name: "extension_build",
683
903
  description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
@@ -816,6 +1036,8 @@ async function build_handler(args) {
816
1036
  duration: Date.now() - start,
817
1037
  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."
818
1038
  });
1039
+ const clobberedSessions = liveProjectSessions(args.projectPath).filter((session)=>session.browser === browser);
1040
+ const warnings = clobberedSessions.map((session)=>`A live dev session (pid ${session.pid}) is running on this project for ${browser}, and this build wrote over its dist/${browser} output. The dev browser may now serve the production artifact instead of the dev build until the next recompile. Run extension_stop, or let dev recompile on the next source change, to resolve it.`);
819
1041
  const cliArgs = [
820
1042
  "build",
821
1043
  args.projectPath,
@@ -858,6 +1080,9 @@ async function build_handler(args) {
858
1080
  manifestWarnings: preflight.warnings
859
1081
  } : {},
860
1082
  ...buildWarnings,
1083
+ ...warnings.length ? {
1084
+ warnings
1085
+ } : {},
861
1086
  duration,
862
1087
  output: lastLines(out, 12),
863
1088
  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."
@@ -884,7 +1109,26 @@ async function build_handler(args) {
884
1109
  productionDivergence: divergence
885
1110
  } : {};
886
1111
  })(),
1112
+ ...warnings.length ? {
1113
+ warnings
1114
+ } : {},
887
1115
  zip: args.zip ?? false,
1116
+ ...args.zip ? (()=>{
1117
+ const zipPath = locateDistZip(args.projectPath, browser, args.zipFilename, start);
1118
+ return zipPath ? {
1119
+ zipPath
1120
+ } : {
1121
+ zipPathNote: `zip: true was requested and the build succeeded, but no .zip file could be located in dist/${browser}. The engine may not have packaged it; check the build output below.`
1122
+ };
1123
+ })() : {},
1124
+ ...args.zipSource ? (()=>{
1125
+ const zipSourcePath = locateSourceZip(args.projectPath, browser, start);
1126
+ return zipSourcePath ? {
1127
+ zipSourcePath
1128
+ } : {
1129
+ zipSourcePathNote: "zipSource: true was requested and the build succeeded, but no *-source.zip file could be located in dist/. The engine may not have packaged it; check the build output below."
1130
+ };
1131
+ })() : {},
888
1132
  duration,
889
1133
  output: lastLines(out, 12)
890
1134
  });
@@ -894,172 +1138,13 @@ async function build_handler(args) {
894
1138
  success: false,
895
1139
  browser,
896
1140
  error: message.slice(0, 1200),
1141
+ ...warnings.length ? {
1142
+ warnings
1143
+ } : {},
897
1144
  duration,
898
1145
  hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
899
1146
  });
900
1147
  }
901
- const sessions = new Map();
902
- function sessionKey(projectPath, browser) {
903
- return `${node_path.resolve(projectPath)}::${browser}`;
904
- }
905
- function markerDir() {
906
- return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
907
- }
908
- function markerPath(projectPath, browser) {
909
- const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
910
- return node_path.join(markerDir(), `${digest}.json`);
911
- }
912
- function removeSessionMarker(projectPath, browser) {
913
- try {
914
- node_fs.rmSync(markerPath(projectPath, browser), {
915
- force: true
916
- });
917
- } catch {}
918
- }
919
- function listSessionMarkers() {
920
- let files;
921
- try {
922
- files = node_fs.readdirSync(markerDir());
923
- } catch {
924
- return [];
925
- }
926
- const out = [];
927
- for (const file of files)if (file.endsWith(".json")) try {
928
- const parsed = JSON.parse(node_fs.readFileSync(node_path.join(markerDir(), file), "utf8"));
929
- if ("string" == typeof parsed?.projectPath && "string" == typeof parsed?.browser) out.push(parsed);
930
- } catch {}
931
- return out;
932
- }
933
- function registerSession(info) {
934
- sessions.set(sessionKey(info.projectPath, info.browser), info);
935
- try {
936
- node_fs.mkdirSync(markerDir(), {
937
- recursive: true
938
- });
939
- node_fs.writeFileSync(markerPath(info.projectPath, info.browser), JSON.stringify({
940
- ...info,
941
- projectPath: node_path.resolve(info.projectPath),
942
- registeredAt: new Date().toISOString()
943
- }));
944
- } catch {}
945
- }
946
- function getSession(projectPath, browser) {
947
- return sessions.get(sessionKey(projectPath, browser));
948
- }
949
- function removeSession(projectPath, browser) {
950
- sessions.delete(sessionKey(projectPath, browser));
951
- }
952
- function listSessions() {
953
- return Array.from(sessions.values());
954
- }
955
- function contractSightings(projectPath) {
956
- const root = node_path.resolve(projectPath, "dist", "extension-js");
957
- let dirs;
958
- try {
959
- dirs = node_fs.readdirSync(root);
960
- } catch {
961
- return [];
962
- }
963
- const sightings = [];
964
- for (const dir of dirs){
965
- const readyPath = node_path.join(root, dir, "ready.json");
966
- try {
967
- const stat = node_fs.statSync(readyPath);
968
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
969
- if (contract?.status !== "ready") continue;
970
- sightings.push({
971
- browser: dir,
972
- mtimeMs: stat.mtimeMs,
973
- pid: "number" == typeof contract.pid ? contract.pid : void 0
974
- });
975
- } catch {}
976
- }
977
- return sightings;
978
- }
979
- function pidAlive(pid) {
980
- try {
981
- process.kill(pid, 0);
982
- return true;
983
- } catch {
984
- return false;
985
- }
986
- }
987
- function knownSessionBrowsers(projectPath) {
988
- const resolved = node_path.resolve(projectPath);
989
- const browsers = [];
990
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) browsers.push(session.browser);
991
- for (const sighting of contractSightings(projectPath))if (void 0 === sighting.pid || pidAlive(sighting.pid)) browsers.push(sighting.browser);
992
- return Array.from(new Set(browsers));
993
- }
994
- function liveProjectSessions(projectPath) {
995
- const resolved = node_path.resolve(projectPath);
996
- const out = new Map();
997
- for (const session of listSessions())if (node_path.resolve(session.projectPath) === resolved) {
998
- if (pidAlive(session.pid)) out.set(session.browser, {
999
- browser: session.browser,
1000
- pid: session.pid,
1001
- source: "registry"
1002
- });
1003
- }
1004
- for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && pidAlive(sighting.pid)) {
1005
- if (!out.has(sighting.browser)) out.set(sighting.browser, {
1006
- browser: sighting.browser,
1007
- pid: sighting.pid,
1008
- source: "contract"
1009
- });
1010
- }
1011
- return [
1012
- ...out.values()
1013
- ];
1014
- }
1015
- function deadReadySession(projectPath) {
1016
- for (const sighting of contractSightings(projectPath))if (void 0 !== sighting.pid && !pidAlive(sighting.pid)) return {
1017
- browser: sighting.browser,
1018
- pid: sighting.pid
1019
- };
1020
- return null;
1021
- }
1022
- function browserExitStamp(projectPath, browser, since) {
1023
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1024
- try {
1025
- const stat = node_fs.statSync(readyPath);
1026
- if (stat.mtimeMs < since) return null;
1027
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1028
- const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1029
- if (contract?.status === "error" && exited) return {
1030
- code: contract.code,
1031
- browserExitCode: contract.browserExitCode ?? null,
1032
- browserExitedAt: contract.browserExitedAt
1033
- };
1034
- } catch {}
1035
- return null;
1036
- }
1037
- function resolveSessionBrowser(projectPath, explicit, fallback = "chrome") {
1038
- if (explicit) return {
1039
- browser: explicit,
1040
- source: "explicit"
1041
- };
1042
- const resolved = node_path.resolve(projectPath);
1043
- const mine = listSessions().filter((s)=>node_path.resolve(s.projectPath) === resolved);
1044
- if (mine.length > 0) return {
1045
- browser: mine[mine.length - 1].browser,
1046
- source: "session"
1047
- };
1048
- const sightings = contractSightings(projectPath).sort((a, b)=>b.mtimeMs - a.mtimeMs);
1049
- const live = sightings.filter((s)=>void 0 === s.pid || pidAlive(s.pid));
1050
- if (live.length > 0) return {
1051
- browser: live[0].browser,
1052
- source: "contract"
1053
- };
1054
- if (sightings.length > 0) return {
1055
- browser: sightings[0].browser,
1056
- source: "stale"
1057
- };
1058
- return {
1059
- browser: fallback,
1060
- source: "fallback"
1061
- };
1062
- }
1063
1148
  const stop_schema = {
1064
1149
  name: "extension_stop",
1065
1150
  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.",
@@ -1380,7 +1465,8 @@ async function dev_handler(args) {
1380
1465
  browser,
1381
1466
  port: args.port,
1382
1467
  projectPath: args.projectPath,
1383
- command: "dev"
1468
+ command: "dev",
1469
+ noBrowser: Boolean(args.noBrowser)
1384
1470
  });
1385
1471
  child.on("exit", ()=>removeSession(args.projectPath, browser));
1386
1472
  await new Promise((resolve)=>setTimeout(resolve, 3000));
@@ -1437,11 +1523,30 @@ async function dev_handler(args) {
1437
1523
  allowEval: Boolean(args.allowEval),
1438
1524
  unlocked: allowControl ? args.allowEval ? `${controlVerbs}, eval` : controlVerbs : "none (read-only: logs, source_inspect, wait, doctor)"
1439
1525
  };
1526
+ const boundPort = contractBoundPort(args.projectPath, browser, spawnedAt);
1527
+ if (null !== boundPort && boundPort !== args.port) registerSession({
1528
+ pid,
1529
+ browser,
1530
+ port: boundPort,
1531
+ projectPath: args.projectPath,
1532
+ command: "dev",
1533
+ noBrowser: Boolean(args.noBrowser)
1534
+ });
1535
+ const portReport = null !== boundPort ? {
1536
+ port: boundPort,
1537
+ ...void 0 !== args.port && args.port !== boundPort ? {
1538
+ requestedPort: args.port,
1539
+ portNote: `Requested port ${args.port} was not available; the dev server bound ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).`
1540
+ } : {}
1541
+ } : {
1542
+ requestedPort: args.port ?? 8080,
1543
+ portNote: "The engine has not stamped its ready.json contract yet, so the bound port is not known at response time (a taken port makes the server bind the next free one). extension_wait reports the bound port from that contract once it lands; requestedPort above is only what was asked for."
1544
+ };
1440
1545
  return JSON.stringify({
1441
1546
  ok: true,
1442
1547
  pid,
1443
1548
  browser,
1444
- port: args.port ?? 8080,
1549
+ ...portReport,
1445
1550
  projectPath: args.projectPath,
1446
1551
  status: "started",
1447
1552
  ...replaced.length > 0 ? {
@@ -1451,7 +1556,7 @@ async function dev_handler(args) {
1451
1556
  } : {}
1452
1557
  } : {},
1453
1558
  capabilities,
1454
- 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). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1559
+ hint: args.noBrowser ? "Build-only session (noBrowser: true): no browser will launch, so no runtime will ever attach. extension_wait returns as soon as the first compile lands (compiled: true, browserAttached: false) instead of waiting out its budget; do not wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session. When you are done, call extension_stop to shut down the dev server." : "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). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
1455
1560
  earlyOutput: denoiseEarlyOutput(earlyOutput).slice(0, 500),
1456
1561
  logPath
1457
1562
  });
@@ -1463,7 +1568,10 @@ function denoiseEarlyOutput(raw) {
1463
1568
  /^npm warn config/i,
1464
1569
  /V8: .*Invalid asm\.js/i,
1465
1570
  /^\(node:\d+\) V8:/i,
1466
- /Use `node --trace-warnings/i
1571
+ /Use `node --trace-warnings/i,
1572
+ /Invalid asm\.js:/i,
1573
+ /Linking failure in asm\.js/i,
1574
+ /Successfully compiled asm\.js/i
1467
1575
  ];
1468
1576
  return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
1469
1577
  }
@@ -2307,6 +2415,9 @@ function walkDir(dir, base = "") {
2307
2415
  else if ([
2308
2416
  ".map"
2309
2417
  ].includes(ext)) type = "sourcemap";
2418
+ else if ([
2419
+ ".zip"
2420
+ ].includes(ext)) type = "archive";
2310
2421
  entries.push({
2311
2422
  path: rel,
2312
2423
  size: stat.size,
@@ -2347,7 +2458,8 @@ async function inspect_handler(args) {
2347
2458
  }
2348
2459
  const sourcemapSize = byType.sourcemap?.size ?? 0;
2349
2460
  const buildType = sourcemapSize > 0 ? "development" : "production";
2350
- const shippableSize = totalSize - sourcemapSize;
2461
+ const archiveSize = byType.archive?.size ?? 0;
2462
+ const shippableSize = totalSize - sourcemapSize - archiveSize;
2351
2463
  const sizeByPath = new Map(files.map((f)=>[
2352
2464
  f.path,
2353
2465
  f.size
@@ -2377,7 +2489,7 @@ async function inspect_handler(args) {
2377
2489
  });
2378
2490
  const PROMO_RE = /(screenshot|promo|marquee|tile|banner|preview)[-_.]?\d*\.(png|jpe?g|webp|gif)$/i;
2379
2491
  const sizeWarnings = [];
2380
- for (const f of files)if ("sourcemap" !== f.type) {
2492
+ for (const f of files)if ("sourcemap" !== f.type && "archive" !== f.type) {
2381
2493
  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.`);
2382
2494
  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.`);
2383
2495
  }
@@ -2397,6 +2509,9 @@ async function inspect_handler(args) {
2397
2509
  ..."development" === buildType ? {
2398
2510
  note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
2399
2511
  } : {},
2512
+ ...archiveSize > 0 ? {
2513
+ archiveNote: `This dist contains ${formatBytes(archiveSize)} of .zip archive(s) (store packaging output, written into dist by zip builds). shippableSize excludes them so the packaged copy does not double-count the files it contains.`
2514
+ } : {},
2400
2515
  manifest: {
2401
2516
  name: manifest.name,
2402
2517
  version: manifest.version,
@@ -2429,7 +2544,7 @@ async function inspect_handler(args) {
2429
2544
  has128Icon: "string" == typeof manifest.icons?.["128"],
2430
2545
  noSourceMaps: !files.some((f)=>"sourcemap" === f.type),
2431
2546
  noPromoAssets: !files.some((f)=>PROMO_RE.test(f.path)),
2432
- under10MB: totalSize < 10485760
2547
+ under10MB: totalSize - archiveSize < 10485760
2433
2548
  }
2434
2549
  };
2435
2550
  return JSON.stringify(result);
@@ -3065,7 +3180,7 @@ async function source_inspect_handler(args) {
3065
3180
  }
3066
3181
  const list_extensions_schema = {
3067
3182
  name: "extension_list_extensions",
3068
- 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.",
3183
+ description: "List the extensions with a live context in the running dev browser via Chrome DevTools Protocol. Returns each extension's id, name, version, and live contexts (service worker, page). The entry for THIS dev session's extension (the project being served) is flagged ownExtension: true, with name and version resolved from the session's ready contract even when the browser exposes no identity. Other extensions resolve via the read-only Extensions domain when available; their contexts are never attached to or evaluated in, so an id that the domain does not describe stays id-only with a note saying why. A dormant MV3 service worker with no open page may be absent until it wakes. Chromium only (Firefox uses RDP, not yet supported). Requires an active dev or start session.",
3069
3184
  inputSchema: {
3070
3185
  type: "object",
3071
3186
  properties: {
@@ -3083,6 +3198,47 @@ const list_extensions_schema = {
3083
3198
  ]
3084
3199
  }
3085
3200
  };
3201
+ function unpackedExtensionId(distPath) {
3202
+ const digest = node_crypto.createHash("sha256").update(distPath).digest();
3203
+ let id = "";
3204
+ for(let i = 0; i < 16; i++){
3205
+ id += String.fromCharCode(97 + (digest[i] >> 4));
3206
+ id += String.fromCharCode(97 + (0x0f & digest[i]));
3207
+ }
3208
+ return id;
3209
+ }
3210
+ function readOwnIdentity(projectPath, browser) {
3211
+ let contract;
3212
+ try {
3213
+ const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
3214
+ contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
3215
+ } catch {
3216
+ return null;
3217
+ }
3218
+ const distPath = "string" == typeof contract?.distPath ? contract.distPath : null;
3219
+ const ids = [];
3220
+ if (distPath) {
3221
+ ids.push(unpackedExtensionId(distPath));
3222
+ try {
3223
+ const real = node_fs.realpathSync(distPath);
3224
+ if (real !== distPath) ids.push(unpackedExtensionId(real));
3225
+ } catch {}
3226
+ }
3227
+ let name = "string" == typeof contract?.extensionName ? contract.extensionName : void 0;
3228
+ let version = "string" == typeof contract?.extensionVersion ? contract.extensionVersion : void 0;
3229
+ if ((!name || !version) && distPath) try {
3230
+ const manifest = JSON.parse(node_fs.readFileSync(node_path.join(distPath, "manifest.json"), "utf8"));
3231
+ if (!name && "string" == typeof manifest?.name && !manifest.name.startsWith("__MSG_")) name = manifest.name;
3232
+ if (!version && "string" == typeof manifest?.version) version = manifest.version;
3233
+ } catch {}
3234
+ if (0 === ids.length && !name) return null;
3235
+ return {
3236
+ ids,
3237
+ name,
3238
+ version
3239
+ };
3240
+ }
3241
+ const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domain returned nothing for this id, and other extensions' contexts are never attached to or evaluated in to read a manifest.";
3086
3242
  async function list_extensions_handler(args) {
3087
3243
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
3088
3244
  if (!isChromiumFamily(browser)) return JSON.stringify({
@@ -3123,6 +3279,7 @@ async function list_extensions_handler(args) {
3123
3279
  });
3124
3280
  byId.set(id, list);
3125
3281
  }
3282
+ const own = readOwnIdentity(args.projectPath, browser);
3126
3283
  const extensions = [];
3127
3284
  for (const [id, ctxTargets] of byId){
3128
3285
  const entry = {
@@ -3143,15 +3300,33 @@ async function list_extensions_handler(args) {
3143
3300
  entry.source = "extensions-domain";
3144
3301
  }
3145
3302
  } catch {}
3303
+ if (own?.ids.includes(id)) {
3304
+ entry.ownExtension = true;
3305
+ if (void 0 === entry.name && void 0 !== own.name) {
3306
+ entry.name = own.name;
3307
+ if (void 0 !== own.version) entry.version = own.version;
3308
+ entry.source = "session-contract";
3309
+ }
3310
+ }
3146
3311
  extensions.push(entry);
3147
3312
  }
3148
- extensions.sort((a, b)=>(a.name ?? a.id).localeCompare(b.name ?? b.id));
3313
+ if (own?.name && !extensions.some((e)=>e.ownExtension)) {
3314
+ const byName = extensions.filter((e)=>e.name === own.name);
3315
+ if (1 === byName.length) byName[0].ownExtension = true;
3316
+ }
3317
+ for (const entry of extensions)if (void 0 === entry.name) entry.note = UNRESOLVED_NOTE;
3318
+ extensions.sort((a, b)=>{
3319
+ if ((a.ownExtension ?? false) !== (b.ownExtension ?? false)) return a.ownExtension ? -1 : 1;
3320
+ return (a.name ?? a.id).localeCompare(b.name ?? b.id);
3321
+ });
3322
+ const ownEntry = extensions.find((e)=>e.ownExtension);
3149
3323
  return JSON.stringify({
3150
3324
  cdpPort,
3151
3325
  browser,
3152
3326
  count: extensions.length,
3327
+ ownExtensionId: ownEntry?.id ?? null,
3153
3328
  extensions,
3154
- note: "Lists extensions that currently have at least one live context (service worker or open page). An MV3 service worker that has gone dormant with no open page may be absent until it wakes. Identity is read read-only via the Extensions domain; other extensions' contexts are never attached to or evaluated in."
3329
+ note: "Lists extensions that currently have at least one live context (service worker or open page). An MV3 service worker that has gone dormant with no open page may be absent until it wakes. ownExtension marks the extension this dev session serves, identified from the session's ready contract. Other identity is read read-only via the Extensions domain; other extensions' contexts are never attached to or evaluated in."
3155
3330
  });
3156
3331
  } catch (error) {
3157
3332
  return JSON.stringify({
@@ -3483,7 +3658,7 @@ async function logs_handler(args) {
3483
3658
  return readFromFile(args, browser, limit);
3484
3659
  }
3485
3660
  function toMcpSpeak(text) {
3486
- return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/--context[= ]([\w-]+)/g, 'context: "$1"').replace(/--tab[= ](\d+)/g, "tab: $1").replace(/--url[= ](\S+)/g, 'url: "$1"').replace(/--browser[= ]([\w-]+)/g, 'browser: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev");
3661
+ return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
3487
3662
  }
3488
3663
  function withSessionContext(message, projectPath) {
3489
3664
  const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
@@ -3536,7 +3711,7 @@ function commonFlags(args) {
3536
3711
  }
3537
3712
  const eval_schema = {
3538
3713
  name: "extension_eval",
3539
- 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`.",
3714
+ description: "Evaluate an expression in a running extension context. Requires the dev session to be started with allowEval: true (extension_dev; writes a 0600 session token the CLI reads). Context default: on a Chromium session whose manifest is MV3 (the default template) the default is `page` (the active tab), because the MV3 background is a service worker whose CSP blocks eval, so a background default would fail on the most common path; on Firefox/MV2 sessions the default stays `background`. Pass `context: \"background\"` explicitly to target the worker anyway (on Chromium MV3 that returns the CSP explanation). Targeting for context content/page: pass `url` to pick the matching tab, or omit both `url` and `tab` to use the ACTIVE tab; a numeric `tab` id is only needed to disambiguate. Extension surfaces (popup/options/sidebar/devtools) and override pages (newtab/history/bookmarks) evaluate over the in-bundle relay and need NO tab id; the surface must be OPEN (extension_open first; a closed surface returns an explicit error). Use extension_dom_inspect with listTabs: true to enumerate {tabId,url,title}. Wraps `extension eval`.",
3540
3715
  inputSchema: {
3541
3716
  type: "object",
3542
3717
  properties: {
@@ -3562,8 +3737,7 @@ const eval_schema = {
3562
3737
  "content",
3563
3738
  "page"
3564
3739
  ],
3565
- default: "background",
3566
- description: "Which extension surface to evaluate in"
3740
+ description: "Which extension surface to evaluate in. Default: `background`, EXCEPT on Chromium sessions whose manifest is MV3, where the default is `page` (the active tab) because the MV3 service worker CSP blocks eval; pass `context: \"background\"` explicitly to target the worker anyway."
3567
3741
  },
3568
3742
  url: {
3569
3743
  type: "string",
@@ -3588,14 +3762,38 @@ const eval_schema = {
3588
3762
  ]
3589
3763
  }
3590
3764
  };
3765
+ function resolveDefaultEvalContext(projectPath, browser) {
3766
+ if (!isChromiumFamily(browser)) return "background";
3767
+ const candidates = [
3768
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
3769
+ node_path.join(projectPath, "dist", "manifest.json"),
3770
+ node_path.join(projectPath, "src", "manifest.json"),
3771
+ node_path.join(projectPath, "manifest.json")
3772
+ ];
3773
+ for (const file of candidates){
3774
+ let manifest;
3775
+ try {
3776
+ manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
3777
+ } catch {
3778
+ continue;
3779
+ }
3780
+ const version = manifest["chromium:manifest_version"] ?? manifest.manifest_version;
3781
+ if (3 === version) return "page";
3782
+ if (2 === version) break;
3783
+ }
3784
+ return "background";
3785
+ }
3591
3786
  async function eval_handler(args) {
3592
3787
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3788
+ const defaulted = !args.context && "page" === resolveDefaultEvalContext(args.projectPath, browser);
3789
+ const context = defaulted ? "page" : args.context;
3593
3790
  const raw = await runActVerb([
3594
3791
  "eval",
3595
3792
  args.expression,
3596
3793
  args.projectPath,
3597
3794
  ...commonFlags({
3598
3795
  ...args,
3796
+ context,
3599
3797
  browser
3600
3798
  })
3601
3799
  ], args.projectPath, args.timeout);
@@ -3606,6 +3804,15 @@ async function eval_handler(args) {
3606
3804
  return JSON.stringify(parsed);
3607
3805
  }
3608
3806
  } catch {}
3807
+ if (defaulted) try {
3808
+ const parsed = JSON.parse(raw);
3809
+ if (parsed && "object" == typeof parsed) {
3810
+ parsed.defaultedContext = "page";
3811
+ parsed.contextNote = 'No context given: defaulted to "page" (the active tab) because this Chromium session\'s MV3 background is a service worker whose CSP blocks eval. Pass context: "background" explicitly to target the worker (works on Firefox/MV2 builds).';
3812
+ if (false === parsed.ok && /cannot access|chrome-extension:\/\/|chrome:\/\//i.test(JSON.stringify(parsed.error ?? ""))) parsed.hint = "The active tab is a browser or extension page that eval cannot reach. Navigate the dev browser to a regular web page, or pass url (match pattern) or tab to pick one; extension_dom_inspect with listTabs: true lists open tabs.";
3813
+ return JSON.stringify(parsed);
3814
+ }
3815
+ } catch {}
3609
3816
  return raw;
3610
3817
  }
3611
3818
  const storage_schema = {
@@ -3834,7 +4041,7 @@ async function navigateToUrl(projectPath, browser, url) {
3834
4041
  } catch {}
3835
4042
  }
3836
4043
  }
3837
- function unpackedExtensionId(distPath) {
4044
+ function open_unpackedExtensionId(distPath) {
3838
4045
  const digest = node_crypto.createHash("sha256").update(distPath).digest();
3839
4046
  let id = "";
3840
4047
  for(let i = 0; i < 16; i++){
@@ -3845,7 +4052,7 @@ function unpackedExtensionId(distPath) {
3845
4052
  }
3846
4053
  async function resolveExtensionId(projectPath, browser) {
3847
4054
  const distPath = readDistPath(projectPath, browser);
3848
- const computed = distPath ? unpackedExtensionId(distPath) : null;
4055
+ const computed = distPath ? open_unpackedExtensionId(distPath) : null;
3849
4056
  const resolved = await resolveCdpPort(projectPath, browser);
3850
4057
  if (!resolved) return computed;
3851
4058
  const ids = new Set();
@@ -3909,6 +4116,57 @@ function surfaceDocument(projectPath, browser, surface) {
3909
4116
  }
3910
4117
  return null;
3911
4118
  }
4119
+ const SURFACE_MANIFEST_KEYS = {
4120
+ popup: "action.default_popup",
4121
+ options: "options_ui.page (or options_page)",
4122
+ sidebar: "side_panel.default_path (or sidebar_action.default_panel)",
4123
+ newtab: "chrome_url_overrides.newtab",
4124
+ history: "chrome_url_overrides.history",
4125
+ bookmarks: "chrome_url_overrides.bookmarks"
4126
+ };
4127
+ function declaredSurfaces(projectPath, browser) {
4128
+ const candidates = [
4129
+ node_path.join(projectPath, "dist", browser, "manifest.json"),
4130
+ node_path.join(projectPath, "dist", "manifest.json"),
4131
+ node_path.join(projectPath, "src", "manifest.json"),
4132
+ node_path.join(projectPath, "manifest.json")
4133
+ ];
4134
+ const readable = candidates.some((file)=>{
4135
+ try {
4136
+ JSON.parse(node_fs.readFileSync(file, "utf8"));
4137
+ return true;
4138
+ } catch {
4139
+ return false;
4140
+ }
4141
+ });
4142
+ if (!readable) return null;
4143
+ return Object.keys(SURFACE_MANIFEST_KEYS).filter((s)=>null !== surfaceDocument(projectPath, browser, s));
4144
+ }
4145
+ function missingSurfaceError(projectPath, browser, surface, consequence) {
4146
+ const declared = declaredSurfaces(projectPath, browser);
4147
+ if (null === declared) return JSON.stringify({
4148
+ ok: false,
4149
+ error: {
4150
+ name: "NoSurfaceDocument",
4151
+ message: `No readable manifest was found for this project (checked dist/${browser}, dist, src, and the project root), so the ${surface} document cannot be resolved.`
4152
+ },
4153
+ hint: "Check projectPath, or build the project first (extension_build)."
4154
+ });
4155
+ const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
4156
+ const others = declared.filter((s)=>s !== surface);
4157
+ const nextVerb = "popup" === surface ? 'To exercise the toolbar button of a popup-less extension, call extension_open with surface: "action", which replays chrome.action.onClicked. To give the extension a popup, set action.default_popup in the manifest and rebuild.' : `To add one, set ${key} in the manifest and rebuild.`;
4158
+ return JSON.stringify({
4159
+ ok: false,
4160
+ error: {
4161
+ name: "NoSurfaceDocument",
4162
+ message: `This extension declares no ${surface}: nothing in its manifest sets ${key}, ${consequence}. That is how the extension is built, not a failure of the session or the tooling.`
4163
+ },
4164
+ ...others.length ? {
4165
+ declaredSurfaces: others
4166
+ } : {},
4167
+ hint: (others.length ? `Surfaces this extension does declare: ${others.join(", ")}; extension_open can target those. ` : "The manifest declares no other UI surface documents either. ") + nextVerb
4168
+ });
4169
+ }
3912
4170
  const POPUP_MIN = 25;
3913
4171
  const POPUP_MAX_WIDTH = 800;
3914
4172
  const POPUP_MAX_HEIGHT = 600;
@@ -3967,14 +4225,7 @@ async function applyPopupBounds(projectPath, browser, targetId) {
3967
4225
  }
3968
4226
  async function openSurfaceAsTab(projectPath, browser, surface) {
3969
4227
  const doc = surfaceDocument(projectPath, browser, surface);
3970
- if (!doc) return JSON.stringify({
3971
- ok: false,
3972
- error: {
3973
- name: "NoSurfaceDocument",
3974
- message: `The manifest declares no document for surface "${surface}", so there is no page to render as a tab.`
3975
- },
3976
- hint: "Check the manifest: popup needs action.default_popup, options needs options_ui.page or options_page, sidebar needs side_panel.default_path."
3977
- });
4228
+ if (!doc) return missingSurfaceError(projectPath, browser, surface, "so there is no page to render as a tab");
3978
4229
  const id = await resolveExtensionId(projectPath, browser);
3979
4230
  if (!id) return JSON.stringify({
3980
4231
  ok: false,
@@ -4087,6 +4338,10 @@ async function open_handler(args) {
4087
4338
  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."
4088
4339
  });
4089
4340
  }
4341
+ if ("popup" === args.surface) {
4342
+ const declared = declaredSurfaces(args.projectPath, browser);
4343
+ if (declared && !declared.includes("popup")) return missingSurfaceError(args.projectPath, browser, "popup", "so there is no popup to open");
4344
+ }
4090
4345
  const cli = [
4091
4346
  "open",
4092
4347
  args.surface,
@@ -4110,20 +4365,38 @@ async function open_handler(args) {
4110
4365
  try {
4111
4366
  const parsedFallback = JSON.parse(fallback);
4112
4367
  if (parsedFallback?.ok) {
4113
- 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.";
4368
+ parsedFallback.note = "The dev browser is headless (EXTENSION_HEADLESS=1), and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, start a headed session: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment, then open the surface again without asTab.";
4114
4369
  return JSON.stringify(parsedFallback);
4115
4370
  }
4116
4371
  } catch {}
4117
4372
  }
4118
- 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.";
4373
+ 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=1), and a popup/sidebar window needs a headed session. Retry with asTab: true to render the surface document in a tab, or start a headed session for the real window: extension_dev with replace: true, with EXTENSION_HEADLESS=0 (or unset) in the environment.";
4119
4374
  return JSON.stringify(parsed);
4120
4375
  }
4121
4376
  } catch {}
4122
4377
  return raw;
4123
4378
  }
4379
+ const TARGET_ID_NOTE = "targetId is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_inspect with listTabs: true.";
4380
+ function filterPageTargets(raw) {
4381
+ return raw.filter((t)=>"page" === t.type && !String(t.url ?? "").startsWith("devtools://")).map((t)=>({
4382
+ targetId: String(t.id),
4383
+ type: String(t.type),
4384
+ url: String(t.url ?? ""),
4385
+ title: String(t.title ?? "")
4386
+ }));
4387
+ }
4388
+ async function listPageTargets(port) {
4389
+ return filterPageTargets(await CDPClient.discoverTargets(port));
4390
+ }
4391
+ function matchTargetsByUrl(targets, needle) {
4392
+ const wanted = needle.toLowerCase();
4393
+ const byUrl = targets.filter((t)=>t.url.toLowerCase().includes(wanted));
4394
+ if (byUrl.length > 0) return byUrl;
4395
+ return targets.filter((t)=>t.title.toLowerCase().includes(wanted));
4396
+ }
4124
4397
  const dom_inspect_schema = {
4125
4398
  name: "extension_dom_inspect",
4126
- description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
4399
+ description: "Inspect a page/content-script DOM via the agent bridge (CDP-free, localhost). Returns a structured snapshot (counts, extension roots, open shadow roots, optional capped HTML). Target a tab by `tabUrl` (case-insensitive URL substring resolved against the browser's live page targets; zero or several matches return the candidates instead of guessing), by `url`, or by numeric `tab`. Discover what is open with listTargets: true (CDP targetIds) or listTabs: true (numeric chrome.tabs ids). Requires the dev session to be started with allowControl: true (extension_dev). For closed shadow roots or deep CDP inspection use extension_source_inspect. Wraps `extension inspect`.",
4127
4400
  inputSchema: {
4128
4401
  type: "object",
4129
4402
  properties: {
@@ -4139,6 +4412,15 @@ const dom_inspect_schema = {
4139
4412
  type: "string",
4140
4413
  description: "For content/page: selects the target tab by url (match pattern, then substring fallback). Preferred over `tab`."
4141
4414
  },
4415
+ tabUrl: {
4416
+ type: "string",
4417
+ description: "Target the tab whose URL contains this substring (case-insensitive; titles are checked only when no url matches). Resolved against the live browser's CDP page targets BEFORE inspecting: exactly one match proceeds; zero or several matches return the candidate targets (targetId/url/title) so you can narrow, never a guess. Chromium sessions only; on Firefox use `url`/`tab`. Alternative to `url`."
4418
+ },
4419
+ listTargets: {
4420
+ type: "boolean",
4421
+ default: false,
4422
+ description: "Enumerate the browser's CDP page targets as {targetId,url,title,type} and return, ignoring the other args. The discovery path for `tabUrl`. targetId is a CDP target id, NOT a numeric chrome.tabs id (for those use listTabs)."
4423
+ },
4142
4424
  listTabs: {
4143
4425
  type: "boolean",
4144
4426
  default: false,
@@ -4199,8 +4481,55 @@ const dom_inspect_schema = {
4199
4481
  ]
4200
4482
  }
4201
4483
  };
4484
+ async function cdpPortOrError(projectPath, browser, feature) {
4485
+ if (!isChromiumFamily(browser)) return {
4486
+ error: JSON.stringify({
4487
+ ok: false,
4488
+ error: {
4489
+ name: "Unsupported",
4490
+ message: `${feature} reads the browser's CDP page targets, which ${browser} (Gecko) does not expose. Target the tab with \`url\` or \`tab\` instead, and discover tabs with listTabs: true (agent bridge, works on every browser).`
4491
+ }
4492
+ })
4493
+ };
4494
+ const resolved = await resolveCdpPort(projectPath, browser);
4495
+ if (!resolved) return {
4496
+ error: JSON.stringify({
4497
+ ok: false,
4498
+ error: {
4499
+ name: "NoSession",
4500
+ message: `No active dev session / CDP port for ${browser}, so ${feature} has no browser to ask. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
4501
+ }
4502
+ })
4503
+ };
4504
+ return {
4505
+ port: resolved.port
4506
+ };
4507
+ }
4202
4508
  async function dom_inspect_handler(args) {
4203
4509
  const withConsole = true === args.withConsole ? 50 : false === args.withConsole ? void 0 : args.withConsole;
4510
+ if (args.listTargets) {
4511
+ const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4512
+ const cdp = await cdpPortOrError(args.projectPath, browser, "listTargets");
4513
+ if ("error" in cdp) return cdp.error;
4514
+ try {
4515
+ const targets = await listPageTargets(cdp.port);
4516
+ return JSON.stringify({
4517
+ ok: true,
4518
+ browser,
4519
+ targets,
4520
+ note: TARGET_ID_NOTE
4521
+ });
4522
+ } catch (e) {
4523
+ return JSON.stringify({
4524
+ ok: false,
4525
+ error: {
4526
+ name: "CdpError",
4527
+ message: `Could not list page targets: ${e instanceof Error ? e.message : String(e)}`
4528
+ },
4529
+ hint: "Confirm the session is ready (extension_wait), then retry. listTabs: true is the CDP-free alternative."
4530
+ });
4531
+ }
4532
+ }
4204
4533
  if (args.listTabs) return runActVerb([
4205
4534
  "inspect",
4206
4535
  args.projectPath,
@@ -4212,19 +4541,78 @@ async function dom_inspect_handler(args) {
4212
4541
  String(args.timeout)
4213
4542
  ] : []
4214
4543
  ], args.projectPath, args.timeout);
4544
+ let targetUrl = args.url;
4545
+ let resolvedTarget = null;
4546
+ if (args.tabUrl) {
4547
+ if (null != args.tab || args.url) return JSON.stringify({
4548
+ ok: false,
4549
+ error: {
4550
+ name: "BadRequest",
4551
+ message: "Pass ONE tab selector: `tabUrl` (URL substring, resolved against live targets), `url` (engine-side match), or `tab` (numeric chrome.tabs id), not several."
4552
+ }
4553
+ });
4554
+ const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
4555
+ const cdp = await cdpPortOrError(args.projectPath, browser, "tabUrl");
4556
+ if ("error" in cdp) return cdp.error;
4557
+ let targets;
4558
+ try {
4559
+ targets = await listPageTargets(cdp.port);
4560
+ } catch (e) {
4561
+ return JSON.stringify({
4562
+ ok: false,
4563
+ error: {
4564
+ name: "CdpError",
4565
+ message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
4566
+ },
4567
+ hint: "Confirm the session is ready (extension_wait), then retry, or target with `url`/`tab` instead."
4568
+ });
4569
+ }
4570
+ const matches = matchTargetsByUrl(targets, args.tabUrl);
4571
+ if (0 === matches.length) return JSON.stringify({
4572
+ ok: false,
4573
+ error: {
4574
+ name: "NoMatchingTarget",
4575
+ message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
4576
+ },
4577
+ availableTargets: targets,
4578
+ hint: `Pick one from availableTargets and retry with a \`tabUrl\` substring of its url, or open the page first (extension_open with \`url\`). ${TARGET_ID_NOTE}`
4579
+ });
4580
+ if (matches.length > 1) return JSON.stringify({
4581
+ ok: false,
4582
+ error: {
4583
+ name: "AmbiguousTabUrl",
4584
+ message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
4585
+ },
4586
+ matchingTargets: matches,
4587
+ hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
4588
+ });
4589
+ resolvedTarget = matches[0];
4590
+ targetUrl = resolvedTarget.url;
4591
+ }
4215
4592
  const cli = [
4216
4593
  "inspect",
4217
4594
  args.projectPath
4218
4595
  ];
4219
4596
  if (null != args.tab) cli.push("--tab", String(args.tab));
4220
- if (args.url) cli.push("--url", args.url);
4597
+ if (targetUrl) cli.push("--url", targetUrl);
4221
4598
  if (args.context) cli.push("--context", args.context);
4222
4599
  if (args.include?.length) cli.push("--include", args.include.join(","));
4223
4600
  if (null != args.maxBytes) cli.push("--max-bytes", String(args.maxBytes));
4224
4601
  if (null != withConsole) cli.push("--with-console", String(withConsole));
4225
4602
  cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
4226
4603
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
4227
- return runActVerb(cli, args.projectPath, args.timeout);
4604
+ const raw = await runActVerb(cli, args.projectPath, args.timeout);
4605
+ if (!resolvedTarget) return raw;
4606
+ try {
4607
+ const parsed = JSON.parse(raw);
4608
+ parsed.resolvedTarget = {
4609
+ ...resolvedTarget,
4610
+ matchedBy: "tabUrl"
4611
+ };
4612
+ return JSON.stringify(parsed);
4613
+ } catch {
4614
+ return raw;
4615
+ }
4228
4616
  }
4229
4617
  function credentialsPath() {
4230
4618
  if ("win32" === process.platform) {
@@ -5227,6 +5615,8 @@ async function doctor_handler(args) {
5227
5615
  }
5228
5616
  }
5229
5617
  const SAFE_CEILING_MS = 50000;
5618
+ const DEFAULT_TIMEOUT_MS = 45000;
5619
+ const MIN_TIMEOUT_MS = 1000;
5230
5620
  function wait_isAlive(pid) {
5231
5621
  try {
5232
5622
  process.kill(pid, 0);
@@ -5237,7 +5627,7 @@ function wait_isAlive(pid) {
5237
5627
  }
5238
5628
  const wait_schema = {
5239
5629
  name: "extension_wait",
5240
- description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status. A single call is bounded to ~50s to stay under the MCP client request timeout; if it returns status:'timeout', call it again to keep waiting (polling resumes on the same contract).",
5630
+ description: "Wait for a running dev or start session to be ready. Polls the ready.json contract file and returns structured status with two separate facts: compiled (the compiler finished) and browserAttached (the extension's runtime connected from a live browser). Every result reports budgetMs (this call's wait budget) and elapsedMs; on status:'timeout' call again to keep waiting (polling resumes on the same contract). In a noBrowser (build-only) session it returns as soon as the compile lands instead of waiting for a browser that will never attach. Ports in the result come from the ready contract, so they always match what the dev server actually bound.",
5241
5631
  inputSchema: {
5242
5632
  type: "object",
5243
5633
  properties: {
@@ -5249,10 +5639,14 @@ const wait_schema = {
5249
5639
  type: "string",
5250
5640
  description: "Browser to check readiness for. Defaults to the active dev session's browser for this project."
5251
5641
  },
5642
+ timeoutMs: {
5643
+ type: "number",
5644
+ default: DEFAULT_TIMEOUT_MS,
5645
+ description: `Wait budget in milliseconds for this call. Default ${DEFAULT_TIMEOUT_MS}; clamped to ${MIN_TIMEOUT_MS}-${SAFE_CEILING_MS} (the ceiling keeps one call under the MCP client's 60s request timeout). On timeout the result reports elapsedMs plus what was observed (compiled, browserAttached); call again to keep waiting, polling resumes on the same contract.`
5646
+ },
5252
5647
  timeout: {
5253
5648
  type: "number",
5254
- default: 45000,
5255
- description: "Requested wait in milliseconds. Clamped to ~50s per call so it never exceeds the MCP client request timeout; call again to keep waiting for slower builds."
5649
+ description: "Deprecated alias of timeoutMs, kept for callers that already pass it. timeoutMs wins when both are given."
5256
5650
  }
5257
5651
  },
5258
5652
  required: [
@@ -5262,26 +5656,47 @@ const wait_schema = {
5262
5656
  };
5263
5657
  async function wait_handler(args) {
5264
5658
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
5265
- const requested = args.timeout ?? 45000;
5266
- const timeout = Math.min(Math.max(requested, 1000), SAFE_CEILING_MS);
5659
+ const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
5660
+ const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
5267
5661
  const clamped = requested > SAFE_CEILING_MS;
5268
5662
  const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
5663
+ const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
5269
5664
  const start = Date.now();
5270
5665
  const pollInterval = 1000;
5271
5666
  let sawCompiledButUnattached = false;
5272
- while(Date.now() - start < timeout){
5667
+ let lastContractStatus = null;
5668
+ while(Date.now() - start < budgetMs){
5273
5669
  try {
5274
5670
  const raw = node_fs.readFileSync(readyPath, "utf8");
5275
5671
  const contract = JSON.parse(raw);
5672
+ lastContractStatus = contract.status;
5276
5673
  if ("ready" === contract.status) {
5277
5674
  if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
5278
5675
  status: "stale",
5279
5676
  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.`,
5280
5677
  browser: contract.browser,
5281
5678
  pid: contract.pid,
5282
- waitDuration: Date.now() - start
5679
+ budgetMs,
5680
+ elapsedMs: Date.now() - start
5283
5681
  });
5284
5682
  const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
5683
+ if (!attached && buildOnly) return JSON.stringify({
5684
+ status: "ready",
5685
+ buildOnly: true,
5686
+ compiled: true,
5687
+ browserAttached: false,
5688
+ message: "Build-only session (noBrowser): the extension compiled and the dev server is live, but no browser was launched, so browserAttached will never become true. Do not call extension_wait again to wait for a browser. The control verbs (storage/reload/open/dom_inspect/eval) need a live browser and will not work against this session.",
5689
+ command: contract.command,
5690
+ browser: contract.browser,
5691
+ port: contract.port,
5692
+ pid: contract.pid,
5693
+ distPath: contract.distPath,
5694
+ manifestPath: contract.manifestPath,
5695
+ compiledAt: contract.compiledAt,
5696
+ startedAt: contract.startedAt,
5697
+ budgetMs,
5698
+ elapsedMs: Date.now() - start
5699
+ });
5285
5700
  if (!attached) {
5286
5701
  await new Promise((r)=>setTimeout(r, pollInterval));
5287
5702
  sawCompiledButUnattached = true;
@@ -5290,6 +5705,8 @@ async function wait_handler(args) {
5290
5705
  const runtimeErrors = recentErrorLogs(args.projectPath, browser, 3);
5291
5706
  return JSON.stringify({
5292
5707
  status: "ready",
5708
+ compiled: true,
5709
+ browserAttached: true,
5293
5710
  command: contract.command,
5294
5711
  browser: contract.browser,
5295
5712
  port: contract.port,
@@ -5298,7 +5715,8 @@ async function wait_handler(args) {
5298
5715
  manifestPath: contract.manifestPath,
5299
5716
  compiledAt: contract.compiledAt,
5300
5717
  startedAt: contract.startedAt,
5301
- waitDuration: Date.now() - start,
5718
+ budgetMs,
5719
+ elapsedMs: Date.now() - start,
5302
5720
  ...runtimeErrors.length ? {
5303
5721
  runtimeErrors,
5304
5722
  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.`
@@ -5311,23 +5729,30 @@ async function wait_handler(args) {
5311
5729
  errors: contract.errors,
5312
5730
  code: contract.code,
5313
5731
  browser: contract.browser,
5314
- waitDuration: Date.now() - start
5732
+ budgetMs,
5733
+ elapsedMs: Date.now() - start
5315
5734
  });
5316
5735
  } catch {}
5317
5736
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
5318
5737
  }
5319
5738
  if (sawCompiledButUnattached) return JSON.stringify({
5320
5739
  status: "compiled-not-attached",
5321
- 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".`,
5740
+ compiled: true,
5741
+ browserAttached: false,
5742
+ message: `The extension compiled, but the runtime executor never attached within this call's ${budgetMs}ms budget. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
5322
5743
  readyPath,
5323
- waitDuration: Date.now() - start,
5744
+ budgetMs,
5745
+ elapsedMs: Date.now() - start,
5324
5746
  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."
5325
5747
  });
5326
5748
  return JSON.stringify({
5327
5749
  status: "timeout",
5328
- message: `Extension not ready after ${timeout}ms this call`,
5750
+ compiled: false,
5751
+ browserAttached: false,
5752
+ message: "starting" === lastContractStatus ? `Not ready after ${budgetMs}ms this call: the dev server stamped its contract (status: starting) but the first compile has not landed yet.` : `Not ready after ${budgetMs}ms this call: no ready contract was observed at ${readyPath}, so neither the compile nor a browser attach has been seen.`,
5329
5753
  readyPath,
5330
- waitDuration: Date.now() - start,
5754
+ budgetMs,
5755
+ elapsedMs: Date.now() - start,
5331
5756
  clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
5332
5757
  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."
5333
5758
  });
@@ -5936,7 +6361,7 @@ async function login_handler(args) {
5936
6361
  }
5937
6362
  const whoami_schema = {
5938
6363
  name: "extension_whoami",
5939
- description: "Report the locally stored extension.dev login (workspace/project and token expiry) without revealing the token. Returns logged-out status when no credentials are stored.",
6364
+ description: "Report the identity carried by the locally stored extension.dev token that extension_login minted (workspace/project scoped), plus its expiry, without revealing the token. The identity comes from that stored token alone; it does not change with the current working directory or whichever project folder you are in. Returns logged-out status when no credentials are stored.",
5940
6365
  inputSchema: {
5941
6366
  type: "object",
5942
6367
  properties: {}
@@ -5961,7 +6386,7 @@ async function whoami_handler() {
5961
6386
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
5962
6387
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
5963
6388
  expired,
5964
- message: expired ? "Stored token has expired. Run extension_login to refresh it." : `Logged in to ${creds.workspaceSlug}/${creds.projectSlug}.`
6389
+ message: expired ? "The stored token has expired. Run extension_login to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_login stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`
5965
6390
  });
5966
6391
  }
5967
6392
  const logout_schema = {