@extension.dev/mcp 10.1.0 → 10.3.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
@@ -8,6 +8,7 @@ import { extensionCreate } from "extension-create";
8
8
  import node_os from "node:os";
9
9
  import cross_spawn from "cross-spawn";
10
10
  import node_crypto, { createHash } from "node:crypto";
11
+ import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, buildSummaryPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir } from "extension-develop/bridge";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { execFile, execFileSync } from "node:child_process";
13
14
  import ws_0 from "ws";
@@ -431,6 +432,7 @@ __webpack_require__.d(browsers_namespaceObject, {
431
432
  var build_namespaceObject = {};
432
433
  __webpack_require__.r(build_namespaceObject);
433
434
  __webpack_require__.d(build_namespaceObject, {
435
+ BUNDLE_ID_PATTERN: ()=>BUNDLE_ID_PATTERN,
434
436
  handler: ()=>build_handler,
435
437
  schema: ()=>build_schema
436
438
  });
@@ -481,6 +483,7 @@ __webpack_require__.d(list_extensions_namespaceObject, {
481
483
  var logs_namespaceObject = {};
482
484
  __webpack_require__.r(logs_namespaceObject);
483
485
  __webpack_require__.d(logs_namespaceObject, {
486
+ controlRefusal: ()=>controlRefusal,
484
487
  handler: ()=>logs_handler,
485
488
  schema: ()=>logs_schema_schema
486
489
  });
@@ -579,7 +582,7 @@ __webpack_require__.d(wait_namespaceObject, {
579
582
  handler: ()=>wait_handler,
580
583
  schema: ()=>wait_schema
581
584
  });
582
- var package_namespaceObject = JSON.parse('{"rE":"10.1.0","El":{"OP":"4.0.17"}}');
585
+ var package_namespaceObject = JSON.parse('{"rE":"10.3.0","El":{"OP":"4.0.20"}}');
583
586
  const ENVELOPE_SCHEMA = 1;
584
587
  const collectWarnings = (warnings)=>{
585
588
  if (!warnings) return [];
@@ -605,7 +608,7 @@ function envelopeObject(init) {
605
608
  if ("string" == typeof init.hint && init.hint) frame.hint = init.hint;
606
609
  return frame;
607
610
  }
608
- function envelope(init) {
611
+ function envelope_envelope(init) {
609
612
  return JSON.stringify(envelopeObject(init));
610
613
  }
611
614
  function sessionCommandSinceEnvelopeOwnsCommand(contract) {
@@ -769,7 +772,7 @@ async function spawnFailedEnvelope(command, spawned) {
769
772
  await new Promise((resolve)=>setTimeout(resolve, 25));
770
773
  cause = spawned.spawnError?.() ?? null;
771
774
  }
772
- return envelope({
775
+ return envelope_envelope({
773
776
  ok: false,
774
777
  command,
775
778
  status: "spawn-failed",
@@ -865,7 +868,11 @@ function tokenTtlNote(workspaceSlug, projectSlug) {
865
868
  function resolveApiBase(api) {
866
869
  return String(api || process.env.EXTENSION_DEV_API_URL || DEFAULT_API).replace(/\/+$/, "");
867
870
  }
868
- function safeApiBase(raw) {
871
+ function isExtensionDevHost(hostname) {
872
+ const host = hostname.toLowerCase().replace(/\.+$/, "");
873
+ return "extension.dev" === host || host.endsWith(".extension.dev");
874
+ }
875
+ function safeApiBase(raw, callerSupplied) {
869
876
  let parsed;
870
877
  try {
871
878
  parsed = new URL(raw);
@@ -880,6 +887,11 @@ function safeApiBase(raw) {
880
887
  ok: false,
881
888
  message: `Refusing to send the access token to ${raw}: use https (http is allowed only for localhost).`
882
889
  };
890
+ const fromCaller = String(callerSupplied || "").trim();
891
+ if (fromCaller && !isLocalhost && !isExtensionDevHost(parsed.hostname)) return {
892
+ ok: false,
893
+ message: `Refusing to send the access token to ${raw}: an api argument may only name an extension.dev host or a local dev server. To use a self-hosted platform, set EXTENSION_DEV_API_URL where the server is launched instead of passing it per call.`
894
+ };
883
895
  return {
884
896
  ok: true,
885
897
  base: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "")
@@ -1081,7 +1093,7 @@ class RegistryAccessTokens {
1081
1093
  status: "no-credential"
1082
1094
  };
1083
1095
  }
1084
- const check = safeApiBase(resolveApiBase(apiHint || creds?.api));
1096
+ const check = safeApiBase(resolveApiBase(apiHint || creds?.api), apiHint);
1085
1097
  if (!check.ok) return {
1086
1098
  status: "denied",
1087
1099
  message: check.message
@@ -1415,7 +1427,7 @@ async function create_handler(args) {
1415
1427
  error: capture("error")
1416
1428
  }
1417
1429
  });
1418
- const failure = (err, transient)=>transient ? envelope({
1430
+ const failure = (err, transient)=>transient ? envelope_envelope({
1419
1431
  ok: false,
1420
1432
  command: COMMAND,
1421
1433
  status: "template-fetch-failed",
@@ -1428,7 +1440,7 @@ async function create_handler(args) {
1428
1440
  duration: Date.now() - start,
1429
1441
  log: logTail()
1430
1442
  }
1431
- }) : envelope({
1443
+ }) : envelope_envelope({
1432
1444
  ok: false,
1433
1445
  command: COMMAND,
1434
1446
  status: "scaffold-failed",
@@ -1455,7 +1467,7 @@ async function create_handler(args) {
1455
1467
  }
1456
1468
  }
1457
1469
  const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
1458
- if (!hasManifest) return envelope({
1470
+ if (!hasManifest) return envelope_envelope({
1459
1471
  ok: false,
1460
1472
  command: COMMAND,
1461
1473
  status: "scaffold-incomplete",
@@ -1483,7 +1495,7 @@ async function create_handler(args) {
1483
1495
  const deployUrl = `${wwwOrigin}${(0, paths_0.wwwNewPath)({
1484
1496
  template: result.template
1485
1497
  })}`;
1486
- return envelope({
1498
+ return envelope_envelope({
1487
1499
  ok: true,
1488
1500
  command: COMMAND,
1489
1501
  status: "created",
@@ -1525,7 +1537,7 @@ async function create_handler(args) {
1525
1537
  }
1526
1538
  const DEFAULT_MEDIA_ORIGIN = "https://media.extension.land";
1527
1539
  const DEFAULT_CHANNEL = "latest";
1528
- const PINNED_COMMIT = "2d2ed9668cca002148d9eecd953a08b54d0bad9d";
1540
+ const PINNED_COMMIT = "520a8ede767fe1937c7184f4857408caf9c5934f";
1529
1541
  const CHANNEL_CACHE_TTL_MS = 300000;
1530
1542
  function mediaOrigin() {
1531
1543
  return (process.env.EXTENSION_MEDIA_ORIGIN || "").trim() || DEFAULT_MEDIA_ORIGIN;
@@ -1733,7 +1745,7 @@ async function searchTemplates(args) {
1733
1745
  repositoryUrl: t.repositoryUrl,
1734
1746
  downloads: t.downloads
1735
1747
  }));
1736
- return envelope({
1748
+ return envelope_envelope({
1737
1749
  ok: true,
1738
1750
  command: "extension_templates",
1739
1751
  status: "listed",
@@ -1746,7 +1758,7 @@ async function searchTemplates(args) {
1746
1758
  const get_template_source_COMMAND = "extension_templates";
1747
1759
  async function readTemplateSource(args) {
1748
1760
  const template = await getTemplateBySlug(args.slug);
1749
- if (!template) return envelope({
1761
+ if (!template) return envelope_envelope({
1750
1762
  ok: false,
1751
1763
  command: get_template_source_COMMAND,
1752
1764
  status: "template-not-found",
@@ -1766,7 +1778,7 @@ async function readTemplateSource(args) {
1766
1778
  keyFiles: template.keyFiles,
1767
1779
  repositoryUrl: template.repositoryUrl
1768
1780
  };
1769
- if (!args.files?.length) return envelope({
1781
+ if (!args.files?.length) return envelope_envelope({
1770
1782
  ok: true,
1771
1783
  command: get_template_source_COMMAND,
1772
1784
  status: "file-list",
@@ -1791,7 +1803,7 @@ async function readTemplateSource(args) {
1791
1803
  } catch {}
1792
1804
  errors.push(`${filePath}: ${lastStatus || "fetch failed"}`);
1793
1805
  }));
1794
- return envelope({
1806
+ return envelope_envelope({
1795
1807
  ok: 0 === errors.length,
1796
1808
  command: get_template_source_COMMAND,
1797
1809
  status: errors.length ? "partial" : "read",
@@ -1873,7 +1885,7 @@ const templates_schema = {
1873
1885
  };
1874
1886
  async function templates_handler(args) {
1875
1887
  if ((args.action ?? "list") === "source" || !args.action && args.slug) {
1876
- if (!args.slug) return envelope({
1888
+ if (!args.slug) return envelope_envelope({
1877
1889
  ok: false,
1878
1890
  command: "extension_templates",
1879
1891
  status: "bad-request",
@@ -1957,14 +1969,239 @@ const LAUNCH_BROWSER = {
1957
1969
  enum: LAUNCHABLE_BROWSERS,
1958
1970
  default: "chrome"
1959
1971
  };
1972
+ const OUTPUT_JSON_FLOOR = {
1973
+ act: "3.18.1",
1974
+ doctor: "4.0.11",
1975
+ dev: "4.0.17",
1976
+ build: "4.0.17"
1977
+ };
1978
+ const VERDICT_TTL_MS = 60000;
1979
+ const PROBE_TIMEOUT_MS = 20000;
1980
+ const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
1981
+ function decompose(version) {
1982
+ const match = SEMVER.exec(version.trim());
1983
+ if (!match) return null;
1984
+ return {
1985
+ release: [
1986
+ Number(match[1]),
1987
+ Number(match[2]),
1988
+ Number(match[3])
1989
+ ],
1990
+ prerelease: match[4] ? match[4].split(".") : []
1991
+ };
1992
+ }
1993
+ function parseVersion(text) {
1994
+ for (const line of text.split("\n")){
1995
+ const candidate = line.trim();
1996
+ if (candidate) {
1997
+ if (decompose(candidate)) return candidate.replace(/^v/, "");
1998
+ }
1999
+ }
2000
+ return null;
2001
+ }
2002
+ function compareNumericIdentifiers(a, b) {
2003
+ const left = a.replace(/^0+(?=\d)/, "");
2004
+ const right = b.replace(/^0+(?=\d)/, "");
2005
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
2006
+ if (left === right) return 0;
2007
+ return left < right ? -1 : 1;
2008
+ }
2009
+ function compareVersions(a, b) {
2010
+ const left = decompose(a);
2011
+ const right = decompose(b);
2012
+ if (!left || !right) return null;
2013
+ for(let i = 0; i < 3; i += 1)if (left.release[i] !== right.release[i]) return left.release[i] < right.release[i] ? -1 : 1;
2014
+ if (!left.prerelease.length && !right.prerelease.length) return 0;
2015
+ if (!left.prerelease.length) return 1;
2016
+ if (!right.prerelease.length) return -1;
2017
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
2018
+ for(let i = 0; i < length; i += 1){
2019
+ const one = left.prerelease[i];
2020
+ const other = right.prerelease[i];
2021
+ if (void 0 === one) return -1;
2022
+ if (void 0 === other) return 1;
2023
+ const oneIsNumeric = /^\d+$/.test(one);
2024
+ const otherIsNumeric = /^\d+$/.test(other);
2025
+ if (oneIsNumeric && otherIsNumeric) {
2026
+ const ordering = compareNumericIdentifiers(one, other);
2027
+ if (0 !== ordering) return ordering;
2028
+ continue;
2029
+ }
2030
+ if (oneIsNumeric !== otherIsNumeric) return oneIsNumeric ? -1 : 1;
2031
+ if (one !== other) return one < other ? -1 : 1;
2032
+ }
2033
+ return 0;
2034
+ }
2035
+ const verdicts = new Map();
2036
+ function invocationKey(command, prefixArgs) {
2037
+ return [
2038
+ command,
2039
+ ...prefixArgs
2040
+ ].join("\0");
2041
+ }
2042
+ const NPX_PIN = /^extension@(.+)$/;
2043
+ function parseCapabilities(stdout) {
2044
+ for (const line of stdout.split("\n")){
2045
+ const candidate = line.trim();
2046
+ if (!candidate.startsWith("{")) continue;
2047
+ let frame;
2048
+ try {
2049
+ frame = JSON.parse(candidate);
2050
+ } catch {
2051
+ continue;
2052
+ }
2053
+ if (!frame || "object" != typeof frame) continue;
2054
+ const envelope = frame;
2055
+ if (1 !== envelope.schema) continue;
2056
+ if (true !== envelope.ok) continue;
2057
+ if ("capabilities" !== envelope.command) continue;
2058
+ const value = envelope.value;
2059
+ if (!value || "object" != typeof value) continue;
2060
+ const version = "string" == typeof value.version ? parseVersion(value.version) : null;
2061
+ const roster = value.outputJsonCommands;
2062
+ if (version && Array.isArray(roster)) {
2063
+ if (roster.every((name)=>"string" == typeof name)) return {
2064
+ version,
2065
+ outputJsonCommands: roster
2066
+ };
2067
+ }
2068
+ }
2069
+ return null;
2070
+ }
2071
+ async function resolvedEngineFacts(projectPath) {
2072
+ const { command, prefixArgs } = resolveExtensionInvocation(projectPath);
2073
+ const key = invocationKey(command, prefixArgs);
2074
+ const cached = verdicts.get(key);
2075
+ if (cached && cached.expiresAt > Date.now()) return {
2076
+ version: cached.version,
2077
+ outputJsonCommands: cached.outputJsonCommands
2078
+ };
2079
+ const remember = (found)=>{
2080
+ verdicts.set(key, {
2081
+ ...found,
2082
+ expiresAt: Date.now() + VERDICT_TTL_MS
2083
+ });
2084
+ return found;
2085
+ };
2086
+ for (const arg of prefixArgs){
2087
+ const pin = NPX_PIN.exec(arg);
2088
+ const pinned = pin ? parseVersion(pin[1]) : null;
2089
+ if (pinned) return remember({
2090
+ version: pinned,
2091
+ outputJsonCommands: null
2092
+ });
2093
+ }
2094
+ try {
2095
+ const probe = await runExtensionCli([
2096
+ "capabilities"
2097
+ ], {
2098
+ cwd: projectPath,
2099
+ timeoutMs: PROBE_TIMEOUT_MS
2100
+ });
2101
+ if (0 === probe.code) {
2102
+ const answered = parseCapabilities(probe.stdout ?? "");
2103
+ if (answered) return remember(answered);
2104
+ }
2105
+ } catch {}
2106
+ try {
2107
+ const probe = await runExtensionCli([
2108
+ "--version"
2109
+ ], {
2110
+ cwd: projectPath,
2111
+ timeoutMs: PROBE_TIMEOUT_MS
2112
+ });
2113
+ if (0 !== probe.code) return remember({
2114
+ version: null,
2115
+ outputJsonCommands: null
2116
+ });
2117
+ return remember({
2118
+ version: parseVersion(probe.stdout ?? "") ?? parseVersion(probe.stderr ?? ""),
2119
+ outputJsonCommands: null
2120
+ });
2121
+ } catch {
2122
+ return remember({
2123
+ version: null,
2124
+ outputJsonCommands: null
2125
+ });
2126
+ }
2127
+ }
2128
+ const ROSTER_NAMES = {
2129
+ act: [
2130
+ "eval",
2131
+ "inspect",
2132
+ "open",
2133
+ "reload",
2134
+ "storage"
2135
+ ],
2136
+ doctor: [
2137
+ "doctor"
2138
+ ],
2139
+ dev: [
2140
+ "dev"
2141
+ ],
2142
+ build: [
2143
+ "build"
2144
+ ]
2145
+ };
2146
+ async function outputJsonVerdict(command, projectPath) {
2147
+ const floor = OUTPUT_JSON_FLOOR[command];
2148
+ let engine;
2149
+ try {
2150
+ engine = await resolvedEngineFacts(projectPath);
2151
+ } catch {
2152
+ return {
2153
+ supported: null,
2154
+ version: null,
2155
+ floor
2156
+ };
2157
+ }
2158
+ const roster = engine.outputJsonCommands;
2159
+ if (roster) return {
2160
+ supported: ROSTER_NAMES[command].every((name)=>roster.includes(name)),
2161
+ version: engine.version,
2162
+ floor
2163
+ };
2164
+ const version = engine.version;
2165
+ if (null === version) return {
2166
+ supported: null,
2167
+ version: null,
2168
+ floor
2169
+ };
2170
+ const ordering = compareVersions(version, floor);
2171
+ if (null === ordering) return {
2172
+ supported: null,
2173
+ version,
2174
+ floor
2175
+ };
2176
+ return {
2177
+ supported: ordering >= 0,
2178
+ version,
2179
+ floor
2180
+ };
2181
+ }
2182
+ const UNKNOWN_OUTPUT_FLAG = /unknown option[^\n]*--output\b/i;
2183
+ function refusedTheOutputFlag(stderr) {
2184
+ return UNKNOWN_OUTPUT_FLAG.test(stderr);
2185
+ }
2186
+ async function outputFlagRefusalMessage(command, cliName, projectPath) {
2187
+ const verdict = await outputJsonVerdict(command, projectPath);
2188
+ const preamble = `The Extension.js resolved for this project refused \`--output json\` on \`extension ${cliName}\`.`;
2189
+ const why = "You did not pass that flag: this server adds it so it can read a structured result instead of parsing a report written for a human, and unlike `build` there is no second source it can fall back to here, so it cannot simply drop it.";
2190
+ if (false === verdict.supported) return `${preamble} It reports ${verdict.version}, and that flag only reached \`extension ${cliName}\` in ${verdict.floor}. ${why} Upgrade the project's Extension.js to ${verdict.floor} or newer and run this again.`;
2191
+ if (true === verdict.supported) return `${preamble} It reports ${verdict.version}, which is at or above ${verdict.floor}, the release where that flag reached \`extension ${cliName}\`, so the binary being run is not the version it claims to be. ${why} Check the project's node_modules/.bin/extension and reinstall it, rather than upgrading a version that already looks new enough.`;
2192
+ return `${preamble} Its version could not be read, so the cause cannot be confirmed, but that flag only reached \`extension ${cliName}\` in ${verdict.floor} and a refusal is what an engine below that does. ${why} Check the project's Extension.js install and bring it to ${verdict.floor} or newer.`;
2193
+ }
1960
2194
  const sessions = new Map();
1961
2195
  const registrationStamps = new Map();
1962
2196
  function sessionKey(projectPath, browser) {
1963
2197
  return `${node_path.resolve(projectPath)}::${browser}`;
1964
2198
  }
1965
- function markerDir() {
2199
+ function sessionStateDir() {
1966
2200
  return process.env.EXTENSION_MCP_SESSION_DIR || node_path.join(node_os.tmpdir(), "extension-dev-mcp-sessions");
1967
2201
  }
2202
+ function markerDir() {
2203
+ return sessionStateDir();
2204
+ }
1968
2205
  function markerPath(projectPath, browser) {
1969
2206
  const digest = node_crypto.createHash("sha1").update(sessionKey(projectPath, browser)).digest("hex").slice(0, 16);
1970
2207
  return node_path.join(markerDir(), `${digest}.json`);
@@ -2047,8 +2284,29 @@ function removeSession(projectPath, browser, pid) {
2047
2284
  function listSessions() {
2048
2285
  return Array.from(sessions.values());
2049
2286
  }
2287
+ function sessionPathHint(file) {
2288
+ return `Looked at ${file} (the session-state layout this MCP's pinned engine publishes). If the project runs an older Extension.js, its layout may differ and this path will never appear.`;
2289
+ }
2290
+ function profilesRootDir(projectPath) {
2291
+ return node_path.join(sessionArtifactsRootDir(projectPath), "profiles");
2292
+ }
2293
+ function browserProfileRootDir(projectPath, browser) {
2294
+ return node_path.join(profilesRootDir(projectPath), `${browser}-profile`);
2295
+ }
2296
+ const PERSISTED_PROFILE_DIR_NAME = "dev";
2297
+ function profileRemediation(input) {
2298
+ const { projectPath, browser, profile } = input;
2299
+ const raw = "string" == typeof profile ? profile.trim() : "";
2300
+ if ("false" === raw.toLowerCase()) return `This session was launched with profile:"false", so it runs your real ${browser} profile and there is no Extension.js profile directory to remove. Quit the ${browser} window that holds it and start again.`;
2301
+ if (raw.length > 0) {
2302
+ const explicit = node_path.isAbsolute(raw) ? raw : node_path.resolve(projectPath, raw);
2303
+ return `This session was launched against the profile you passed, ${explicit}. Close whatever still holds it, or remove that directory, before retrying.`;
2304
+ }
2305
+ const root = browserProfileRootDir(projectPath, browser);
2306
+ return `The engine keeps this session's profile in a directory inside ${root}, one per run: "${PERSISTED_PROFILE_DIR_NAME}" when the profile is persisted, otherwise three random words drawn fresh on every start, which no caller can predict. List ${root} to see which run directories exist and remove the one the stuck browser holds, or remove ${root} entirely once no session is running.`;
2307
+ }
2050
2308
  function contractSightings(projectPath) {
2051
- const root = node_path.resolve(projectPath, "dist", "extension-js");
2309
+ const root = sessionArtifactsRootDir(projectPath);
2052
2310
  let dirs;
2053
2311
  try {
2054
2312
  dirs = node_fs.readdirSync(root);
@@ -2057,7 +2315,7 @@ function contractSightings(projectPath) {
2057
2315
  }
2058
2316
  const sightings = [];
2059
2317
  for (const dir of dirs){
2060
- const readyPath = node_path.join(root, dir, "ready.json");
2318
+ const readyPath = bridge_readyContractPath(projectPath, dir);
2061
2319
  try {
2062
2320
  const stat = node_fs.statSync(readyPath);
2063
2321
  const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
@@ -2126,7 +2384,7 @@ function deadReadySession(projectPath) {
2126
2384
  return null;
2127
2385
  }
2128
2386
  function contractBoundPort(projectPath, browser, since) {
2129
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
2387
+ const readyPath = bridge_readyContractPath(projectPath, browser);
2130
2388
  try {
2131
2389
  const stat = node_fs.statSync(readyPath);
2132
2390
  if (stat.mtimeMs < since) return null;
@@ -2189,9 +2447,18 @@ function isChromiumFamily(browser) {
2189
2447
  function isGeckoFamily(browser) {
2190
2448
  return GECKO_FAMILY.has(browser);
2191
2449
  }
2450
+ function insideRepository(projectPath) {
2451
+ let current = node_path.resolve(projectPath);
2452
+ for(;;){
2453
+ if (node_fs.existsSync(node_path.join(current, ".git"))) return true;
2454
+ const parent = node_path.dirname(current);
2455
+ if (parent === current) return false;
2456
+ current = parent;
2457
+ }
2458
+ }
2192
2459
  function ensureProjectIgnored(projectPath, options) {
2193
2460
  const entry = options.entry;
2194
- if (!node_fs.existsSync(node_path.join(projectPath, ".git"))) return {
2461
+ if (!insideRepository(projectPath)) return {
2195
2462
  state: "not-a-repo",
2196
2463
  entry
2197
2464
  };
@@ -2223,6 +2490,56 @@ function ensureProjectIgnored(projectPath, options) {
2223
2490
  };
2224
2491
  }
2225
2492
  }
2493
+ const placedHere = new Set();
2494
+ function recordDir() {
2495
+ return node_path.join(sessionStateDir(), "carriers");
2496
+ }
2497
+ function recordPath(resolved) {
2498
+ const digest = node_crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 16);
2499
+ return node_path.join(recordDir(), `${digest}.json`);
2500
+ }
2501
+ function rememberCarrier(projectPath) {
2502
+ const resolved = node_path.resolve(projectPath);
2503
+ placedHere.add(resolved);
2504
+ try {
2505
+ node_fs.mkdirSync(recordDir(), {
2506
+ recursive: true
2507
+ });
2508
+ node_fs.writeFileSync(recordPath(resolved), `${JSON.stringify({
2509
+ projectPath: resolved,
2510
+ pid: process.pid,
2511
+ placedAt: new Date().toISOString()
2512
+ })}\n`);
2513
+ } catch {}
2514
+ }
2515
+ function forgetCarrier(projectPath) {
2516
+ const resolved = node_path.resolve(projectPath);
2517
+ placedHere.delete(resolved);
2518
+ try {
2519
+ node_fs.rmSync(recordPath(resolved), {
2520
+ force: true
2521
+ });
2522
+ } catch {}
2523
+ }
2524
+ function carriersPlacedHere() {
2525
+ return [
2526
+ ...placedHere
2527
+ ];
2528
+ }
2529
+ function rememberedCarriers() {
2530
+ let files;
2531
+ try {
2532
+ files = node_fs.readdirSync(recordDir());
2533
+ } catch {
2534
+ return [];
2535
+ }
2536
+ const out = [];
2537
+ for (const file of files)if (file.endsWith(".json")) try {
2538
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(recordDir(), file), "utf8"));
2539
+ if ("string" == typeof parsed.projectPath && parsed.projectPath) out.push(parsed.projectPath);
2540
+ } catch {}
2541
+ return out;
2542
+ }
2226
2543
  const CARRIER_DIR_NAME = "extension-dev-live-preview";
2227
2544
  const CARRIER_EXTENSION_ID = "ibppeifnekhjjjmpjfiobccjlicbmgcb";
2228
2545
  const MARKER_FILE = "managed-by-extension-dev-mcp.json";
@@ -2241,16 +2558,78 @@ function deriveCarrierId(source) {
2241
2558
  function carrierPath(projectPath) {
2242
2559
  return node_path.join(projectPath, "extensions", CARRIER_DIR_NAME);
2243
2560
  }
2561
+ function relativeFiles(dir, base = dir, depth = 0) {
2562
+ if (depth > 4) return null;
2563
+ let entries;
2564
+ try {
2565
+ entries = node_fs.readdirSync(dir, {
2566
+ withFileTypes: true
2567
+ });
2568
+ } catch {
2569
+ return null;
2570
+ }
2571
+ const out = [];
2572
+ for (const entry of entries){
2573
+ const full = node_path.join(dir, entry.name);
2574
+ if (entry.isDirectory()) {
2575
+ const nested = relativeFiles(full, base, depth + 1);
2576
+ if (null === nested) return null;
2577
+ out.push(...nested);
2578
+ continue;
2579
+ }
2580
+ out.push(node_path.relative(base, full));
2581
+ if (out.length > 500) return null;
2582
+ }
2583
+ return out;
2584
+ }
2585
+ function claimCarrier(target) {
2586
+ if (node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
2587
+ ours: true,
2588
+ how: "marker"
2589
+ };
2590
+ if (deriveCarrierId(target) === CARRIER_EXTENSION_ID) return {
2591
+ ours: true,
2592
+ how: "payload"
2593
+ };
2594
+ if (node_fs.existsSync(node_path.join(target, "manifest.json"))) return {
2595
+ ours: false,
2596
+ how: "foreign"
2597
+ };
2598
+ const source = findBundledCarrier("chromium");
2599
+ const bundled = source ? relativeFiles(source) : null;
2600
+ const present = relativeFiles(target);
2601
+ if (!bundled || !present) return {
2602
+ ours: false,
2603
+ how: "foreign"
2604
+ };
2605
+ const known = new Set(bundled);
2606
+ return present.every((file)=>known.has(file)) ? {
2607
+ ours: true,
2608
+ how: "partial"
2609
+ } : {
2610
+ ours: false,
2611
+ how: "foreign"
2612
+ };
2613
+ }
2614
+ const RECOVERY_NOTE = {
2615
+ payload: `Its ${MARKER_FILE} marker was missing, but its manifest key derives the carrier's own extension id ${CARRIER_EXTENSION_ID}, which only this package's payload has, so it was recognised as ours and taken back.`,
2616
+ partial: `Its ${MARKER_FILE} marker was missing and it holds no manifest, but every file in it belongs to the bundled carrier payload, so it was recognised as a half-written copy of ours and taken back.`
2617
+ };
2618
+ const FOREIGN_NOTE = `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, does not carry the carrier's own manifest key, and holds files this package never ships, so it is not the carrier this tool placed and was left untouched. Nothing here deletes a directory this tool did not write: rename it or move it out of ./extensions yourself if you want the carrier to live at that path.`;
2244
2619
  function removeCarrier(projectPath) {
2245
2620
  const target = carrierPath(projectPath);
2246
- if (!node_fs.existsSync(target)) return {
2247
- removed: false,
2248
- path: target
2249
- };
2250
- if (!node_fs.existsSync(node_path.join(target, MARKER_FILE))) return {
2621
+ if (!node_fs.existsSync(target)) {
2622
+ forgetCarrier(projectPath);
2623
+ return {
2624
+ removed: false,
2625
+ path: target
2626
+ };
2627
+ }
2628
+ const claim = claimCarrier(target);
2629
+ if (!claim.ours) return {
2251
2630
  removed: false,
2252
2631
  path: target,
2253
- note: `extensions/${CARRIER_DIR_NAME} has no ${MARKER_FILE} marker, so it is not the carrier this tool placed and was left untouched.`
2632
+ note: FOREIGN_NOTE
2254
2633
  };
2255
2634
  try {
2256
2635
  node_fs.rmSync(target, {
@@ -2264,13 +2643,17 @@ function removeCarrier(projectPath) {
2264
2643
  note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
2265
2644
  };
2266
2645
  }
2646
+ forgetCarrier(projectPath);
2267
2647
  const parent = node_path.join(projectPath, "extensions");
2268
2648
  try {
2269
2649
  if (0 === node_fs.readdirSync(parent).length) node_fs.rmdirSync(parent);
2270
2650
  } catch {}
2271
2651
  return {
2272
2652
  removed: true,
2273
- path: target
2653
+ path: target,
2654
+ ..."marker" === claim.how ? {} : {
2655
+ note: RECOVERY_NOTE[claim.how]
2656
+ }
2274
2657
  };
2275
2658
  }
2276
2659
  function ensureCarrierIgnored(projectPath) {
@@ -2308,10 +2691,14 @@ function materializeCarrier(projectPath, browser) {
2308
2691
  };
2309
2692
  const target = carrierPath(projectPath);
2310
2693
  const marker = node_path.join(target, MARKER_FILE);
2311
- if (node_fs.existsSync(target) && !node_fs.existsSync(marker)) return {
2694
+ const claim = node_fs.existsSync(target) ? claimCarrier(target) : {
2695
+ ours: true,
2696
+ how: "marker"
2697
+ };
2698
+ if (!claim.ours) return {
2312
2699
  loaded: false,
2313
2700
  path: target,
2314
- note: `A directory already exists at extensions/${CARRIER_DIR_NAME} without the ${MARKER_FILE} marker, so it is not managed by this tool and was left untouched. Remove or rename it to let extension_dev place the carrier there.`
2701
+ note: `A directory already exists at extensions/${CARRIER_DIR_NAME} that this tool did not place: no ${MARKER_FILE} marker, no carrier manifest key, and files this package never ships. It was left untouched. Rename it or move it out of ./extensions to let extension_dev place the carrier there.`
2315
2702
  };
2316
2703
  const carrierId = deriveCarrierId(source);
2317
2704
  try {
@@ -2328,6 +2715,7 @@ function materializeCarrier(projectPath, browser) {
2328
2715
  carrierVersion: manifest.version ?? "unknown",
2329
2716
  note: "Safe to delete; extension_dev recreates it when carrier: true. extension_stop and extension_build remove it for you."
2330
2717
  }, null, 2)}\n`);
2718
+ rememberCarrier(projectPath);
2331
2719
  const ignored = ensureCarrierIgnored(projectPath);
2332
2720
  return {
2333
2721
  loaded: true,
@@ -2335,7 +2723,7 @@ function materializeCarrier(projectPath, browser) {
2335
2723
  ...ignored ? {
2336
2724
  gitignored: ignored
2337
2725
  } : {},
2338
- note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://preview.extension.dev/?session=live in the dev browser (any http://localhost origin works too) to watch the session's real-lane chrome.* trace on the Trace tab. It is a debug companion, never part of a release: extension_stop and extension_build remove it again" + (ignored ? `, and ${ignored} was added to .gitignore.` : "."),
2726
+ note: "Live-preview carrier placed in ./extensions; Extension.js loads it as a companion beside your extension. Open https://preview.extension.dev/ in the dev browser, load a build from this machine, and switch the lane toggle to Real to watch the session's real-lane chrome.* trace on the Trace tab. It is a debug companion, never part of a release: extension_stop and extension_build remove it again" + (ignored ? `, and ${ignored} was added to .gitignore.` : "."),
2339
2727
  limitations: [
2340
2728
  "The trace shows calls a PAGE bridges to the carrier. Your extension's own chrome.* calls run directly in its contexts and never cross the carrier, so they do not appear.",
2341
2729
  "Bridged calls run under the CARRIER's identity, not your extension's. The preview assumes a single active guest and does not namespace per-extension state, so storage, action/badge state, messaging delivery, offscreen documents and relative script paths belong to the carrier. Rows affected are badged carrier-scoped in the Trace tab.",
@@ -2345,7 +2733,7 @@ function materializeCarrier(projectPath, browser) {
2345
2733
  ...carrierId ? {
2346
2734
  bridgeProtocol: {
2347
2735
  carrierExtensionId: carrierId,
2348
- allowedOrigins: "https://preview.extension.dev, https://code.extension.dev, https://themes.extension.dev, http://localhost/*, http://127.0.0.1/*",
2736
+ allowedOrigins: "https://preview.extension.dev, https://code.extension.dev, https://themes.extension.dev, and those same apps' dev servers on http://localhost and http://127.0.0.1 (ports 3103, 3104, 3110, 3111). The carrier checks the sender's origin, so a page on any other localhost port is refused.",
2349
2737
  howTo: "From a page on an allowed origin, register your guest once with a 'session' message (it declares the permissions the carrier enforces), then send 'bridge' messages to run chrome.* for real; each one streams into the Trace tab. Use the EXACT dotted wire names the bridge dispatcher accepts: storage is storage.get/set/remove/clear with the AREA AS AN ARGUMENT, NOT storage.local.get.",
2350
2738
  example: [
2351
2739
  `const id = '${carrierId}'`,
@@ -2364,16 +2752,115 @@ function materializeCarrier(projectPath, browser) {
2364
2752
  };
2365
2753
  }
2366
2754
  }
2755
+ const EOCD_SIGNATURE = 0x06054b50;
2756
+ const CENTRAL_SIGNATURE = 0x02014b50;
2757
+ const EOCD_MIN_LENGTH = 22;
2758
+ const MAX_COMMENT_LENGTH = 0xffff;
2759
+ const MAX_DIRECTORY_BYTES = 33554432;
2760
+ const UNREADABLE = {
2761
+ names: [],
2762
+ readable: false
2763
+ };
2764
+ function readZipEntryNames(zipPath) {
2765
+ let handle = null;
2766
+ try {
2767
+ const size = node_fs.statSync(zipPath).size;
2768
+ if (size < EOCD_MIN_LENGTH) return UNREADABLE;
2769
+ handle = node_fs.openSync(zipPath, "r");
2770
+ const tailLength = Math.min(size, MAX_COMMENT_LENGTH + EOCD_MIN_LENGTH);
2771
+ const tail = Buffer.alloc(tailLength);
2772
+ node_fs.readSync(handle, tail, 0, tailLength, size - tailLength);
2773
+ let eocd = -1;
2774
+ for(let at = tail.length - EOCD_MIN_LENGTH; at >= 0; at -= 1)if (tail.readUInt32LE(at) === EOCD_SIGNATURE) {
2775
+ eocd = at;
2776
+ break;
2777
+ }
2778
+ if (eocd < 0) return UNREADABLE;
2779
+ const expected = tail.readUInt16LE(eocd + 10);
2780
+ const directoryBytes = tail.readUInt32LE(eocd + 12);
2781
+ const directoryOffset = tail.readUInt32LE(eocd + 16);
2782
+ if (0xffff === expected || 0xffffffff === directoryBytes || 0xffffffff === directoryOffset || directoryBytes > MAX_DIRECTORY_BYTES || directoryOffset + directoryBytes > size) return UNREADABLE;
2783
+ if (0 === directoryBytes) return {
2784
+ names: [],
2785
+ readable: 0 === expected
2786
+ };
2787
+ const directory = Buffer.alloc(directoryBytes);
2788
+ node_fs.readSync(handle, directory, 0, directoryBytes, directoryOffset);
2789
+ const names = [];
2790
+ let at = 0;
2791
+ while(at + 46 <= directory.length){
2792
+ if (directory.readUInt32LE(at) !== CENTRAL_SIGNATURE) return UNREADABLE;
2793
+ const nameLength = directory.readUInt16LE(at + 28);
2794
+ const extraLength = directory.readUInt16LE(at + 30);
2795
+ const commentLength = directory.readUInt16LE(at + 32);
2796
+ const nameStart = at + 46;
2797
+ if (nameStart + nameLength > directory.length) return UNREADABLE;
2798
+ names.push(directory.subarray(nameStart, nameStart + nameLength).toString("utf8"));
2799
+ at = nameStart + nameLength + extraLength + commentLength;
2800
+ }
2801
+ return {
2802
+ names,
2803
+ readable: names.length === expected
2804
+ };
2805
+ } catch {
2806
+ return UNREADABLE;
2807
+ } finally{
2808
+ if (null !== handle) try {
2809
+ node_fs.closeSync(handle);
2810
+ } catch {}
2811
+ }
2812
+ }
2367
2813
  const build_COMMAND = "extension_build";
2368
2814
  function readBuildSummary(projectPath, browser, since) {
2369
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
2815
+ const file = buildSummaryPath(projectPath, browser);
2370
2816
  try {
2371
2817
  const stat = node_fs.statSync(file);
2372
- if (stat.mtimeMs < since) return null;
2373
- const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
2374
- if (summary && "object" == typeof summary) return summary;
2818
+ if (stat.mtimeMs >= since) {
2819
+ const summary = JSON.parse(node_fs.readFileSync(file, "utf8"));
2820
+ if (summary && "object" == typeof summary) return {
2821
+ file,
2822
+ summary
2823
+ };
2824
+ }
2375
2825
  } catch {}
2376
- return null;
2826
+ return {
2827
+ file,
2828
+ summary: null
2829
+ };
2830
+ }
2831
+ function readEngineOutput(stdout, stderr) {
2832
+ let frame = null;
2833
+ const rest = [];
2834
+ for (const line of stdout.split("\n")){
2835
+ const text = line.trim();
2836
+ if (!frame && text.startsWith("{")) {
2837
+ let parsed = null;
2838
+ try {
2839
+ parsed = JSON.parse(text);
2840
+ } catch {}
2841
+ if (isEnvelope(parsed)) {
2842
+ frame = parsed;
2843
+ continue;
2844
+ }
2845
+ }
2846
+ rest.push(line);
2847
+ }
2848
+ const narration = [
2849
+ rest.join("\n").trim(),
2850
+ stderr.trim()
2851
+ ].filter(Boolean).join("\n");
2852
+ return {
2853
+ frame,
2854
+ narration
2855
+ };
2856
+ }
2857
+ function engineSummaries(frame) {
2858
+ const value = frame?.value;
2859
+ if (!value || !Array.isArray(value.summaries)) return [];
2860
+ return value.summaries.filter((entry)=>Boolean(entry) && "object" == typeof entry);
2861
+ }
2862
+ function summaryForBrowser(summaries, browser) {
2863
+ return summaries.find((entry)=>entry.browser === browser) ?? summaries[0] ?? null;
2377
2864
  }
2378
2865
  function builtEntrypoints(distDir) {
2379
2866
  let manifest;
@@ -2462,7 +2949,7 @@ function locateSourceZip(projectPath, browser, since) {
2462
2949
  }
2463
2950
  const build_schema = {
2464
2951
  name: "extension_build",
2465
- description: "Build a browser extension for production. The output lands in dist/<browser>/. Pass zip:true to also package a .zip for store submission. The build refuses a manifest with build-blocking errors unless you pass skipValidation:true, because such a manifest yields a broken bundle the bundler itself never flags.",
2952
+ description: "Build a browser extension for production. The output lands in dist/<browser>/. Pass zip:true to also package a .zip for store submission. With browser:'safari' the build converts the extension into a macOS app through Xcode, and bundleId sets the identifier it ships under. The build refuses a manifest with build-blocking errors unless you pass skipValidation:true, because such a manifest yields a broken bundle the bundler itself never flags.",
2466
2953
  inputSchema: {
2467
2954
  type: "object",
2468
2955
  properties: {
@@ -2506,6 +2993,24 @@ const build_schema = {
2506
2993
  type: "boolean",
2507
2994
  default: false,
2508
2995
  description: "Build even when extension_manifest_validate reports build-blocking errors. The build normally refuses: a manifest error yields a broken bundle the bundler itself never flags."
2996
+ },
2997
+ appName: {
2998
+ type: "string",
2999
+ description: "Safari targets only: name of the generated macOS app, which also names the Xcode scheme and the .app on disk. Defaults to the manifest name."
3000
+ },
3001
+ bundleId: {
3002
+ type: "string",
3003
+ description: "Safari targets only: a reverse-DNS bundle identifier you own, such as com.acme.readinglist. Without one the app is packaged under a generated dev.extensionjs.* identifier derived from the app name, which every project built from the same template shares, and the first team to register it takes it."
3004
+ },
3005
+ macOsOnly: {
3006
+ type: "boolean",
3007
+ default: true,
3008
+ description: "Safari targets only: generate a macOS-only Xcode project. Pass false for a universal project that also targets iOS and iPadOS, which is what you want if the extension ships on iPhone or iPad."
3009
+ },
3010
+ forceRegenerate: {
3011
+ type: "boolean",
3012
+ default: false,
3013
+ description: "Safari targets only: regenerate the Xcode project even when the engine considers it up to date. Use it when an earlier packaging run left the project broken."
2509
3014
  }
2510
3015
  },
2511
3016
  required: [
@@ -2513,6 +3018,11 @@ const build_schema = {
2513
3018
  ]
2514
3019
  }
2515
3020
  };
3021
+ const SAFARI_VENDORS = new Set([
3022
+ "safari",
3023
+ "webkit-based"
3024
+ ]);
3025
+ const BUNDLE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$/;
2516
3026
  function manifestDivergence(projectPath, browser) {
2517
3027
  const read = (p)=>{
2518
3028
  try {
@@ -2540,23 +3050,58 @@ function manifestDivergence(projectPath, browser) {
2540
3050
  return notes;
2541
3051
  }
2542
3052
  const MARKER_FILE_NAME = "managed-by-extension-dev-mcp.json";
2543
- function carrierEntriesInDist(distDir, depth = 0) {
2544
- if (depth > 3) return [];
3053
+ function namesCarrier(entryName) {
3054
+ return entryName === CARRIER_DIR_NAME || entryName === MARKER_FILE_NAME;
3055
+ }
3056
+ function carrierEntriesInZip(zipPath) {
3057
+ const listing = readZipEntryNames(zipPath);
3058
+ if (!listing.readable) return {
3059
+ paths: [],
3060
+ unchecked: [
3061
+ zipPath
3062
+ ]
3063
+ };
3064
+ const hits = listing.names.filter((name)=>name.split("/").some(namesCarrier)).map((name)=>`${zipPath} -> ${name}`);
3065
+ return {
3066
+ paths: hits,
3067
+ unchecked: []
3068
+ };
3069
+ }
3070
+ function carrierContamination(dir, depth = 0) {
3071
+ if (depth > 4) return {
3072
+ paths: [],
3073
+ unchecked: []
3074
+ };
2545
3075
  let entries;
2546
3076
  try {
2547
- entries = node_fs.readdirSync(distDir, {
3077
+ entries = node_fs.readdirSync(dir, {
2548
3078
  withFileTypes: true
2549
3079
  });
2550
3080
  } catch {
2551
- return [];
3081
+ return {
3082
+ paths: [],
3083
+ unchecked: []
3084
+ };
2552
3085
  }
2553
- const found = [];
3086
+ const found = {
3087
+ paths: [],
3088
+ unchecked: []
3089
+ };
3090
+ const absorb = (other)=>{
3091
+ found.paths.push(...other.paths);
3092
+ found.unchecked.push(...other.unchecked);
3093
+ };
2554
3094
  for (const entry of entries){
2555
- if (entry.name === CARRIER_DIR_NAME || entry.name === MARKER_FILE_NAME) {
2556
- found.push(node_path.join(distDir, entry.name));
3095
+ const full = node_path.join(dir, entry.name);
3096
+ if (namesCarrier(entry.name)) {
3097
+ found.paths.push(full);
2557
3098
  continue;
2558
3099
  }
2559
- if (entry.isDirectory()) found.push(...carrierEntriesInDist(node_path.join(distDir, entry.name), depth + 1));
3100
+ if (entry.isDirectory()) {
3101
+ absorb(carrierContamination(full, depth + 1));
3102
+ continue;
3103
+ }
3104
+ if (entry.isFile() && entry.name.endsWith(".zip")) absorb(carrierEntriesInZip(full));
2560
3105
  }
2561
3106
  return found;
2562
3107
  }
@@ -2585,12 +3130,61 @@ async function validationPreflight(projectPath, browser) {
2585
3130
  async function build_handler(args) {
2586
3131
  const start = Date.now();
2587
3132
  const browser = args.browser ?? "chrome";
3133
+ const safari = SAFARI_VENDORS.has(browser);
3134
+ const safariOnly = [
3135
+ [
3136
+ "appName",
3137
+ args.appName
3138
+ ],
3139
+ [
3140
+ "bundleId",
3141
+ args.bundleId
3142
+ ],
3143
+ [
3144
+ "macOsOnly",
3145
+ args.macOsOnly
3146
+ ],
3147
+ [
3148
+ "forceRegenerate",
3149
+ true === args.forceRegenerate ? true : void 0
3150
+ ]
3151
+ ].filter(([, value])=>void 0 !== value).map(([name])=>name);
3152
+ if (safariOnly.length > 0 && !safari) return envelope_envelope({
3153
+ ok: false,
3154
+ command: build_COMMAND,
3155
+ status: "safari-only-option",
3156
+ error: {
3157
+ code: "E_SAFARI_ONLY_OPTION",
3158
+ message: `${safariOnly.join(", ")} configure the Safari web-extension conversion and mean nothing for a ${browser} build. Nothing was built, so the options were not silently ignored.`
3159
+ },
3160
+ value: {
3161
+ browser,
3162
+ options: safariOnly,
3163
+ duration: Date.now() - start
3164
+ },
3165
+ hint: `Pass browser: "safari" to package a Safari app, or drop these options to build for ${browser}.`
3166
+ });
3167
+ if (void 0 !== args.bundleId && !BUNDLE_ID_PATTERN.test(args.bundleId)) return envelope_envelope({
3168
+ ok: false,
3169
+ command: build_COMMAND,
3170
+ status: "invalid-bundle-id",
3171
+ error: {
3172
+ code: "E_INVALID_BUNDLE_ID",
3173
+ message: `bundleId ${JSON.stringify(args.bundleId)} is not a reverse-DNS identifier, so Xcode would reject it. Expected two or more dot-separated segments of letters, digits and hyphens, each starting with a letter.`
3174
+ },
3175
+ value: {
3176
+ browser,
3177
+ bundleId: args.bundleId,
3178
+ duration: Date.now() - start
3179
+ },
3180
+ hint: 'Use an identifier under a domain you own, for example "com.acme.readinglist".'
3181
+ });
2588
3182
  const carrierCleanup = removeCarrier(args.projectPath);
2589
3183
  const carrierNotes = [];
2590
3184
  if (carrierCleanup.removed) carrierNotes.push("Removed the Extension.dev live-preview carrier from ./extensions before building. It is a debug companion, not part of your extension; run extension_dev with carrier: true to get it back.");
2591
3185
  else if (carrierCleanup.note) carrierNotes.push(carrierCleanup.note);
2592
3186
  const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
2593
- if (preflight?.buildBlocking) return envelope({
3187
+ if (preflight?.buildBlocking) return envelope_envelope({
2594
3188
  ok: false,
2595
3189
  command: build_COMMAND,
2596
3190
  status: "manifest-blocked",
@@ -2623,40 +3217,63 @@ async function build_handler(args) {
2623
3217
  if (args.polyfill) cliArgs.push("--polyfill");
2624
3218
  if (args.silent) cliArgs.push("--silent");
2625
3219
  if (args.mode) cliArgs.push("--mode", args.mode);
2626
- const { code, stdout, stderr } = await runExtensionCli(cliArgs, {
3220
+ if (args.appName) cliArgs.push("--app-name", args.appName);
3221
+ if (args.bundleId) cliArgs.push("--bundle-id", args.bundleId);
3222
+ if (void 0 !== args.macOsOnly) cliArgs.push("--macos-only", String(args.macOsOnly));
3223
+ if (args.forceRegenerate) cliArgs.push("--force-regenerate");
3224
+ const spawn = {
2627
3225
  cwd: args.projectPath,
2628
3226
  timeoutMs: 180000
2629
- });
3227
+ };
3228
+ const verdict = await outputJsonVerdict("build", args.projectPath);
3229
+ const engineKnownTooOld = false === verdict.supported;
3230
+ let attempt = engineKnownTooOld ? await runExtensionCli(cliArgs, spawn) : await runExtensionCli([
3231
+ ...cliArgs,
3232
+ "--output",
3233
+ "json"
3234
+ ], spawn);
3235
+ const engineRefusedJsonOutput = !engineKnownTooOld && 0 !== attempt.code && refusedTheOutputFlag(attempt.stderr ?? "");
3236
+ if (engineRefusedJsonOutput) attempt = await runExtensionCli(cliArgs, spawn);
3237
+ const { code, stdout, stderr } = attempt;
3238
+ if (engineRefusedJsonOutput) warnings.push("The Extension.js installed in this project is older than the one this server expects: it rejected --output json on build, so the build was run a second time without that flag and the result was read from the build summary the engine writes into dist/extension-js/. The extension that came out is exactly the same one. Upgrade the project's Extension.js to get the richer report back, including the Safari app identity and the byte totals from the run that just happened, and to stop paying for the second build.");
3239
+ else if (engineKnownTooOld) warnings.push(`The Extension.js installed in this project is older than the one this server expects: it reports ${verdict.version}, and --output json only reached extension build in ${verdict.floor}, so the build was run without that flag and the result was read from the build summary the engine writes into dist/extension-js/. The extension that came out is exactly the same one, and nothing was built twice. Upgrade the project's Extension.js to get the richer report back, including the Safari app identity and the byte totals from the run that just happened.`);
2630
3240
  const duration = Date.now() - start;
2631
- const out = (stdout ?? "").trim();
3241
+ const engine = readEngineOutput(stdout ?? "", stderr ?? "");
3242
+ const out = engine.narration;
2632
3243
  const lastLines = (text, n)=>text.split("\n").slice(-n).join("\n");
2633
3244
  if (0 === code) {
2634
- const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
2635
- const status = out.match(/Build Status:\s*(\w+)/)?.[1];
2636
- const engineSummary = readBuildSummary(args.projectPath, browser, start);
3245
+ const inlineSummary = summaryForBrowser(engineSummaries(engine.frame), browser);
3246
+ const persisted = inlineSummary ? null : readBuildSummary(args.projectPath, browser, start);
3247
+ const engineSummary = inlineSummary ?? persisted?.summary ?? null;
3248
+ const summaryPathNote = persisted && !persisted.summary ? `This build reported no summary of its own, and no summary from this run was found on disk either, so the byte totals and the engine's structured warnings are missing from the result below. The extension that was built is unaffected. ${sessionPathHint(persisted.file)}` : null;
3249
+ const status = engine.frame?.status;
2637
3250
  const buildWarnings = engineSummary?.warnings?.length ? engineSummary.warnings : [];
2638
3251
  const buildWarningsTruncated = buildWarnings.length && "number" == typeof engineSummary?.warnings_count && engineSummary.warnings_count > buildWarnings.length ? engineSummary.warnings_count : void 0;
2639
3252
  const distDir = node_path.resolve(args.projectPath, "dist", browser);
2640
3253
  const entrypoints = builtEntrypoints(distDir);
2641
- const contamination = carrierEntriesInDist(distDir);
2642
- if (contamination.length) return envelope({
3254
+ const contamination = carrierContamination(node_path.resolve(args.projectPath, "dist"));
3255
+ const uncheckedNote = contamination.unchecked.length ? `Could not read the entry table of ${contamination.unchecked.join(", ")}, so those archives were not checked for the live-preview carrier. Unpack and check them yourself before submitting.` : null;
3256
+ if (contamination.paths.length) return envelope_envelope({
2643
3257
  ok: false,
2644
3258
  command: build_COMMAND,
2645
3259
  status: "carrier-in-dist",
2646
3260
  error: {
2647
3261
  code: "E_CARRIER_IN_DIST",
2648
- message: `The build output contains the Extension.dev live-preview carrier: ${contamination.join(", ")}. That is a local debug companion and must never ship. This artifact is not safe to submit.`
3262
+ message: `The build output contains the Extension.dev live-preview carrier: ${contamination.paths.join(", ")}. That is a local debug companion and must never ship. This artifact is not safe to submit.`
2649
3263
  },
2650
3264
  value: {
2651
3265
  browser,
2652
3266
  buildExitCode: 0,
2653
3267
  duration
2654
3268
  },
2655
- warnings,
2656
- hint: "Delete the listed paths from dist and build again. The carrier normally lives in ./extensions and is removed before every build."
3269
+ warnings: [
3270
+ ...warnings,
3271
+ uncheckedNote
3272
+ ],
3273
+ hint: "Delete the listed paths from dist and build again. The carrier lives in ./extensions and is taken back before every build run through this tool, so an entry inside a zip means that archive was packed by something else, usually 'extension build --zip-source' driven straight at the engine while a dev session had the carrier in place."
2657
3274
  });
2658
3275
  const missing = entrypoints.filter((e)=>!e.present);
2659
- if (missing.length) return envelope({
3276
+ if (missing.length) return envelope_envelope({
2660
3277
  ok: false,
2661
3278
  command: build_COMMAND,
2662
3279
  status: "entrypoint-missing",
@@ -2684,18 +3301,36 @@ async function build_handler(args) {
2684
3301
  const zipSourcePath = args.zipSource ? locateSourceZip(args.projectPath, browser, start) : null;
2685
3302
  if (args.zipSource && !zipSourcePath) zipNotes.push("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.");
2686
3303
  const divergence = manifestDivergence(args.projectPath, browser);
2687
- return envelope({
3304
+ const safariIdentity = safari ? engineSummary?.safari ?? null : null;
3305
+ const derivedBundleIdNote = safariIdentity?.bundleIdDerived === true ? `The Safari app was packaged under the generated bundle identifier ${safariIdentity.bundleId ?? "the engine derived for you"}, which the engine derived from your app name rather than one you chose. It is fine for running the app locally. Every project built from the same template derives the same identifier, and Apple binds one permanently to the first team that registers it, so whoever submits first takes it and everyone after is locked out. Rebuild with bundleId set to a reverse-DNS identifier under a domain you own, which regenerates the Xcode project, and do it before your first submission: afterwards a new identifier is a new extension carrying none of your users.` : null;
3306
+ const safariIdentityMissingNote = safari && !safariIdentity ? `The build succeeded but reported no Safari app identity, so this run cannot tell you which bundle identifier the app carries. Either the packager did not run (a non-macOS host, or Xcode missing, skips packaging and leaves a plain bundle in dist/${browser}), or the engine installed in this project predates the reporting contract. Check the build output below, and run extension_doctor if you expected an app.` : null;
3307
+ return envelope_envelope({
2688
3308
  ok: true,
2689
3309
  command: build_COMMAND,
2690
3310
  status: "built",
2691
3311
  value: {
2692
3312
  browser,
2693
- ...size ? {
2694
- size
3313
+ ...safariIdentity ? {
3314
+ safariApp: safariIdentity
3315
+ } : {},
3316
+ ..."string" == typeof engineSummary?.output_path ? {
3317
+ outputPath: engineSummary.output_path
3318
+ } : {},
3319
+ ..."number" == typeof engineSummary?.total_bytes ? {
3320
+ totalBytes: engineSummary.total_bytes
3321
+ } : {},
3322
+ ..."number" == typeof engineSummary?.total_assets ? {
3323
+ totalAssets: engineSummary.total_assets
3324
+ } : {},
3325
+ ..."number" == typeof engineSummary?.largest_asset_bytes ? {
3326
+ largestAssetBytes: engineSummary.largest_asset_bytes
2695
3327
  } : {},
2696
3328
  ...status ? {
2697
3329
  engineBuildStatus: status
2698
3330
  } : {},
3331
+ ...engineRefusedJsonOutput ? {
3332
+ engineRejectedJsonOutput: true
3333
+ } : {},
2699
3334
  ...entrypoints.length ? {
2700
3335
  entrypoints
2701
3336
  } : {},
@@ -2719,12 +3354,17 @@ async function build_handler(args) {
2719
3354
  ...warnings,
2720
3355
  ...preflight?.warnings ?? [],
2721
3356
  ...buildWarnings,
2722
- ...zipNotes
3357
+ ...zipNotes,
3358
+ uncheckedNote,
3359
+ derivedBundleIdNote,
3360
+ safariIdentityMissingNote,
3361
+ summaryPathNote
2723
3362
  ]
2724
3363
  });
2725
3364
  }
2726
- const message = stderr.trim() || out || `extension build exited with code ${code}`;
2727
- return envelope({
3365
+ const engineFailure = "string" == typeof engine.frame?.error?.message ? engine.frame.error.message.trim() : "";
3366
+ const message = engineFailure || stderr.trim() || out || `extension build exited with code ${code}`;
3367
+ return envelope_envelope({
2728
3368
  ok: false,
2729
3369
  command: build_COMMAND,
2730
3370
  status: "build-failed",
@@ -2737,7 +3377,7 @@ async function build_handler(args) {
2737
3377
  duration
2738
3378
  },
2739
3379
  warnings,
2740
- hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
3380
+ hint: "Check that the project has a valid src/manifest.json. Missing dependencies are installed by the build itself, so a failure here is usually the manifest, a compile error, or a Safari toolchain the host does not have."
2741
3381
  });
2742
3382
  }
2743
3383
  const MERGED_FD_CHATTER = [
@@ -2764,12 +3404,9 @@ function legacyProfileLockScrape(cleanOutput) {
2764
3404
  }
2765
3405
  const LEGACY_FIDELITY_WARNING = "This session ran a CLI that does not stamp the machine contract, so the boot verdict was read from the dev server's output instead of its ready contract. Diagnostics are less precise: compile errors come back as a text tail rather than a list. Upgrade the project's extension dependency to get the precise verdict.";
2766
3406
  const READY_STAMP_PROFILE_LOCKED_CODE = "profile_locked";
2767
- function readyContractPath(projectPath, browser) {
2768
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
2769
- }
2770
3407
  function readContract(projectPath, browser, since) {
2771
3408
  try {
2772
- const file = readyContractPath(projectPath, browser);
3409
+ const file = bridge_readyContractPath(projectPath, browser);
2773
3410
  const stat = node_fs.statSync(file);
2774
3411
  const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
2775
3412
  if (!contract || "object" != typeof contract) return null;
@@ -2893,6 +3530,71 @@ async function pollBootVerdict(projectPath, browser, options) {
2893
3530
  warnings: []
2894
3531
  };
2895
3532
  }
3533
+ function sweepCarriers(projectPaths) {
3534
+ const out = [];
3535
+ const seen = new Set();
3536
+ for (const projectPath of projectPaths){
3537
+ const resolved = node_path.resolve(projectPath);
3538
+ if (!seen.has(resolved)) {
3539
+ seen.add(resolved);
3540
+ try {
3541
+ const removal = removeCarrier(resolved);
3542
+ if (!removal.removed && !removal.note) continue;
3543
+ out.push({
3544
+ projectPath: resolved,
3545
+ ...removal
3546
+ });
3547
+ } catch (error) {
3548
+ out.push({
3549
+ projectPath: resolved,
3550
+ removed: false,
3551
+ path: carrierPath(resolved),
3552
+ note: `Could not remove the carrier: ${error instanceof Error ? error.message : String(error)}`
3553
+ });
3554
+ }
3555
+ }
3556
+ }
3557
+ return out;
3558
+ }
3559
+ function sweepCarriersPlacedHere() {
3560
+ try {
3561
+ return sweepCarriers(carriersPlacedHere());
3562
+ } catch {
3563
+ return [];
3564
+ }
3565
+ }
3566
+ const EXIT_SIGNALS = [
3567
+ "SIGINT",
3568
+ "SIGTERM",
3569
+ "SIGHUP"
3570
+ ];
3571
+ let carrier_exit_installed = [];
3572
+ function installCarrierExitCleanup() {
3573
+ if (carrier_exit_installed.length) return;
3574
+ const onExit = ()=>{
3575
+ sweepCarriersPlacedHere();
3576
+ };
3577
+ process.on("exit", onExit);
3578
+ carrier_exit_installed.push({
3579
+ event: "exit",
3580
+ handler: onExit
3581
+ });
3582
+ for (const signal of EXIT_SIGNALS){
3583
+ const onSignal = ()=>{
3584
+ sweepCarriersPlacedHere();
3585
+ process.removeListener(signal, onSignal);
3586
+ carrier_exit_installed = carrier_exit_installed.filter((entry)=>entry.handler !== onSignal);
3587
+ if (0 === process.listenerCount(signal)) try {
3588
+ process.kill(process.pid, signal);
3589
+ } catch {}
3590
+ };
3591
+ process.on(signal, onSignal);
3592
+ carrier_exit_installed.push({
3593
+ event: signal,
3594
+ handler: onSignal
3595
+ });
3596
+ }
3597
+ }
2896
3598
  const stop_schema = {
2897
3599
  name: "extension_stop",
2898
3600
  description: "Stop a session that extension_dev or extension_start is running: terminate the server and the browser it launched, and remove the live-preview carrier if extension_dev placed one. This covers extension_start build:false too, which the registry records as a preview session. Call it when you are done verifying, so sessions do not accumulate.",
@@ -2907,7 +3609,7 @@ const stop_schema = {
2907
3609
  all: {
2908
3610
  type: "boolean",
2909
3611
  default: false,
2910
- description: "Stop every known session across projects and browsers, found from this server's registry AND the on-disk markers earlier runs left, so it still works after an MCP restart. projectPath/browser are then ignored."
3612
+ description: "Stop every known session across projects and browsers, found from this server's registry AND the on-disk markers earlier runs left, so it still works after an MCP restart. It also takes back every live-preview carrier still recorded on this machine, including one in a project whose session was never stopped. projectPath/browser are then ignored."
2911
3613
  }
2912
3614
  },
2913
3615
  required: []
@@ -2973,7 +3675,7 @@ function sessionProcessPids(projectPath) {
2973
3675
  const escaped = escapeRegex(form);
2974
3676
  const patterns = [
2975
3677
  `extension[^ ]* (dev|start|preview) ${escaped}`,
2976
- `${escaped}${escapeRegex(node_path.sep)}dist${escapeRegex(node_path.sep)}extension-profile-`
3678
+ `${escapeRegex(profilesRootDir(form))}${escapeRegex(node_path.sep)}`
2977
3679
  ];
2978
3680
  for (const pattern of patterns)for (const pid of pgrepPids(pattern))pids.add(pid);
2979
3681
  }
@@ -3009,12 +3711,9 @@ function stop_signal(pid, sig) {
3009
3711
  }
3010
3712
  }
3011
3713
  }
3012
- function readyJsonPath(projectPath, browser) {
3013
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
3014
- }
3015
3714
  function pidFromReadyContract(projectPath, browser) {
3016
3715
  try {
3017
- const raw = node_fs.readFileSync(readyJsonPath(projectPath, browser), "utf8");
3716
+ const raw = node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8");
3018
3717
  const contract = JSON.parse(raw);
3019
3718
  return "number" == typeof contract.pid ? contract.pid : null;
3020
3719
  } catch {
@@ -3051,7 +3750,7 @@ async function stopOne(projectPath, browser) {
3051
3750
  removeSession(projectPath, browser);
3052
3751
  removeSessionMarker(projectPath, browser);
3053
3752
  try {
3054
- node_fs.rmSync(readyJsonPath(projectPath, browser), {
3753
+ node_fs.rmSync(bridge_readyContractPath(projectPath, browser), {
3055
3754
  force: true
3056
3755
  });
3057
3756
  } catch {}
@@ -3077,28 +3776,36 @@ async function stop_handler(args) {
3077
3776
  const key = `${node_path.resolve(m.projectPath)}::${m.browser}`;
3078
3777
  if (!candidates.has(key)) candidates.set(key, m);
3079
3778
  }
3080
- if (0 === candidates.size) return envelope({
3779
+ const outcomes = [];
3780
+ for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
3781
+ const visited = new Set(outcomes.map((outcome)=>node_path.resolve(outcome.projectPath)));
3782
+ const carriers = sweepCarriers(rememberedCarriers().filter((p)=>!visited.has(node_path.resolve(p))));
3783
+ if (0 === candidates.size && 0 === carriers.length) return envelope_envelope({
3081
3784
  ok: true,
3082
3785
  command: stop_schema.name,
3083
3786
  status: "nothing-to-stop",
3084
3787
  value: {
3085
3788
  stopped: []
3086
3789
  },
3087
- hint: "No sessions registered in this server and no session markers on disk. Nothing to stop."
3790
+ hint: "No sessions registered in this server, no session markers on disk, and no carrier left in any project this machine recorded. Nothing to stop."
3088
3791
  });
3089
- const outcomes = [];
3090
- for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
3091
- return envelope({
3792
+ return envelope_envelope({
3092
3793
  ok: outcomes.every((o)=>o.stopped),
3093
3794
  command: stop_schema.name,
3094
3795
  status: "stopped-all",
3095
3796
  value: {
3096
- stopped: outcomes
3797
+ stopped: outcomes,
3798
+ ...carriers.length ? {
3799
+ carriersSwept: carriers
3800
+ } : {}
3097
3801
  },
3098
- warnings: outcomes.map((o)=>o.stopped ? null : o.detail)
3802
+ warnings: [
3803
+ ...outcomes.map((o)=>o.stopped ? null : o.detail),
3804
+ ...carriers.map((c)=>c.removed ? `Took the live-preview carrier back out of ${c.projectPath}, which had no session left to stop it.` : `Left the carrier in ${c.projectPath} alone: ${c.note ?? "unknown reason"}`)
3805
+ ]
3099
3806
  });
3100
3807
  }
3101
- if (!args.projectPath) return envelope({
3808
+ if (!args.projectPath) return envelope_envelope({
3102
3809
  ok: false,
3103
3810
  command: stop_schema.name,
3104
3811
  status: "bad-request",
@@ -3109,7 +3816,7 @@ async function stop_handler(args) {
3109
3816
  });
3110
3817
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
3111
3818
  const outcome = await stopOne(args.projectPath, browser);
3112
- return envelope({
3819
+ return envelope_envelope({
3113
3820
  ok: outcome.stopped,
3114
3821
  command: stop_schema.name,
3115
3822
  status: null === outcome.pid ? "not-found" : outcome.stopped ? "stopped" : "still-alive",
@@ -3219,7 +3926,7 @@ async function dev_handler(args) {
3219
3926
  if (existing.length > 0) {
3220
3927
  if (!args.replace) {
3221
3928
  const listed = existing.map((s)=>`pid ${s.pid} (${s.browser})`).join(", ");
3222
- return envelope({
3929
+ return envelope_envelope({
3223
3930
  ok: false,
3224
3931
  command: dev_schema.name,
3225
3932
  status: "session-exists",
@@ -3277,6 +3984,9 @@ async function dev_handler(args) {
3277
3984
  child.on("exit", ()=>{
3278
3985
  removeSession(args.projectPath, browser, pid);
3279
3986
  removeSessionMarker(args.projectPath, browser, pid);
3987
+ if (carrier) try {
3988
+ removeCarrier(args.projectPath);
3989
+ } catch {}
3280
3990
  });
3281
3991
  const boot = await pollBootVerdict(args.projectPath, browser, {
3282
3992
  child,
@@ -3294,7 +4004,7 @@ async function dev_handler(args) {
3294
4004
  };
3295
4005
  if ("exited" === boot.verdict.kind) {
3296
4006
  const { exitCode: code, signal } = boot.verdict;
3297
- return envelope({
4007
+ return envelope_envelope({
3298
4008
  ok: false,
3299
4009
  command: dev_schema.name,
3300
4010
  status: "exited",
@@ -3314,7 +4024,7 @@ async function dev_handler(args) {
3314
4024
  }
3315
4025
  if ("compile-failed" === boot.verdict.kind) {
3316
4026
  const { compileErrors } = boot.verdict;
3317
- return envelope({
4027
+ return envelope_envelope({
3318
4028
  ok: false,
3319
4029
  command: dev_schema.name,
3320
4030
  status: "compile-failed",
@@ -3333,10 +4043,16 @@ async function dev_handler(args) {
3333
4043
  warnings: boot.warnings
3334
4044
  });
3335
4045
  }
3336
- const profileDir = node_path.join(args.projectPath, "dist", `extension-profile-${browser}`);
4046
+ const usesManagedProfile = "string" != typeof args.profile || 0 === args.profile.trim().length;
4047
+ const profileRootDir = usesManagedProfile ? browserProfileRootDir(args.projectPath, browser) : null;
4048
+ const profileAdvice = profileRemediation({
4049
+ projectPath: args.projectPath,
4050
+ browser,
4051
+ profile: args.profile
4052
+ });
3337
4053
  if ("profile-locked" === boot.verdict.kind) {
3338
4054
  const { owner, lockedAt } = boot.verdict;
3339
- return envelope({
4055
+ return envelope_envelope({
3340
4056
  ok: false,
3341
4057
  command: dev_schema.name,
3342
4058
  status: "profile-locked",
@@ -3346,18 +4062,20 @@ async function dev_handler(args) {
3346
4062
  },
3347
4063
  value: {
3348
4064
  ...session,
3349
- profileDir,
4065
+ ...profileRootDir ? {
4066
+ profileRootDir
4067
+ } : {},
3350
4068
  owner,
3351
4069
  ...lockedAt ? {
3352
4070
  lockedAt
3353
4071
  } : {},
3354
4072
  output: cleanOutput.slice(0, 2000)
3355
4073
  },
3356
- hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, remove ${profileDir} manually before retrying.`,
4074
+ hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, clear it by hand before retrying. ${profileAdvice}`,
3357
4075
  warnings: boot.warnings
3358
4076
  });
3359
4077
  }
3360
- if ("browser-exited" === boot.verdict.kind) return envelope({
4078
+ if ("browser-exited" === boot.verdict.kind) return envelope_envelope({
3361
4079
  ok: false,
3362
4080
  command: dev_schema.name,
3363
4081
  status: "browser-exited",
@@ -3370,7 +4088,7 @@ async function dev_handler(args) {
3370
4088
  ...boot.verdict.stamp,
3371
4089
  output: cleanOutput.slice(0, 2000)
3372
4090
  },
3373
- hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, remove ${profileDir} manually before retrying.`,
4091
+ hint: `A locked profile means another session's browser still holds it: call extension_stop with this projectPath to kill that session, then start extension_dev again. If the lock survives a crash, clear it by hand before retrying. ${profileAdvice}`,
3374
4092
  warnings: boot.warnings
3375
4093
  });
3376
4094
  const controlVerbs = "storage, reload, open, dom_snapshot";
@@ -3397,7 +4115,7 @@ async function dev_handler(args) {
3397
4115
  requestedPort: args.port ?? 8080
3398
4116
  };
3399
4117
  const portNote = null !== boundPort ? void 0 !== args.port && args.port !== boundPort ? `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).` : null : "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.";
3400
- return envelope({
4118
+ return envelope_envelope({
3401
4119
  ok: true,
3402
4120
  command: dev_schema.name,
3403
4121
  status: "started",
@@ -3473,6 +4191,7 @@ async function start_handler(args) {
3473
4191
  if (void 0 !== args.port) cliArgs.push("--port", String(args.port));
3474
4192
  if (args.noBrowser) cliArgs.push("--no-browser");
3475
4193
  cliArgs.push(...launchFlagArgs(args));
4194
+ const stale = removeCarrier(args.projectPath);
3476
4195
  const spawnedAt = Date.now();
3477
4196
  const spawned = spawnExtensionCli(cliArgs, {
3478
4197
  projectDir: args.projectPath
@@ -3506,7 +4225,7 @@ async function start_handler(args) {
3506
4225
  };
3507
4226
  if ("exited" === boot.verdict.kind) {
3508
4227
  const { exitCode: code, signal } = boot.verdict;
3509
- return envelope({
4228
+ return envelope_envelope({
3510
4229
  ok: false,
3511
4230
  command: start_schema.name,
3512
4231
  status: "exited",
@@ -3526,7 +4245,7 @@ async function start_handler(args) {
3526
4245
  }
3527
4246
  if ("compile-failed" === boot.verdict.kind) {
3528
4247
  const { compileErrors } = boot.verdict;
3529
- return envelope({
4248
+ return envelope_envelope({
3530
4249
  ok: false,
3531
4250
  command: start_schema.name,
3532
4251
  status: "compile-failed",
@@ -3547,7 +4266,7 @@ async function start_handler(args) {
3547
4266
  }
3548
4267
  if ("browser-exited" === boot.verdict.kind || "profile-locked" === boot.verdict.kind) {
3549
4268
  const stamp = "browser-exited" === boot.verdict.kind ? boot.verdict.stamp : {};
3550
- return envelope({
4269
+ return envelope_envelope({
3551
4270
  ok: false,
3552
4271
  command: start_schema.name,
3553
4272
  status: boot.verdict.kind,
@@ -3564,7 +4283,7 @@ async function start_handler(args) {
3564
4283
  warnings: boot.warnings
3565
4284
  });
3566
4285
  }
3567
- return envelope({
4286
+ return envelope_envelope({
3568
4287
  ok: true,
3569
4288
  command: start_schema.name,
3570
4289
  status: building ? "started" : "launched",
@@ -3575,7 +4294,10 @@ async function start_handler(args) {
3575
4294
  logPath
3576
4295
  },
3577
4296
  hint: building ? "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." : "Call extension_stop when you are done to close the preview browser.",
3578
- warnings: boot.warnings
4297
+ warnings: [
4298
+ ...boot.warnings,
4299
+ stale.removed && "Removed a Live Preview carrier left behind by an earlier dev session, so it was not loaded beside your extension here."
4300
+ ]
3579
4301
  });
3580
4302
  }
3581
4303
  function toMcpSpeak(text) {
@@ -3685,8 +4407,19 @@ async function runActVerb(args, projectPath, timeoutMs, tool) {
3685
4407
  }
3686
4408
  if (frame && "object" == typeof frame) return JSON.stringify(wrapLegacyFrame(frame, command));
3687
4409
  } catch {}
4410
+ if (refusedTheOutputFlag(stderr ?? "")) return envelope_envelope({
4411
+ ok: false,
4412
+ command,
4413
+ status: "engine-too-old",
4414
+ error: {
4415
+ code: "E_ENGINE_TOO_OLD",
4416
+ name: "CliError",
4417
+ message: await outputFlagRefusalMessage("act", args[0], projectPath)
4418
+ },
4419
+ hint: "extension_doctor reports the project's engine version next to the one this server pins."
4420
+ });
3688
4421
  const message = stderr.trim() || `extension exited with code ${code}`;
3689
- return envelope({
4422
+ return envelope_envelope({
3690
4423
  ok: false,
3691
4424
  command,
3692
4425
  status: "cli-failed",
@@ -4140,7 +4873,7 @@ class CDPClient extends CDPConnection {
4140
4873
  async function resolveContractPort(projectPath, browser, field, options) {
4141
4874
  const waitMs = options?.waitMs ?? 20000;
4142
4875
  const graceMs = options?.graceMs ?? 2500;
4143
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
4876
+ const readyPath = bridge_readyContractPath(projectPath, browser);
4144
4877
  const deadline = Date.now() + waitMs;
4145
4878
  let contractSeen = false;
4146
4879
  let contractSeenAt = null;
@@ -4209,6 +4942,10 @@ const ENGINE_COMPANION_IDS = new Set([
4209
4942
  CARRIER_EXTENSION_ID
4210
4943
  ]);
4211
4944
  const EXTENSION_URL = /^chrome-extension:\/\/([a-p]{32})\//i;
4945
+ function isEngineCompanionUrl(url) {
4946
+ const match = EXTENSION_URL.exec(String(url ?? ""));
4947
+ return match ? ENGINE_COMPANION_IDS.has(match[1].toLowerCase()) : false;
4948
+ }
4212
4949
  async function verifyGuestLoaded(projectPath, browser, options) {
4213
4950
  let cdpPort;
4214
4951
  try {
@@ -4339,7 +5076,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
4339
5076
  return raw;
4340
5077
  }
4341
5078
  const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
4342
- if (!settled) return envelope({
5079
+ if (!settled) return envelope_envelope({
4343
5080
  ok: false,
4344
5081
  command: tool,
4345
5082
  status: "navigate-failed",
@@ -4350,7 +5087,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
4350
5087
  },
4351
5088
  hint: "Confirm the URL, or discover open tabs with extension_dom_snapshot listTabs: true. For an extension page, the path must match the BUILT manifest."
4352
5089
  });
4353
- return envelope({
5090
+ return envelope_envelope({
4354
5091
  ok: true,
4355
5092
  command: tool,
4356
5093
  status: "navigated",
@@ -4422,7 +5159,7 @@ function isDisposableTab(tabUrl, destination) {
4422
5159
  async function navigateToUrl(projectPath, browser, url, timeout) {
4423
5160
  if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
4424
5161
  const resolved = await resolveCdpPort(projectPath, browser);
4425
- if (!resolved) return envelope({
5162
+ if (!resolved) return envelope_envelope({
4426
5163
  ok: false,
4427
5164
  command: open_schema.name,
4428
5165
  status: "no-session",
@@ -4458,7 +5195,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4458
5195
  const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
4459
5196
  if (!settled) {
4460
5197
  const isExtensionPage = url.startsWith("chrome-extension://");
4461
- return envelope({
5198
+ return envelope_envelope({
4462
5199
  ok: false,
4463
5200
  command: open_schema.name,
4464
5201
  status: "navigate-failed",
@@ -4470,7 +5207,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4470
5207
  hint: isExtensionPage ? "Confirm the path exists in the built dist (extension_build / extension_analyze list entrypoints). For an extension page, the path must match the BUILT manifest, which may differ from your source layout." : "Confirm the URL loads in a normal browser and that the dev session's browser has network access. Nothing about your extension bundle is implicated in a failed http(s) navigation."
4471
5208
  });
4472
5209
  }
4473
- return envelope({
5210
+ return envelope_envelope({
4474
5211
  ok: true,
4475
5212
  command: open_schema.name,
4476
5213
  status: "navigated",
@@ -4494,7 +5231,7 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
4494
5231
  hint: "Inspect it with extension_dom_snapshot or extension_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_snapshot with listTabs: true."
4495
5232
  });
4496
5233
  } catch (e) {
4497
- return envelope({
5234
+ return envelope_envelope({
4498
5235
  ok: false,
4499
5236
  command: open_schema.name,
4500
5237
  status: "navigate-failed",
@@ -4569,7 +5306,7 @@ function declaredCommands(projectPath, browser) {
4569
5306
  }
4570
5307
  function readDistPath(projectPath, browser) {
4571
5308
  try {
4572
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
5309
+ const file = bridge_readyContractPath(projectPath, browser);
4573
5310
  const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
4574
5311
  return "string" == typeof contract?.distPath ? contract.distPath : null;
4575
5312
  } catch {
@@ -4624,7 +5361,7 @@ function declaredSurfaces(projectPath, browser) {
4624
5361
  }
4625
5362
  function missingSurfaceError(projectPath, browser, surface, consequence) {
4626
5363
  const declared = declaredSurfaces(projectPath, browser);
4627
- if (null === declared) return envelope({
5364
+ if (null === declared) return envelope_envelope({
4628
5365
  ok: false,
4629
5366
  command: open_schema.name,
4630
5367
  status: "no-manifest",
@@ -4638,7 +5375,7 @@ function missingSurfaceError(projectPath, browser, surface, consequence) {
4638
5375
  const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
4639
5376
  const others = declared.filter((s)=>s !== surface);
4640
5377
  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.`;
4641
- return envelope({
5378
+ return envelope_envelope({
4642
5379
  ok: false,
4643
5380
  command: open_schema.name,
4644
5381
  status: "no-surface",
@@ -4716,7 +5453,7 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4716
5453
  let extensionId = null;
4717
5454
  if (isChromiumFamily(browser)) {
4718
5455
  extensionId = await resolveExtensionId(projectPath, browser);
4719
- if (!extensionId) return envelope({
5456
+ if (!extensionId) return envelope_envelope({
4720
5457
  ok: false,
4721
5458
  command: open_schema.name,
4722
5459
  status: "no-extension-id",
@@ -4730,7 +5467,7 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4730
5467
  url = `chrome-extension://${extensionId}/${doc}`;
4731
5468
  } else {
4732
5469
  const base = await resolveBridgeBaseUrl(projectPath, browser);
4733
- if (!base) return envelope({
5470
+ if (!base) return envelope_envelope({
4734
5471
  ok: false,
4735
5472
  command: open_schema.name,
4736
5473
  status: "no-extension-id",
@@ -4793,7 +5530,7 @@ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
4793
5530
  });
4794
5531
  return actFrameJson(parsed);
4795
5532
  }
4796
- return envelope({
5533
+ return envelope_envelope({
4797
5534
  ok: false,
4798
5535
  command: open_schema.name,
4799
5536
  status: "surface-did-not-open",
@@ -4867,7 +5604,7 @@ async function open_handler(args) {
4867
5604
  "bookmarks"
4868
5605
  ];
4869
5606
  if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
4870
- if (!args.surface) return envelope({
5607
+ if (!args.surface) return envelope_envelope({
4871
5608
  ok: false,
4872
5609
  command: open_schema.name,
4873
5610
  status: "bad-request",
@@ -4879,7 +5616,7 @@ async function open_handler(args) {
4879
5616
  });
4880
5617
  if ("command" === args.surface) {
4881
5618
  const declared = declaredCommands(args.projectPath, browser);
4882
- if (declared && args.name && !declared.includes(args.name)) return envelope({
5619
+ if (declared && args.name && !declared.includes(args.name)) return envelope_envelope({
4883
5620
  ok: false,
4884
5621
  command: open_schema.name,
4885
5622
  status: "unknown-command",
@@ -4916,7 +5653,7 @@ async function open_handler(args) {
4916
5653
  const parsed = JSON.parse(raw);
4917
5654
  const msg = String(parsed?.error?.message ?? "");
4918
5655
  const code = "string" == typeof parsed?.error?.code ? parsed.error.code : "";
4919
- const refusedWindow = "E_NO_TARGET" === code || /active browser window|no active|headless|user gesture/i.test(msg);
5656
+ const refusedWindow = "E_TARGET_NOT_FOUND" === code || /active browser window|no active|headless|user gesture/i.test(msg);
4920
5657
  if (parsed?.ok === false && refusedWindow) {
4921
5658
  if (AS_TAB_SURFACES.includes(args.surface)) {
4922
5659
  const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
@@ -4950,7 +5687,7 @@ async function publish(options = {}) {
4950
5687
  }
4951
5688
  };
4952
5689
  const doFetch = options.fetchImpl ?? fetch;
4953
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5690
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
4954
5691
  if (!apiCheck.ok) return {
4955
5692
  ok: false,
4956
5693
  error: {
@@ -5003,15 +5740,207 @@ async function publish(options = {}) {
5003
5740
  data
5004
5741
  };
5005
5742
  }
5743
+ const ARTIFACT_ID = /^gen_(?:[0-9a-f]{32}|[0-9a-f]{64})$/;
5744
+ const ARTIFACT_ID_CANDIDATE = /gen_[0-9a-f]+/;
5745
+ function parseArtifactRef(input) {
5746
+ const raw = String(input ?? "").trim();
5747
+ if (!raw) return null;
5748
+ if (ARTIFACT_ID.test(raw)) return raw;
5749
+ let parsed = null;
5750
+ try {
5751
+ parsed = new URL(raw);
5752
+ } catch {
5753
+ parsed = null;
5754
+ }
5755
+ if (parsed) {
5756
+ const fromQuery = parsed.searchParams.get("preview");
5757
+ if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
5758
+ const segments = parsed.pathname.split("/").filter(Boolean);
5759
+ for(let i = segments.length - 1; i >= 0; i -= 1){
5760
+ const raw = segments[i];
5761
+ if (!raw) continue;
5762
+ const segment = decodeURIComponent(raw);
5763
+ if (ARTIFACT_ID.test(segment)) return segment;
5764
+ }
5765
+ }
5766
+ const loose = ARTIFACT_ID_CANDIDATE.exec(raw);
5767
+ return loose && ARTIFACT_ID.test(loose[0]) ? loose[0] : null;
5768
+ }
5769
+ function wwwRevokeUrl(value) {
5770
+ let parsed;
5771
+ try {
5772
+ parsed = new URL(value);
5773
+ } catch {
5774
+ return value;
5775
+ }
5776
+ if ("extension.dev" !== parsed.hostname.toLowerCase()) return value;
5777
+ parsed.hostname = "www.extension.dev";
5778
+ return parsed.toString();
5779
+ }
5780
+ function authError(name) {
5781
+ return {
5782
+ ok: false,
5783
+ error: {
5784
+ name,
5785
+ message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
5786
+ }
5787
+ };
5788
+ }
5789
+ async function readBody(res) {
5790
+ const text = await res.text();
5791
+ try {
5792
+ return JSON.parse(text);
5793
+ } catch {
5794
+ return {
5795
+ message: text
5796
+ };
5797
+ }
5798
+ }
5799
+ async function listArtifacts(options = {}) {
5800
+ const token = options.token ?? resolveToken();
5801
+ if (!token) return authError("SharesAuthError");
5802
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
5803
+ if (!apiCheck.ok) return {
5804
+ ok: false,
5805
+ error: {
5806
+ name: "SharesConfigError",
5807
+ message: apiCheck.message
5808
+ }
5809
+ };
5810
+ const url = new URL(`${apiCheck.base}/api/artifacts`);
5811
+ if (null != options.limit) url.searchParams.set("limit", String(options.limit));
5812
+ if (options.liveOnly) url.searchParams.set("status", "live");
5813
+ const doFetch = options.fetchImpl ?? fetch;
5814
+ let res;
5815
+ try {
5816
+ res = await doFetch(url.toString(), {
5817
+ headers: {
5818
+ authorization: `Bearer ${token}`,
5819
+ accept: "application/json",
5820
+ ...identityHeaders("extension_shares")
5821
+ }
5822
+ });
5823
+ } catch (err) {
5824
+ return {
5825
+ ok: false,
5826
+ error: {
5827
+ name: "SharesNetworkError",
5828
+ message: `Could not reach ${url.toString()}: ${err?.message || err}`
5829
+ }
5830
+ };
5831
+ }
5832
+ const data = await readBody(res);
5833
+ if (401 === res.status) return authError("SharesAuthError");
5834
+ if (!res.ok) return {
5835
+ ok: false,
5836
+ error: {
5837
+ name: "SharesListError",
5838
+ status: res.status,
5839
+ message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
5840
+ }
5841
+ };
5842
+ const artifacts = Array.isArray(data.artifacts) ? data.artifacts.map((artifact)=>artifact && "string" == typeof artifact.revokeUrl ? {
5843
+ ...artifact,
5844
+ revokeUrl: wwwRevokeUrl(artifact.revokeUrl)
5845
+ } : artifact) : [];
5846
+ const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
5847
+ return {
5848
+ ok: true,
5849
+ data: {
5850
+ artifacts,
5851
+ count: num(data.count, artifacts.length),
5852
+ matched: num(data.matched, artifacts.length),
5853
+ limit: num(data.limit, artifacts.length),
5854
+ truncated: true === data.truncated,
5855
+ truncatedReported: "boolean" == typeof data.truncated,
5856
+ scanned: num(data.scanned, 0)
5857
+ }
5858
+ };
5859
+ }
5860
+ async function revokeArtifact(options) {
5861
+ const token = options.token ?? resolveToken();
5862
+ if (!token) return authError("SharesAuthError");
5863
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
5864
+ if (!apiCheck.ok) return {
5865
+ ok: false,
5866
+ error: {
5867
+ name: "SharesConfigError",
5868
+ message: apiCheck.message
5869
+ }
5870
+ };
5871
+ const url = `${apiCheck.base}/api/artifacts/${encodeURIComponent(options.artifactId)}`;
5872
+ const doFetch = options.fetchImpl ?? fetch;
5873
+ let res;
5874
+ try {
5875
+ res = await doFetch(url, {
5876
+ method: "DELETE",
5877
+ headers: {
5878
+ authorization: `Bearer ${token}`,
5879
+ accept: "application/json",
5880
+ ...identityHeaders("extension_shares")
5881
+ }
5882
+ });
5883
+ } catch (err) {
5884
+ return {
5885
+ ok: false,
5886
+ error: {
5887
+ name: "SharesNetworkError",
5888
+ message: `Could not reach ${url}: ${err?.message || err}`
5889
+ }
5890
+ };
5891
+ }
5892
+ const data = await readBody(res);
5893
+ if (401 === res.status) return authError("SharesAuthError");
5894
+ if (404 === res.status) return {
5895
+ ok: false,
5896
+ error: {
5897
+ name: "SharesNotFoundError",
5898
+ status: 404,
5899
+ message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
5900
+ }
5901
+ };
5902
+ if (!res.ok) return {
5903
+ ok: false,
5904
+ error: {
5905
+ name: "SharesRevokeError",
5906
+ status: res.status,
5907
+ message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
5908
+ }
5909
+ };
5910
+ return {
5911
+ ok: true,
5912
+ data: {
5913
+ artifactId: options.artifactId,
5914
+ revoked: true === data.revoked,
5915
+ ..."string" == typeof data.revokedAt ? {
5916
+ revokedAt: data.revokedAt
5917
+ } : {}
5918
+ }
5919
+ };
5920
+ }
5006
5921
  const IGNORED_SEGMENTS = new Set([
5007
5922
  "node_modules",
5008
5923
  ".git",
5009
5924
  ".DS_Store",
5010
5925
  "__MACOSX"
5011
5926
  ]);
5927
+ const IGNORED_FILES = /^\.env(\..*)?$|\.(pem|key|p12|pfx|keystore)$/i;
5012
5928
  const TEXTUAL = /\.(json|js|mjs|cjs|ts|tsx|jsx|html|htm|css|svg|txt|md|map)$/i;
5013
5929
  const MAX_FILES = 2000;
5014
5930
  const MAX_CONTENT_CHARS = 67108864;
5931
+ function encodeFile(relativePath, bytes) {
5932
+ if (TEXTUAL.test(relativePath)) {
5933
+ const text = bytes.toString("utf8");
5934
+ if (Buffer.from(text, "utf8").equals(bytes)) return {
5935
+ content: text,
5936
+ encoding: "utf8"
5937
+ };
5938
+ }
5939
+ return {
5940
+ content: bytes.toString("base64"),
5941
+ encoding: "base64"
5942
+ };
5943
+ }
5015
5944
  function collectDistFiles(distDir) {
5016
5945
  const files = [];
5017
5946
  const walk = (dir)=>{
@@ -5026,12 +5955,13 @@ function collectDistFiles(distDir) {
5026
5955
  continue;
5027
5956
  }
5028
5957
  if (!entry.isFile()) continue;
5958
+ if (IGNORED_FILES.test(entry.name)) continue;
5029
5959
  const relative = node_path.relative(distDir, absolute).split(node_path.sep).join("/");
5030
5960
  const bytes = node_fs.readFileSync(absolute);
5031
- const encoding = TEXTUAL.test(relative) ? "utf8" : "base64";
5961
+ const { content, encoding } = encodeFile(relative, bytes);
5032
5962
  files.push({
5033
5963
  path: relative,
5034
- content: bytes.toString(encoding),
5964
+ content,
5035
5965
  encoding
5036
5966
  });
5037
5967
  }
@@ -5048,7 +5978,7 @@ async function uploadPreview(options) {
5048
5978
  message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
5049
5979
  }
5050
5980
  };
5051
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5981
+ const apiCheck = safeApiBase(resolveApiBase(options.api), options.api);
5052
5982
  if (!apiCheck.ok) return {
5053
5983
  ok: false,
5054
5984
  error: {
@@ -5083,13 +6013,17 @@ async function uploadPreview(options) {
5083
6013
  }
5084
6014
  };
5085
6015
  const totalChars = files.reduce((sum, file)=>sum + file.content.length, 0);
5086
- if (totalChars > MAX_CONTENT_CHARS) return {
5087
- ok: false,
5088
- error: {
5089
- name: "PreviewTooLargeError",
5090
- message: `${options.distDir} is too large to share (over ${Math.floor(MAX_CONTENT_CHARS / 1048576)}MB). Build without source maps, or trim the bundled assets.`
5091
- }
5092
- };
6016
+ if (totalChars > MAX_CONTENT_CHARS) {
6017
+ const encodedBytes = files.reduce((sum, file)=>sum + Buffer.byteLength(file.content, file.encoding), 0);
6018
+ const mb = (value)=>(value / 1048576).toFixed(1);
6019
+ return {
6020
+ ok: false,
6021
+ error: {
6022
+ name: "PreviewTooLargeError",
6023
+ message: `${options.distDir} is too large to share: ${mb(encodedBytes)}MB of files. A shared preview holds about ${mb(MAX_CONTENT_CHARS)}MB of text, and roughly ${mb(3 * MAX_CONTENT_CHARS / 4)}MB when the build is mostly images, fonts or wasm, which travel base64-encoded. Trim the bundled assets, or build without source maps if yours are large.`
6024
+ }
6025
+ };
6026
+ }
5093
6027
  const name = "string" == typeof options.manifest.name && options.manifest.name.trim() ? options.manifest.name : node_path.basename(options.distDir);
5094
6028
  const version = "string" == typeof options.manifest.version ? options.manifest.version : void 0;
5095
6029
  const doFetch = options.fetchImpl ?? fetch;
@@ -5100,6 +6034,7 @@ async function uploadPreview(options) {
5100
6034
  headers: {
5101
6035
  authorization: `Bearer ${token}`,
5102
6036
  "content-type": "application/json",
6037
+ "x-extensiondev-origin": "mcp",
5103
6038
  ...identityHeaders("extension_preview_web")
5104
6039
  },
5105
6040
  body: JSON.stringify({
@@ -5153,14 +6088,14 @@ async function uploadPreview(options) {
5153
6088
  artifactId,
5154
6089
  previewUrl,
5155
6090
  zipUrl: "string" == typeof data.zipUrl ? data.zipUrl : void 0,
5156
- revokeUrl: "string" == typeof data.revokeUrl ? data.revokeUrl : void 0,
6091
+ revokeUrl: "string" == typeof data.revokeUrl ? wwwRevokeUrl(data.revokeUrl) : void 0,
5157
6092
  expiresAt: "string" == typeof data.expiresAt ? data.expiresAt : void 0
5158
6093
  }
5159
6094
  };
5160
6095
  }
5161
6096
  const SHARE_STATE_DIR = ".extension.dev";
5162
6097
  const SHARE_RECORD_FILE = "shared-previews.json";
5163
- const FILE_NOTE = "Every preview link shared from this project. revokeUrl is the only handle that kills a link before expiresAt, and the platform mints a new id on every share, so a lost revokeUrl cannot be recovered. Safe to delete once every link below has expired.";
6098
+ const FILE_NOTE = "Every preview link shared from this project. revokeUrl is the handle that kills a link before expiresAt. Losing it is recoverable: run extension_shares to list every share this token owns, each with its revokeUrl. Re-sharing an unchanged build returns the same link rather than a new one, and only a revoked link is replaced. Safe to delete once every link below has expired.";
5164
6099
  const IGNORE_COMMENT = "# Extension.dev local state: revoke handles for shared previews, not part of your extension.";
5165
6100
  function sharedPreviewsPath(projectPath) {
5166
6101
  return node_path.join(projectPath, SHARE_STATE_DIR, SHARE_RECORD_FILE);
@@ -5233,7 +6168,7 @@ function recordSharedPreview(projectPath, entry) {
5233
6168
  return {
5234
6169
  recorded: false,
5235
6170
  path: file,
5236
- note: `Could not write the share record to ${file}: ${error instanceof Error ? error.message : String(error)}. The revokeUrl in this response is then the only copy, so keep it: re-sharing mints a new link and leaves this one live until expiresAt.`
6171
+ note: `Could not write the share record to ${file}: ${error instanceof Error ? error.message : String(error)}. Keep the revokeUrl in this response, or run extension_shares later to list every share this token owns with its revokeUrl.`
5237
6172
  };
5238
6173
  }
5239
6174
  const ignore = ensureProjectIgnored(projectPath, {
@@ -5259,8 +6194,125 @@ function recordSharedPreview(projectPath, entry) {
5259
6194
  } : {}
5260
6195
  };
5261
6196
  }
6197
+ const MAX_HOPS = 4;
6198
+ function allows(allowOrigin, origin) {
6199
+ if (!allowOrigin) return false;
6200
+ const value = allowOrigin.trim();
6201
+ return "*" === value || value.toLowerCase() === origin.toLowerCase();
6202
+ }
6203
+ async function probeShareCors(options) {
6204
+ const doFetch = options.fetchImpl ?? fetch;
6205
+ const origin = options.origin;
6206
+ let url = options.zipUrl;
6207
+ let redirects = 0;
6208
+ const verdict = (extra)=>({
6209
+ checkedUrl: options.zipUrl,
6210
+ origin,
6211
+ finalUrl: url,
6212
+ finalStatus: 0,
6213
+ redirects,
6214
+ allowOrigin: null,
6215
+ ...extra
6216
+ });
6217
+ for(let hop = 0; hop < MAX_HOPS; hop++){
6218
+ const controller = new AbortController();
6219
+ let res;
6220
+ try {
6221
+ res = await doFetch(url, {
6222
+ method: "GET",
6223
+ redirect: "manual",
6224
+ signal: controller.signal,
6225
+ headers: {
6226
+ origin,
6227
+ "sec-fetch-mode": "cors"
6228
+ }
6229
+ });
6230
+ } catch (err) {
6231
+ return verdict({
6232
+ ok: false,
6233
+ reason: `Could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`
6234
+ });
6235
+ }
6236
+ try {
6237
+ await res.body?.cancel();
6238
+ } catch {
6239
+ controller.abort();
6240
+ }
6241
+ const status = res.status;
6242
+ const allowOrigin = res.headers.get("access-control-allow-origin");
6243
+ if (status >= 300 && status < 400) {
6244
+ const location = res.headers.get("location");
6245
+ if (!location) return verdict({
6246
+ ok: false,
6247
+ finalStatus: status,
6248
+ allowOrigin,
6249
+ reason: `${url} answered ${status} with no Location, so the download goes nowhere.`
6250
+ });
6251
+ url = new URL(location, url).toString();
6252
+ redirects += 1;
6253
+ continue;
6254
+ }
6255
+ if (status >= 400) return verdict({
6256
+ ok: false,
6257
+ finalStatus: status,
6258
+ allowOrigin,
6259
+ reason: `The build's zip answered ${status}, so the link has nothing to render.`
6260
+ });
6261
+ if (!allows(allowOrigin, origin)) return verdict({
6262
+ ok: false,
6263
+ finalStatus: status,
6264
+ allowOrigin,
6265
+ reason: `${url} answered ${status} but with ` + (allowOrigin ? `access-control-allow-origin: ${allowOrigin}, which does not cover ${origin}` : "no access-control-allow-origin header") + `. A browser at ${origin} will refuse to read it, so the link opens to an error even though this fetch succeeded.` + (redirects > 0 ? " The header has to be on this response, not on the redirect that led here." : "")
6266
+ });
6267
+ return verdict({
6268
+ ok: true,
6269
+ finalStatus: status,
6270
+ allowOrigin,
6271
+ reason: `A browser at ${origin} can read the build's zip: the final response after ${redirects} redirect(s) answered ${status} with access-control-allow-origin: ${allowOrigin}.`
6272
+ });
6273
+ }
6274
+ return verdict({
6275
+ ok: false,
6276
+ reason: `The build's zip redirected more than ${MAX_HOPS} times, so nothing could be read from it.`
6277
+ });
6278
+ }
5262
6279
  const preview_web_COMMAND = "extension_preview_web";
5263
6280
  const DEFAULT_PREVIEW_DEV_URL = "http://localhost:3110";
6281
+ const LOCAL_HOSTS = new Set([
6282
+ "localhost",
6283
+ "127.0.0.1",
6284
+ "[::1]",
6285
+ "::1"
6286
+ ]);
6287
+ function safeHostBase(raw) {
6288
+ const trimmed = String(raw || "").replace(/\/+$/, "");
6289
+ let parsed;
6290
+ try {
6291
+ parsed = new URL(trimmed);
6292
+ } catch {
6293
+ return {
6294
+ ok: false,
6295
+ message: `hostUrl is not a URL: ${raw}. Leave it unset to use ${DEFAULT_PREVIEW_DEV_URL}.`
6296
+ };
6297
+ }
6298
+ const isLocal = LOCAL_HOSTS.has(parsed.hostname) || parsed.hostname.endsWith(".localhost") || "preview.extension.dev" === parsed.hostname;
6299
+ if (!isLocal) return {
6300
+ ok: false,
6301
+ message: `Refusing to use ${raw} as the preview host: hostUrl may only name a local preview dev server. Leave it unset to use ${DEFAULT_PREVIEW_DEV_URL}, or pass share:true for a link that needs no local server.`
6302
+ };
6303
+ return {
6304
+ ok: true,
6305
+ base: trimmed
6306
+ };
6307
+ }
6308
+ const HOST_CLAIM_MAX_CHARS = 120;
6309
+ function clipHostClaim(value) {
6310
+ return value.length > HOST_CLAIM_MAX_CHARS ? value.slice(0, HOST_CLAIM_MAX_CHARS) : value;
6311
+ }
6312
+ function expectedPreviewIdentifier(name, distDir) {
6313
+ const base = (name ?? node_path.basename(distDir)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "extension";
6314
+ return base.startsWith("local-") ? base : `local-${base}`;
6315
+ }
5264
6316
  const SURFACE = {
5265
6317
  defaultOrigin: DEFAULT_PREVIEW_DEV_URL,
5266
6318
  scheme: (encoded)=>`preview://build/${encoded}`,
@@ -5268,7 +6320,58 @@ const SURFACE = {
5268
6320
  devCommand: "pnpm --filter preview.extension.dev dev",
5269
6321
  label: "preview.extension.dev"
5270
6322
  };
5271
- async function buildShare(projectPath, distDir, manifest, browser) {
6323
+ const PREVIEW_APP_LOCATIONS = [
6324
+ [
6325
+ "apps",
6326
+ "web",
6327
+ "preview.extension.dev"
6328
+ ],
6329
+ [
6330
+ "apps",
6331
+ "preview.extension.dev"
6332
+ ],
6333
+ [
6334
+ "preview.extension.dev"
6335
+ ]
6336
+ ];
6337
+ function previewDevCheckout(startPaths) {
6338
+ const seen = new Set();
6339
+ for (const start of startPaths){
6340
+ let dir;
6341
+ try {
6342
+ dir = node_path.resolve(start);
6343
+ } catch {
6344
+ continue;
6345
+ }
6346
+ for(let depth = 0; depth < 8; depth++){
6347
+ if (seen.has(dir)) break;
6348
+ seen.add(dir);
6349
+ for (const location of PREVIEW_APP_LOCATIONS){
6350
+ const manifest = node_path.join(dir, ...location, "package.json");
6351
+ try {
6352
+ const parsed = JSON.parse(node_fs.readFileSync(manifest, "utf8"));
6353
+ if (parsed.name === SURFACE.label) return dir;
6354
+ } catch {}
6355
+ }
6356
+ const parent = node_path.dirname(dir);
6357
+ if (parent === dir) break;
6358
+ dir = parent;
6359
+ }
6360
+ }
6361
+ return null;
6362
+ }
6363
+ function localLaneRemedy(checkout) {
6364
+ const shareOut = "Pass share:true for a link that needs no local server and opens for anyone.";
6365
+ return checkout ? `Start it with '${SURFACE.devCommand}' in ${checkout}. ${shareOut}` : `${SURFACE.label} is a private app of the extension.dev monorepo and no npm install of this server can start it, so the default lane cannot resolve on this machine. ${shareOut}`;
6366
+ }
6367
+ function previewOriginOf(previewUrl) {
6368
+ try {
6369
+ return new URL(previewUrl).origin;
6370
+ } catch {
6371
+ return null;
6372
+ }
6373
+ }
6374
+ async function buildShare(projectPath, distDir, manifest, browser, verifyInBrowserTerms) {
5272
6375
  const result = await uploadPreview({
5273
6376
  distDir,
5274
6377
  manifest,
@@ -5287,6 +6390,11 @@ async function buildShare(projectPath, distDir, manifest, browser) {
5287
6390
  } : {}
5288
6391
  };
5289
6392
  }
6393
+ const origin = previewOriginOf(result.data.previewUrl);
6394
+ const browserCheck = verifyInBrowserTerms && result.data.zipUrl && origin ? await probeShareCors({
6395
+ zipUrl: result.data.zipUrl,
6396
+ origin
6397
+ }) : null;
5290
6398
  const sharedAt = new Date().toISOString();
5291
6399
  const record = recordSharedPreview(projectPath, {
5292
6400
  sharedAt,
@@ -5327,8 +6435,14 @@ async function buildShare(projectPath, distDir, manifest, browser) {
5327
6435
  } : {},
5328
6436
  serves: "uploaded-local-build",
5329
6437
  localBuildUploaded: true,
6438
+ ...browserCheck ? {
6439
+ browserLoadable: browserCheck.ok,
6440
+ browserCheck
6441
+ } : {
6442
+ browserLoadable: null
6443
+ },
5330
6444
  record,
5331
- note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the only handle that pulls this link early and re-sharing mints a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (record.warning ? ` ${record.warning}` : "")
6445
+ note: "Anyone with this link can open the build you just made, running in the emulator. No install, no sign-in, no dev server. They can also download the whole build as a zip from zipUrl, so the link hands over the built code. It stays live until expiresAt; DELETE revokeUrl with the same token to kill it sooner, and a revoked link stays dead. revokeUrl is the handle that pulls this link early. Re-sharing an unchanged build returns this same link rather than a second one, and only a revoked link is replaced by a different one, so " + (record.recorded ? `it was also written to ${record.path} (record.path), which lists every share from this project.` : `keep it: ${record.note}`) + " To find this link again later, or to pull it back once it has left this conversation, run extension_shares: it lists every link this token has shared with its live or dead state, and revokes one by artifactId or by pasting any of its URLs." + (record.warning ? ` ${record.warning}` : "")
5332
6446
  };
5333
6447
  }
5334
6448
  function detectSurfaces(manifest) {
@@ -5351,7 +6465,7 @@ function detectSurfaces(manifest) {
5351
6465
  }
5352
6466
  const preview_web_schema = {
5353
6467
  name: "extension_preview_web",
5354
- description: "Preview an in-progress extension in the web emulator, with no real browser. This builds the project (unless build:false), points preview.extension.dev at dist/<browser> over the dev-only preview://build scheme, and returns a deep link plus a loadability check. Use it as the author's door for a local build: it renders your build and carries the Emulated/Real lane toggle and the Trace tab, but the deep link resolves only on this machine. Pass share:true to get a public link that reaches anyone. Call extension_shares to list and revoke every link shared this way, so one never vanishes with this response.",
6468
+ description: "Preview an in-progress extension in the web emulator, with no real browser. This builds the project (unless build:false) and previews dist/<browser>. Pass share:true unless you are working inside the extension.dev monorepo: it uploads the build and returns a link anyone can open, with no install, sign-in or dev server, and it is the only lane that works from an npm install of this server. Sharing also serves the build as a zip, so it hands over the built code; read the share property before using it. The default lane instead returns a deep link over the dev-only preview://build scheme, which resolves only against a preview.extension.dev dev server on this machine, so it is for people developing extension.dev itself. Call extension_shares to list and revoke every link shared this way, so one never vanishes with this response.",
5355
6469
  inputSchema: {
5356
6470
  type: "object",
5357
6471
  properties: {
@@ -5378,7 +6492,7 @@ const preview_web_schema = {
5378
6492
  probe: {
5379
6493
  type: "boolean",
5380
6494
  default: true,
5381
- description: "Fetch the surface's dev middleware first to confirm the artifact loads."
6495
+ description: "Fetch the surface's dev middleware first to confirm the artifact loads on the local host. With share:true it also checks the shared link the way a browser would, following the zip's redirects and asserting the final response allows the preview origin, and reports that as share.browserLoadable."
5382
6496
  },
5383
6497
  open: {
5384
6498
  type: "boolean",
@@ -5393,7 +6507,7 @@ const preview_web_schema = {
5393
6507
  share: {
5394
6508
  type: "boolean",
5395
6509
  default: false,
5396
- description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes for anyone: no install, sign-in or dev server. It also serves the build as a zip (share.zipUrl), so sharing hands over the code. Needs a token scoped to an extension.dev project (extension_auth or EXTENSION_DEV_TOKEN); without one you get a login hint and the local preview still succeeds. Live until share.expiresAt; DELETE share.revokeUrl to kill it sooner. Revocation is permanent and re-sharing mints a different link, so each share is also appended to the project's gitignored .extension.dev/shared-previews.json."
6510
+ description: "Upload the built dist and return a public link (share.previewUrl) that renders those exact bytes for anyone: no install, sign-in or dev server. It also serves the build as a zip (share.zipUrl), so sharing hands over the code. Needs a token scoped to an extension.dev project (extension_auth or EXTENSION_DEV_TOKEN); without one you get a login hint and the local preview still succeeds. Live until share.expiresAt; DELETE share.revokeUrl to kill it sooner. Revocation is permanent, and re-sharing an unchanged build returns the same link unless it was revoked, so each share is also appended to the project's gitignored .extension.dev/shared-previews.json."
5397
6511
  }
5398
6512
  },
5399
6513
  required: [
@@ -5403,7 +6517,21 @@ const preview_web_schema = {
5403
6517
  };
5404
6518
  async function preview_web_handler(args) {
5405
6519
  const browser = args.browser ?? "chrome";
5406
- const hostBase = (args.hostUrl ?? SURFACE.defaultOrigin).replace(/\/+$/, "");
6520
+ const host = safeHostBase(args.hostUrl ?? SURFACE.defaultOrigin);
6521
+ if (!host.ok) return envelope_envelope({
6522
+ ok: false,
6523
+ command: preview_web_COMMAND,
6524
+ status: "bad-host-url",
6525
+ error: {
6526
+ code: "E_BAD_HOST_URL",
6527
+ message: host.message
6528
+ },
6529
+ value: {
6530
+ stage: "resolve-host",
6531
+ hostUrl: args.hostUrl
6532
+ }
6533
+ });
6534
+ const hostBase = host.base;
5407
6535
  const shouldBuild = args.distPath ? false : false !== args.build;
5408
6536
  let buildResult = null;
5409
6537
  if (shouldBuild) {
@@ -5419,7 +6547,7 @@ async function preview_web_handler(args) {
5419
6547
  raw
5420
6548
  };
5421
6549
  }
5422
- if (!buildResult || true !== buildResult.ok) return envelope({
6550
+ if (!buildResult || true !== buildResult.ok) return envelope_envelope({
5423
6551
  ok: false,
5424
6552
  command: preview_web_COMMAND,
5425
6553
  status: "build-failed",
@@ -5435,7 +6563,7 @@ async function preview_web_handler(args) {
5435
6563
  }
5436
6564
  const distDir = args.distPath ? node_path.resolve(args.distPath) : node_path.resolve(args.projectPath, "dist", browser);
5437
6565
  const manifestPath = node_path.join(distDir, "manifest.json");
5438
- if (!node_fs.existsSync(manifestPath)) return envelope({
6566
+ if (!node_fs.existsSync(manifestPath)) return envelope_envelope({
5439
6567
  ok: false,
5440
6568
  command: preview_web_COMMAND,
5441
6569
  status: "no-dist",
@@ -5452,7 +6580,7 @@ async function preview_web_handler(args) {
5452
6580
  try {
5453
6581
  manifest = JSON.parse(node_fs.readFileSync(manifestPath, "utf8"));
5454
6582
  } catch (err) {
5455
- return envelope({
6583
+ return envelope_envelope({
5456
6584
  ok: false,
5457
6585
  command: preview_web_COMMAND,
5458
6586
  status: "bad-dist-manifest",
@@ -5484,7 +6612,12 @@ async function preview_web_handler(args) {
5484
6612
  built: false
5485
6613
  }
5486
6614
  };
5487
- const hint = `Open deepLink in a browser to see the extension render in ${SURFACE.label}'s emulator. It must be running (${SURFACE.devCommand}). Once it renders, the Trace tab shows every chrome.* call it makes, and the lane toggle switches between the emulated backend and a real carrier-equipped browser.`;
6615
+ const checkout = previewDevCheckout([
6616
+ args.projectPath,
6617
+ process.cwd()
6618
+ ]);
6619
+ const remedy = localLaneRemedy(checkout);
6620
+ const hint = `Open deepLink in a browser to see the extension render in ${SURFACE.label}'s emulator, where the Trace tab shows every chrome.* call it makes and the lane toggle switches between the emulated backend and a real carrier-equipped browser. It needs that surface's dev server running on this machine. ${remedy}`;
5488
6621
  const previewWarnings = [];
5489
6622
  if (args.open) {
5490
6623
  const sessionBrowser = args.openIn ?? browser;
@@ -5502,8 +6635,15 @@ async function preview_web_handler(args) {
5502
6635
  result.openedIn = sessionBrowser;
5503
6636
  if (true !== opened.ok) previewWarnings.push("Could not open the preview in a browser. This needs a live dev session (run extension_dev, then extension_wait for ready). The deepLink above still works if you open it yourself.");
5504
6637
  }
5505
- if (args.share) result.share = await buildShare(args.projectPath, distDir, manifest, browser);
5506
- if (false === args.probe) return envelope({
6638
+ if (args.share) {
6639
+ const share = await buildShare(args.projectPath, distDir, manifest, browser, false !== args.probe);
6640
+ result.share = share;
6641
+ if (true === share.ok && false === share.browserLoadable) {
6642
+ const check = share.browserCheck;
6643
+ previewWarnings.push(`The link uploaded, but it will not render for anyone: ${check?.reason ?? "its zip is not readable from the preview origin."} Do not hand this link out as working until that is fixed.`);
6644
+ }
6645
+ }
6646
+ if (false === args.probe) return envelope_envelope({
5507
6647
  ok: true,
5508
6648
  command: preview_web_COMMAND,
5509
6649
  status: "previewed",
@@ -5515,229 +6655,166 @@ async function preview_web_handler(args) {
5515
6655
  try {
5516
6656
  const res = await fetch(probeUrl, {
5517
6657
  headers: {
5518
- accept: "application/json"
5519
- }
5520
- });
5521
- const contentType = res.headers.get("content-type") ?? "";
5522
- if (!res.ok || !contentType.includes("application/json")) return envelope({
5523
- ok: true,
5524
- command: preview_web_COMMAND,
5525
- status: "host-not-serving-preview",
5526
- value: {
5527
- ...result,
5528
- hostReachable: true,
5529
- previewLoadable: false,
5530
- probe: {
5531
- status: res.status,
5532
- contentType
5533
- }
5534
- },
5535
- hint,
5536
- warnings: [
5537
- ...previewWarnings,
5538
- `${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist (dev-only); run a local dev server (${SURFACE.devCommand}) to use web preview.`
5539
- ]
5540
- });
5541
- const payload = await res.json();
5542
- return envelope({
5543
- ok: true,
5544
- command: preview_web_COMMAND,
5545
- status: "previewed",
5546
- value: {
5547
- ...result,
5548
- hostReachable: true,
5549
- previewLoadable: true,
5550
- probe: {
5551
- identifier: payload.identifier,
5552
- loadedName: payload.manifest?.name,
5553
- loadedVersion: payload.version,
5554
- fileCount: Array.isArray(payload.files) ? payload.files.length : 0
5555
- }
5556
- },
5557
- hint,
5558
- warnings: previewWarnings
5559
- });
5560
- } catch (err) {
5561
- return envelope({
5562
- ok: true,
5563
- command: preview_web_COMMAND,
5564
- status: "host-unreachable",
5565
- value: {
5566
- ...result,
5567
- hostReachable: false,
5568
- previewLoadable: false,
5569
- probe: {
5570
- error: err instanceof Error ? err.message : String(err)
5571
- }
5572
- },
5573
- hint,
5574
- warnings: [
5575
- ...previewWarnings,
5576
- `Could not reach ${SURFACE.label} at ${hostBase}. Start it with '${SURFACE.devCommand}', then open deepLink.`
5577
- ]
5578
- });
5579
- }
5580
- }
5581
- const ARTIFACT_ID = /^gen_[0-9a-f]{32}$/;
5582
- const ARTIFACT_ID_ANYWHERE = /gen_[0-9a-f]{32}/;
5583
- function parseArtifactRef(input) {
5584
- const raw = String(input ?? "").trim();
5585
- if (!raw) return null;
5586
- if (ARTIFACT_ID.test(raw)) return raw;
5587
- let parsed = null;
5588
- try {
5589
- parsed = new URL(raw);
5590
- } catch {
5591
- parsed = null;
5592
- }
5593
- if (parsed) {
5594
- const fromQuery = parsed.searchParams.get("preview");
5595
- if (fromQuery && ARTIFACT_ID.test(fromQuery.trim())) return fromQuery.trim();
5596
- const segments = parsed.pathname.split("/").filter(Boolean);
5597
- for(let i = segments.length - 1; i >= 0; i -= 1){
5598
- const segment = decodeURIComponent(segments[i]);
5599
- if (ARTIFACT_ID.test(segment)) return segment;
5600
- }
5601
- }
5602
- const loose = ARTIFACT_ID_ANYWHERE.exec(raw);
5603
- return loose ? loose[0] : null;
5604
- }
5605
- function authError(name) {
5606
- return {
5607
- ok: false,
5608
- error: {
5609
- name,
5610
- message: "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard)."
5611
- }
5612
- };
5613
- }
5614
- async function readBody(res) {
5615
- const text = await res.text();
5616
- try {
5617
- return JSON.parse(text);
5618
- } catch {
5619
- return {
5620
- message: text
5621
- };
5622
- }
5623
- }
5624
- async function listArtifacts(options = {}) {
5625
- const token = options.token ?? resolveToken();
5626
- if (!token) return authError("SharesAuthError");
5627
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5628
- if (!apiCheck.ok) return {
5629
- ok: false,
5630
- error: {
5631
- name: "SharesConfigError",
5632
- message: apiCheck.message
5633
- }
5634
- };
5635
- const url = new URL(`${apiCheck.base}/api/artifacts`);
5636
- if (null != options.limit) url.searchParams.set("limit", String(options.limit));
5637
- if (options.liveOnly) url.searchParams.set("status", "live");
5638
- const doFetch = options.fetchImpl ?? fetch;
5639
- let res;
5640
- try {
5641
- res = await doFetch(url.toString(), {
5642
- headers: {
5643
- authorization: `Bearer ${token}`,
5644
- accept: "application/json",
5645
- ...identityHeaders("extension_shares")
5646
- }
5647
- });
5648
- } catch (err) {
5649
- return {
5650
- ok: false,
5651
- error: {
5652
- name: "SharesNetworkError",
5653
- message: `Could not reach ${url.toString()}: ${err?.message || err}`
5654
- }
5655
- };
5656
- }
5657
- const data = await readBody(res);
5658
- if (401 === res.status) return authError("SharesAuthError");
5659
- if (!res.ok) return {
5660
- ok: false,
5661
- error: {
5662
- name: "SharesListError",
5663
- status: res.status,
5664
- message: `Listing shares failed (${res.status}): ${data?.message || "unknown error"}`
5665
- }
5666
- };
5667
- const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
5668
- const num = (value, fallback)=>"number" == typeof value && Number.isFinite(value) ? value : fallback;
5669
- return {
5670
- ok: true,
5671
- data: {
5672
- artifacts,
5673
- count: num(data.count, artifacts.length),
5674
- matched: num(data.matched, artifacts.length),
5675
- limit: num(data.limit, artifacts.length),
5676
- truncated: true === data.truncated,
5677
- scanned: num(data.scanned, 0)
5678
- }
5679
- };
5680
- }
5681
- async function revokeArtifact(options) {
5682
- const token = options.token ?? resolveToken();
5683
- if (!token) return authError("SharesAuthError");
5684
- const apiCheck = safeApiBase(resolveApiBase(options.api));
5685
- if (!apiCheck.ok) return {
5686
- ok: false,
5687
- error: {
5688
- name: "SharesConfigError",
5689
- message: apiCheck.message
5690
- }
5691
- };
5692
- const url = `${apiCheck.base}/api/artifacts/${encodeURIComponent(options.artifactId)}`;
5693
- const doFetch = options.fetchImpl ?? fetch;
5694
- let res;
5695
- try {
5696
- res = await doFetch(url, {
5697
- method: "DELETE",
5698
- headers: {
5699
- authorization: `Bearer ${token}`,
5700
- accept: "application/json",
5701
- ...identityHeaders("extension_shares")
6658
+ accept: "application/json"
5702
6659
  }
5703
6660
  });
6661
+ const contentType = res.headers.get("content-type") ?? "";
6662
+ let raw = null;
6663
+ if (res.ok && contentType.includes("application/json")) try {
6664
+ raw = await res.json();
6665
+ } catch {
6666
+ raw = null;
6667
+ }
6668
+ const payload = raw && "object" == typeof raw && !Array.isArray(raw) ? raw : null;
6669
+ if (!payload) return envelope_envelope({
6670
+ ok: true,
6671
+ command: preview_web_COMMAND,
6672
+ status: "host-not-serving-preview",
6673
+ value: {
6674
+ ...result,
6675
+ hostReachable: true,
6676
+ previewLoadable: false,
6677
+ probe: {
6678
+ status: res.status,
6679
+ contentType
6680
+ }
6681
+ },
6682
+ hint,
6683
+ warnings: [
6684
+ ...previewWarnings,
6685
+ `${SURFACE.label} answered but not with a preview payload. On the deployed host ${SURFACE.fetchPath} does not exist, because it is dev-only. ${remedy}`
6686
+ ]
6687
+ });
6688
+ const localName = "string" == typeof manifest.name ? manifest.name : null;
6689
+ const localVersion = "string" == typeof manifest.version ? manifest.version : null;
6690
+ const remoteName = "string" == typeof payload.manifest?.name ? payload.manifest.name : null;
6691
+ const remoteVersion = "string" == typeof payload.version ? payload.version : null;
6692
+ const remoteIdentifier = "string" == typeof payload.identifier ? payload.identifier : null;
6693
+ const fileCount = Array.isArray(payload.files) ? payload.files.length : 0;
6694
+ const matchesDist = (null === localName || remoteName === localName) && (null === localVersion || remoteVersion === localVersion);
6695
+ if (!matchesDist) {
6696
+ const hostReported = {
6697
+ ...null !== remoteIdentifier ? {
6698
+ identifier: clipHostClaim(remoteIdentifier)
6699
+ } : {},
6700
+ ...null !== remoteName ? {
6701
+ name: clipHostClaim(remoteName)
6702
+ } : {},
6703
+ ...null !== remoteVersion ? {
6704
+ version: clipHostClaim(remoteVersion)
6705
+ } : {}
6706
+ };
6707
+ return envelope_envelope({
6708
+ ok: true,
6709
+ command: preview_web_COMMAND,
6710
+ status: "host-serving-different-artifact",
6711
+ value: {
6712
+ ...result,
6713
+ hostReachable: true,
6714
+ previewLoadable: false,
6715
+ probe: {
6716
+ method: "server-fetch",
6717
+ provesBrowserLoad: false,
6718
+ matchesDist: false,
6719
+ fileCount,
6720
+ ...Object.keys(hostReported).length ? {
6721
+ hostReported
6722
+ } : {}
6723
+ }
6724
+ },
6725
+ hint,
6726
+ warnings: [
6727
+ ...previewWarnings,
6728
+ `Something answered at ${hostBase}${SURFACE.fetchPath} but described a different artifact than the build this call minted, so previewLoadable stays false. probe.hostReported carries that host's own claims, clipped and unverified, not facts about your build; another local server on this port is the usual cause. ${remedy}`
6729
+ ]
6730
+ });
6731
+ }
6732
+ const identifierConfirmed = null !== remoteIdentifier && remoteIdentifier === expectedPreviewIdentifier(localName, distDir);
6733
+ const unconfirmed = {
6734
+ ...!identifierConfirmed && null !== remoteIdentifier ? {
6735
+ identifier: clipHostClaim(remoteIdentifier)
6736
+ } : {},
6737
+ ...null === localName && null !== remoteName ? {
6738
+ name: clipHostClaim(remoteName)
6739
+ } : {},
6740
+ ...null === localVersion && null !== remoteVersion ? {
6741
+ version: clipHostClaim(remoteVersion)
6742
+ } : {}
6743
+ };
6744
+ return envelope_envelope({
6745
+ ok: true,
6746
+ command: preview_web_COMMAND,
6747
+ status: "previewed",
6748
+ value: {
6749
+ ...result,
6750
+ hostReachable: true,
6751
+ previewLoadable: true,
6752
+ previewLoadableLane: "local-dev-host",
6753
+ probe: {
6754
+ ...identifierConfirmed ? {
6755
+ identifier: remoteIdentifier
6756
+ } : {},
6757
+ ...null !== localName ? {
6758
+ loadedName: localName
6759
+ } : {},
6760
+ ...null !== localVersion ? {
6761
+ loadedVersion: localVersion
6762
+ } : {},
6763
+ fileCount,
6764
+ method: "server-fetch",
6765
+ provesBrowserLoad: false,
6766
+ matchesDist: true,
6767
+ ...Object.keys(unconfirmed).length ? {
6768
+ hostReported: unconfirmed
6769
+ } : {}
6770
+ }
6771
+ },
6772
+ hint,
6773
+ warnings: previewWarnings
6774
+ });
5704
6775
  } catch (err) {
5705
- return {
6776
+ const shared = result.share;
6777
+ if (args.share && shared?.ok === true) return envelope_envelope({
6778
+ ok: true,
6779
+ command: preview_web_COMMAND,
6780
+ status: "shared",
6781
+ value: {
6782
+ ...result,
6783
+ hostReachable: false,
6784
+ previewLoadable: false,
6785
+ probe: {
6786
+ error: err instanceof Error ? err.message : String(err)
6787
+ }
6788
+ },
6789
+ hint: "share.previewUrl is live and needs no local server. The deepLink lane is separate: it only resolves against a preview.extension.dev dev server on this machine, and none answered.",
6790
+ warnings: [
6791
+ ...previewWarnings,
6792
+ `The share link works; only the local ${SURFACE.label} dev lane at ${hostBase} is unreachable, which is expected outside the extension.dev monorepo.`
6793
+ ]
6794
+ });
6795
+ return envelope_envelope({
5706
6796
  ok: false,
6797
+ command: preview_web_COMMAND,
6798
+ status: "host-unreachable",
5707
6799
  error: {
5708
- name: "SharesNetworkError",
5709
- message: `Could not reach ${url}: ${err?.message || err}`
5710
- }
5711
- };
6800
+ code: "E_PREVIEW_HOST_UNREACHABLE",
6801
+ message: `Nothing is serving ${SURFACE.label} at ${hostBase}, so deepLink has nothing to open. ${remedy}`
6802
+ },
6803
+ value: {
6804
+ ...result,
6805
+ hostReachable: false,
6806
+ previewLoadable: false,
6807
+ probe: {
6808
+ error: err instanceof Error ? err.message : String(err)
6809
+ }
6810
+ },
6811
+ hint,
6812
+ warnings: [
6813
+ ...previewWarnings,
6814
+ `Could not reach ${SURFACE.label} at ${hostBase}. ${remedy}`
6815
+ ]
6816
+ });
5712
6817
  }
5713
- const data = await readBody(res);
5714
- if (401 === res.status) return authError("SharesAuthError");
5715
- if (404 === res.status) return {
5716
- ok: false,
5717
- error: {
5718
- name: "SharesNotFoundError",
5719
- status: 404,
5720
- message: `The platform has no live share ${options.artifactId} for this token. It may already be revoked, already expired, owned by a different project than the one this token is scoped to, or a teammate's personal share, which belongs to that person alone and no project token can revoke.`
5721
- }
5722
- };
5723
- if (!res.ok) return {
5724
- ok: false,
5725
- error: {
5726
- name: "SharesRevokeError",
5727
- status: res.status,
5728
- message: `Revoking ${options.artifactId} failed (${res.status}): ${data?.message || "unknown error"}`
5729
- }
5730
- };
5731
- return {
5732
- ok: true,
5733
- data: {
5734
- artifactId: options.artifactId,
5735
- revoked: true === data.revoked,
5736
- ..."string" == typeof data.revokedAt ? {
5737
- revokedAt: data.revokedAt
5738
- } : {}
5739
- }
5740
- };
5741
6818
  }
5742
6819
  const LOGIN_HINT = "Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).";
5743
6820
  const shares_schema = {
@@ -5839,8 +6916,9 @@ function localIndex(entries) {
5839
6916
  }
5840
6917
  return index;
5841
6918
  }
5842
- function localOnlyStatus(entry, truncated, liveFiltered, now) {
5843
- if (truncated) return "unknown: the platform list was cut short, so this share may simply be past the returned window.";
6919
+ function localOnlyStatus(entry, completeness, liveFiltered, now) {
6920
+ if ("cut" === completeness) return "unknown: the platform list was cut short, so this share may simply be past the returned window.";
6921
+ if ("unsaid" === completeness) return "unknown: the platform did not say whether that list was whole, so this share may simply be past the returned window.";
5844
6922
  const expiresAt = entry.expiresAt ? Date.parse(entry.expiresAt) : NaN;
5845
6923
  if (Number.isFinite(expiresAt) && expiresAt <= now) return "expired: its own expiresAt has passed, and the platform no longer lists it.";
5846
6924
  if (liveFiltered) return 'not in this live-only listing: the platform was asked for live shares only, so this one may be expired or revoked rather than not owned. Rerun with status:"all" to tell.';
@@ -5871,7 +6949,7 @@ async function listShares(args) {
5871
6949
  });
5872
6950
  if (!listing.ok) {
5873
6951
  const isAuth = "SharesAuthError" === listing.error.name;
5874
- return envelope({
6952
+ return envelope_envelope({
5875
6953
  ok: true,
5876
6954
  command: "extension_shares",
5877
6955
  status: "listed-local-only",
@@ -5887,7 +6965,10 @@ async function listShares(args) {
5887
6965
  },
5888
6966
  shares: [],
5889
6967
  localOnly: (local?.entries ?? []).map((entry)=>({
5890
- ...entry
6968
+ ...entry,
6969
+ ...entry.revokeUrl ? {
6970
+ revokeUrl: wwwRevokeUrl(entry.revokeUrl)
6971
+ } : {}
5891
6972
  })),
5892
6973
  localRecord
5893
6974
  },
@@ -5921,9 +7002,13 @@ async function listShares(args) {
5921
7002
  } : {}
5922
7003
  };
5923
7004
  });
7005
+ const completeness = listing.data.truncatedReported ? listing.data.truncated ? "cut" : "whole" : "unsaid";
5924
7006
  const localOnly = (local?.entries ?? []).filter((entry)=>!seen.has(entry.artifactId)).map((entry)=>({
5925
7007
  ...entry,
5926
- status: localOnlyStatus(entry, listing.data.truncated, liveFiltered, now)
7008
+ ...entry.revokeUrl ? {
7009
+ revokeUrl: wwwRevokeUrl(entry.revokeUrl)
7010
+ } : {},
7011
+ status: localOnlyStatus(entry, completeness, liveFiltered, now)
5927
7012
  }));
5928
7013
  const liveCount = shares.filter((share)=>share.live).length;
5929
7014
  const ownership = {
@@ -5931,8 +7016,8 @@ async function listShares(args) {
5931
7016
  personal: shares.filter((s)=>"personal" === s.attribution.ownership).length,
5932
7017
  unknown: shares.filter((s)=>"unknown" === s.attribution.ownership).length
5933
7018
  };
5934
- const truncatedNote = listing.data.truncated ? `This is not the whole set: ${listing.data.count} of ${listing.data.matched} matched shares came back at limit ${listing.data.limit}. truncated also goes true when the server spent its budget working out which shares you are entitled to see, so matched is a floor and not a total. Raise limit (max 200) or pass status:"live" to narrow it, and do not read a missing share as revoked.` : null;
5935
- return envelope({
7019
+ const truncatedNote = "cut" === completeness ? `This is not the whole set: ${listing.data.count} of ${listing.data.matched} matched shares came back at limit ${listing.data.limit}. truncated also goes true when the server spent its budget working out which shares you are entitled to see, so matched is a floor and not a total. Raise limit (max 200) or pass status:"live" to narrow it, and do not read a missing share as revoked.` : "unsaid" === completeness ? `This listing cannot be called whole: the platform returned no truncated field, so whether ${listing.data.count} shares are all of them is unstated. A share missing from it is unaccounted for rather than dead, and no missing share is reported as revoked or unowned here.` : null;
7020
+ return envelope_envelope({
5936
7021
  ok: true,
5937
7022
  command: "extension_shares",
5938
7023
  status: "listed",
@@ -5944,6 +7029,7 @@ async function listShares(args) {
5944
7029
  matched: listing.data.matched,
5945
7030
  limit: listing.data.limit,
5946
7031
  truncated: listing.data.truncated,
7032
+ truncatedReported: listing.data.truncatedReported,
5947
7033
  scanned: listing.data.scanned,
5948
7034
  ownership,
5949
7035
  ...truncatedNote ? {
@@ -5958,25 +7044,29 @@ async function listShares(args) {
5958
7044
  },
5959
7045
  hint: `${liveCount} of ${shares.length} listed shares still resolve${local ? `; ${localOnly.length} local record ${1 === localOnly.length ? "entry is" : "entries are"} ${liveFiltered ? 'not in this live-only listing (possibly dead rather than not owned; rerun with status:"all" to tell)' : "without an artifact behind them"}` : ""}. Revoke one with action:"revoke" and its artifactId or any of its URLs.`,
5960
7046
  warnings: [
5961
- "previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned and re-sharing the same build mints a different link.",
7047
+ "previewUrl and zipUrl are null for a share that is no longer live, because a revoked or expired link cannot resolve for anyone. revokeUrl stays on every row. Revocation is permanent: a revoked id is burned, and re-sharing that build mints a different link. Re-sharing a build that was NOT revoked returns its existing link instead.",
5962
7048
  "attribution.ownership says who the share belongs to and therefore who may revoke it: project means the owning workspace holds it and any member can pull it back, personal means one person holds it alone. attribution.credit names the publisher and is attribution only, granting and restricting nothing. A credit of \"CLI token ...\" means the platform could not resolve which human minted that token, and a credit of \"not recorded\" means it never knew; neither is a name, and neither should be reported as one.",
5963
7049
  truncatedNote
5964
7050
  ]
5965
7051
  });
5966
7052
  }
5967
7053
  async function revokeShare(args) {
5968
- const ref = parseArtifactRef(args.artifactId || args.url || "");
5969
- if (!ref) return envelope({
7054
+ const supplied = String(args.artifactId || args.url || "").trim();
7055
+ const ref = parseArtifactRef(supplied);
7056
+ if (!ref) return envelope_envelope({
5970
7057
  ok: false,
5971
7058
  command: "extension_shares",
5972
7059
  status: "bad-request",
5973
7060
  value: {
5974
- action: "revoke"
7061
+ action: "revoke",
7062
+ ...supplied ? {
7063
+ ref: supplied
7064
+ } : {}
5975
7065
  },
5976
7066
  error: {
5977
7067
  code: "E_BAD_REQUEST",
5978
7068
  name: "SharesInputError",
5979
- message: "Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:\"list\" to see both."
7069
+ message: supplied ? `The reference is malformed and nothing was sent to the platform: no share id could be read out of ${JSON.stringify(supplied.length > 200 ? `${supplied.slice(0, 200)}...` : supplied)}. A share id is gen_ followed by exactly 64 lowercase hex characters (32 on shares minted before 2026-07-30), and a url revokes only when it contains one whole. Check the ref for a copy-paste cut, or run action:"list" and copy the exact artifactId.` : 'Nothing to revoke. Pass artifactId (a gen_... id) or url (the previewUrl, zipUrl, viewUrl, or revokeUrl of the share). Run action:"list" to see both.'
5980
7070
  }
5981
7071
  });
5982
7072
  const result = await revokeArtifact({
@@ -5990,7 +7080,7 @@ async function revokeShare(args) {
5990
7080
  const recordNote = local ? entry ? `${local.path} still lists this share as its own append-only history and was not rewritten, so the entry stays with its original sharedAt. The platform is the truth for whether a link resolves.` : `${local.path} has no entry for this share, so it was made from another machine or another checkout.` : void 0;
5991
7081
  if (!result.ok) {
5992
7082
  const isAuth = "SharesAuthError" === result.error.name;
5993
- return envelope({
7083
+ return envelope_envelope({
5994
7084
  ok: false,
5995
7085
  command: "extension_shares",
5996
7086
  status: "revoke-failed",
@@ -6011,7 +7101,7 @@ async function revokeShare(args) {
6011
7101
  ]
6012
7102
  });
6013
7103
  }
6014
- if (true !== result.data.revoked) return envelope({
7104
+ if (true !== result.data.revoked) return envelope_envelope({
6015
7105
  ok: true,
6016
7106
  command: "extension_shares",
6017
7107
  status: "revoke-unconfirmed",
@@ -6028,7 +7118,7 @@ async function revokeShare(args) {
6028
7118
  recordNote
6029
7119
  ]
6030
7120
  });
6031
- return envelope({
7121
+ return envelope_envelope({
6032
7122
  ok: true,
6033
7123
  command: "extension_shares",
6034
7124
  status: "revoked",
@@ -6374,7 +7464,7 @@ async function manifest_validate_handler(args) {
6374
7464
  const errors = [
6375
7465
  args.projectPath ? `No manifest.json found under ${args.projectPath} (looked in the root and src/).` : "Pass manifestPath (path to manifest.json) or projectPath (project root)."
6376
7466
  ];
6377
- return envelope({
7467
+ return envelope_envelope({
6378
7468
  ok: false,
6379
7469
  command: manifest_validate_COMMAND,
6380
7470
  status: "manifest-not-found",
@@ -6400,7 +7490,7 @@ async function manifest_validate_handler(args) {
6400
7490
  const errors = [
6401
7491
  `Cannot read manifest: ${err instanceof Error ? err.message : err}`
6402
7492
  ];
6403
- return envelope({
7493
+ return envelope_envelope({
6404
7494
  ok: false,
6405
7495
  command: manifest_validate_COMMAND,
6406
7496
  status: "manifest-unreadable",
@@ -6535,7 +7625,7 @@ async function manifest_validate_handler(args) {
6535
7625
  else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
6536
7626
  }
6537
7627
  result.valid = 0 === result.errors.length;
6538
- return envelope({
7628
+ return envelope_envelope({
6539
7629
  ok: result.valid,
6540
7630
  command: manifest_validate_COMMAND,
6541
7631
  status: result.valid ? "valid" : "invalid",
@@ -7217,7 +8307,7 @@ async function theme_verify_handler(args) {
7217
8307
  try {
7218
8308
  text = await promises.readFile(abs, "utf8");
7219
8309
  } catch {
7220
- return envelope({
8310
+ return envelope_envelope({
7221
8311
  ok: false,
7222
8312
  command: theme_verify_COMMAND,
7223
8313
  status: "bad-input",
@@ -7231,7 +8321,7 @@ async function theme_verify_handler(args) {
7231
8321
  try {
7232
8322
  raw = JSON.parse(text);
7233
8323
  } catch (err) {
7234
- return envelope({
8324
+ return envelope_envelope({
7235
8325
  ok: false,
7236
8326
  command: theme_verify_COMMAND,
7237
8327
  status: "bad-input",
@@ -7243,7 +8333,7 @@ async function theme_verify_handler(args) {
7243
8333
  });
7244
8334
  }
7245
8335
  } else {
7246
- if (!args.manifest) return envelope({
8336
+ if (!args.manifest) return envelope_envelope({
7247
8337
  ok: false,
7248
8338
  command: theme_verify_COMMAND,
7249
8339
  status: "bad-request",
@@ -7260,7 +8350,7 @@ async function theme_verify_handler(args) {
7260
8350
  try {
7261
8351
  ({ manifest, theme } = coerceInput(raw));
7262
8352
  } catch (err) {
7263
- return envelope({
8353
+ return envelope_envelope({
7264
8354
  ok: false,
7265
8355
  command: theme_verify_COMMAND,
7266
8356
  status: "bad-input",
@@ -7364,7 +8454,7 @@ async function theme_verify_handler(args) {
7364
8454
  how: "a Chrome theme is an unpacked extension: extension_dev then extension_logs on the exported theme dir (headless, focus-safe)"
7365
8455
  }
7366
8456
  ];
7367
- return envelope({
8457
+ return envelope_envelope({
7368
8458
  ok: true,
7369
8459
  command: theme_verify_COMMAND,
7370
8460
  status: verdict,
@@ -7526,7 +8616,7 @@ function formatBytes(bytes) {
7526
8616
  async function analyze_handler(args) {
7527
8617
  const browser = args.browser ?? "chrome";
7528
8618
  const distPath = node_path.resolve(args.projectPath, "dist", browser);
7529
- if (!node_fs.existsSync(distPath)) return envelope({
8619
+ if (!node_fs.existsSync(distPath)) return envelope_envelope({
7530
8620
  ok: false,
7531
8621
  command: analyze_COMMAND,
7532
8622
  status: "no-dist",
@@ -7636,7 +8726,7 @@ async function analyze_handler(args) {
7636
8726
  under10MB: totalSize - archiveSize < 10485760
7637
8727
  }
7638
8728
  };
7639
- return envelope({
8729
+ return envelope_envelope({
7640
8730
  ok: true,
7641
8731
  command: analyze_COMMAND,
7642
8732
  status: "analyzed",
@@ -8124,7 +9214,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
8124
9214
  }
8125
9215
  const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
8126
9216
  if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
8127
- else if (parsed?.ok === true) return envelope({
9217
+ else if (parsed?.ok === true) return envelope_envelope({
8128
9218
  ok: false,
8129
9219
  command: TOOL,
8130
9220
  status: "inspect-failed",
@@ -8167,7 +9257,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
8167
9257
  const cap = maxBytes > 0 ? maxBytes : 65536;
8168
9258
  await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
8169
9259
  }
8170
- return envelope({
9260
+ return envelope_envelope({
8171
9261
  ok: true,
8172
9262
  command: TOOL,
8173
9263
  status: "inspected",
@@ -8188,7 +9278,7 @@ async function inspect_handler(args) {
8188
9278
  const maxBytes = args.maxBytes ?? 262144;
8189
9279
  if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
8190
9280
  const resolved = await resolveCdpPort(args.projectPath, browser);
8191
- if (!resolved) return envelope({
9281
+ if (!resolved) return envelope_envelope({
8192
9282
  ok: false,
8193
9283
  command: inspect_schema_schema.name,
8194
9284
  status: "no-session",
@@ -8212,7 +9302,7 @@ async function inspect_handler(args) {
8212
9302
  const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
8213
9303
  if (0 === pageTargets.length) {
8214
9304
  const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
8215
- return envelope({
9305
+ return envelope_envelope({
8216
9306
  ok: false,
8217
9307
  command: inspect_schema_schema.name,
8218
9308
  status: "no-inspectable-target",
@@ -8231,7 +9321,17 @@ async function inspect_handler(args) {
8231
9321
  });
8232
9322
  }
8233
9323
  const isExtensionSurface = (u)=>u.startsWith("chrome-extension://") || u.startsWith("moz-extension://");
8234
- const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets.find((t)=>!isExtensionSurface(t.url)) ?? pageTargets[0] : pageTargets[0];
9324
+ const defaultRank = (u)=>{
9325
+ if (isEngineCompanionUrl(u)) return 3;
9326
+ if (isOverridePage(u)) return 2;
9327
+ if (isExtensionSurface(u)) return 0;
9328
+ return 1;
9329
+ };
9330
+ const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets.find((t)=>!isExtensionSurface(t.url)) ?? pageTargets[0] : pageTargets.map((t, index)=>({
9331
+ t,
9332
+ index,
9333
+ rank: defaultRank(t.url)
9334
+ })).sort((a, b)=>a.rank - b.rank || a.index - b.index)[0].t;
8235
9335
  const browserWsUrl = await CDPClient.discoverBrowserWsUrl(cdpPort);
8236
9336
  await cdp.connect(browserWsUrl);
8237
9337
  const sessionId = await cdp.attachToTarget(target.id);
@@ -8240,13 +9340,27 @@ async function inspect_handler(args) {
8240
9340
  await cdp.navigate(sessionId, args.url);
8241
9341
  await new Promise((r)=>setTimeout(r, 1500));
8242
9342
  } else await new Promise((r)=>setTimeout(r, 500));
9343
+ let documentUrl = "";
9344
+ let toolchainWarning = null;
9345
+ if (!args.url && defaultRank(target.url) >= 2) {
9346
+ try {
9347
+ const href = await cdp.evaluate(sessionId, "location.href");
9348
+ documentUrl = "string" == typeof href ? href : "";
9349
+ } catch {
9350
+ documentUrl = "";
9351
+ }
9352
+ toolchainWarning = isEngineCompanionUrl(documentUrl) || isEngineCompanionUrl(target.url) ? `Inspected ${target.url}, which is rendered by the Extension.js toolchain's own companion extension, not by this project. Pass url, or open one of the extension's surfaces first (extension_open), then inspect again.` : `No url was given and only override pages were open, so this inspected ${target.url}. Unless this extension provides that override itself, this is the toolchain's welcome surface, not your extension: pass url or open a surface with extension_open.`;
9353
+ }
8243
9354
  const result = {
8244
9355
  cdpPort,
8245
9356
  browser,
8246
9357
  target: {
8247
9358
  id: target.id,
8248
9359
  url: target.url,
8249
- title: target.title
9360
+ title: target.title,
9361
+ ...documentUrl && documentUrl !== target.url ? {
9362
+ documentUrl
9363
+ } : {}
8250
9364
  },
8251
9365
  targets: pageTargets.map((t)=>({
8252
9366
  id: t.id,
@@ -8294,17 +9408,18 @@ async function inspect_handler(args) {
8294
9408
  }
8295
9409
  const probeWarning = result.probeWarning;
8296
9410
  delete result.probeWarning;
8297
- return envelope({
9411
+ return envelope_envelope({
8298
9412
  ok: true,
8299
9413
  command: inspect_schema_schema.name,
8300
9414
  status: "inspected",
8301
9415
  value: result,
8302
9416
  warnings: [
8303
- "string" == typeof probeWarning ? probeWarning : null
9417
+ "string" == typeof probeWarning ? probeWarning : null,
9418
+ toolchainWarning
8304
9419
  ]
8305
9420
  });
8306
9421
  } catch (err) {
8307
- return envelope({
9422
+ return envelope_envelope({
8308
9423
  ok: false,
8309
9424
  command: inspect_schema_schema.name,
8310
9425
  status: "cdp-failed",
@@ -8347,8 +9462,7 @@ function list_extensions_unpackedExtensionId(distPath) {
8347
9462
  function readOwnIdentity(projectPath, browser) {
8348
9463
  let contract;
8349
9464
  try {
8350
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
8351
- contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
9465
+ contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
8352
9466
  } catch {
8353
9467
  return null;
8354
9468
  }
@@ -8379,7 +9493,7 @@ const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domai
8379
9493
  async function list_extensions_handler(args) {
8380
9494
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
8381
9495
  if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
8382
- if (!isChromiumFamily(browser)) return envelope({
9496
+ if (!isChromiumFamily(browser)) return envelope_envelope({
8383
9497
  ok: false,
8384
9498
  command: list_extensions_schema.name,
8385
9499
  status: "unsupported-browser",
@@ -8390,7 +9504,7 @@ async function list_extensions_handler(args) {
8390
9504
  hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
8391
9505
  });
8392
9506
  const resolved = await resolveCdpPort(args.projectPath, browser);
8393
- if (!resolved) return envelope({
9507
+ if (!resolved) return envelope_envelope({
8394
9508
  ok: false,
8395
9509
  command: list_extensions_schema.name,
8396
9510
  status: "no-session",
@@ -8470,7 +9584,7 @@ async function list_extensions_handler(args) {
8470
9584
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
8471
9585
  });
8472
9586
  const ownEntry = extensions.find((e)=>e.ownExtension);
8473
- return envelope({
9587
+ return envelope_envelope({
8474
9588
  ok: true,
8475
9589
  command: list_extensions_schema.name,
8476
9590
  status: "listed",
@@ -8486,7 +9600,7 @@ async function list_extensions_handler(args) {
8486
9600
  ]
8487
9601
  });
8488
9602
  } catch (error) {
8489
- return envelope({
9603
+ return envelope_envelope({
8490
9604
  ok: false,
8491
9605
  command: list_extensions_schema.name,
8492
9606
  status: "cdp-failed",
@@ -8501,7 +9615,7 @@ async function list_extensions_handler(args) {
8501
9615
  }
8502
9616
  async function listGeckoExtensions(projectPath, browser) {
8503
9617
  const resolved = await resolveRdpPort(projectPath, browser);
8504
- if (!resolved) return envelope({
9618
+ if (!resolved) return envelope_envelope({
8505
9619
  ok: false,
8506
9620
  command: list_extensions_schema.name,
8507
9621
  status: "no-session",
@@ -8555,7 +9669,7 @@ async function listGeckoExtensions(projectPath, browser) {
8555
9669
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
8556
9670
  });
8557
9671
  const ownEntry = extensions.find((e)=>e.ownExtension);
8558
- return envelope({
9672
+ return envelope_envelope({
8559
9673
  ok: true,
8560
9674
  command: list_extensions_schema.name,
8561
9675
  status: "listed",
@@ -8571,7 +9685,7 @@ async function listGeckoExtensions(projectPath, browser) {
8571
9685
  ]
8572
9686
  });
8573
9687
  } catch (error) {
8574
- return envelope({
9688
+ return envelope_envelope({
8575
9689
  ok: false,
8576
9690
  command: list_extensions_schema.name,
8577
9691
  status: "rdp-failed",
@@ -8582,57 +9696,23 @@ async function listGeckoExtensions(projectPath, browser) {
8582
9696
  });
8583
9697
  }
8584
9698
  }
8585
- const CONTROL_WS_PATH = "/extjs-control";
8586
- const LEVEL_ORDER = [
8587
- "error",
8588
- "warn",
8589
- "info",
8590
- "debug",
8591
- "trace"
8592
- ];
8593
- function levelRank(level) {
8594
- const l = "log" === level ? "info" : level;
8595
- const i = LEVEL_ORDER.indexOf(l);
8596
- return -1 === i ? LEVEL_ORDER.length : i;
8597
- }
8598
- function makeUrlMatcher(pattern) {
8599
- const hasGlob = pattern.includes("*");
8600
- let re = null;
8601
- if (hasGlob) {
8602
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
8603
- re = new RegExp(escaped);
8604
- }
8605
- return (event)=>{
8606
- const candidates = [
8607
- event.url,
8608
- event.hostname
8609
- ].filter((v)=>"string" == typeof v);
8610
- if (0 === candidates.length) return false;
8611
- return candidates.some((c)=>re ? re.test(c) : c.includes(pattern));
8612
- };
8613
- }
9699
+ const CLOSE_REFUSAL_FLOOR = 4000;
9700
+ const DEFAULT_LIMIT = 200;
9701
+ const DEFAULT_FOLLOW_MS = 4000;
9702
+ const MIN_FOLLOW_MS = 500;
9703
+ const MAX_FOLLOW_MS = 15000;
8614
9704
  function makeFilter(args) {
8615
- const minLevel = String(args.level || "all").toLowerCase();
8616
- const rawContexts = Array.isArray(args.context) ? args.context : "string" == typeof args.context ? args.context.split(",") : null;
8617
- const contexts = rawContexts && rawContexts.length ? new Set(rawContexts.map((c)=>c.trim()).filter(Boolean)) : null;
8618
- const sinceSeq = null != args.since ? Number(args.since) : null;
8619
- const urlMatches = args.url ? makeUrlMatcher(args.url) : null;
8620
- const tabId = null != args.tab ? Number(args.tab) : null;
8621
- return (event)=>{
8622
- if (!event || "object" != typeof event) return false;
8623
- if ("header" === event.type) return false;
8624
- if (args.signalsOnly && "dx.signal" !== event.eventType) return false;
8625
- if (contexts && !contexts.has(event.context)) return false;
8626
- if ("off" === minLevel) {
8627
- if ("dx.signal" !== event.eventType) return false;
8628
- } else if ("all" !== minLevel) {
8629
- if (levelRank(event.level) > levelRank(minLevel)) return false;
8630
- }
8631
- if (null != sinceSeq && Number.isFinite(sinceSeq) && "number" == typeof event.seq && event.seq <= sinceSeq) return false;
8632
- if (urlMatches && !urlMatches(event)) return false;
8633
- if (null != tabId && Number.isFinite(tabId) && event.tabId !== tabId) return false;
8634
- return true;
8635
- };
9705
+ const level = String(args.level || "all").toLowerCase();
9706
+ const loggingOff = "off" === level;
9707
+ const query = {
9708
+ context: args.context,
9709
+ level: loggingOff ? "all" : level,
9710
+ signalsOnly: Boolean(args.signalsOnly) || loggingOff,
9711
+ since: args.since,
9712
+ url: args.url,
9713
+ tab: args.tab
9714
+ };
9715
+ return (event)=>matchesLogQuery(event, query);
8636
9716
  }
8637
9717
  const logs_schema_schema = {
8638
9718
  name: "extension_logs",
@@ -8702,12 +9782,12 @@ const logs_schema_schema = {
8702
9782
  },
8703
9783
  followMs: {
8704
9784
  type: "number",
8705
- default: 4000,
8706
- description: "How long to collect live frames when follow=true (clamped 500–15000ms)."
9785
+ default: DEFAULT_FOLLOW_MS,
9786
+ description: `How long to collect live frames when follow=true (clamped ${MIN_FOLLOW_MS}–${MAX_FOLLOW_MS}ms).`
8707
9787
  },
8708
9788
  limit: {
8709
9789
  type: "number",
8710
- default: 200,
9790
+ default: DEFAULT_LIMIT,
8711
9791
  description: "How many of the most recent events to return."
8712
9792
  }
8713
9793
  },
@@ -8717,23 +9797,6 @@ const logs_schema_schema = {
8717
9797
  }
8718
9798
  };
8719
9799
  const logs_TOOL = "extension_logs";
8720
- function logsFilePath(projectPath, browser) {
8721
- return node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
8722
- }
8723
- function readReadyContract(projectPath, browser) {
8724
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
8725
- try {
8726
- const c = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
8727
- if ("number" != typeof c.controlPort || !c.instanceId) return null;
8728
- return {
8729
- controlPort: c.controlPort,
8730
- instanceId: String(c.instanceId),
8731
- runId: String(c.runId || "")
8732
- };
8733
- } catch {
8734
- return null;
8735
- }
8736
- }
8737
9800
  function capRecent(events, limit) {
8738
9801
  if (events.length <= limit) return {
8739
9802
  events,
@@ -8747,9 +9810,9 @@ function capRecent(events, limit) {
8747
9810
  function emptyReason(projectPath, browser) {
8748
9811
  let contract;
8749
9812
  try {
8750
- contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
9813
+ contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
8751
9814
  } catch {
8752
- 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.";
9815
+ 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. ${sessionPathHint(bridge_readyContractPath(projectPath, browser))}`;
8753
9816
  }
8754
9817
  if ("error" === contract.status) {
8755
9818
  const errs = contract.errors;
@@ -8767,7 +9830,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
8767
9830
  const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
8768
9831
  const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
8769
9832
  const stale = Boolean(staleNote) && matched > 0;
8770
- return envelope({
9833
+ return envelope_envelope({
8771
9834
  ok: true,
8772
9835
  command: logs_TOOL,
8773
9836
  status: 0 === matched ? "empty" : stale ? "stale" : "read",
@@ -8792,7 +9855,7 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
8792
9855
  function staleFileNote(projectPath, browser, eventsRunId) {
8793
9856
  let contract;
8794
9857
  try {
8795
- contract = JSON.parse(node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8"));
9858
+ contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
8796
9859
  } catch {
8797
9860
  return "These events survive from a previous session: no ready.json exists for this project/browser now, so nothing current is producing logs.";
8798
9861
  }
@@ -8808,8 +9871,8 @@ function staleFileNote(projectPath, browser, eventsRunId) {
8808
9871
  if (eventsRunId && liveIds.length > 0 && !liveIds.includes(eventsRunId)) return `These events carry runId ${eventsRunId} but the current session is run ${liveIds.join(" / ")}, which has written nothing yet. Do not read these as the current run's output.`;
8809
9872
  }
8810
9873
  async function readFromFile(args, browser, limit) {
8811
- const file = logsFilePath(args.projectPath, browser);
8812
- if (!node_fs.existsSync(file)) return envelope({
9874
+ const file = logsPath(args.projectPath, browser);
9875
+ if (!node_fs.existsSync(file)) return envelope_envelope({
8813
9876
  ok: false,
8814
9877
  command: logs_TOOL,
8815
9878
  status: "no-log-file",
@@ -8817,7 +9880,7 @@ async function readFromFile(args, browser, limit) {
8817
9880
  code: "E_LOGS_MISSING",
8818
9881
  message: `No logs found at ${file}.`
8819
9882
  },
8820
- hint: "Start a dev session first (extension_dev), or pass browser to match it. For live frames before any line is written, use follow:true."
9883
+ hint: `Start a dev session first (extension_dev), or pass browser to match it. For live frames before any line is written, use follow:true. ${sessionPathHint(file)}`
8821
9884
  });
8822
9885
  const matches = makeFilter(args);
8823
9886
  const lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
@@ -8838,12 +9901,47 @@ async function readFromFile(args, browser, limit) {
8838
9901
  }
8839
9902
  return summarize(events, "file", browser, runId, limit, 0, args.projectPath, staleFileNote(args.projectPath, browser, runId));
8840
9903
  }
9904
+ function controlRefusal(closeCode, reason, url, sentVersion) {
9905
+ if (!Number.isFinite(closeCode) || closeCode < CLOSE_REFUSAL_FLOOR) return;
9906
+ const said = reason.trim();
9907
+ const preamble = `The dev server refused the control channel at ${url}: close code ${closeCode}${said ? ` ("${said}")` : ""}.`;
9908
+ if (closeCode === CLOSE_BAD_HELLO) return {
9909
+ code: "E_CONTROL_ENVELOPE",
9910
+ status: "control-channel-refused",
9911
+ message: `${preamble} That code is the broker rejecting the hello frame itself. This MCP dialed as a consumer with control envelope version ${sentVersion}, the version its pinned Extension.js speaks, and the role is one the broker always accepts, so the envelope version is the part that differs: the engine installed in this project is older or newer and speaks a different one. The session may be logging normally even though this read returned nothing.`,
9912
+ hint: "Update the project's Extension.js so its control envelope matches this MCP's pinned engine, or read the file instead by calling extension_logs without follow. extension_doctor reports both versions."
9913
+ };
9914
+ if (closeCode === CLOSE_BAD_INSTANCE) return {
9915
+ code: "E_STALE_CONTRACT",
9916
+ status: "control-channel-stale",
9917
+ message: `${preamble} That code means the instanceId this MCP sent belongs to a PREVIOUS dev session: the ready.json it read is stale, so the dev server was replaced after that file was written. Nothing here is a version problem, and the engine is not at fault; the session that is running now has an instance this read never named.`,
9918
+ hint: "Wait for the current session to publish its contract with extension_wait and retry, or start one with extension_dev if none is running. extension_stop clears a contract left behind by a session that has gone away."
9919
+ };
9920
+ if (closeCode === CLOSE_CONTROL_UNAVAILABLE) return {
9921
+ code: "E_NO_CONTROL_CHANNEL",
9922
+ status: "control-channel-unavailable",
9923
+ message: `${preamble} That code means the broker has no control channel to hand out: the session was started without allowControl, so it turns controlling clients away. Following logs is a read, not a control operation, so a session that refuses this connection cannot be streamed at all until it is relaunched.`,
9924
+ hint: "Relaunch the session with extension_dev and allowControl: true, or read the file instead by calling extension_logs without follow."
9925
+ };
9926
+ if (closeCode === CLOSE_SLOW_CONSUMER) return {
9927
+ code: "E_CONTROL_CHANNEL",
9928
+ status: "control-channel-dropped",
9929
+ message: `${preamble} That code means this reader fell far enough behind that the broker dropped it to protect itself. The session, the engine version and the control envelope are all fine: the log volume simply outran this follow window, and whatever came back stops at the moment of the drop rather than at the end of the window.`,
9930
+ hint: "Narrow the query before following again (level, context, url or tab), lower followMs, or read the completed file by calling extension_logs without follow."
9931
+ };
9932
+ return {
9933
+ code: "E_CONTROL_CHANNEL",
9934
+ status: "control-channel-refused",
9935
+ message: `${preamble} That code is an application-level refusal this MCP does not recognise, so the broker's own reason above is the whole diagnosis. It dialed as a consumer with control envelope version ${sentVersion}, the version its pinned Extension.js speaks; an engine newer than the pin can refuse for reasons this release has never seen.`,
9936
+ hint: "Read the file instead by calling extension_logs without follow, and compare the engine versions with extension_doctor."
9937
+ };
9938
+ }
8841
9939
  async function readFromStream(args, browser, limit) {
8842
9940
  const ready = readReadyContract(args.projectPath, browser);
8843
9941
  if (!ready) {
8844
9942
  const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
8845
9943
  const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
8846
- return envelope({
9944
+ return envelope_envelope({
8847
9945
  ok: false,
8848
9946
  command: logs_TOOL,
8849
9947
  status: "no-control-channel",
@@ -8854,7 +9952,7 @@ async function readFromStream(args, browser, limit) {
8854
9952
  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.`
8855
9953
  });
8856
9954
  }
8857
- const followMs = Math.min(Math.max(args.followMs ?? 4000, 500), 15000);
9955
+ const followMs = Math.min(Math.max(args.followMs ?? DEFAULT_FOLLOW_MS, MIN_FOLLOW_MS), MAX_FOLLOW_MS);
8858
9956
  const matches = makeFilter(args);
8859
9957
  const events = [];
8860
9958
  let dropped = 0;
@@ -8866,7 +9964,7 @@ async function readFromStream(args, browser, limit) {
8866
9964
  try {
8867
9965
  socket = new ws_0(url);
8868
9966
  } catch (err) {
8869
- resolve(envelope({
9967
+ resolve(envelope_envelope({
8870
9968
  ok: false,
8871
9969
  command: logs_TOOL,
8872
9970
  status: "control-channel-failed",
@@ -8892,7 +9990,7 @@ async function readFromStream(args, browser, limit) {
8892
9990
  try {
8893
9991
  socket.send(JSON.stringify({
8894
9992
  type: "hello",
8895
- v: 1,
9993
+ v: CONTROL_ENVELOPE_VERSION,
8896
9994
  role: "consumer",
8897
9995
  instanceId: ready.instanceId
8898
9996
  }));
@@ -8922,7 +10020,7 @@ async function readFromStream(args, browser, limit) {
8922
10020
  try {
8923
10021
  socket.close();
8924
10022
  } catch {}
8925
- resolve(envelope({
10023
+ resolve(envelope_envelope({
8926
10024
  ok: false,
8927
10025
  command: logs_TOOL,
8928
10026
  status: "control-channel-failed",
@@ -8933,12 +10031,33 @@ async function readFromStream(args, browser, limit) {
8933
10031
  hint: "The dev session may have stopped or the control port changed. Re-check with extension_wait."
8934
10032
  }));
8935
10033
  });
8936
- socket.on("close", finish);
10034
+ socket.on("close", (code, reason)=>{
10035
+ if (settled) return;
10036
+ const refusal = controlRefusal(code, reason?.toString() ?? "", url, CONTROL_ENVELOPE_VERSION);
10037
+ if (!refusal) return void finish();
10038
+ if (events.length > 0 || dropped > 0) {
10039
+ streamNote = refusal.message;
10040
+ finish();
10041
+ return;
10042
+ }
10043
+ settled = true;
10044
+ clearTimeout(timer);
10045
+ resolve(envelope_envelope({
10046
+ ok: false,
10047
+ command: logs_TOOL,
10048
+ status: refusal.status,
10049
+ error: {
10050
+ code: refusal.code,
10051
+ message: refusal.message
10052
+ },
10053
+ hint: refusal.hint
10054
+ }));
10055
+ });
8937
10056
  });
8938
10057
  }
8939
10058
  async function logs_handler(args) {
8940
10059
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
8941
- const limit = args.limit && args.limit > 0 ? args.limit : 200;
10060
+ const limit = args.limit && args.limit > 0 ? args.limit : DEFAULT_LIMIT;
8942
10061
  if (args.follow) return readFromStream(args, browser, limit);
8943
10062
  return readFromFile(args, browser, limit);
8944
10063
  }
@@ -9106,7 +10225,7 @@ async function storage_handler(args) {
9106
10225
  if (args.area) cli.push("--area", args.area);
9107
10226
  if (args.key) cli.push("--key", args.key);
9108
10227
  if ("set" === args.action) {
9109
- if (void 0 === args.value) return envelope({
10228
+ if (void 0 === args.value) return envelope_envelope({
9110
10229
  ok: false,
9111
10230
  command: storage_schema.name,
9112
10231
  status: "bad-request",
@@ -9116,7 +10235,7 @@ async function storage_handler(args) {
9116
10235
  message: "storage set requires a value"
9117
10236
  }
9118
10237
  });
9119
- if (void 0 === args.key) return envelope({
10238
+ if (void 0 === args.key) return envelope_envelope({
9120
10239
  ok: false,
9121
10240
  command: storage_schema.name,
9122
10241
  status: "bad-request",
@@ -9271,7 +10390,7 @@ const dom_snapshot_schema = {
9271
10390
  };
9272
10391
  async function cdpPortOrError(projectPath, browser, feature) {
9273
10392
  if (!isChromiumFamily(browser)) return {
9274
- error: envelope({
10393
+ error: envelope_envelope({
9275
10394
  ok: false,
9276
10395
  command: dom_snapshot_schema.name,
9277
10396
  status: "unsupported-browser",
@@ -9284,7 +10403,7 @@ async function cdpPortOrError(projectPath, browser, feature) {
9284
10403
  };
9285
10404
  const resolved = await resolveCdpPort(projectPath, browser);
9286
10405
  if (!resolved) return {
9287
- error: envelope({
10406
+ error: envelope_envelope({
9288
10407
  ok: false,
9289
10408
  command: dom_snapshot_schema.name,
9290
10409
  status: "no-session",
@@ -9305,7 +10424,7 @@ async function dom_snapshot_handler(args) {
9305
10424
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
9306
10425
  if (isGeckoFamily(browser)) {
9307
10426
  const resolved = await resolveRdpPort(args.projectPath, browser);
9308
- if (!resolved) return envelope({
10427
+ if (!resolved) return envelope_envelope({
9309
10428
  ok: false,
9310
10429
  command: dom_snapshot_schema.name,
9311
10430
  status: "no-session",
@@ -9317,7 +10436,7 @@ async function dom_snapshot_handler(args) {
9317
10436
  });
9318
10437
  try {
9319
10438
  const tabs = await rdpListTabs(resolved.port);
9320
- return envelope({
10439
+ return envelope_envelope({
9321
10440
  ok: true,
9322
10441
  command: dom_snapshot_schema.name,
9323
10442
  status: "listed-targets",
@@ -9339,7 +10458,7 @@ async function dom_snapshot_handler(args) {
9339
10458
  ]
9340
10459
  });
9341
10460
  } catch (e) {
9342
- return envelope({
10461
+ return envelope_envelope({
9343
10462
  ok: false,
9344
10463
  command: dom_snapshot_schema.name,
9345
10464
  status: "rdp-failed",
@@ -9356,7 +10475,7 @@ async function dom_snapshot_handler(args) {
9356
10475
  if ("error" in cdp) return cdp.error;
9357
10476
  try {
9358
10477
  const targets = await listPageTargets(cdp.port);
9359
- return envelope({
10478
+ return envelope_envelope({
9360
10479
  ok: true,
9361
10480
  command: dom_snapshot_schema.name,
9362
10481
  status: "listed-targets",
@@ -9369,7 +10488,7 @@ async function dom_snapshot_handler(args) {
9369
10488
  ]
9370
10489
  });
9371
10490
  } catch (e) {
9372
- return envelope({
10491
+ return envelope_envelope({
9373
10492
  ok: false,
9374
10493
  command: dom_snapshot_schema.name,
9375
10494
  status: "cdp-failed",
@@ -9397,7 +10516,7 @@ async function dom_snapshot_handler(args) {
9397
10516
  let targetTab = args.tab;
9398
10517
  let resolvedTarget = null;
9399
10518
  if (args.tabUrl) {
9400
- if (null != args.tab || args.url) return envelope({
10519
+ if (null != args.tab || args.url) return envelope_envelope({
9401
10520
  ok: false,
9402
10521
  command: dom_snapshot_schema.name,
9403
10522
  status: "bad-request",
@@ -9415,7 +10534,7 @@ async function dom_snapshot_handler(args) {
9415
10534
  try {
9416
10535
  targets = await listPageTargets(cdp.port);
9417
10536
  } catch (e) {
9418
- return envelope({
10537
+ return envelope_envelope({
9419
10538
  ok: false,
9420
10539
  command: dom_snapshot_schema.name,
9421
10540
  status: "cdp-failed",
@@ -9428,7 +10547,7 @@ async function dom_snapshot_handler(args) {
9428
10547
  });
9429
10548
  }
9430
10549
  const matches = matchTargetsByUrl(targets, args.tabUrl);
9431
- if (0 === matches.length) return envelope({
10550
+ if (0 === matches.length) return envelope_envelope({
9432
10551
  ok: false,
9433
10552
  command: dom_snapshot_schema.name,
9434
10553
  status: "no-matching-target",
@@ -9442,7 +10561,7 @@ async function dom_snapshot_handler(args) {
9442
10561
  },
9443
10562
  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}`
9444
10563
  });
9445
- if (matches.length > 1) return envelope({
10564
+ if (matches.length > 1) return envelope_envelope({
9446
10565
  ok: false,
9447
10566
  command: dom_snapshot_schema.name,
9448
10567
  status: "ambiguous-target",
@@ -9464,7 +10583,7 @@ async function dom_snapshot_handler(args) {
9464
10583
  const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
9465
10584
  if ("error" in listed) return listed.error;
9466
10585
  const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
9467
- if (0 === matches.length) return envelope({
10586
+ if (0 === matches.length) return envelope_envelope({
9468
10587
  ok: false,
9469
10588
  command: dom_snapshot_schema.name,
9470
10589
  status: "no-matching-target",
@@ -9478,7 +10597,7 @@ async function dom_snapshot_handler(args) {
9478
10597
  },
9479
10598
  hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
9480
10599
  });
9481
- if (matches.length > 1) return envelope({
10600
+ if (matches.length > 1) return envelope_envelope({
9482
10601
  ok: false,
9483
10602
  command: dom_snapshot_schema.name,
9484
10603
  status: "ambiguous-target",
@@ -9546,7 +10665,7 @@ const publish_schema = {
9546
10665
  }
9547
10666
  };
9548
10667
  function fail(name, message, status, code) {
9549
- return envelope({
10668
+ return envelope_envelope({
9550
10669
  ok: false,
9551
10670
  command: "extension_publish",
9552
10671
  status,
@@ -9573,16 +10692,22 @@ async function publish_handler(args) {
9573
10692
  api: args.api,
9574
10693
  token
9575
10694
  });
9576
- if (!result.ok) return envelope({
9577
- ok: false,
9578
- command: "extension_publish",
9579
- status: "publish-failed",
9580
- error: {
9581
- code: "E_PLATFORM",
9582
- name: result.error.name,
9583
- message: result.error.message
9584
- }
9585
- });
10695
+ if (!result.ok) {
10696
+ const projectMissing = /\(404\)/.test(result.error.message);
10697
+ return envelope_envelope({
10698
+ ok: false,
10699
+ command: "extension_publish",
10700
+ status: "publish-failed",
10701
+ error: {
10702
+ code: "E_PLATFORM",
10703
+ name: result.error.name,
10704
+ message: result.error.message
10705
+ },
10706
+ ...projectMissing ? {
10707
+ hint: "The token's project does not exist on the host this call targeted. Run extension_auth (action: status) to see which workspace/project the token is scoped to, create that project first (import a repo or a template at extension.dev/new), or run extension_auth (action: login) against a project that exists there."
10708
+ } : {}
10709
+ });
10710
+ }
9586
10711
  const data = result.data;
9587
10712
  let note = null;
9588
10713
  if (null != args.ttlHours && "public" === data.visibility) note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
@@ -9627,7 +10752,7 @@ async function publish_handler(args) {
9627
10752
  }
9628
10753
  }
9629
10754
  }
9630
- return envelope({
10755
+ return envelope_envelope({
9631
10756
  ok: true,
9632
10757
  command: "extension_publish",
9633
10758
  status: "published",
@@ -9680,7 +10805,7 @@ const release_promote_schema = {
9680
10805
  }
9681
10806
  };
9682
10807
  function release_promote_fail(name, message, status, code) {
9683
- return envelope({
10808
+ return envelope_envelope({
9684
10809
  ok: false,
9685
10810
  command: "extension_release_promote",
9686
10811
  status,
@@ -9697,7 +10822,7 @@ async function release_promote_handler(args) {
9697
10822
  const buildId = String(args.buildId || "").trim();
9698
10823
  const channel = String(args.channel || "").trim();
9699
10824
  if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.", "bad-request", "E_BAD_REQUEST");
9700
- const apiCheck = safeApiBase(resolveApiBase(args.api));
10825
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
9701
10826
  if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message, "bad-config", "E_CONFIG");
9702
10827
  const url = `${apiCheck.base}/api/cli/release/promote`;
9703
10828
  const body = {
@@ -9754,7 +10879,7 @@ async function release_promote_handler(args) {
9754
10879
  }
9755
10880
  }
9756
10881
  }
9757
- return envelope({
10882
+ return envelope_envelope({
9758
10883
  ok: false,
9759
10884
  command: "extension_release_promote",
9760
10885
  status: "promote-failed",
@@ -9782,7 +10907,7 @@ async function release_promote_handler(args) {
9782
10907
  publicBuildUrl
9783
10908
  } : {}
9784
10909
  } : data;
9785
- return envelope({
10910
+ return envelope_envelope({
9786
10911
  ok: true,
9787
10912
  command: "extension_release_promote",
9788
10913
  status: "promoted",
@@ -9790,7 +10915,7 @@ async function release_promote_handler(args) {
9790
10915
  });
9791
10916
  }
9792
10917
  function release_list_fail(name, message, status, code, extra) {
9793
- return envelope({
10918
+ return envelope_envelope({
9794
10919
  ok: false,
9795
10920
  command: "extension_release_status",
9796
10921
  status,
@@ -9847,7 +10972,7 @@ async function readReleases(args) {
9847
10972
  const publicUrlNote = publicProjectUrl ? isPrivate ? "publicUrl links open only for workspace members. This project is private, so an outside recipient needs a share link from extension_publish." : "publicUrl links are the public build pages: no login needed, and they carry the per-browser downloads and the run locally instructions." : null;
9848
10973
  const channelsUnavailable = channelsRes.ok ? null : `channels.json unreadable: ${channelsRes.message}`;
9849
10974
  const buildsUnavailable = buildsRes.ok ? null : `builds/index.json unreadable: ${buildsRes.message}`;
9850
- return envelope({
10975
+ return envelope_envelope({
9851
10976
  ok: true,
9852
10977
  command: "extension_release_status",
9853
10978
  status: "read",
@@ -9961,7 +11086,7 @@ function normalizeStoresStatus(json) {
9961
11086
  return out;
9962
11087
  }
9963
11088
  function store_status_fail(name, message, status, code, extra) {
9964
- return envelope({
11089
+ return envelope_envelope({
9965
11090
  ok: false,
9966
11091
  command: "extension_release_status",
9967
11092
  status,
@@ -10053,7 +11178,7 @@ async function readStores(args) {
10053
11178
  const healthUnavailable = healthRes.ok ? null : `stores/health.json unreadable: ${healthRes.message}`;
10054
11179
  const statusUnavailable = statusRes.ok ? null : `stores/status.json unreadable: ${statusRes.message}`;
10055
11180
  const submissionsUnavailable = submissionsRes.ok || 404 === submissionsRes.status ? null : `stores/submissions.json unreadable: ${submissionsRes.message}`;
10056
- return envelope({
11181
+ return envelope_envelope({
10057
11182
  ok: true,
10058
11183
  command: "extension_release_status",
10059
11184
  status: "read",
@@ -10151,7 +11276,7 @@ async function release_status_handler(args) {
10151
11276
  stores
10152
11277
  ].filter(Boolean);
10153
11278
  const ok = sections.some((section)=>true === section.ok);
10154
- return envelope({
11279
+ return envelope_envelope({
10155
11280
  ok,
10156
11281
  command: "extension_release_status",
10157
11282
  status: ok ? "read" : "unavailable",
@@ -10240,7 +11365,7 @@ const submit_schema = {
10240
11365
  }
10241
11366
  };
10242
11367
  function submit_fail(name, message, status, code) {
10243
- return envelope({
11368
+ return envelope_envelope({
10244
11369
  ok: false,
10245
11370
  command: "extension_submit",
10246
11371
  status,
@@ -10258,7 +11383,7 @@ async function submit_handler(args) {
10258
11383
  if (0 === browsers.length) return submit_fail("SubmitInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).', "bad-request", "E_BAD_REQUEST");
10259
11384
  const buildSha = String(args.buildSha || "").trim();
10260
11385
  if (!buildSha) return submit_fail("SubmitInputError", "buildSha is required (the built commit to submit).", "bad-request", "E_BAD_REQUEST");
10261
- const apiCheck = safeApiBase(resolveApiBase(args.api));
11386
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
10262
11387
  if (!apiCheck.ok) return submit_fail("SubmitConfigError", apiCheck.message, "bad-config", "E_CONFIG");
10263
11388
  const url = `${apiCheck.base}/api/cli/stores/submit`;
10264
11389
  const dryRun = false !== args.dryRun;
@@ -10390,7 +11515,7 @@ async function submit_handler(args) {
10390
11515
  message = summaryParts.join(" ");
10391
11516
  }
10392
11517
  if (!dryRun) statusNote = "Track this submission with extension_release_status: it reads the recorded outcome, per-store credential health, and review state from the public registry.";
10393
- return envelope({
11518
+ return envelope_envelope({
10394
11519
  ok,
10395
11520
  command: "extension_submit",
10396
11521
  status: dryRun ? "preflight" : "submitted",
@@ -10403,14 +11528,31 @@ async function submit_handler(args) {
10403
11528
  ]
10404
11529
  });
10405
11530
  }
10406
- function doctor_readReadyContract(projectPath, browser) {
11531
+ function readContractForDiagnosis(projectPath, browser) {
10407
11532
  try {
10408
- const raw = node_fs.readFileSync(node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json"), "utf8");
11533
+ const raw = node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8");
10409
11534
  return JSON.parse(raw);
10410
11535
  } catch {
10411
11536
  return null;
10412
11537
  }
10413
11538
  }
11539
+ function sightedContractBrowser(projectPath) {
11540
+ let dirs;
11541
+ try {
11542
+ dirs = node_fs.readdirSync(sessionArtifactsRootDir(projectPath));
11543
+ } catch {
11544
+ return null;
11545
+ }
11546
+ let best = null;
11547
+ for (const dir of dirs)try {
11548
+ const stat = node_fs.statSync(bridge_readyContractPath(projectPath, dir));
11549
+ if (!best || stat.mtimeMs > best.mtimeMs) best = {
11550
+ browser: dir,
11551
+ mtimeMs: stat.mtimeMs
11552
+ };
11553
+ } catch {}
11554
+ return best ? best.browser : null;
11555
+ }
10414
11556
  const doctor_schema = {
10415
11557
  name: "extension_doctor",
10416
11558
  description: "Diagnose a dev session end to end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, browser liveness. This returns one {check, status, detail, remediation?} per leg, in dependency order. Read a 'skip' as blocked, not as a pass: it names the check that blocked it. Run this first when any act tool (storage, reload, eval, open) errors unexpectedly. Call it with no projectPath for a pre-flight environment check (node, the Extension.js CLI, the template cache) before any project exists.",
@@ -10457,7 +11599,7 @@ async function environmentPreflight() {
10457
11599
  detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (extension_templates will fetch it)"
10458
11600
  });
10459
11601
  const healthy = checks.every((c)=>"fail" !== c.status);
10460
- return envelope({
11602
+ return envelope_envelope({
10461
11603
  ok: healthy,
10462
11604
  command: doctor_schema.name,
10463
11605
  status: healthy ? "healthy" : "unhealthy",
@@ -10477,22 +11619,11 @@ function safeStringify(value) {
10477
11619
  }
10478
11620
  }
10479
11621
  function recentErrorLogs(projectPath, browser, max = 5) {
10480
- const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
10481
- let lines;
10482
- try {
10483
- lines = node_fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
10484
- } catch {
10485
- return [];
10486
- }
10487
11622
  const errs = [];
10488
- for (const line of lines){
10489
- let ev;
10490
- try {
10491
- ev = JSON.parse(line);
10492
- } catch {
10493
- continue;
10494
- }
10495
- if (!ev || "error" !== ev.level) continue;
11623
+ for (const event of readLogEvents(projectPath, browser, {
11624
+ level: "error"
11625
+ })){
11626
+ const ev = event;
10496
11627
  const parts = Array.isArray(ev.messageParts) ? ev.messageParts : Array.isArray(ev.args) ? ev.args : null;
10497
11628
  let msg = parts ? parts.map((p)=>"string" == typeof p ? p : safeStringify(p)).join(" ") : ev.message || ev.text || "";
10498
11629
  if (!msg && ev.errorName) msg = ev.stack ? `${ev.errorName}: ${ev.stack}` : ev.errorName;
@@ -10517,7 +11648,8 @@ function capabilityProbeChecks(parsed) {
10517
11648
  async function doctor_handler(args) {
10518
11649
  if (!args.projectPath) return environmentPreflight();
10519
11650
  const projectPath = args.projectPath;
10520
- const { browser } = resolveSessionBrowser(projectPath, args.browser);
11651
+ const resolved = resolveSessionBrowser(projectPath, args.browser);
11652
+ const browser = "fallback" === resolved.source ? sightedContractBrowser(projectPath) ?? resolved.browser : resolved.browser;
10521
11653
  const { code, stdout, stderr } = await runExtensionCli([
10522
11654
  "doctor",
10523
11655
  projectPath,
@@ -10528,6 +11660,17 @@ async function doctor_handler(args) {
10528
11660
  ], {
10529
11661
  cwd: projectPath
10530
11662
  });
11663
+ if (refusedTheOutputFlag(stderr ?? "")) return envelope_envelope({
11664
+ ok: false,
11665
+ command: doctor_schema.name,
11666
+ status: "engine-too-old",
11667
+ error: {
11668
+ code: "E_ENGINE_TOO_OLD",
11669
+ name: "CliError",
11670
+ message: await outputFlagRefusalMessage("doctor", "doctor", projectPath)
11671
+ },
11672
+ hint: "Until then, extension_logs without follow still reads this project's log file, and the session's ready.json still records how the last build ended."
11673
+ });
10531
11674
  const out = stdout.trim();
10532
11675
  try {
10533
11676
  const parsed = JSON.parse(out);
@@ -10538,7 +11681,7 @@ async function doctor_handler(args) {
10538
11681
  if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
10539
11682
  }
10540
11683
  let healthy = 0 === code;
10541
- const contract = doctor_readReadyContract(projectPath, browser);
11684
+ const contract = readContractForDiagnosis(projectPath, browser);
10542
11685
  if (contract?.status === "error") {
10543
11686
  healthy = false;
10544
11687
  const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
@@ -10574,7 +11717,7 @@ async function doctor_handler(args) {
10574
11717
  } : {}
10575
11718
  });
10576
11719
  }
10577
- return envelope({
11720
+ return envelope_envelope({
10578
11721
  ok: healthy,
10579
11722
  command: doctor_schema.name,
10580
11723
  status: healthy ? "healthy" : "unhealthy",
@@ -10588,7 +11731,8 @@ async function doctor_handler(args) {
10588
11731
  });
10589
11732
  } catch {
10590
11733
  const message = stderr.trim() || `extension exited with code ${code}`;
10591
- return envelope({
11734
+ const cliReport = toMcpSpeak(out).trim().slice(0, 4000);
11735
+ return envelope_envelope({
10592
11736
  ok: false,
10593
11737
  command: doctor_schema.name,
10594
11738
  status: "cli-failed",
@@ -10597,7 +11741,13 @@ async function doctor_handler(args) {
10597
11741
  name: "CliError",
10598
11742
  message: toMcpSpeak(message)
10599
11743
  },
10600
- hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
11744
+ value: {
11745
+ browser,
11746
+ ...cliReport ? {
11747
+ cliReport
11748
+ } : {}
11749
+ },
11750
+ hint: cliReport ? "cliReport is the doctor's own output, unparsed: read the failing checks and remediations there." : "extension doctor requires a recent extension CLI, the project's local install may predate it."
10601
11751
  });
10602
11752
  }
10603
11753
  }
@@ -10641,7 +11791,7 @@ async function wait_handler(args) {
10641
11791
  const requested = args.timeoutMs ?? args.timeout ?? DEFAULT_TIMEOUT_MS;
10642
11792
  const budgetMs = Math.min(Math.max(requested, MIN_TIMEOUT_MS), SAFE_CEILING_MS);
10643
11793
  const clamped = requested > SAFE_CEILING_MS;
10644
- const readyPath = node_path.resolve(args.projectPath, "dist", "extension-js", browser, "ready.json");
11794
+ const readyPath = bridge_readyContractPath(args.projectPath, browser);
10645
11795
  const buildOnly = findSessionInfo(args.projectPath, browser)?.noBrowser === true;
10646
11796
  const since = sessionSinceMs(args.projectPath, browser);
10647
11797
  const start = Date.now();
@@ -10663,7 +11813,7 @@ async function wait_handler(args) {
10663
11813
  }
10664
11814
  lastContractStatus = contract.status;
10665
11815
  if ("ready" === contract.status) {
10666
- if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return envelope({
11816
+ if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return envelope_envelope({
10667
11817
  ok: false,
10668
11818
  command: wait_schema.name,
10669
11819
  status: "stale",
@@ -10679,7 +11829,7 @@ async function wait_handler(args) {
10679
11829
  }
10680
11830
  });
10681
11831
  const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
10682
- if (!attached && buildOnly) return envelope({
11832
+ if (!attached && buildOnly) return envelope_envelope({
10683
11833
  ok: true,
10684
11834
  command: wait_schema.name,
10685
11835
  status: "ready",
@@ -10710,7 +11860,7 @@ async function wait_handler(args) {
10710
11860
  const warnings = [];
10711
11861
  if (runtimeErrors.length) warnings.push(`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.`);
10712
11862
  if (guestCheck.checked && !guestCheck.loaded) warnings.push("The engine reports the runtime attached, but the browser's own target list shows no chrome-extension:// target for your extension, only the engine companion. This is the signature of a silently rejected --load-extension (extension.js BUGS_TO_FIX §83): the CLI and ready.json cannot see it, and the control verbs will fail against a guest that is not there. Check the manifest and extension_logs.");
10713
- return envelope({
11863
+ return envelope_envelope({
10714
11864
  ok: true,
10715
11865
  command: wait_schema.name,
10716
11866
  status: "ready",
@@ -10740,7 +11890,7 @@ async function wait_handler(args) {
10740
11890
  warnings
10741
11891
  });
10742
11892
  }
10743
- if ("error" === contract.status) return envelope({
11893
+ if ("error" === contract.status) return envelope_envelope({
10744
11894
  ok: false,
10745
11895
  command: wait_schema.name,
10746
11896
  status: "contract-error",
@@ -10759,7 +11909,7 @@ async function wait_handler(args) {
10759
11909
  } catch {}
10760
11910
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
10761
11911
  }
10762
- if (sawCompiledButUnattached) return envelope({
11912
+ if (sawCompiledButUnattached) return envelope_envelope({
10763
11913
  ok: false,
10764
11914
  command: wait_schema.name,
10765
11915
  status: "compiled-not-attached",
@@ -10776,7 +11926,7 @@ async function wait_handler(args) {
10776
11926
  },
10777
11927
  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."
10778
11928
  });
10779
- return envelope({
11929
+ return envelope_envelope({
10780
11930
  ok: false,
10781
11931
  command: wait_schema.name,
10782
11932
  status: "timeout",
@@ -10954,7 +12104,7 @@ async function add_feature_handler(args) {
10954
12104
  const projectPath = node_path.resolve(args.projectPath);
10955
12105
  const srcDir = node_path.join(projectPath, "src");
10956
12106
  const manifestPath = node_path.join(srcDir, "manifest.json");
10957
- if (!node_fs.existsSync(manifestPath)) return envelope({
12107
+ if (!node_fs.existsSync(manifestPath)) return envelope_envelope({
10958
12108
  ok: false,
10959
12109
  command: add_feature_COMMAND,
10960
12110
  status: "manifest-not-found",
@@ -10965,7 +12115,7 @@ async function add_feature_handler(args) {
10965
12115
  hint: "Ensure projectPath points to an extension project root with src/manifest.json"
10966
12116
  });
10967
12117
  const templateSlug = FEATURE_TEMPLATE_MAP[args.feature]?.[framework];
10968
- if (!templateSlug) return envelope({
12118
+ if (!templateSlug) return envelope_envelope({
10969
12119
  ok: false,
10970
12120
  command: add_feature_COMMAND,
10971
12121
  status: "no-reference-template",
@@ -11048,7 +12198,7 @@ async function add_feature_handler(args) {
11048
12198
  });
11049
12199
  const conflicts = filesToCreate.filter((f)=>node_fs.existsSync(node_path.join(projectPath, f.path)));
11050
12200
  const conflictHint = `Warning: ${conflicts.length} file(s) already exist and would be overwritten.`;
11051
- return envelope({
12201
+ return envelope_envelope({
11052
12202
  ok: true,
11053
12203
  command: add_feature_COMMAND,
11054
12204
  status: conflicts.length ? "planned-with-conflicts" : "planned",
@@ -11191,7 +12341,7 @@ const FIRST_CALL_BUDGET_MS = 8000;
11191
12341
  const RESUME_BUDGET_MS = 22000;
11192
12342
  const PENDING_TTL_NOTE = "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).";
11193
12343
  function login_fail(name, message, status, code) {
11194
- return envelope({
12344
+ return envelope_envelope({
11195
12345
  ok: false,
11196
12346
  command: "extension_auth",
11197
12347
  status,
@@ -11204,7 +12354,7 @@ function login_fail(name, message, status, code) {
11204
12354
  }
11205
12355
  function success(creds) {
11206
12356
  const expiresAt = creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null;
11207
- return envelope({
12357
+ return envelope_envelope({
11208
12358
  ok: true,
11209
12359
  command: "extension_auth",
11210
12360
  status: "logged-in",
@@ -11223,7 +12373,7 @@ function login_pending(start) {
11223
12373
  const complete = String(start.verificationUriComplete || "").trim();
11224
12374
  const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
11225
12375
  const message = hasCompleteLink ? `Open ${complete} and approve (code ${start.userCode} is pre-filled), then call extension_auth (action: login) again with this deviceCode and the same project. If the page asks for a code, enter ${start.userCode} at ${start.verificationUri}.` : `Open ${start.verificationUri} and enter code ${start.userCode}, then call extension_auth (action: login) again with this deviceCode and the same project.`;
11226
- return envelope({
12376
+ return envelope_envelope({
11227
12377
  ok: false,
11228
12378
  command: "extension_auth",
11229
12379
  status: "authorization-pending",
@@ -11248,7 +12398,7 @@ function login_pending(start) {
11248
12398
  }
11249
12399
  function resumePending(deviceCode, verificationUri) {
11250
12400
  const message = `Still waiting for authorization. The one-click link and code from the previous response are still valid: open that link (or enter the code at ${verificationUri}), then call extension_auth (action: login) again with this same deviceCode and the same project.`;
11251
- return envelope({
12401
+ return envelope_envelope({
11252
12402
  ok: false,
11253
12403
  command: "extension_auth",
11254
12404
  status: "authorization-pending",
@@ -11269,8 +12419,8 @@ function resumePending(deviceCode, verificationUri) {
11269
12419
  }
11270
12420
  async function loginToProject(args) {
11271
12421
  const project = String(args.project || "").trim();
11272
- if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
11273
- const apiCheck = safeApiBase(resolveApiBase(args.api));
12422
+ if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. If the project does not exist yet, create it at extension.dev/new, then log in with the slugs the console shows.", "bad-request", "E_BAD_REQUEST");
12423
+ const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
11274
12424
  if (!apiCheck.ok) return login_fail("LoginConfigError", apiCheck.message, "login-failed", "E_AUTH_FAILED");
11275
12425
  const apiBase = apiCheck.base;
11276
12426
  let config;
@@ -11323,9 +12473,92 @@ async function loginToProject(args) {
11323
12473
  verificationUriComplete: start.verificationUriComplete
11324
12474
  });
11325
12475
  }
11326
- async function readIdentity() {
12476
+ const server_identity_DEFAULT_TIMEOUT_MS = 5000;
12477
+ async function askServerIdentity(args) {
12478
+ const fetchImpl = args.fetchImpl ?? fetch;
12479
+ const controller = new AbortController();
12480
+ const timer = setTimeout(()=>controller.abort(), args.timeoutMs ?? server_identity_DEFAULT_TIMEOUT_MS);
12481
+ try {
12482
+ const res = await fetchImpl(`${args.apiBase.replace(/\/+$/, "")}/api/cli/whoami`, {
12483
+ headers: {
12484
+ authorization: `Bearer ${args.token}`,
12485
+ accept: "application/json"
12486
+ },
12487
+ signal: controller.signal
12488
+ });
12489
+ if (200 === res.status) {
12490
+ const data = await res.json().catch(()=>null);
12491
+ const login = String(data?.login || "").trim();
12492
+ if (!login) return {
12493
+ kind: "unavailable",
12494
+ detail: "the server answered 200 without a login"
12495
+ };
12496
+ return {
12497
+ kind: "confirmed",
12498
+ login,
12499
+ live: data?.live === true
12500
+ };
12501
+ }
12502
+ if (401 === res.status) return {
12503
+ kind: "refused"
12504
+ };
12505
+ return {
12506
+ kind: "unavailable",
12507
+ detail: `the server answered ${res.status}, which is not a verdict on this credential`
12508
+ };
12509
+ } catch (error) {
12510
+ return {
12511
+ kind: "unavailable",
12512
+ detail: error?.message || "network error"
12513
+ };
12514
+ } finally{
12515
+ clearTimeout(timer);
12516
+ }
12517
+ }
12518
+ function describeServer(check, api) {
12519
+ if ("confirmed" === check.kind) return {
12520
+ status: "logged-in",
12521
+ note: `The server at ${api} confirms this token: it resolves to ${check.login} and is live there.`,
12522
+ warning: null,
12523
+ value: {
12524
+ verdict: "confirmed",
12525
+ api,
12526
+ login: check.login,
12527
+ live: check.live
12528
+ }
12529
+ };
12530
+ if ("refused" === check.kind) return {
12531
+ status: "refused-by-server",
12532
+ note: `The server at ${api} refused this credential: it does not resolve to an identity there (expired, revoked, or minted for another environment). The workspace/project above is only what the local file claims. Run extension_auth (action: login) to re-authenticate.`,
12533
+ warning: null,
12534
+ value: {
12535
+ verdict: "refused",
12536
+ api
12537
+ }
12538
+ };
12539
+ if ("unavailable" === check.kind) return {
12540
+ status: "logged-in",
12541
+ note: null,
12542
+ warning: `Could not verify this login with the server at ${api} (${check.detail}). The identity above is the local file's claim only, not server-confirmed.`,
12543
+ value: {
12544
+ verdict: "unavailable",
12545
+ api,
12546
+ detail: check.detail
12547
+ }
12548
+ };
12549
+ return {
12550
+ status: "expired",
12551
+ note: null,
12552
+ warning: null,
12553
+ value: {
12554
+ verdict: "not-asked",
12555
+ detail: check.detail
12556
+ }
12557
+ };
12558
+ }
12559
+ async function readIdentity(deps) {
11327
12560
  const creds = readCredentials();
11328
- if (!creds) return envelope({
12561
+ if (!creds) return envelope_envelope({
11329
12562
  ok: true,
11330
12563
  command: "extension_auth",
11331
12564
  status: "logged-out",
@@ -11337,19 +12570,39 @@ async function readIdentity() {
11337
12570
  const recordedApi = String(creds.api || "").trim();
11338
12571
  const effectiveDefaultApi = resolveApiBase();
11339
12572
  const apiDiverges = Boolean(recordedApi) && recordedApi !== effectiveDefaultApi;
12573
+ const askApi = recordedApi || effectiveDefaultApi;
11340
12574
  const envTokenSet = Boolean(String(process.env.EXTENSION_DEV_TOKEN || "").trim());
12575
+ let check;
12576
+ if (expired) check = {
12577
+ kind: "not-asked",
12578
+ detail: "the stored token has already expired locally"
12579
+ };
12580
+ else {
12581
+ const safe = safeApiBase(askApi);
12582
+ check = safe.ok ? await askServerIdentity({
12583
+ apiBase: safe.base,
12584
+ token: creds.token,
12585
+ fetchImpl: deps?.fetchImpl
12586
+ }) : {
12587
+ kind: "unavailable",
12588
+ detail: safe.message
12589
+ };
12590
+ }
12591
+ const server = describeServer(check, askApi);
11341
12592
  const identityNote = expired ? "The stored token has expired. Run extension_auth (action: login) to refresh it." : `Logged in as ${creds.workspaceSlug}/${creds.projectSlug}, per the token extension_auth stored on this machine. That token is what scopes the identity: it does not follow the current working directory or project folder.`;
11342
12593
  const apiDivergesNote = apiDiverges ? `This login was minted via ${recordedApi}: access grants for private registry reads use that recorded base when no api argument is given, while other authenticated tools target ${effectiveDefaultApi} unless given one.` : null;
11343
12594
  const envTokenNote = envTokenSet ? "EXTENSION_DEV_TOKEN is set and takes precedence over this stored login for authenticated tools; this report describes only the stored login." : null;
11344
12595
  const message = [
11345
12596
  identityNote,
12597
+ server.note,
12598
+ server.warning,
11346
12599
  apiDivergesNote,
11347
12600
  envTokenNote
11348
12601
  ].filter(Boolean).join(" ");
11349
- return envelope({
12602
+ return envelope_envelope({
11350
12603
  ok: true,
11351
12604
  command: "extension_auth",
11352
- status: expired ? "expired" : "logged-in",
12605
+ status: expired ? "expired" : server.status,
11353
12606
  value: {
11354
12607
  workspaceSlug: creds.workspaceSlug,
11355
12608
  projectSlug: creds.projectSlug,
@@ -11360,11 +12613,13 @@ async function readIdentity() {
11360
12613
  provider: creds.provider ?? "extensiondev",
11361
12614
  expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
11362
12615
  expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
11363
- expired
12616
+ expired,
12617
+ server: server.value
11364
12618
  },
11365
12619
  hint: message,
11366
12620
  warnings: [
11367
12621
  tokenTtlNote(creds.workspaceSlug, creds.projectSlug),
12622
+ server.warning,
11368
12623
  apiDivergesNote,
11369
12624
  envTokenNote
11370
12625
  ]
@@ -11377,7 +12632,7 @@ async function clearLocalCredentials() {
11377
12632
  project: creds.projectSlug
11378
12633
  }, "settings/access-tokens") : null;
11379
12634
  const result = clearCredentials();
11380
- return envelope({
12635
+ return envelope_envelope({
11381
12636
  ok: true,
11382
12637
  command: "extension_auth",
11383
12638
  status: result.cleared ? "logged-out" : "nothing-to-clear",
@@ -11390,7 +12645,7 @@ async function clearLocalCredentials() {
11390
12645
  }
11391
12646
  const auth_schema = {
11392
12647
  name: "extension_auth",
11393
- description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
12648
+ description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only; the token stays valid server-side until it is revoked at the URL the response returns.",
11394
12649
  inputSchema: {
11395
12650
  type: "object",
11396
12651
  properties: {
@@ -11405,7 +12660,7 @@ const auth_schema = {
11405
12660
  },
11406
12661
  project: {
11407
12662
  type: "string",
11408
- description: "login: target project as '<workspace>/<project>'; the token is scoped to it."
12663
+ description: "login: target project as '<workspace>/<project>'; the token is scoped to it. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. Create one at extension.dev/new if none exists yet."
11409
12664
  },
11410
12665
  deviceCode: {
11411
12666
  type: "string",
@@ -11710,7 +12965,7 @@ async function detectBrowsers(browsers) {
11710
12965
  }
11711
12966
  const available = detected.filter((d)=>"not_found" !== d.source);
11712
12967
  const missing = detected.filter((d)=>"not_found" === d.source);
11713
- return envelope({
12968
+ return envelope_envelope({
11714
12969
  ok: true,
11715
12970
  command: "extension_browsers",
11716
12971
  status: "detected",
@@ -11767,7 +13022,7 @@ async function listManagedBrowsers() {
11767
13022
  });
11768
13023
  }
11769
13024
  }
11770
- return envelope({
13025
+ return envelope_envelope({
11771
13026
  ok: true,
11772
13027
  command: "extension_browsers",
11773
13028
  status: "listed",
@@ -11786,7 +13041,7 @@ async function installManagedBrowser(browser) {
11786
13041
  await extensionInstall({
11787
13042
  browser
11788
13043
  });
11789
- return envelope({
13044
+ return envelope_envelope({
11790
13045
  ok: true,
11791
13046
  command: "extension_browsers",
11792
13047
  status: "installed",
@@ -11797,7 +13052,7 @@ async function installManagedBrowser(browser) {
11797
13052
  hint: `Browser "${browser}" is now available. Use extension_dev or extension_start with browser: "${browser}".`
11798
13053
  });
11799
13054
  } catch (err) {
11800
- return envelope({
13055
+ return envelope_envelope({
11801
13056
  ok: false,
11802
13057
  command: "extension_browsers",
11803
13058
  status: "install-failed",
@@ -11815,7 +13070,7 @@ async function installManagedBrowser(browser) {
11815
13070
  }
11816
13071
  async function uninstallManagedBrowser(args) {
11817
13072
  const start = Date.now();
11818
- if (!args.browser && !args.all) return envelope({
13073
+ if (!args.browser && !args.all) return envelope_envelope({
11819
13074
  ok: false,
11820
13075
  command: "extension_browsers",
11821
13076
  status: "bad-request",
@@ -11829,7 +13084,7 @@ async function uninstallManagedBrowser(args) {
11829
13084
  browser: args.browser,
11830
13085
  all: args.all
11831
13086
  });
11832
- return envelope({
13087
+ return envelope_envelope({
11833
13088
  ok: true,
11834
13089
  command: "extension_browsers",
11835
13090
  status: "uninstalled",
@@ -11840,7 +13095,7 @@ async function uninstallManagedBrowser(args) {
11840
13095
  hint: 'Use extension_browsers with action: "list" to confirm what remains in the managed cache.'
11841
13096
  });
11842
13097
  } catch (err) {
11843
- return envelope({
13098
+ return envelope_envelope({
11844
13099
  ok: false,
11845
13100
  command: "extension_browsers",
11846
13101
  status: "uninstall-failed",
@@ -11897,7 +13152,7 @@ async function browsers_handler(args) {
11897
13152
  const action = args.action ?? "detect";
11898
13153
  if ("list" === action) return listManagedBrowsers();
11899
13154
  if ("install" === action) {
11900
- if (!args.browser) return envelope({
13155
+ if (!args.browser) return envelope_envelope({
11901
13156
  ok: false,
11902
13157
  command: "extension_browsers",
11903
13158
  status: "bad-request",
@@ -12066,7 +13321,7 @@ function describeToolArgs(inputSchema) {
12066
13321
  };
12067
13322
  }
12068
13323
  function inputValidationError(toolName, issues, inputSchema) {
12069
- return envelope({
13324
+ return envelope_envelope({
12070
13325
  ok: false,
12071
13326
  command: toolName,
12072
13327
  status: "invalid-arguments",
@@ -12113,9 +13368,30 @@ const tools = [
12113
13368
  browsers_namespaceObject,
12114
13369
  doctor_namespaceObject
12115
13370
  ];
13371
+ function toolResultFrame(result) {
13372
+ let refused = false;
13373
+ try {
13374
+ const parsed = JSON.parse(result);
13375
+ refused = isEnvelope(parsed) && false === parsed.ok;
13376
+ } catch {
13377
+ refused = false;
13378
+ }
13379
+ return {
13380
+ content: [
13381
+ {
13382
+ type: "text",
13383
+ text: result
13384
+ }
13385
+ ],
13386
+ ...refused ? {
13387
+ isError: true
13388
+ } : {}
13389
+ };
13390
+ }
12116
13391
  const toolMap = new Map();
12117
13392
  for (const tool of tools)toolMap.set(tool.schema.name, tool);
12118
13393
  async function startServer() {
13394
+ installCarrierExitCleanup();
12119
13395
  const server = new Server({
12120
13396
  name: "extension-dev",
12121
13397
  version: package_namespaceObject.rE
@@ -12138,7 +13414,7 @@ async function startServer() {
12138
13414
  content: [
12139
13415
  {
12140
13416
  type: "text",
12141
- text: envelope({
13417
+ text: envelope_envelope({
12142
13418
  ok: false,
12143
13419
  command: name,
12144
13420
  status: "unknown-tool",
@@ -12167,20 +13443,13 @@ async function startServer() {
12167
13443
  };
12168
13444
  try {
12169
13445
  const result = await tool.handler(normalizedArgs);
12170
- return {
12171
- content: [
12172
- {
12173
- type: "text",
12174
- text: result
12175
- }
12176
- ]
12177
- };
13446
+ return toolResultFrame(result);
12178
13447
  } catch (err) {
12179
13448
  return {
12180
13449
  content: [
12181
13450
  {
12182
13451
  type: "text",
12183
- text: envelope({
13452
+ text: envelope_envelope({
12184
13453
  ok: false,
12185
13454
  command: name,
12186
13455
  status: "internal-error",
@@ -12290,4 +13559,5 @@ async function runCli(cmd, args) {
12290
13559
  log(`Unknown command: ${cmd}. Expected one of: login, logout, whoami, release.`);
12291
13560
  return 1;
12292
13561
  }
12293
- export { runCli, startServer };
13562
+ var version_0 = package_namespaceObject.rE;
13563
+ export { runCli, startServer, version_0 as version };