@extension.dev/mcp 9.0.0 → 10.0.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
@@ -579,7 +579,7 @@ __webpack_require__.d(wait_namespaceObject, {
579
579
  handler: ()=>wait_handler,
580
580
  schema: ()=>wait_schema
581
581
  });
582
- var package_namespaceObject = JSON.parse('{"rE":"9.0.0","El":{"OP":"4.0.16-canary.1784889479.74e12044"}}');
582
+ var package_namespaceObject = JSON.parse('{"rE":"10.0.0","El":{"OP":"4.0.17"}}');
583
583
  function credentialsPath() {
584
584
  if ("win32" === process.platform) {
585
585
  const base = process.env.APPDATA || process.env.LOCALAPPDATA || node_path.join(node_os.homedir(), "AppData", "Roaming");
@@ -1109,6 +1109,38 @@ function parseBuildIndex(json) {
1109
1109
  }
1110
1110
  return out;
1111
1111
  }
1112
+ const ENVELOPE_SCHEMA = 1;
1113
+ const collectWarnings = (warnings)=>{
1114
+ if (!warnings) return [];
1115
+ const kept = [];
1116
+ for (const warning of warnings){
1117
+ if ("string" != typeof warning) continue;
1118
+ const text = warning.trim();
1119
+ if (text && !kept.includes(text)) kept.push(text);
1120
+ }
1121
+ return kept;
1122
+ };
1123
+ function envelopeObject(init) {
1124
+ const frame = {
1125
+ schema: ENVELOPE_SCHEMA,
1126
+ ok: init.ok,
1127
+ command: init.command,
1128
+ status: init.status,
1129
+ value: void 0 === init.value ? null : init.value,
1130
+ error: init.error ?? null,
1131
+ warnings: collectWarnings(init.warnings)
1132
+ };
1133
+ if (void 0 !== init.truncated) frame.truncated = init.truncated;
1134
+ if ("string" == typeof init.hint && init.hint) frame.hint = init.hint;
1135
+ return frame;
1136
+ }
1137
+ function envelope(init) {
1138
+ return JSON.stringify(envelopeObject(init));
1139
+ }
1140
+ function isEnvelope(frame) {
1141
+ return !!frame && "object" == typeof frame && frame.schema === ENVELOPE_SCHEMA;
1142
+ }
1143
+ const COMMAND = "extension_create";
1112
1144
  function scaffoldEnginePin(projectPath) {
1113
1145
  try {
1114
1146
  const pkg = JSON.parse(node_fs.readFileSync(node_path.join(projectPath, "package.json"), "utf8"));
@@ -1146,7 +1178,7 @@ function detectPackageManager(projectPath) {
1146
1178
  }
1147
1179
  const create_schema = {
1148
1180
  name: "extension_create",
1149
- description: "Create a new browser extension project from a template in the extension.dev catalog. extension_templates lists what is available. The scaffolder may initialize a git repository in the new project; the result's defaultsApplied block reports whether it did, along with every other decision made without being asked.",
1181
+ description: "Create a browser extension project from a template in the extension.dev catalog. Call extension_templates first to see what is available. The scaffolder may initialize a git repository in the new project. Read the result's defaultsApplied block for that, and for every other decision made without being asked.",
1150
1182
  inputSchema: {
1151
1183
  type: "object",
1152
1184
  properties: {
@@ -1205,13 +1237,31 @@ async function create_handler(args) {
1205
1237
  error: capture("error")
1206
1238
  }
1207
1239
  });
1208
- const failure = (err, transient)=>JSON.stringify({
1209
- error: transient ? "Template download failed (network/timeout/rate-limit). This is not a bad template name. Retry, or check connectivity/GitHub rate limits." : err instanceof Error ? err.message : String(err),
1210
- ...transient ? {
1211
- cause: err instanceof Error ? err.message : String(err)
1212
- } : {},
1213
- duration: Date.now() - start,
1214
- log: logTail()
1240
+ const failure = (err, transient)=>transient ? envelope({
1241
+ ok: false,
1242
+ command: COMMAND,
1243
+ status: "template-fetch-failed",
1244
+ error: {
1245
+ code: "E_TEMPLATE_FETCH",
1246
+ message: "Template download failed (network/timeout/rate-limit). This is not a bad template name. Retry, or check connectivity/GitHub rate limits."
1247
+ },
1248
+ value: {
1249
+ cause: err instanceof Error ? err.message : String(err),
1250
+ duration: Date.now() - start,
1251
+ log: logTail()
1252
+ }
1253
+ }) : envelope({
1254
+ ok: false,
1255
+ command: COMMAND,
1256
+ status: "scaffold-failed",
1257
+ error: {
1258
+ code: "E_SCAFFOLD_FAILED",
1259
+ message: err instanceof Error ? err.message : String(err)
1260
+ },
1261
+ value: {
1262
+ duration: Date.now() - start,
1263
+ log: logTail()
1264
+ }
1215
1265
  });
1216
1266
  let result;
1217
1267
  try {
@@ -1227,13 +1277,19 @@ async function create_handler(args) {
1227
1277
  }
1228
1278
  }
1229
1279
  const hasManifest = node_fs.existsSync(node_path.join(result.projectPath, "manifest.json")) || node_fs.existsSync(node_path.join(result.projectPath, "src", "manifest.json"));
1230
- if (!hasManifest) return JSON.stringify({
1280
+ if (!hasManifest) return envelope({
1231
1281
  ok: false,
1232
- status: "incomplete",
1233
- projectPath: result.projectPath,
1234
- error: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`,
1235
- duration: Date.now() - start,
1236
- log: logTail(),
1282
+ command: COMMAND,
1283
+ status: "scaffold-incomplete",
1284
+ error: {
1285
+ code: "E_SCAFFOLD_INCOMPLETE",
1286
+ message: `The scaffold is incomplete: no manifest.json exists under ${result.projectPath} (checked the root and src/). Do not run extension_dev against it.`
1287
+ },
1288
+ value: {
1289
+ projectPath: result.projectPath,
1290
+ duration: Date.now() - start,
1291
+ log: logTail()
1292
+ },
1237
1293
  hint: "Delete the directory and retry extension_create; a template download interrupted mid-way can leave a partial tree."
1238
1294
  });
1239
1295
  const packageManager = result.packageManager || (result.depsInstalled ? detectPackageManager(result.projectPath) : "npm");
@@ -1249,41 +1305,44 @@ async function create_handler(args) {
1249
1305
  const deployUrl = `${wwwOrigin}${(0, paths_0.wwwNewPath)({
1250
1306
  template: result.template
1251
1307
  })}`;
1252
- return JSON.stringify({
1253
- resolvedPath: result.projectPath,
1254
- projectPath: result.projectPath,
1255
- projectName: result.projectName,
1256
- template: result.template,
1257
- depsInstalled: result.depsInstalled,
1258
- packageManager: result.depsInstalled ? packageManager : null,
1259
- deployUrl,
1260
- defaultsApplied: {
1261
- parentDir: args.parentDir ? `${resolvedParent} (explicit)` : `${resolvedParent} (default: the MCP server process cwd, not yours; pass parentDir to choose)`,
1262
- ...void 0 === args.template ? {
1263
- template: "typescript (default; call extension_templates to pick another, e.g. javascript for plain JS)"
1264
- } : {},
1265
- packageManager: `${packageManager} (auto-detected by the scaffolder, not asked)`,
1266
- browser: "chrome (default: extension_dev and extension_build target chrome unless you pass browser)",
1267
- gitInit
1308
+ return envelope({
1309
+ ok: true,
1310
+ command: COMMAND,
1311
+ status: "created",
1312
+ value: {
1313
+ resolvedPath: result.projectPath,
1314
+ projectPath: result.projectPath,
1315
+ projectName: result.projectName,
1316
+ template: result.template,
1317
+ depsInstalled: result.depsInstalled,
1318
+ packageManager: result.depsInstalled ? packageManager : null,
1319
+ deployUrl,
1320
+ defaultsApplied: {
1321
+ parentDir: args.parentDir ? `${resolvedParent} (explicit)` : `${resolvedParent} (default: the MCP server process cwd, not yours; pass parentDir to choose)`,
1322
+ ...void 0 === args.template ? {
1323
+ template: "typescript (default; call extension_templates to pick another, e.g. javascript for plain JS)"
1324
+ } : {},
1325
+ packageManager: `${packageManager} (auto-detected by the scaffolder, not asked)`,
1326
+ browser: "chrome (default: extension_dev and extension_build target chrome unless you pass browser)",
1327
+ gitInit
1328
+ },
1329
+ duration: Date.now() - start,
1330
+ nextSteps: [
1331
+ ...result.depsInstalled ? [
1332
+ `cd ${result.projectPath}`,
1333
+ runDev
1334
+ ] : [
1335
+ `cd ${result.projectPath}`,
1336
+ `${packageManager} install`,
1337
+ runDev
1338
+ ],
1339
+ `To ship: extension_create scaffolds and runs locally, it does not host. Open ${deployUrl} to deploy this template to the web.`
1340
+ ]
1268
1341
  },
1269
- duration: Date.now() - start,
1270
- nextSteps: [
1271
- ...result.depsInstalled ? [
1272
- `cd ${result.projectPath}`,
1273
- runDev
1274
- ] : [
1275
- `cd ${result.projectPath}`,
1276
- `${packageManager} install`,
1277
- runDev
1278
- ],
1279
- `To ship: extension_create scaffolds and runs locally, it does not host. Open ${deployUrl} to deploy this template to the web.`
1280
- ],
1281
- ...engineWarning ? {
1282
- engineWarning
1283
- } : {},
1284
- ...result.depsInstalled ? {} : {
1285
- warnings: logTail()
1286
- }
1342
+ warnings: [
1343
+ engineWarning,
1344
+ ...result.depsInstalled ? [] : logTail()
1345
+ ]
1287
1346
  });
1288
1347
  }
1289
1348
  const DEFAULT_MEDIA_ORIGIN = "https://media.extension.land";
@@ -1479,15 +1538,27 @@ async function searchTemplates(args) {
1479
1538
  repositoryUrl: t.repositoryUrl,
1480
1539
  downloads: t.downloads
1481
1540
  }));
1482
- return JSON.stringify({
1483
- count: results.length,
1484
- templates: results
1541
+ return envelope({
1542
+ ok: true,
1543
+ command: "extension_templates",
1544
+ status: "listed",
1545
+ value: {
1546
+ count: results.length,
1547
+ templates: results
1548
+ }
1485
1549
  });
1486
1550
  }
1551
+ const get_template_source_COMMAND = "extension_templates";
1487
1552
  async function readTemplateSource(args) {
1488
1553
  const template = await getTemplateBySlug(args.slug);
1489
- if (!template) return JSON.stringify({
1490
- error: `Template '${args.slug}' not found in the catalog`,
1554
+ if (!template) return envelope({
1555
+ ok: false,
1556
+ command: get_template_source_COMMAND,
1557
+ status: "template-not-found",
1558
+ error: {
1559
+ code: "E_TEMPLATE_NOT_FOUND",
1560
+ message: `Template '${args.slug}' not found in the catalog`
1561
+ },
1491
1562
  hint: 'Use extension_templates with action: "list" to see available templates.'
1492
1563
  });
1493
1564
  const meta = {
@@ -1500,9 +1571,14 @@ async function readTemplateSource(args) {
1500
1571
  keyFiles: template.keyFiles,
1501
1572
  repositoryUrl: template.repositoryUrl
1502
1573
  };
1503
- if (!args.files?.length) return JSON.stringify({
1504
- ...meta,
1505
- files: template.files.map((f)=>stripTemplatePathPrefix(template.slug, f)),
1574
+ if (!args.files?.length) return envelope({
1575
+ ok: true,
1576
+ command: get_template_source_COMMAND,
1577
+ status: "file-list",
1578
+ value: {
1579
+ ...meta,
1580
+ files: template.files.map((f)=>stripTemplatePathPrefix(template.slug, f))
1581
+ },
1506
1582
  hint: "Pass specific file paths in the files parameter to read their contents."
1507
1583
  });
1508
1584
  const fileContents = {};
@@ -1520,17 +1596,24 @@ async function readTemplateSource(args) {
1520
1596
  } catch {}
1521
1597
  errors.push(`${filePath}: ${lastStatus || "fetch failed"}`);
1522
1598
  }));
1523
- return JSON.stringify({
1524
- ...meta,
1525
- fileContents,
1526
- ...errors.length ? {
1527
- errors
1528
- } : {}
1599
+ return envelope({
1600
+ ok: 0 === errors.length,
1601
+ command: get_template_source_COMMAND,
1602
+ status: errors.length ? "partial" : "read",
1603
+ error: errors.length ? {
1604
+ code: "E_TEMPLATE_FETCH",
1605
+ message: `${errors.length} of ${errors.length + Object.keys(fileContents).length} file(s) could not be read from the template.`
1606
+ } : null,
1607
+ value: {
1608
+ ...meta,
1609
+ fileContents
1610
+ },
1611
+ warnings: errors
1529
1612
  });
1530
1613
  }
1531
1614
  const templates_schema = {
1532
1615
  name: "extension_templates",
1533
- description: "Browse the extension.dev template catalog. action:'list' (default) searches and filters it and returns metadata per template; action:'source' reads one template's files by `slug`, for learning a pattern before building something similar. `framework` is the UI framework ONLY, never the language: TypeScript and JavaScript templates live under slugs ('typescript', 'content-typescript'), shadcn is a React variant ('sidebar-shadcn'), and provider AIs are tagged 'ai' ('ai-chatgpt', 'ai-claude'). Reach those with query/tags/slug.",
1616
+ description: "Browse the extension.dev template catalog. Pass action:'list' (the default) to search and filter it and get metadata per template. Pass action:'source' with a `slug` to read one template's files, for learning a pattern before building something similar. Read `framework` as the UI framework only, never the language: TypeScript and JavaScript templates live under slugs ('typescript', 'content-typescript'), shadcn is a React variant ('sidebar-shadcn'), and provider AIs carry the 'ai' tag ('ai-chatgpt', 'ai-claude'). Reach those through query, tags or slug.",
1534
1617
  inputSchema: {
1535
1618
  type: "object",
1536
1619
  properties: {
@@ -1595,9 +1678,14 @@ const templates_schema = {
1595
1678
  };
1596
1679
  async function templates_handler(args) {
1597
1680
  if ((args.action ?? "list") === "source" || !args.action && args.slug) {
1598
- if (!args.slug) return JSON.stringify({
1681
+ if (!args.slug) return envelope({
1599
1682
  ok: false,
1600
- error: "action 'source' needs a slug.",
1683
+ command: "extension_templates",
1684
+ status: "bad-request",
1685
+ error: {
1686
+ code: "E_BAD_REQUEST",
1687
+ message: "action 'source' needs a slug."
1688
+ },
1601
1689
  hint: 'Call extension_templates with action: "list" to find one.'
1602
1690
  });
1603
1691
  return readTemplateSource({
@@ -1938,21 +2026,6 @@ function deadReadySession(projectPath) {
1938
2026
  };
1939
2027
  return null;
1940
2028
  }
1941
- function browserExitStamp(projectPath, browser, since) {
1942
- const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1943
- try {
1944
- const stat = node_fs.statSync(readyPath);
1945
- if (stat.mtimeMs < since) return null;
1946
- const contract = JSON.parse(node_fs.readFileSync(readyPath, "utf8"));
1947
- const exited = contract?.code === "browser_exited" || contract?.browserExitCode !== void 0 || contract?.browserExitedAt !== void 0;
1948
- if (contract?.status === "error" && exited) return {
1949
- code: contract.code,
1950
- browserExitCode: contract.browserExitCode ?? null,
1951
- browserExitedAt: contract.browserExitedAt
1952
- };
1953
- } catch {}
1954
- return null;
1955
- }
1956
2029
  function contractBoundPort(projectPath, browser, since) {
1957
2030
  const readyPath = node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
1958
2031
  try {
@@ -2192,6 +2265,7 @@ function materializeCarrier(projectPath, browser) {
2192
2265
  };
2193
2266
  }
2194
2267
  }
2268
+ const build_COMMAND = "extension_build";
2195
2269
  function readBuildSummary(projectPath, browser, since) {
2196
2270
  const file = node_path.resolve(projectPath, "dist", "extension-js", browser, "build-summary.json");
2197
2271
  try {
@@ -2289,7 +2363,7 @@ function locateSourceZip(projectPath, browser, since) {
2289
2363
  }
2290
2364
  const build_schema = {
2291
2365
  name: "extension_build",
2292
- description: "Build a browser extension for production. Outputs to dist/<browser>/. Optionally creates .zip for store submission.",
2366
+ 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.",
2293
2367
  inputSchema: {
2294
2368
  type: "object",
2295
2369
  properties: {
@@ -2398,11 +2472,12 @@ async function validationPreflight(projectPath, browser) {
2398
2472
  browser
2399
2473
  ]
2400
2474
  }));
2475
+ const value = parsed?.value ?? {};
2401
2476
  return {
2402
- valid: Boolean(parsed.valid),
2403
- buildBlocking: Boolean(parsed.buildBlocking),
2404
- errors: Array.isArray(parsed.errors) ? parsed.errors : [],
2405
- warnings: Array.isArray(parsed.warnings) ? parsed.warnings : []
2477
+ valid: Boolean(value.valid),
2478
+ buildBlocking: Boolean(value.buildBlocking),
2479
+ errors: Array.isArray(value.errors) ? value.errors : [],
2480
+ warnings: Array.isArray(value.warnings) ? value.warnings : []
2406
2481
  };
2407
2482
  } catch {
2408
2483
  return null;
@@ -2416,17 +2491,23 @@ async function build_handler(args) {
2416
2491
  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.");
2417
2492
  else if (carrierCleanup.note) carrierNotes.push(carrierCleanup.note);
2418
2493
  const preflight = args.skipValidation ? null : await validationPreflight(args.projectPath, browser);
2419
- if (preflight?.buildBlocking) return JSON.stringify({
2420
- success: false,
2421
- status: "blocked",
2422
- browser,
2423
- error: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds.",
2424
- errors: preflight.errors,
2494
+ if (preflight?.buildBlocking) return envelope({
2495
+ ok: false,
2496
+ command: build_COMMAND,
2497
+ status: "manifest-blocked",
2498
+ error: {
2499
+ code: "E_MANIFEST_BLOCKING",
2500
+ message: "Build refused: the manifest has errors that produce a broken extension even when the bundler succeeds."
2501
+ },
2502
+ value: {
2503
+ browser,
2504
+ errors: preflight.errors,
2505
+ duration: Date.now() - start
2506
+ },
2425
2507
  warnings: [
2426
2508
  ...carrierNotes,
2427
2509
  ...preflight.warnings
2428
2510
  ],
2429
- duration: Date.now() - start,
2430
2511
  hint: "Fix the errors above, then build again. Run extension_manifest_validate for the full report. To build anyway (for example to inspect the broken output), pass skipValidation: true."
2431
2512
  });
2432
2513
  const clobberedSessions = liveProjectSessions(args.projectPath).filter((session)=>session.browser === browser);
@@ -2454,107 +2535,268 @@ async function build_handler(args) {
2454
2535
  const size = out.match(/Size:\s*([\d.]+\s*[kKmMgG]?B)/)?.[1];
2455
2536
  const status = out.match(/Build Status:\s*(\w+)/)?.[1];
2456
2537
  const engineSummary = readBuildSummary(args.projectPath, browser, start);
2457
- const buildWarnings = engineSummary?.warnings?.length ? {
2458
- buildWarnings: engineSummary.warnings,
2459
- ..."number" == typeof engineSummary.warnings_count && engineSummary.warnings_count > engineSummary.warnings.length ? {
2460
- buildWarningsTruncated: engineSummary.warnings_count
2461
- } : {}
2462
- } : {};
2538
+ const buildWarnings = engineSummary?.warnings?.length ? engineSummary.warnings : [];
2539
+ const buildWarningsTruncated = buildWarnings.length && "number" == typeof engineSummary?.warnings_count && engineSummary.warnings_count > buildWarnings.length ? engineSummary.warnings_count : void 0;
2463
2540
  const distDir = node_path.resolve(args.projectPath, "dist", browser);
2464
2541
  const entrypoints = builtEntrypoints(distDir);
2465
2542
  const contamination = carrierEntriesInDist(distDir);
2466
- if (contamination.length) return JSON.stringify({
2467
- success: false,
2468
- status: "contaminated",
2469
- browser,
2470
- buildExitCode: 0,
2471
- error: `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.`,
2472
- ...warnings.length ? {
2473
- warnings
2474
- } : {},
2475
- duration,
2543
+ if (contamination.length) return envelope({
2544
+ ok: false,
2545
+ command: build_COMMAND,
2546
+ status: "carrier-in-dist",
2547
+ error: {
2548
+ code: "E_CARRIER_IN_DIST",
2549
+ 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.`
2550
+ },
2551
+ value: {
2552
+ browser,
2553
+ buildExitCode: 0,
2554
+ duration
2555
+ },
2556
+ warnings,
2476
2557
  hint: "Delete the listed paths from dist and build again. The carrier normally lives in ./extensions and is removed before every build."
2477
2558
  });
2478
2559
  const missing = entrypoints.filter((e)=>!e.present);
2479
- if (missing.length) return JSON.stringify({
2480
- success: false,
2481
- status: "incomplete",
2482
- browser,
2483
- buildExitCode: 0,
2484
- error: `The build reported success but ${missing.length} declared entrypoint(s) are missing from dist/${browser}: ` + missing.map((m)=>`${m.role} -> ${m.path}`).join(", ") + ". The browser will refuse to load this build.",
2485
- entrypoints,
2486
- ...preflight?.warnings.length ? {
2487
- manifestWarnings: preflight.warnings
2488
- } : {},
2489
- ...buildWarnings,
2490
- ...warnings.length ? {
2491
- warnings
2492
- } : {},
2493
- duration,
2494
- output: lastLines(out, 12),
2560
+ if (missing.length) return envelope({
2561
+ ok: false,
2562
+ command: build_COMMAND,
2563
+ status: "entrypoint-missing",
2564
+ error: {
2565
+ code: "E_ENTRYPOINT_MISSING",
2566
+ message: `The build reported success but ${missing.length} declared entrypoint(s) are missing from dist/${browser}: ` + missing.map((m)=>`${m.role} -> ${m.path}`).join(", ") + ". The browser will refuse to load this build."
2567
+ },
2568
+ value: {
2569
+ browser,
2570
+ buildExitCode: 0,
2571
+ entrypoints,
2572
+ duration,
2573
+ output: lastLines(out, 12)
2574
+ },
2575
+ warnings: [
2576
+ ...warnings,
2577
+ ...preflight?.warnings ?? [],
2578
+ ...buildWarnings
2579
+ ],
2495
2580
  hint: "The bundler exited 0 but did not emit these files. Check that the manifest paths match what the build produces, and that nothing references a file outside the source tree."
2496
2581
  });
2497
- return JSON.stringify({
2498
- success: true,
2499
- browser,
2500
- ...size ? {
2501
- size
2502
- } : {},
2503
- ...status ? {
2504
- status
2505
- } : {},
2506
- ...entrypoints.length ? {
2507
- entrypoints
2508
- } : {},
2509
- ...preflight?.warnings.length ? {
2510
- manifestWarnings: preflight.warnings
2511
- } : {},
2512
- ...buildWarnings,
2513
- ...(()=>{
2514
- const divergence = manifestDivergence(args.projectPath, browser);
2515
- return divergence.length ? {
2582
+ const zipNotes = [];
2583
+ const zipPath = args.zip ? locateDistZip(args.projectPath, browser, args.zipFilename, start) : null;
2584
+ if (args.zip && !zipPath) zipNotes.push(`zip: true was requested and the build succeeded, but no .zip file could be located in dist/${browser}. The engine may not have packaged it; check the build output below.`);
2585
+ const zipSourcePath = args.zipSource ? locateSourceZip(args.projectPath, browser, start) : null;
2586
+ 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.");
2587
+ const divergence = manifestDivergence(args.projectPath, browser);
2588
+ return envelope({
2589
+ ok: true,
2590
+ command: build_COMMAND,
2591
+ status: "built",
2592
+ value: {
2593
+ browser,
2594
+ ...size ? {
2595
+ size
2596
+ } : {},
2597
+ ...status ? {
2598
+ engineBuildStatus: status
2599
+ } : {},
2600
+ ...entrypoints.length ? {
2601
+ entrypoints
2602
+ } : {},
2603
+ ...void 0 !== buildWarningsTruncated ? {
2604
+ buildWarningsTruncated
2605
+ } : {},
2606
+ ...divergence.length ? {
2516
2607
  productionDivergence: divergence
2517
- } : {};
2518
- })(),
2519
- ...warnings.length ? {
2520
- warnings
2521
- } : {},
2522
- zip: args.zip ?? false,
2523
- ...args.zip ? (()=>{
2524
- const zipPath = locateDistZip(args.projectPath, browser, args.zipFilename, start);
2525
- return zipPath ? {
2608
+ } : {},
2609
+ zip: args.zip ?? false,
2610
+ ...zipPath ? {
2526
2611
  zipPath
2527
- } : {
2528
- zipPathNote: `zip: true was requested and the build succeeded, but no .zip file could be located in dist/${browser}. The engine may not have packaged it; check the build output below.`
2529
- };
2530
- })() : {},
2531
- ...args.zipSource ? (()=>{
2532
- const zipSourcePath = locateSourceZip(args.projectPath, browser, start);
2533
- return zipSourcePath ? {
2612
+ } : {},
2613
+ ...zipSourcePath ? {
2534
2614
  zipSourcePath
2535
- } : {
2536
- zipSourcePathNote: "zipSource: true was requested and the build succeeded, but no *-source.zip file could be located in dist/. The engine may not have packaged it; check the build output below."
2537
- };
2538
- })() : {},
2539
- duration,
2540
- output: lastLines(out, 12)
2615
+ } : {},
2616
+ duration,
2617
+ output: lastLines(out, 12)
2618
+ },
2619
+ warnings: [
2620
+ ...warnings,
2621
+ ...preflight?.warnings ?? [],
2622
+ ...buildWarnings,
2623
+ ...zipNotes
2624
+ ]
2541
2625
  });
2542
2626
  }
2543
2627
  const message = stderr.trim() || out || `extension build exited with code ${code}`;
2544
- return JSON.stringify({
2545
- success: false,
2546
- browser,
2547
- error: message.slice(0, 1200),
2548
- ...warnings.length ? {
2549
- warnings
2550
- } : {},
2551
- duration,
2628
+ return envelope({
2629
+ ok: false,
2630
+ command: build_COMMAND,
2631
+ status: "build-failed",
2632
+ error: {
2633
+ code: "E_BUILD_FAILED",
2634
+ message: message.slice(0, 1200)
2635
+ },
2636
+ value: {
2637
+ browser,
2638
+ duration
2639
+ },
2640
+ warnings,
2552
2641
  hint: "Check that the project has a valid src/manifest.json and its dependencies are installed (extension_dev auto-installs; build does not)."
2553
2642
  });
2554
2643
  }
2644
+ const NOISE = [
2645
+ /^npm warn Unknown project config/i,
2646
+ /This will stop working in the next major version of npm/i,
2647
+ /^npm warn config/i,
2648
+ /^npm warn exec/i,
2649
+ /The following package(s)? (was|were) not found and will be installed/i,
2650
+ /V8: .*Invalid asm\.js/i,
2651
+ /^\(node:\d+\) V8:/i,
2652
+ /Use `node --trace-warnings/i,
2653
+ /Invalid asm\.js:/i,
2654
+ /Linking failure in asm\.js/i,
2655
+ /Successfully compiled asm\.js/i
2656
+ ];
2657
+ function denoiseCliLog(raw) {
2658
+ return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
2659
+ }
2660
+ function legacyCompileScrape(cleanOutput) {
2661
+ return /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(cleanOutput);
2662
+ }
2663
+ function legacyProfileLockScrape(cleanOutput) {
2664
+ return /SingletonLock|ProcessSingleton|profile[^\n]*(in use|locked)|already (open|running)/i.test(cleanOutput);
2665
+ }
2666
+ 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.";
2667
+ const PROFILE_LOCKED_CODE = "profile_locked";
2668
+ function readyContractPath(projectPath, browser) {
2669
+ return node_path.resolve(projectPath, "dist", "extension-js", browser, "ready.json");
2670
+ }
2671
+ function readContract(projectPath, browser, since) {
2672
+ try {
2673
+ const file = readyContractPath(projectPath, browser);
2674
+ const stat = node_fs.statSync(file);
2675
+ const contract = JSON.parse(node_fs.readFileSync(file, "utf8"));
2676
+ if (!contract || "object" != typeof contract) return null;
2677
+ return {
2678
+ contract,
2679
+ fresh: stat.mtimeMs >= since
2680
+ };
2681
+ } catch {
2682
+ return null;
2683
+ }
2684
+ }
2685
+ function speaksMachineContract(contract) {
2686
+ return !!contract && "object" == typeof contract && 1 === contract.schema;
2687
+ }
2688
+ function profileLockOwner(contract) {
2689
+ const owner = contract.profileLockOwner;
2690
+ if (!owner || "object" != typeof owner) return null;
2691
+ const { host, pid } = owner;
2692
+ return {
2693
+ ...host ? {
2694
+ host
2695
+ } : {},
2696
+ ...null != pid ? {
2697
+ pid
2698
+ } : {}
2699
+ };
2700
+ }
2701
+ function contractVerdict(reading, noBrowser) {
2702
+ const { contract, fresh } = reading;
2703
+ if (!fresh) return null;
2704
+ if ("error" !== contract.status) return null;
2705
+ if (!noBrowser && contract.code === PROFILE_LOCKED_CODE) return {
2706
+ kind: "profile-locked",
2707
+ owner: profileLockOwner(contract),
2708
+ message: "string" == typeof contract.message ? contract.message : void 0,
2709
+ lockedAt: "string" == typeof contract.profileLockedAt ? contract.profileLockedAt : void 0
2710
+ };
2711
+ const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode || void 0 !== contract.browserExitedAt;
2712
+ if (!noBrowser && browserExited) return {
2713
+ kind: "browser-exited",
2714
+ stamp: {
2715
+ code: contract.code,
2716
+ browserExitCode: contract.browserExitCode ?? null,
2717
+ browserExitedAt: contract.browserExitedAt
2718
+ }
2719
+ };
2720
+ return {
2721
+ kind: "compile-failed",
2722
+ message: contract.message,
2723
+ compileErrors: Array.isArray(contract.errors) ? contract.errors : []
2724
+ };
2725
+ }
2726
+ async function pollBootVerdict(projectPath, browser, options) {
2727
+ const { child, readOutput, budgetMs, since } = options;
2728
+ const noBrowser = Boolean(options.noBrowser);
2729
+ const intervalMs = options.intervalMs ?? 250;
2730
+ const deadline = Date.now() + budgetMs;
2731
+ let contractSeen = null;
2732
+ for(;;){
2733
+ if (null !== child.exitCode || null !== child.signalCode) return {
2734
+ verdict: {
2735
+ kind: "exited",
2736
+ exitCode: child.exitCode,
2737
+ signal: child.signalCode
2738
+ },
2739
+ machineContract: speaksMachineContract(contractSeen?.contract),
2740
+ output: denoiseCliLog(readOutput()),
2741
+ warnings: []
2742
+ };
2743
+ contractSeen = readContract(projectPath, browser, since) ?? contractSeen;
2744
+ if (contractSeen) {
2745
+ const verdict = contractVerdict(contractSeen, noBrowser);
2746
+ if (verdict) return {
2747
+ verdict,
2748
+ machineContract: speaksMachineContract(contractSeen.contract),
2749
+ output: denoiseCliLog(readOutput()),
2750
+ warnings: []
2751
+ };
2752
+ }
2753
+ if (Date.now() >= deadline) break;
2754
+ await new Promise((resolve)=>setTimeout(resolve, Math.min(intervalMs, Math.max(deadline - Date.now(), 1))));
2755
+ }
2756
+ const machineContract = speaksMachineContract(contractSeen?.contract);
2757
+ const output = denoiseCliLog(readOutput());
2758
+ if (machineContract) return {
2759
+ verdict: {
2760
+ kind: "alive"
2761
+ },
2762
+ machineContract,
2763
+ output,
2764
+ warnings: []
2765
+ };
2766
+ if (legacyCompileScrape(output)) return {
2767
+ verdict: {
2768
+ kind: "compile-failed",
2769
+ compileErrors: []
2770
+ },
2771
+ machineContract,
2772
+ output,
2773
+ warnings: [
2774
+ LEGACY_FIDELITY_WARNING
2775
+ ]
2776
+ };
2777
+ if (!noBrowser && legacyProfileLockScrape(output)) return {
2778
+ verdict: {
2779
+ kind: "profile-locked",
2780
+ owner: null
2781
+ },
2782
+ machineContract,
2783
+ output,
2784
+ warnings: [
2785
+ LEGACY_FIDELITY_WARNING
2786
+ ]
2787
+ };
2788
+ return {
2789
+ verdict: {
2790
+ kind: "alive"
2791
+ },
2792
+ machineContract,
2793
+ output,
2794
+ warnings: []
2795
+ };
2796
+ }
2555
2797
  const stop_schema = {
2556
2798
  name: "extension_stop",
2557
- description: "Stop a session that extension_dev or extension_start is running: terminates the server and the browser it launched, and removes the live-preview carrier if extension_dev placed one. Covers extension_start build:false too, which the registry still records as a preview session. Call it when you are done verifying so sessions do not accumulate.",
2799
+ 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.",
2558
2800
  inputSchema: {
2559
2801
  type: "object",
2560
2802
  properties: {
@@ -2698,22 +2940,47 @@ async function stop_handler(args) {
2698
2940
  const key = `${node_path.resolve(m.projectPath)}::${m.browser}`;
2699
2941
  if (!candidates.has(key)) candidates.set(key, m);
2700
2942
  }
2701
- if (0 === candidates.size) return JSON.stringify({
2702
- stopped: [],
2703
- message: "No sessions registered in this server and no session markers on disk. Nothing to stop."
2943
+ if (0 === candidates.size) return envelope({
2944
+ ok: true,
2945
+ command: stop_schema.name,
2946
+ status: "nothing-to-stop",
2947
+ value: {
2948
+ stopped: []
2949
+ },
2950
+ hint: "No sessions registered in this server and no session markers on disk. Nothing to stop."
2704
2951
  });
2705
2952
  const outcomes = [];
2706
2953
  for (const c of candidates.values())outcomes.push(await stopOne(c.projectPath, c.browser));
2707
- return JSON.stringify({
2708
- stopped: outcomes
2954
+ return envelope({
2955
+ ok: outcomes.every((o)=>o.stopped),
2956
+ command: stop_schema.name,
2957
+ status: "stopped-all",
2958
+ value: {
2959
+ stopped: outcomes
2960
+ },
2961
+ warnings: outcomes.map((o)=>o.stopped ? null : o.detail)
2709
2962
  });
2710
2963
  }
2711
- if (!args.projectPath) return JSON.stringify({
2712
- error: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
2964
+ if (!args.projectPath) return envelope({
2965
+ ok: false,
2966
+ command: stop_schema.name,
2967
+ status: "bad-request",
2968
+ error: {
2969
+ code: "E_BAD_REQUEST",
2970
+ message: "projectPath is required unless all=true. Pass the same projectPath used with extension_dev/extension_start."
2971
+ }
2713
2972
  });
2714
2973
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
2715
2974
  const outcome = await stopOne(args.projectPath, browser);
2716
- return JSON.stringify(outcome);
2975
+ return envelope({
2976
+ ok: outcome.stopped,
2977
+ command: stop_schema.name,
2978
+ status: null === outcome.pid ? "not-found" : outcome.stopped ? "stopped" : "still-alive",
2979
+ value: outcome,
2980
+ warnings: [
2981
+ outcome.stopped ? null : outcome.detail
2982
+ ]
2983
+ });
2717
2984
  }
2718
2985
  const LAUNCH_FLAG_SCHEMA = {
2719
2986
  profile: {
@@ -2761,7 +3028,7 @@ function launchFlagArgs(args) {
2761
3028
  }
2762
3029
  const dev_schema = {
2763
3030
  name: "extension_dev",
2764
- description: "Run the extension WHILE YOU EDIT IT: dev build, hot module replacement, and a browser with it loaded. The default answer to \"run my extension\". Only this tool can unlock the control channel that extension_storage/reload/open/dom_snapshot need (allowControl) and that extension_eval needs (allowEval). For the production build in a browser instead, use extension_start. Returns process info for extension_wait and extension_inspect.",
3031
+ description: "Run the extension while you edit it: dev build, hot module replacement, and a browser with the extension loaded. Reach for this first when the ask is \"run my extension\". ONLY this tool unlocks the control channel that extension_storage, extension_reload, extension_open and extension_dom_snapshot need (allowControl:true) and the eval channel that extension_eval needs (allowEval:true, which implies allowControl, so you never need to pass both). Use extension_start instead to run the production build in a browser. The result carries the process info that extension_wait and extension_inspect need.",
2765
3032
  inputSchema: {
2766
3033
  type: "object",
2767
3034
  properties: {
@@ -2815,15 +3082,21 @@ async function dev_handler(args) {
2815
3082
  if (existing.length > 0) {
2816
3083
  if (!args.replace) {
2817
3084
  const listed = existing.map((s)=>`pid ${s.pid} (${s.browser})`).join(", ");
2818
- return JSON.stringify({
3085
+ return envelope({
2819
3086
  ok: false,
3087
+ command: dev_schema.name,
2820
3088
  status: "session-exists",
2821
- projectPath: args.projectPath,
2822
- sessions: existing.map((s)=>({
2823
- pid: s.pid,
2824
- browser: s.browser
2825
- })),
2826
- error: `A dev session is already running for this project (${listed}). Starting another would fork the session: both browsers contend for the same profile and the new one dies on the profile lock.`,
3089
+ error: {
3090
+ code: "E_SESSION_EXISTS",
3091
+ message: `A dev session is already running for this project (${listed}). Starting another would fork the session: both browsers contend for the same profile and the new one dies on the profile lock.`
3092
+ },
3093
+ value: {
3094
+ projectPath: args.projectPath,
3095
+ sessions: existing.map((s)=>({
3096
+ pid: s.pid,
3097
+ browser: s.browser
3098
+ }))
3099
+ },
2827
3100
  hint: "Call extension_stop with this projectPath first, or pass replace: true to have extension_dev stop the old session before starting the new one."
2828
3101
  });
2829
3102
  }
@@ -2864,55 +3137,101 @@ async function dev_handler(args) {
2864
3137
  noBrowser: Boolean(args.noBrowser)
2865
3138
  });
2866
3139
  child.on("exit", ()=>removeSession(args.projectPath, browser));
2867
- await new Promise((resolve)=>setTimeout(resolve, 3000));
2868
- const earlyOutput = spawned.readOutput();
2869
- const cleanOutput = denoiseEarlyOutput(earlyOutput);
2870
- if (null !== child.exitCode || null !== child.signalCode) {
2871
- const code = child.exitCode;
2872
- const signal = child.signalCode;
2873
- return JSON.stringify({
3140
+ const boot = await pollBootVerdict(args.projectPath, browser, {
3141
+ child,
3142
+ readOutput: spawned.readOutput,
3143
+ budgetMs: 3000,
3144
+ since: spawnedAt,
3145
+ noBrowser: Boolean(args.noBrowser)
3146
+ });
3147
+ const cleanOutput = boot.output;
3148
+ const session = {
3149
+ projectPath: args.projectPath,
3150
+ browser,
3151
+ pid,
3152
+ logPath
3153
+ };
3154
+ if ("exited" === boot.verdict.kind) {
3155
+ const { exitCode: code, signal } = boot.verdict;
3156
+ return envelope({
2874
3157
  ok: false,
3158
+ command: dev_schema.name,
2875
3159
  status: "exited",
2876
- projectPath: args.projectPath,
2877
- browser,
2878
- pid,
2879
- exitCode: code,
2880
- signal,
2881
- error: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`,
2882
- output: cleanOutput.slice(0, 2000),
2883
- logPath,
2884
- hint: "Read `output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded."
3160
+ error: {
3161
+ code: "E_SESSION_EXITED",
3162
+ message: `The dev server exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running, so extension_logs/wait/eval and the control verbs have nothing to attach to.`
3163
+ },
3164
+ value: {
3165
+ ...session,
3166
+ exitCode: code,
3167
+ signal,
3168
+ output: cleanOutput.slice(0, 2000)
3169
+ },
3170
+ hint: "Read `value.output` above for the cause: a port already in use, a manifest the build rejects, or a missing browser binary are the common ones. Fix it and call extension_dev again; extension_doctor with this projectPath will also report what the last session recorded.",
3171
+ warnings: boot.warnings
2885
3172
  });
2886
3173
  }
2887
- const compileFailed = /compiled with errors|✖✖✖|ERROR in |Module not found|NOT FOUND/i.test(cleanOutput);
2888
- if (compileFailed) return JSON.stringify({
2889
- ok: false,
2890
- status: "compile-failed",
2891
- projectPath: args.projectPath,
2892
- browser,
2893
- pid,
2894
- error: "The dev server started but the FIRST COMPILE FAILED, so the browser has nothing usable to load. The session is running; the extension is not.",
2895
- output: cleanOutput.slice(0, 2000),
2896
- logPath,
2897
- hint: "Fix the compile error in `output` above and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed."
2898
- });
2899
- const exitStamp = args.noBrowser ? null : browserExitStamp(args.projectPath, browser, spawnedAt);
2900
- const profileLockHit = !args.noBrowser && /SingletonLock|ProcessSingleton|profile[^\n]*(in use|locked)|already (open|running)/i.test(cleanOutput);
2901
- if (exitStamp || profileLockHit) {
2902
- const profileDir = node_path.join(args.projectPath, "dist", `extension-profile-${browser}`);
2903
- return JSON.stringify({
3174
+ if ("compile-failed" === boot.verdict.kind) {
3175
+ const { compileErrors } = boot.verdict;
3176
+ return envelope({
2904
3177
  ok: false,
2905
- status: "browser-exited",
2906
- projectPath: args.projectPath,
2907
- browser,
2908
- pid,
2909
- ...exitStamp ?? {},
2910
- error: `The dev server is running but the ${browser} browser it launched died during startup` + (profileLockHit ? " because its profile is locked by another browser instance." : "."),
2911
- output: cleanOutput.slice(0, 2000),
2912
- logPath,
2913
- 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.`
3178
+ command: dev_schema.name,
3179
+ status: "compile-failed",
3180
+ error: {
3181
+ code: "E_FIRST_COMPILE",
3182
+ message: boot.verdict.message ?? "The dev server started but the first compile failed, so the browser has nothing usable to load. The session is running; the extension is not."
3183
+ },
3184
+ value: {
3185
+ ...session,
3186
+ compileErrors,
3187
+ ...compileErrors.length ? {} : {
3188
+ output: cleanOutput.slice(0, 2000)
3189
+ }
3190
+ },
3191
+ hint: "Fix the compile error listed in `value.compileErrors` and save: the dev server is still running and will recompile. Do not call extension_wait yet, it will report ready for a build that failed.",
3192
+ warnings: boot.warnings
2914
3193
  });
2915
3194
  }
3195
+ const profileDir = node_path.join(args.projectPath, "dist", `extension-profile-${browser}`);
3196
+ if ("profile-locked" === boot.verdict.kind) {
3197
+ const { owner, lockedAt } = boot.verdict;
3198
+ return envelope({
3199
+ ok: false,
3200
+ command: dev_schema.name,
3201
+ status: "profile-locked",
3202
+ error: {
3203
+ code: "E_PROFILE_LOCKED",
3204
+ message: boot.verdict.message ?? `The dev server is running but the ${browser} browser it launched died during startup because its profile is locked by another browser instance` + (owner?.pid ? ` (pid ${owner.pid}${owner.host ? ` on ${owner.host}` : ""})` : "") + "."
3205
+ },
3206
+ value: {
3207
+ ...session,
3208
+ profileDir,
3209
+ owner,
3210
+ ...lockedAt ? {
3211
+ lockedAt
3212
+ } : {},
3213
+ output: cleanOutput.slice(0, 2000)
3214
+ },
3215
+ 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.`,
3216
+ warnings: boot.warnings
3217
+ });
3218
+ }
3219
+ if ("browser-exited" === boot.verdict.kind) return envelope({
3220
+ ok: false,
3221
+ command: dev_schema.name,
3222
+ status: "browser-exited",
3223
+ error: {
3224
+ code: "E_BROWSER_EXITED",
3225
+ message: `The dev server is running but the ${browser} browser it launched died during startup.`
3226
+ },
3227
+ value: {
3228
+ ...session,
3229
+ ...boot.verdict.stamp,
3230
+ output: cleanOutput.slice(0, 2000)
3231
+ },
3232
+ 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.`,
3233
+ warnings: boot.warnings
3234
+ });
2916
3235
  const controlVerbs = "storage, reload, open, dom_snapshot";
2917
3236
  const capabilities = {
2918
3237
  allowControl,
@@ -2931,54 +3250,43 @@ async function dev_handler(args) {
2931
3250
  const portReport = null !== boundPort ? {
2932
3251
  port: boundPort,
2933
3252
  ...void 0 !== args.port && args.port !== boundPort ? {
2934
- requestedPort: args.port,
2935
- portNote: `Requested port ${args.port} was not available; the dev server bound ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).`
3253
+ requestedPort: args.port
2936
3254
  } : {}
2937
3255
  } : {
2938
- requestedPort: args.port ?? 8080,
2939
- portNote: "The engine has not stamped its ready.json contract yet, so the bound port is not known at response time (a taken port makes the server bind the next free one). extension_wait reports the bound port from that contract once it lands; requestedPort above is only what was asked for."
3256
+ requestedPort: args.port ?? 8080
2940
3257
  };
2941
- return JSON.stringify({
3258
+ 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.";
3259
+ return envelope({
2942
3260
  ok: true,
2943
- pid,
2944
- browser,
2945
- ...portReport,
2946
- projectPath: args.projectPath,
3261
+ command: dev_schema.name,
2947
3262
  status: "started",
2948
- ...carrier ? {
2949
- carrier
2950
- } : {},
2951
- ...replaced.length > 0 ? {
2952
- replacedSession: replaced[0],
2953
- ...replaced.length > 1 ? {
2954
- replacedSessions: replaced
2955
- } : {}
2956
- } : {},
2957
- capabilities,
2958
- hint: args.noBrowser ? "Build-only session (noBrowser: true): no browser will launch, so no runtime will ever attach. extension_wait returns as soon as the first compile lands (compiled: true, browserAttached: false) instead of waiting out its budget; do not wait for a browser. The control verbs (storage/reload/open/dom_snapshot/eval) need a live browser and will not work against this session. When you are done, call extension_stop to shut down the dev server." : "Use extension_wait to check when the extension is fully loaded, then extension_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_snapshot need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser.",
2959
- earlyOutput: cleanOutput.slice(0, 500),
2960
- logPath
3263
+ value: {
3264
+ pid,
3265
+ browser,
3266
+ ...portReport,
3267
+ projectPath: args.projectPath,
3268
+ ...carrier ? {
3269
+ carrier
3270
+ } : {},
3271
+ ...replaced.length > 0 ? {
3272
+ replacedSession: replaced[0],
3273
+ ...replaced.length > 1 ? {
3274
+ replacedSessions: replaced
3275
+ } : {}
3276
+ } : {},
3277
+ capabilities,
3278
+ logPath
3279
+ },
3280
+ warnings: [
3281
+ portNote,
3282
+ ...boot.warnings
3283
+ ],
3284
+ hint: args.noBrowser ? "Build-only session (noBrowser: true): no browser will launch, so no runtime will ever attach. extension_wait returns as soon as the first compile lands (compiled: true, browserAttached: false) instead of waiting out its budget; do not wait for a browser. The control verbs (storage/reload/open/dom_snapshot/eval) need a live browser and will not work against this session. When you are done, call extension_stop to shut down the dev server." : "Use extension_wait to check when the extension is fully loaded, then extension_inspect to inspect the live state. " + (allowControl ? `Control channel is ON: extension_${controlVerbs.split(", ").join("/extension_")}${args.allowEval ? "/extension_eval" : ""} will work against this session.` : "Control channel is OFF: extension_storage/reload/open/dom_snapshot need allowControl: true, and extension_eval needs allowEval: true (which also implies allowControl). To unlock them, call extension_dev again with the flag you need plus replace: true (it stops this session first); a plain second call is refused so the session does not fork.") + " When you are done, call extension_stop to shut down the dev server and browser."
2961
3285
  });
2962
3286
  }
2963
- function denoiseEarlyOutput(raw) {
2964
- const NOISE = [
2965
- /^npm warn Unknown project config/i,
2966
- /This will stop working in the next major version of npm/i,
2967
- /^npm warn config/i,
2968
- /^npm warn exec/i,
2969
- /The following package(s)? (was|were) not found and will be installed/i,
2970
- /V8: .*Invalid asm\.js/i,
2971
- /^\(node:\d+\) V8:/i,
2972
- /Use `node --trace-warnings/i,
2973
- /Invalid asm\.js:/i,
2974
- /Linking failure in asm\.js/i,
2975
- /Successfully compiled asm\.js/i
2976
- ];
2977
- return raw.split("\n").filter((line)=>!NOISE.some((re)=>re.test(line.trim()))).join("\n").replace(/\n{2,}/g, "\n").trimStart();
2978
- }
2979
3287
  const start_schema = {
2980
3288
  name: "extension_start",
2981
- description: "Run the PRODUCTION build in a browser: builds the project, then serves it and launches. No hot module replacement and no control channel, so your edits are not picked up and extension_eval/storage/reload/open/dom_snapshot cannot attach to this session. Use extension_dev while writing code; use this to check what actually ships. Pass build:false to launch on an existing dist/<browser> without rebuilding.",
3289
+ description: "Run the PRODUCTION build in a browser: build the project, serve it, and launch. There is no hot module replacement and no control channel, so your edits are not picked up and extension_eval, extension_storage, extension_reload, extension_open and extension_dom_snapshot cannot attach to this session. Use extension_dev while writing code, and this to check what actually ships. Pass build:false to launch an existing dist/<browser> without rebuilding.",
2982
3290
  inputSchema: {
2983
3291
  type: "object",
2984
3292
  properties: {
@@ -3037,54 +3345,106 @@ async function start_handler(args) {
3037
3345
  command
3038
3346
  });
3039
3347
  child.on("exit", ()=>removeSession(args.projectPath, browser));
3040
- await new Promise((resolve)=>setTimeout(resolve, 5000));
3041
- const earlyOutput = spawned.readOutput();
3042
- if (null !== child.exitCode || null !== child.signalCode) {
3043
- const code = child.exitCode;
3044
- const signal = child.signalCode;
3045
- return JSON.stringify({
3348
+ const boot = await pollBootVerdict(args.projectPath, browser, {
3349
+ child,
3350
+ readOutput: spawned.readOutput,
3351
+ budgetMs: 5000,
3352
+ since: spawnedAt,
3353
+ noBrowser: Boolean(args.noBrowser)
3354
+ });
3355
+ const cleanOutput = boot.output;
3356
+ const session = {
3357
+ projectPath: args.projectPath,
3358
+ browser,
3359
+ pid,
3360
+ logPath
3361
+ };
3362
+ if ("exited" === boot.verdict.kind) {
3363
+ const { exitCode: code, signal } = boot.verdict;
3364
+ return envelope({
3046
3365
  ok: false,
3366
+ command: start_schema.name,
3047
3367
  status: "exited",
3048
- projectPath: args.projectPath,
3049
- browser,
3050
- pid,
3051
- exitCode: code,
3052
- signal,
3053
- error: `The ${command} process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`,
3054
- output: earlyOutput.slice(0, 2000),
3055
- logPath,
3056
- hint: building ? "Read `output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own." : "Read `output` above for the cause: a missing or broken dist/ (run extension_build first, or drop build:false), or a missing browser binary are the common ones."
3368
+ error: {
3369
+ code: "E_SESSION_EXITED",
3370
+ message: `The ${command} process exited during startup (${signal ? `signal ${signal}` : `exit code ${code}`}). No session is running.`
3371
+ },
3372
+ value: {
3373
+ ...session,
3374
+ exitCode: code,
3375
+ signal,
3376
+ output: cleanOutput.slice(0, 2000)
3377
+ },
3378
+ hint: building ? "Read `value.output` above for the cause: a failed production build, a port already in use, or a missing browser binary are the common ones. extension_build will surface a build error on its own." : "Read `value.output` above for the cause: a missing or broken dist/ (run extension_build first, or drop build:false), or a missing browser binary are the common ones.",
3379
+ warnings: boot.warnings
3057
3380
  });
3058
3381
  }
3059
- const exitStamp = browserExitStamp(args.projectPath, browser, spawnedAt);
3060
- if (exitStamp) return JSON.stringify({
3061
- ok: false,
3062
- status: "browser-exited",
3063
- projectPath: args.projectPath,
3064
- browser,
3065
- pid,
3066
- ...exitStamp,
3067
- error: `The ${command} process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.`,
3068
- output: earlyOutput.slice(0, 2000),
3069
- logPath,
3070
- hint: "Read `output` above and extension_logs for the cause, then call extension_stop to clean up before retrying."
3071
- });
3072
- return JSON.stringify({
3382
+ if ("compile-failed" === boot.verdict.kind) {
3383
+ const { compileErrors } = boot.verdict;
3384
+ return envelope({
3385
+ ok: false,
3386
+ command: start_schema.name,
3387
+ status: "compile-failed",
3388
+ error: {
3389
+ code: "E_FIRST_COMPILE",
3390
+ message: boot.verdict.message ?? `The ${command} process is running but the build failed, so the browser has nothing usable to load.`
3391
+ },
3392
+ value: {
3393
+ ...session,
3394
+ buildErrors: compileErrors,
3395
+ ...compileErrors.length ? {} : {
3396
+ output: cleanOutput.slice(0, 2000)
3397
+ }
3398
+ },
3399
+ hint: "Fix the build error listed in `value.buildErrors`, then call extension_start again. extension_build reports the same failure on its own.",
3400
+ warnings: boot.warnings
3401
+ });
3402
+ }
3403
+ if ("browser-exited" === boot.verdict.kind || "profile-locked" === boot.verdict.kind) {
3404
+ const stamp = "browser-exited" === boot.verdict.kind ? boot.verdict.stamp : {};
3405
+ return envelope({
3406
+ ok: false,
3407
+ command: start_schema.name,
3408
+ status: boot.verdict.kind,
3409
+ error: {
3410
+ code: "profile-locked" === boot.verdict.kind ? "E_PROFILE_LOCKED" : "E_BROWSER_EXITED",
3411
+ message: `The ${command} process is running but the browser it launched has exited (the extension may have been rejected or the browser crashed). The session cannot be driven.`
3412
+ },
3413
+ value: {
3414
+ ...session,
3415
+ ...stamp,
3416
+ output: cleanOutput.slice(0, 2000)
3417
+ },
3418
+ hint: "Read `value.output` above and extension_logs for the cause, then call extension_stop to clean up before retrying.",
3419
+ warnings: boot.warnings
3420
+ });
3421
+ }
3422
+ return envelope({
3073
3423
  ok: true,
3074
- pid,
3075
- browser,
3076
- projectPath: args.projectPath,
3424
+ command: start_schema.name,
3077
3425
  status: building ? "started" : "launched",
3426
+ value: {
3427
+ pid,
3428
+ browser,
3429
+ projectPath: args.projectPath,
3430
+ logPath
3431
+ },
3078
3432
  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.",
3079
- earlyOutput: earlyOutput.slice(0, 500),
3080
- logPath
3433
+ warnings: boot.warnings
3081
3434
  });
3082
3435
  }
3083
3436
  function toMcpSpeak(text) {
3084
3437
  return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
3085
3438
  }
3086
- function withSessionContext(message, projectPath) {
3087
- const isControlError = /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3439
+ const CONTROL_CODES = new Set([
3440
+ "E_CONTROL_DENIED",
3441
+ "E_CONTROL_UNAVAILABLE",
3442
+ "E_NO_CONTROL_CHANNEL",
3443
+ "E_NOT_ATTACHED",
3444
+ "E_SESSION_NOT_FOUND"
3445
+ ]);
3446
+ function withSessionContext(message, projectPath, code) {
3447
+ const isControlError = code ? CONTROL_CODES.has(code) : /no active control channel|control channel refused|\b1006\b|no executor connected|is the session started with allowControl/i.test(message);
3088
3448
  if (!isControlError) return message;
3089
3449
  const dead = deadReadySession(projectPath);
3090
3450
  if (dead) return `${message}\nLikely cause: the dev server has exited, ${dead.browser} ready.json still says ready but its pid ${dead.pid} is dead. Restart with extension_dev (this is not an allowControl problem); extension_doctor confirms.`;
@@ -3094,12 +3454,61 @@ function withSessionContext(message, projectPath) {
3094
3454
  }
3095
3455
  function translateFrame(frame, projectPath) {
3096
3456
  if (!frame || false !== frame.ok) return frame;
3097
- if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath);
3457
+ if (frame.error && "string" == typeof frame.error.message) frame.error.message = withSessionContext(toMcpSpeak(frame.error.message), projectPath, "string" == typeof frame.error.code ? frame.error.code : void 0);
3098
3458
  if ("string" == typeof frame.error?.hint) frame.error.hint = toMcpSpeak(frame.error.hint);
3099
3459
  if ("string" == typeof frame.hint) frame.hint = toMcpSpeak(frame.hint);
3100
3460
  return frame;
3101
3461
  }
3102
- async function runActVerb(args, projectPath, timeoutMs) {
3462
+ const VERB_TOOL = {
3463
+ eval: "extension_eval",
3464
+ inspect: "extension_dom_snapshot",
3465
+ open: "extension_open",
3466
+ reload: "extension_reload",
3467
+ storage: "extension_storage"
3468
+ };
3469
+ const LEGACY_CODES = {
3470
+ BadRequest: "E_BAD_REQUEST",
3471
+ CliError: "E_CLI",
3472
+ NoSession: "E_NO_SESSION"
3473
+ };
3474
+ function wrapLegacyFrame(frame, command) {
3475
+ const { ok, value, truncated, error, hint, note, notes, warning, ...extras } = frame;
3476
+ const raw = error ?? {};
3477
+ const name = "string" == typeof raw.name ? raw.name : void 0;
3478
+ const wrapped = envelopeObject({
3479
+ ok: true === ok,
3480
+ command,
3481
+ status: true === ok ? "ok" : "failed",
3482
+ value: void 0 === value ? null : value,
3483
+ error: true === ok ? null : {
3484
+ code: "string" == typeof raw.code && raw.code ? raw.code : name && LEGACY_CODES[name] || "E_CLI",
3485
+ message: "string" == typeof raw.message ? raw.message : "command failed",
3486
+ ...name ? {
3487
+ name
3488
+ } : {},
3489
+ ...void 0 !== raw.engine ? {
3490
+ engine: raw.engine
3491
+ } : {}
3492
+ },
3493
+ ...true === truncated ? {
3494
+ truncated: true
3495
+ } : {},
3496
+ ..."string" == typeof hint ? {
3497
+ hint
3498
+ } : {},
3499
+ warnings: [
3500
+ "string" == typeof note ? note : null,
3501
+ "string" == typeof warning ? warning : null,
3502
+ ...Array.isArray(notes) ? notes.map((n)=>String(n)) : []
3503
+ ]
3504
+ });
3505
+ return {
3506
+ ...extras,
3507
+ ...wrapped
3508
+ };
3509
+ }
3510
+ async function runActVerb(args, projectPath, timeoutMs, tool) {
3511
+ const command = tool ?? VERB_TOOL[args[0]] ?? "extension_act";
3103
3512
  const { code, stdout, stderr } = await runExtensionCli([
3104
3513
  ...args,
3105
3514
  "--output",
@@ -3110,19 +3519,44 @@ async function runActVerb(args, projectPath, timeoutMs) {
3110
3519
  });
3111
3520
  const out = stdout.trim();
3112
3521
  if (out) try {
3113
- const frame = JSON.parse(out);
3114
- if (frame && false === frame.ok) return JSON.stringify(translateFrame(frame, projectPath));
3115
- return out;
3522
+ const frame = translateFrame(JSON.parse(out), projectPath);
3523
+ if (isEnvelope(frame)) {
3524
+ frame.command = command;
3525
+ if (!Array.isArray(frame.warnings)) frame.warnings = [];
3526
+ return JSON.stringify(frame);
3527
+ }
3528
+ if (frame && "object" == typeof frame) return JSON.stringify(wrapLegacyFrame(frame, command));
3116
3529
  } catch {}
3117
3530
  const message = stderr.trim() || `extension exited with code ${code}`;
3118
- return JSON.stringify({
3531
+ return envelope({
3119
3532
  ok: false,
3533
+ command,
3534
+ status: "cli-failed",
3120
3535
  error: {
3536
+ code: "E_CLI",
3121
3537
  name: "CliError",
3122
3538
  message: withSessionContext(toMcpSpeak(message), projectPath)
3123
3539
  }
3124
3540
  });
3125
3541
  }
3542
+ function addWarning(frame, text) {
3543
+ if ("string" != typeof text || !text.trim()) return;
3544
+ if (!Array.isArray(frame.warnings)) frame.warnings = [];
3545
+ if (!frame.warnings.includes(text)) frame.warnings.push(text);
3546
+ }
3547
+ function actFrameJson(frame) {
3548
+ return JSON.stringify(frame);
3549
+ }
3550
+ function patchValue(frame, patch) {
3551
+ const current = frame.value;
3552
+ if (current && "object" == typeof current && !Array.isArray(current)) return void Object.assign(current, patch);
3553
+ frame.value = null == current ? {
3554
+ ...patch
3555
+ } : {
3556
+ result: current,
3557
+ ...patch
3558
+ };
3559
+ }
3126
3560
  function commonFlags(args) {
3127
3561
  const flags = [];
3128
3562
  if (args.context) flags.push("--context", args.context);
@@ -3609,7 +4043,7 @@ function isCdpEndpoint(port) {
3609
4043
  });
3610
4044
  });
3611
4045
  }
3612
- async function listBridgeTabs(projectPath, browser, timeout) {
4046
+ async function listBridgeTabs(projectPath, browser, timeout, tool = "extension_dom_snapshot") {
3613
4047
  const raw = await runActVerb([
3614
4048
  "inspect",
3615
4049
  projectPath,
@@ -3620,7 +4054,7 @@ async function listBridgeTabs(projectPath, browser, timeout) {
3620
4054
  "--timeout",
3621
4055
  String(timeout)
3622
4056
  ] : []
3623
- ], projectPath, timeout);
4057
+ ], projectPath, timeout, tool);
3624
4058
  let parsed;
3625
4059
  try {
3626
4060
  parsed = JSON.parse(raw);
@@ -3662,7 +4096,7 @@ async function pollForBridgeTab(projectPath, browser, url, budgetMs) {
3662
4096
  await new Promise((r)=>setTimeout(r, 250));
3663
4097
  }
3664
4098
  }
3665
- async function navigateToUrlViaBridge(projectPath, browser, url, timeout) {
4099
+ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool = "extension_open") {
3666
4100
  const expression = `(async () => { const api = typeof browser !== "undefined" ? browser : chrome; const tabs = await api.tabs.query({ active: true, currentWindow: true }); const active = tabs && tabs[0]; const tab = active && active.id != null ? await api.tabs.update(active.id, { url: ${JSON.stringify(url)} }) : await api.tabs.create({ url: ${JSON.stringify(url)} }); return { tabId: tab && tab.id != null ? tab.id : null }; })()`;
3667
4101
  const raw = await runActVerb([
3668
4102
  "eval",
@@ -3676,32 +4110,39 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout) {
3676
4110
  "--timeout",
3677
4111
  String(timeout)
3678
4112
  ] : []
3679
- ], projectPath, timeout);
4113
+ ], projectPath, timeout, tool);
3680
4114
  try {
3681
4115
  const parsed = JSON.parse(raw);
3682
- if (parsed?.ok === false) {
3683
- if (!parsed.hint) parsed.hint = "On this browser family URL navigation rides the agent bridge (a background eval of tabs.update), so the dev session must be started with allowEval: true (extension_dev).";
3684
- return JSON.stringify(parsed);
3685
- }
4116
+ if (parsed?.ok === false) return actFrameJson(parsed.hint ? parsed : {
4117
+ ...parsed,
4118
+ hint: "On this browser family URL navigation rides the agent bridge (a background eval of tabs.update), so the dev session must be started with allowEval: true (extension_dev)."
4119
+ });
3686
4120
  } catch {
3687
4121
  return raw;
3688
4122
  }
3689
4123
  const settled = await pollForBridgeTab(projectPath, browser, url, null != timeout ? Math.min(timeout, 6000) : 6000);
3690
- if (!settled) return JSON.stringify({
4124
+ if (!settled) return envelope({
3691
4125
  ok: false,
4126
+ command: tool,
4127
+ status: "navigate-failed",
3692
4128
  error: {
4129
+ code: "E_NAVIGATE_FAILED",
3693
4130
  name: "NavigateFailed",
3694
4131
  message: `Navigation to ${url} did not produce a tab reporting that URL. The URL may not exist, or the browser refused the navigation (Firefox rejects privileged about:/chrome: URLs and other extensions' moz-extension: pages).`
3695
4132
  },
3696
4133
  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."
3697
4134
  });
3698
- return JSON.stringify({
4135
+ return envelope({
3699
4136
  ok: true,
3700
- navigated: url,
3701
- tab: {
3702
- tabId: settled.tabId,
3703
- url: settled.url,
3704
- title: settled.title
4137
+ command: tool,
4138
+ status: "navigated",
4139
+ value: {
4140
+ navigated: url,
4141
+ tab: {
4142
+ tabId: settled.tabId,
4143
+ url: settled.url,
4144
+ title: settled.title
4145
+ }
3705
4146
  },
3706
4147
  hint: "Inspect it with extension_dom_snapshot or extension_eval using url or this numeric tab id (context: 'page'/'content')."
3707
4148
  });
@@ -3763,9 +4204,12 @@ function isDisposableTab(tabUrl, destination) {
3763
4204
  async function navigateToUrl(projectPath, browser, url, timeout) {
3764
4205
  if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
3765
4206
  const resolved = await resolveCdpPort(projectPath, browser);
3766
- if (!resolved) return JSON.stringify({
4207
+ if (!resolved) return envelope({
3767
4208
  ok: false,
4209
+ command: open_schema.name,
4210
+ status: "no-session",
3768
4211
  error: {
4212
+ code: "E_NO_SESSION",
3769
4213
  name: "NoSession",
3770
4214
  message: `No active dev session / CDP port for ${browser}. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
3771
4215
  }
@@ -3796,38 +4240,48 @@ async function navigateToUrl(projectPath, browser, url, timeout) {
3796
4240
  const settled = await pollForTarget(resolved.port, url, 6000, navigatedTargetId);
3797
4241
  if (!settled) {
3798
4242
  const isExtensionPage = url.startsWith("chrome-extension://");
3799
- return JSON.stringify({
4243
+ return envelope({
3800
4244
  ok: false,
4245
+ command: open_schema.name,
4246
+ status: "navigate-failed",
3801
4247
  error: {
4248
+ code: "E_NAVIGATE_FAILED",
3802
4249
  name: "NavigateFailed",
3803
4250
  message: `Navigation to ${url} did not produce a live page target. ${isExtensionPage ? "The URL may not exist in the extension bundle, or Chrome refused the navigation." : "The page may have failed to load, or the browser refused the navigation."}`
3804
4251
  },
3805
4252
  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."
3806
4253
  });
3807
4254
  }
3808
- return JSON.stringify({
4255
+ return envelope({
3809
4256
  ok: true,
3810
- navigated: url,
3811
- ...openedNewTab ? {
3812
- openedNewTab: true
3813
- } : {},
3814
- ...settled.redirectedFrom ? {
3815
- redirected: {
3816
- from: settled.redirectedFrom,
3817
- to: settled.url
4257
+ command: open_schema.name,
4258
+ status: "navigated",
4259
+ value: {
4260
+ navigated: url,
4261
+ ...openedNewTab ? {
4262
+ openedNewTab: true
4263
+ } : {},
4264
+ ...settled.redirectedFrom ? {
4265
+ redirected: {
4266
+ from: settled.redirectedFrom,
4267
+ to: settled.url
4268
+ }
4269
+ } : {},
4270
+ target: {
4271
+ targetId: settled.id,
4272
+ title: settled.title,
4273
+ url: settled.url
3818
4274
  }
3819
- } : {},
3820
- target: {
3821
- targetId: settled.id,
3822
- title: settled.title,
3823
- url: settled.url
3824
4275
  },
3825
4276
  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."
3826
4277
  });
3827
4278
  } catch (e) {
3828
- return JSON.stringify({
4279
+ return envelope({
3829
4280
  ok: false,
4281
+ command: open_schema.name,
4282
+ status: "navigate-failed",
3830
4283
  error: {
4284
+ code: "E_NAVIGATE_ERROR",
3831
4285
  name: "NavigateError",
3832
4286
  message: e instanceof Error ? e.message : String(e)
3833
4287
  }
@@ -3941,9 +4395,12 @@ function declaredSurfaces(projectPath, browser) {
3941
4395
  }
3942
4396
  function missingSurfaceError(projectPath, browser, surface, consequence) {
3943
4397
  const declared = declaredSurfaces(projectPath, browser);
3944
- if (null === declared) return JSON.stringify({
4398
+ if (null === declared) return envelope({
3945
4399
  ok: false,
4400
+ command: open_schema.name,
4401
+ status: "no-manifest",
3946
4402
  error: {
4403
+ code: "E_MANIFEST_NOT_FOUND",
3947
4404
  name: "NoSurfaceDocument",
3948
4405
  message: `No readable manifest was found for this project (checked dist/${browser}, dist, src, and the project root), so the ${surface} document cannot be resolved.`
3949
4406
  },
@@ -3952,15 +4409,18 @@ function missingSurfaceError(projectPath, browser, surface, consequence) {
3952
4409
  const key = SURFACE_MANIFEST_KEYS[surface] ?? surface;
3953
4410
  const others = declared.filter((s)=>s !== surface);
3954
4411
  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.`;
3955
- return JSON.stringify({
4412
+ return envelope({
3956
4413
  ok: false,
4414
+ command: open_schema.name,
4415
+ status: "no-surface",
3957
4416
  error: {
4417
+ code: "E_NO_SURFACE_DOCUMENT",
3958
4418
  name: "NoSurfaceDocument",
3959
4419
  message: `This extension declares no ${surface}: nothing in its manifest sets ${key}, ${consequence}. That is how the extension is built, not a failure of the session or the tooling.`
3960
4420
  },
3961
- ...others.length ? {
4421
+ value: others.length ? {
3962
4422
  declaredSurfaces: others
3963
- } : {},
4423
+ } : null,
3964
4424
  hint: (others.length ? `Surfaces this extension does declare: ${others.join(", ")}; extension_open can target those. ` : "The manifest declares no other UI surface documents either. ") + nextVerb
3965
4425
  });
3966
4426
  }
@@ -4027,9 +4487,12 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4027
4487
  let extensionId = null;
4028
4488
  if (isChromiumFamily(browser)) {
4029
4489
  extensionId = await resolveExtensionId(projectPath, browser);
4030
- if (!extensionId) return JSON.stringify({
4490
+ if (!extensionId) return envelope({
4031
4491
  ok: false,
4492
+ command: open_schema.name,
4493
+ status: "no-extension-id",
4032
4494
  error: {
4495
+ code: "E_NO_EXTENSION_ID",
4033
4496
  name: "NoExtensionId",
4034
4497
  message: "Could not resolve the extension id from the live session's CDP targets."
4035
4498
  },
@@ -4038,9 +4501,12 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4038
4501
  url = `chrome-extension://${extensionId}/${doc}`;
4039
4502
  } else {
4040
4503
  const base = await resolveBridgeBaseUrl(projectPath, browser);
4041
- if (!base) return JSON.stringify({
4504
+ if (!base) return envelope({
4042
4505
  ok: false,
4506
+ command: open_schema.name,
4507
+ status: "no-extension-id",
4043
4508
  error: {
4509
+ code: "E_NO_EXTENSION_ID",
4044
4510
  name: "NoExtensionId",
4045
4511
  message: "Could not resolve the extension's moz-extension:// base URL from the live session (a background eval of runtime.getURL)."
4046
4512
  },
@@ -4053,18 +4519,22 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
4053
4519
  try {
4054
4520
  const parsed = JSON.parse(raw);
4055
4521
  if (parsed?.ok) {
4056
- parsed.renderedAsTab = {
4522
+ const renderedAsTab = {
4057
4523
  surface,
4058
4524
  document: doc,
4059
4525
  extensionId
4060
4526
  };
4527
+ patchValue(parsed, {
4528
+ renderedAsTab
4529
+ });
4530
+ const target = parsed.value?.target ?? parsed.target;
4061
4531
  let popupBounds = null;
4062
- if (("popup" === surface || "action" === surface) && "string" == typeof parsed.target?.targetId) {
4063
- popupBounds = await applyPopupBounds(projectPath, browser, parsed.target.targetId);
4064
- if (popupBounds) parsed.renderedAsTab.popupBounds = popupBounds;
4532
+ if (("popup" === surface || "action" === surface) && "string" == typeof target?.targetId) {
4533
+ popupBounds = await applyPopupBounds(projectPath, browser, target.targetId);
4534
+ if (popupBounds) renderedAsTab.popupBounds = popupBounds;
4065
4535
  }
4066
4536
  parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_snapshot context: '${surface}' (include: ['html']), or extension_inspect with this url. ` + "Do NOT pass this extension-page url to extension_dom_snapshot or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
4067
- return JSON.stringify(parsed);
4537
+ return actFrameJson(parsed);
4068
4538
  }
4069
4539
  } catch {}
4070
4540
  return raw;
@@ -4086,25 +4556,32 @@ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
4086
4556
  const wanted = `chrome-extension://${extensionId}/${doc}`;
4087
4557
  const settled = await pollForTarget(resolved.port, wanted, 3000);
4088
4558
  if (settled) {
4089
- parsed.surfaceTarget = {
4090
- targetId: settled.id,
4091
- url: settled.url
4092
- };
4093
- return JSON.stringify(parsed);
4559
+ patchValue(parsed, {
4560
+ surfaceTarget: {
4561
+ targetId: settled.id,
4562
+ url: settled.url
4563
+ }
4564
+ });
4565
+ return actFrameJson(parsed);
4094
4566
  }
4095
- return JSON.stringify({
4567
+ return envelope({
4096
4568
  ok: false,
4569
+ command: open_schema.name,
4570
+ status: "surface-did-not-open",
4097
4571
  error: {
4572
+ code: "E_SURFACE_DID_NOT_OPEN",
4098
4573
  name: "SurfaceDidNotOpen",
4099
4574
  message: `The engine reported the ${surface} as opened, but no page target for ${wanted} appeared within 3s, so nothing is there to inspect.`
4100
4575
  },
4101
- engineResult: parsed,
4576
+ value: {
4577
+ engineResult: parsed
4578
+ },
4102
4579
  hint: `Retry with asTab: true to render ${doc} in a real tab, which works headed or headless. If you expected a window, check that the session is headed and that the surface is declared in the BUILT manifest.`
4103
4580
  });
4104
4581
  }
4105
4582
  const open_schema = {
4106
4583
  name: "extension_open",
4107
- description: "Open an extension surface or replay an event in a running session. popup/options/sidebar open UI surfaces; newtab/history/bookmarks open the matching chrome_url_overrides page in a tab. 'action' triggers the toolbar action, opening its popup or replaying chrome.action.onClicked when there is none; 'command' replays a chrome.commands.onCommand shortcut (pass `name`). NOTE: action/command replay invokes your listener WITHOUT a user gesture, so the gesture-derived activeTab grant does not apply (the result reports gesture:false and warns when activeTab is declared). Requires the session to have been started with allowControl: true (extension_dev).",
4584
+ description: "Open an extension surface, or replay an event, in a running session. Pass surface:'popup', 'options' or 'sidebar' to open a UI surface, or 'newtab', 'history' or 'bookmarks' to open the matching chrome_url_overrides page in a tab. Pass surface:'action' to trigger the toolbar action, which opens its popup or replays chrome.action.onClicked when there is none. Pass surface:'command' with `name` to replay a chrome.commands.onCommand shortcut. Note that action and command replay invoke your listener without a user gesture, so the gesture-derived activeTab grant does not apply; the result reports gesture:false and warns when activeTab is declared. Start the session with allowControl:true (extension_dev).",
4108
4585
  inputSchema: {
4109
4586
  type: "object",
4110
4587
  properties: {
@@ -4161,22 +4638,30 @@ async function open_handler(args) {
4161
4638
  "bookmarks"
4162
4639
  ];
4163
4640
  if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
4164
- if (!args.surface) return JSON.stringify({
4641
+ if (!args.surface) return envelope({
4165
4642
  ok: false,
4643
+ command: open_schema.name,
4644
+ status: "bad-request",
4166
4645
  error: {
4646
+ code: "E_BAD_REQUEST",
4167
4647
  name: "BadRequest",
4168
4648
  message: "Pass `surface` (popup/options/sidebar/action/command) to open a surface, or `url` to navigate a tab."
4169
4649
  }
4170
4650
  });
4171
4651
  if ("command" === args.surface) {
4172
4652
  const declared = declaredCommands(args.projectPath, browser);
4173
- if (declared && args.name && !declared.includes(args.name)) return JSON.stringify({
4653
+ if (declared && args.name && !declared.includes(args.name)) return envelope({
4174
4654
  ok: false,
4655
+ command: open_schema.name,
4656
+ status: "unknown-command",
4175
4657
  error: {
4658
+ code: "E_UNKNOWN_COMMAND",
4176
4659
  name: "UnknownCommand",
4177
4660
  message: `"${args.name}" is not declared in the manifest's \`commands\`, so triggering it can only ever be a no-op.`
4178
4661
  },
4179
- declaredCommands: declared,
4662
+ value: {
4663
+ declaredCommands: declared
4664
+ },
4180
4665
  hint: declared.length ? `Declared commands are: ${declared.join(", ")}. Check for a typo, or add "${args.name}" to the manifest.` : "This manifest declares no commands at all. Add a `commands` block, rebuild, then retry."
4181
4666
  });
4182
4667
  }
@@ -4192,7 +4677,7 @@ async function open_handler(args) {
4192
4677
  if ("command" === args.surface && args.name) cli.push("--name", args.name);
4193
4678
  cli.push("--browser", browser);
4194
4679
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
4195
- const raw = await runActVerb(cli, args.projectPath, args.timeout);
4680
+ const raw = await runActVerb(cli, args.projectPath, args.timeout, open_schema.name);
4196
4681
  const headless = sessionIsHeadless();
4197
4682
  if (headless && [
4198
4683
  "popup",
@@ -4201,19 +4686,21 @@ async function open_handler(args) {
4201
4686
  ].includes(args.surface)) try {
4202
4687
  const parsed = JSON.parse(raw);
4203
4688
  const msg = String(parsed?.error?.message ?? "");
4204
- if (parsed?.ok === false && /active browser window|no active|headless|user gesture/i.test(msg)) {
4689
+ const code = "string" == typeof parsed?.error?.code ? parsed.error.code : "";
4690
+ const refusedWindow = "E_NO_TARGET" === code || /active browser window|no active|headless|user gesture/i.test(msg);
4691
+ if (parsed?.ok === false && refusedWindow) {
4205
4692
  if (AS_TAB_SURFACES.includes(args.surface)) {
4206
4693
  const fallback = await openSurfaceAsTab(args.projectPath, browser, args.surface);
4207
4694
  try {
4208
4695
  const parsedFallback = JSON.parse(fallback);
4209
4696
  if (parsedFallback?.ok) {
4210
- parsedFallback.note = `The dev browser is headless, and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, ${HEADED_RELAUNCH}, then open the surface again without asTab.`;
4211
- return JSON.stringify(parsedFallback);
4697
+ addWarning(parsedFallback, `The dev browser is headless, and a real popup/sidebar window can only open in a headed session, so the surface was rendered as a tab instead. For the real window, ${HEADED_RELAUNCH}, then open the surface again without asTab.`);
4698
+ return actFrameJson(parsedFallback);
4212
4699
  }
4213
4700
  } catch {}
4214
4701
  }
4215
4702
  if (!parsed.hint) parsed.hint = /user gesture/i.test(msg) ? "This surface can only open from a real user gesture, which headless automation cannot produce. Retry with asTab: true to render the surface document in a tab instead." : `The dev browser is running headless, and a popup/sidebar window needs a headed session. Retry with asTab: true to render the surface document in a tab, or for the real window, ${HEADED_RELAUNCH}.`;
4216
- return JSON.stringify(parsed);
4703
+ return actFrameJson(parsed);
4217
4704
  }
4218
4705
  } catch {}
4219
4706
  return AS_TAB_SURFACES.includes(args.surface) ? confirmSurfaceTarget(args.projectPath, browser, args.surface, raw) : raw;
@@ -4543,6 +5030,7 @@ function recordSharedPreview(projectPath, entry) {
4543
5030
  } : {}
4544
5031
  };
4545
5032
  }
5033
+ const preview_web_COMMAND = "extension_preview_web";
4546
5034
  const DEFAULT_PREVIEW_DEV_URL = "http://localhost:3110";
4547
5035
  const SURFACE = {
4548
5036
  defaultOrigin: DEFAULT_PREVIEW_DEV_URL,
@@ -4634,7 +5122,7 @@ function detectSurfaces(manifest) {
4634
5122
  }
4635
5123
  const preview_web_schema = {
4636
5124
  name: "extension_preview_web",
4637
- description: "Preview an in-progress extension in the web emulator, with no real browser. 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. This is 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 only resolves on this machine. To reach anyone who is not at this machine, pass share:true and hand out the public link it returns. extension_shares lists and revokes every link shared this way, so one does not vanish with this response.",
5125
+ 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.",
4638
5126
  inputSchema: {
4639
5127
  type: "object",
4640
5128
  properties: {
@@ -4698,41 +5186,61 @@ async function preview_web_handler(args) {
4698
5186
  buildResult = JSON.parse(raw);
4699
5187
  } catch {
4700
5188
  buildResult = {
4701
- success: false,
5189
+ ok: false,
4702
5190
  raw
4703
5191
  };
4704
5192
  }
4705
- if (!buildResult || true !== buildResult.success) return JSON.stringify({
5193
+ if (!buildResult || true !== buildResult.ok) return envelope({
4706
5194
  ok: false,
4707
- stage: "build",
4708
- error: "The build failed, so there is nothing to preview. See buildResult for the cause.",
4709
- buildResult
5195
+ command: preview_web_COMMAND,
5196
+ status: "build-failed",
5197
+ error: {
5198
+ code: "E_BUILD_FAILED",
5199
+ message: "The build failed, so there is nothing to preview. See buildResult for the cause."
5200
+ },
5201
+ value: {
5202
+ stage: "build",
5203
+ buildResult
5204
+ }
4710
5205
  });
4711
5206
  }
4712
5207
  const distDir = args.distPath ? node_path.resolve(args.distPath) : node_path.resolve(args.projectPath, "dist", browser);
4713
5208
  const manifestPath = node_path.join(distDir, "manifest.json");
4714
- if (!node_fs.existsSync(manifestPath)) return JSON.stringify({
5209
+ if (!node_fs.existsSync(manifestPath)) return envelope({
4715
5210
  ok: false,
4716
- stage: "resolve-dist",
4717
- distDir,
4718
- error: `No manifest.json in ${distDir}. Build the project first (build:true), or pass distPath to an already-built directory.`
5211
+ command: preview_web_COMMAND,
5212
+ status: "no-dist",
5213
+ error: {
5214
+ code: "E_NO_DIST",
5215
+ message: `No manifest.json in ${distDir}. Build the project first (build:true), or pass distPath to an already-built directory.`
5216
+ },
5217
+ value: {
5218
+ stage: "resolve-dist",
5219
+ distDir
5220
+ }
4719
5221
  });
4720
5222
  let manifest = {};
4721
5223
  try {
4722
5224
  manifest = JSON.parse(node_fs.readFileSync(manifestPath, "utf8"));
4723
5225
  } catch (err) {
4724
- return JSON.stringify({
5226
+ return envelope({
4725
5227
  ok: false,
4726
- stage: "resolve-dist",
4727
- distDir,
4728
- error: `manifest.json in ${distDir} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
5228
+ command: preview_web_COMMAND,
5229
+ status: "bad-dist-manifest",
5230
+ error: {
5231
+ code: "E_BAD_MANIFEST",
5232
+ message: `manifest.json in ${distDir} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
5233
+ },
5234
+ value: {
5235
+ stage: "resolve-dist",
5236
+ distDir
5237
+ }
4729
5238
  });
4730
5239
  }
4731
5240
  const encoded = Buffer.from(distDir).toString("base64url");
4732
5241
  const internalUrl = SURFACE.scheme(encoded);
4733
5242
  const deepLink = `${hostBase}/?url=${encodeURIComponent(internalUrl)}`;
4734
5243
  const result = {
4735
- ok: true,
4736
5244
  deepLink,
4737
5245
  distDir,
4738
5246
  manifest: {
@@ -4745,9 +5253,10 @@ async function preview_web_handler(args) {
4745
5253
  built: true
4746
5254
  } : {
4747
5255
  built: false
4748
- },
4749
- 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.`
5256
+ }
4750
5257
  };
5258
+ 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.`;
5259
+ const previewWarnings = [];
4751
5260
  if (args.open) {
4752
5261
  const sessionBrowser = args.openIn ?? browser;
4753
5262
  const navRaw = await navigateToUrl(args.projectPath, sessionBrowser, deepLink);
@@ -4762,10 +5271,17 @@ async function preview_web_handler(args) {
4762
5271
  }
4763
5272
  result.opened = opened;
4764
5273
  result.openedIn = sessionBrowser;
4765
- if (true !== opened.ok) result.openHint = "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.";
5274
+ 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.");
4766
5275
  }
4767
5276
  if (args.share) result.share = await buildShare(args.projectPath, distDir, manifest, browser);
4768
- if (false === args.probe) return JSON.stringify(result);
5277
+ if (false === args.probe) return envelope({
5278
+ ok: true,
5279
+ command: preview_web_COMMAND,
5280
+ status: "previewed",
5281
+ value: result,
5282
+ hint,
5283
+ warnings: previewWarnings
5284
+ });
4769
5285
  const probeUrl = `${hostBase}${SURFACE.fetchPath}?url=${encodeURIComponent(internalUrl)}`;
4770
5286
  try {
4771
5287
  const res = await fetch(probeUrl, {
@@ -4774,37 +5290,62 @@ async function preview_web_handler(args) {
4774
5290
  }
4775
5291
  });
4776
5292
  const contentType = res.headers.get("content-type") ?? "";
4777
- if (!res.ok || !contentType.includes("application/json")) return JSON.stringify({
4778
- ...result,
4779
- hostReachable: true,
4780
- previewLoadable: false,
4781
- probe: {
4782
- status: res.status,
4783
- contentType,
4784
- note: `${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.`
4785
- }
5293
+ if (!res.ok || !contentType.includes("application/json")) return envelope({
5294
+ ok: true,
5295
+ command: preview_web_COMMAND,
5296
+ status: "host-not-serving-preview",
5297
+ value: {
5298
+ ...result,
5299
+ hostReachable: true,
5300
+ previewLoadable: false,
5301
+ probe: {
5302
+ status: res.status,
5303
+ contentType
5304
+ }
5305
+ },
5306
+ hint,
5307
+ warnings: [
5308
+ ...previewWarnings,
5309
+ `${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.`
5310
+ ]
4786
5311
  });
4787
5312
  const payload = await res.json();
4788
- return JSON.stringify({
4789
- ...result,
4790
- hostReachable: true,
4791
- previewLoadable: true,
4792
- probe: {
4793
- identifier: payload.identifier,
4794
- loadedName: payload.manifest?.name,
4795
- loadedVersion: payload.version,
4796
- fileCount: Array.isArray(payload.files) ? payload.files.length : 0
4797
- }
5313
+ return envelope({
5314
+ ok: true,
5315
+ command: preview_web_COMMAND,
5316
+ status: "previewed",
5317
+ value: {
5318
+ ...result,
5319
+ hostReachable: true,
5320
+ previewLoadable: true,
5321
+ probe: {
5322
+ identifier: payload.identifier,
5323
+ loadedName: payload.manifest?.name,
5324
+ loadedVersion: payload.version,
5325
+ fileCount: Array.isArray(payload.files) ? payload.files.length : 0
5326
+ }
5327
+ },
5328
+ hint,
5329
+ warnings: previewWarnings
4798
5330
  });
4799
5331
  } catch (err) {
4800
- return JSON.stringify({
4801
- ...result,
4802
- hostReachable: false,
4803
- previewLoadable: false,
4804
- probe: {
4805
- error: err instanceof Error ? err.message : String(err),
4806
- note: `Could not reach ${SURFACE.label} at ${hostBase}. Start it with '${SURFACE.devCommand}', then open deepLink.`
4807
- }
5332
+ return envelope({
5333
+ ok: true,
5334
+ command: preview_web_COMMAND,
5335
+ status: "host-unreachable",
5336
+ value: {
5337
+ ...result,
5338
+ hostReachable: false,
5339
+ previewLoadable: false,
5340
+ probe: {
5341
+ error: err instanceof Error ? err.message : String(err)
5342
+ }
5343
+ },
5344
+ hint,
5345
+ warnings: [
5346
+ ...previewWarnings,
5347
+ `Could not reach ${SURFACE.label} at ${hostBase}. Start it with '${SURFACE.devCommand}', then open deepLink.`
5348
+ ]
4808
5349
  });
4809
5350
  }
4810
5351
  }
@@ -4972,7 +5513,7 @@ async function revokeArtifact(options) {
4972
5513
  const LOGIN_HINT = "Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).";
4973
5514
  const shares_schema = {
4974
5515
  name: "extension_shares",
4975
- description: "List and revoke the public preview links this token has shared (what extension_preview_web share:true hands out). action:'list' (default) returns every artifact the logged-in project owns with its artifactId, name, version, live/dead state, createdAt, expiresAt, revokedAt, size, previewUrl, zipUrl and revokeUrl, so a link whose response you lost is findable again. Each row carries owner and sharedBy as the platform returned them plus an attribution block: attribution.ownership is 'project' when the workspace holds the share and any member can revoke it, 'personal' when one person holds it alone, 'unknown' when no owner was disclosed; attribution.credit names the publisher for credit only, never access, and reads 'CLI token <id>' or 'not recorded' when no person can be named. action:'revoke' kills one by artifactId or by pasting any of its URLs, and is PERMANENT. Pass projectPath to reconcile against the project's own append-only .extension.dev/shared-previews.json (read-only, never rewritten): a share made on another machine shows as remoteOnly, a record with no live artifact as localOnly. Needs the same token as sharing (extension_auth or EXTENSION_DEV_TOKEN); without one, listing still returns the local record with a login hint.",
5516
+ description: "List and revoke the public preview links this token has shared, which is what extension_preview_web share:true hands out. Pass action:'list' (the default) for every artifact the logged-in project owns, with its artifactId, name, version, live or dead state, createdAt, expiresAt, revokedAt, size, previewUrl, zipUrl and revokeUrl, so a link whose response you lost is findable again. Each row carries owner and sharedBy as the platform returned them. Read attribution.ownership for who may revoke a share: 'project' means the workspace holds it and any member can pull it back, 'personal' means one person holds it alone, 'unknown' means no owner was disclosed. Read attribution.credit as credit only, never access; it names the publisher, and reads 'CLI token <id>' or 'not recorded' when no person can be named. Pass action:'revoke' with an artifactId, or with any URL of the share, to kill one permanently. Pass projectPath to reconcile against the project's own append-only .extension.dev/shared-previews.json, which is read and never rewritten: a share made on another machine shows as remoteOnly, a record with no live artifact as localOnly. This needs the same token as sharing (extension_auth or EXTENSION_DEV_TOKEN); without one, listing still returns the local record with a login hint.",
4976
5517
  inputSchema: {
4977
5518
  type: "object",
4978
5519
  properties: {
@@ -5099,23 +5640,29 @@ async function listShares(args) {
5099
5640
  });
5100
5641
  if (!listing.ok) {
5101
5642
  const isAuth = "SharesAuthError" === listing.error.name;
5102
- return JSON.stringify({
5643
+ return envelope({
5103
5644
  ok: true,
5104
- action: "list",
5105
- server: {
5106
- listed: false,
5107
- errorName: listing.error.name,
5108
- reason: listing.error.message,
5109
- ...isAuth ? {
5110
- loginHint: LOGIN_HINT
5111
- } : {}
5645
+ command: "extension_shares",
5646
+ status: "listed-local-only",
5647
+ value: {
5648
+ action: "list",
5649
+ server: {
5650
+ listed: false,
5651
+ errorName: listing.error.name,
5652
+ reason: listing.error.message,
5653
+ ...isAuth ? {
5654
+ loginHint: LOGIN_HINT
5655
+ } : {}
5656
+ },
5657
+ shares: [],
5658
+ localOnly: (local?.entries ?? []).map((entry)=>({
5659
+ ...entry
5660
+ })),
5661
+ localRecord
5112
5662
  },
5113
- shares: [],
5114
- localOnly: (local?.entries ?? []).map((entry)=>({
5115
- ...entry
5116
- })),
5117
- localRecord,
5118
- note: isAuth ? `The platform was not asked, so live and dead cannot be told apart here. ${LOGIN_HINT} localOnly is this project's own record of every link it ever shared, revoke handles included.` : "The platform could not be reached, so localOnly below is this project's own record and not a statement about what is still live."
5663
+ warnings: [
5664
+ isAuth ? `The platform was not asked, so live and dead cannot be told apart here. ${LOGIN_HINT} localOnly is this project's own record of every link it ever shared, revoke handles included.` : "The platform could not be reached, so localOnly below is this project's own record and not a statement about what is still live."
5665
+ ]
5119
5666
  });
5120
5667
  }
5121
5668
  const now = Date.now();
@@ -5153,37 +5700,50 @@ async function listShares(args) {
5153
5700
  personal: shares.filter((s)=>"personal" === s.attribution.ownership).length,
5154
5701
  unknown: shares.filter((s)=>"unknown" === s.attribution.ownership).length
5155
5702
  };
5156
- return JSON.stringify({
5703
+ 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;
5704
+ return envelope({
5157
5705
  ok: true,
5158
- action: "list",
5159
- server: {
5160
- listed: true,
5161
- count: listing.data.count,
5162
- matched: listing.data.matched,
5163
- limit: listing.data.limit,
5164
- truncated: listing.data.truncated,
5165
- scanned: listing.data.scanned,
5166
- ownership,
5167
- ...listing.data.truncated ? {
5168
- truncatedNote: `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.`
5169
- } : {}
5706
+ command: "extension_shares",
5707
+ status: "listed",
5708
+ value: {
5709
+ action: "list",
5710
+ server: {
5711
+ listed: true,
5712
+ count: listing.data.count,
5713
+ matched: listing.data.matched,
5714
+ limit: listing.data.limit,
5715
+ truncated: listing.data.truncated,
5716
+ scanned: listing.data.scanned,
5717
+ ownership,
5718
+ ...truncatedNote ? {
5719
+ truncatedNote
5720
+ } : {}
5721
+ },
5722
+ shares,
5723
+ ...local ? {
5724
+ localOnly
5725
+ } : {},
5726
+ localRecord
5170
5727
  },
5171
- shares,
5172
- ...local ? {
5173
- localOnly
5174
- } : {},
5175
- localRecord,
5176
- message: `${liveCount} of ${shares.length} listed shares still resolve${local ? `; ${localOnly.length} local record ${1 === localOnly.length ? "entry has" : "entries have"} no artifact behind them` : ""}. Revoke one with action:"revoke" and its artifactId or any of its URLs.`,
5177
- note: "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.",
5178
- attributionNote: "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."
5728
+ hint: `${liveCount} of ${shares.length} listed shares still resolve${local ? `; ${localOnly.length} local record ${1 === localOnly.length ? "entry has" : "entries have"} no artifact behind them` : ""}. Revoke one with action:"revoke" and its artifactId or any of its URLs.`,
5729
+ warnings: [
5730
+ "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.",
5731
+ "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.",
5732
+ truncatedNote
5733
+ ]
5179
5734
  });
5180
5735
  }
5181
5736
  async function revokeShare(args) {
5182
5737
  const ref = parseArtifactRef(args.artifactId || args.url || "");
5183
- if (!ref) return JSON.stringify({
5738
+ if (!ref) return envelope({
5184
5739
  ok: false,
5185
- action: "revoke",
5740
+ command: "extension_shares",
5741
+ status: "bad-request",
5742
+ value: {
5743
+ action: "revoke"
5744
+ },
5186
5745
  error: {
5746
+ code: "E_BAD_REQUEST",
5187
5747
  name: "SharesInputError",
5188
5748
  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."
5189
5749
  }
@@ -5199,34 +5759,43 @@ async function revokeShare(args) {
5199
5759
  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;
5200
5760
  if (!result.ok) {
5201
5761
  const isAuth = "SharesAuthError" === result.error.name;
5202
- return JSON.stringify({
5762
+ return envelope({
5203
5763
  ok: false,
5204
- action: "revoke",
5205
- artifactId: ref,
5764
+ command: "extension_shares",
5765
+ status: "revoke-failed",
5766
+ value: {
5767
+ action: "revoke",
5768
+ artifactId: ref
5769
+ },
5206
5770
  error: {
5771
+ code: isAuth ? "E_AUTH_REQUIRED" : "E_PLATFORM",
5207
5772
  name: result.error.name,
5208
5773
  message: result.error.message
5209
5774
  },
5210
5775
  ...isAuth ? {
5211
- loginHint: LOGIN_HINT
5776
+ hint: LOGIN_HINT
5212
5777
  } : {},
5213
- ...recordNote ? {
5778
+ warnings: [
5214
5779
  recordNote
5215
- } : {}
5780
+ ]
5216
5781
  });
5217
5782
  }
5218
- return JSON.stringify({
5783
+ return envelope({
5219
5784
  ok: true,
5220
- action: "revoke",
5221
- artifactId: ref,
5222
- revoked: result.data.revoked,
5223
- ...result.data.revokedAt ? {
5224
- revokedAt: result.data.revokedAt
5225
- } : {},
5226
- ...recordNote ? {
5785
+ command: "extension_shares",
5786
+ status: "revoked",
5787
+ value: {
5788
+ action: "revoke",
5789
+ artifactId: ref,
5790
+ revoked: result.data.revoked,
5791
+ ...result.data.revokedAt ? {
5792
+ revokedAt: result.data.revokedAt
5793
+ } : {}
5794
+ },
5795
+ warnings: [
5796
+ "The link is dead for everyone, permanently: the zip is deleted and the id is burned, so it can never resolve again. Sharing the same build later returns a different link, and anyone holding the old one gets nothing.",
5227
5797
  recordNote
5228
- } : {},
5229
- note: "The link is dead for everyone, permanently: the zip is deleted and the id is burned, so it can never resolve again. Sharing the same build later returns a different link, and anyone holding the old one gets nothing."
5798
+ ]
5230
5799
  });
5231
5800
  }
5232
5801
  async function shares_handler(args) {
@@ -5259,6 +5828,7 @@ async function shares_handler(args) {
5259
5828
  } : {}
5260
5829
  });
5261
5830
  }
5831
+ const manifest_validate_COMMAND = "extension_manifest_validate";
5262
5832
  const CHROME_DESKTOP_ONLY_KEYS = [
5263
5833
  "file_browser_handlers",
5264
5834
  "file_system_provider_capabilities",
@@ -5352,7 +5922,7 @@ const KNOWN_PERMISSIONS = new Set([
5352
5922
  ]);
5353
5923
  const manifest_validate_schema = {
5354
5924
  name: "extension_manifest_validate",
5355
- description: "Validate a manifest.json file for correctness across browsers. Reports missing fields, invalid permissions, and cross-browser compatibility issues.",
5925
+ description: "Validate a manifest.json across browsers. This reports missing fields, invalid permissions, dangling file references, and cross-browser compatibility issues. Read buildBlocking for the errors that make extension_build refuse.",
5356
5926
  inputSchema: {
5357
5927
  type: "object",
5358
5928
  properties: {
@@ -5552,29 +6122,51 @@ async function manifest_validate_handler(args) {
5552
6122
  similarTemplates: []
5553
6123
  };
5554
6124
  const manifestPath = args.manifestPath ?? (args.projectPath ? findManifest(args.projectPath) : null);
5555
- if (!manifestPath) return JSON.stringify({
5556
- valid: false,
5557
- errors: [
6125
+ if (!manifestPath) {
6126
+ const errors = [
5558
6127
  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)."
5559
- ],
5560
- warnings: [],
5561
- browserSupport: {},
5562
- similarTemplates: []
5563
- });
6128
+ ];
6129
+ return envelope({
6130
+ ok: false,
6131
+ command: manifest_validate_COMMAND,
6132
+ status: "manifest-not-found",
6133
+ error: {
6134
+ code: "E_MANIFEST_NOT_FOUND",
6135
+ message: errors[0]
6136
+ },
6137
+ value: {
6138
+ valid: false,
6139
+ errors,
6140
+ browserSupport: {},
6141
+ similarTemplates: []
6142
+ },
6143
+ warnings: []
6144
+ });
6145
+ }
5564
6146
  const manifestDir = node_path.dirname(node_path.resolve(manifestPath));
5565
6147
  let manifest;
5566
6148
  try {
5567
6149
  const raw = node_fs.readFileSync(node_path.resolve(manifestPath), "utf8");
5568
6150
  manifest = JSON.parse(raw);
5569
6151
  } catch (err) {
5570
- return JSON.stringify({
5571
- valid: false,
5572
- errors: [
5573
- `Cannot read manifest: ${err instanceof Error ? err.message : err}`
5574
- ],
5575
- warnings: [],
5576
- browserSupport: {},
5577
- similarTemplates: []
6152
+ const errors = [
6153
+ `Cannot read manifest: ${err instanceof Error ? err.message : err}`
6154
+ ];
6155
+ return envelope({
6156
+ ok: false,
6157
+ command: manifest_validate_COMMAND,
6158
+ status: "manifest-unreadable",
6159
+ error: {
6160
+ code: "E_BAD_MANIFEST",
6161
+ message: errors[0]
6162
+ },
6163
+ value: {
6164
+ valid: false,
6165
+ errors,
6166
+ browserSupport: {},
6167
+ similarTemplates: []
6168
+ },
6169
+ warnings: []
5578
6170
  });
5579
6171
  }
5580
6172
  if (!manifest.name) result.errors.push("Missing required field: name");
@@ -5694,9 +6286,15 @@ async function manifest_validate_handler(args) {
5694
6286
  else result.warnings.push(`${browser} (not requested, checked by default): ${issues}`);
5695
6287
  }
5696
6288
  result.valid = 0 === result.errors.length;
5697
- return JSON.stringify({
5698
- ...result,
5699
- buildBlocking: result.errors.length > 0
6289
+ return envelope({
6290
+ ok: result.valid,
6291
+ command: manifest_validate_COMMAND,
6292
+ status: result.valid ? "valid" : "invalid",
6293
+ value: {
6294
+ ...result,
6295
+ buildBlocking: result.errors.length > 0
6296
+ },
6297
+ warnings: result.warnings
5700
6298
  });
5701
6299
  }
5702
6300
  const CHROME_THEME_COLOR_KEYS = {
@@ -6304,9 +6902,10 @@ function resolveChromeTheme(theme) {
6304
6902
  caveats
6305
6903
  };
6306
6904
  }
6905
+ const theme_verify_COMMAND = "extension_theme_verify";
6307
6906
  const theme_verify_schema = {
6308
6907
  name: "extension_theme_verify",
6309
- description: "Verify a Chrome theme manifest before it ships. Settles the four-leg WYSIWYG contract (app-shows == manifest-says == chrome-paints, plus chrome-accepts) as far as is possible headless: it derives every color current Chrome would paint from the manifest (the transcribed Chromium resolver) and classifies any problem as D1 fabrication, D3 parity gap, or D4 acceptance gap (keys Chrome silently discards: dead legacy, incognito, unknown, out-of-range). Verification only: it never authors or mutates a theme. The app-rendered and real-pixel legs need a browser and come back as needsAttended pointing at the assert:theme and install-parity harnesses, never as passed.",
6908
+ description: "Verify a Chrome theme manifest before it ships. This settles the four-leg WYSIWYG contract (app-shows == manifest-says == chrome-paints, plus chrome-accepts) as far as is possible headless: it derives every color current Chrome would paint from the manifest through the transcribed Chromium resolver, and classifies each problem as D1 fabrication, D3 parity gap, or D4 acceptance gap (keys Chrome silently discards: dead legacy, incognito, unknown, out-of-range). It verifies only, and never authors or mutates a theme. The app-rendered and real-pixel legs need a browser, so they come back as needsAttended pointing at the assert:theme and install-parity harnesses, never as passed.",
6310
6909
  inputSchema: {
6311
6910
  type: "object",
6312
6911
  properties: {
@@ -6369,9 +6968,12 @@ async function theme_verify_handler(args) {
6369
6968
  try {
6370
6969
  text = await promises.readFile(abs, "utf8");
6371
6970
  } catch {
6372
- return JSON.stringify({
6971
+ return envelope({
6373
6972
  ok: false,
6973
+ command: theme_verify_COMMAND,
6974
+ status: "bad-input",
6374
6975
  error: {
6976
+ code: "E_BAD_REQUEST",
6375
6977
  name: "InputError",
6376
6978
  message: `Cannot read ${abs}`
6377
6979
  }
@@ -6380,18 +6982,24 @@ async function theme_verify_handler(args) {
6380
6982
  try {
6381
6983
  raw = JSON.parse(text);
6382
6984
  } catch (err) {
6383
- return JSON.stringify({
6985
+ return envelope({
6384
6986
  ok: false,
6987
+ command: theme_verify_COMMAND,
6988
+ status: "bad-input",
6385
6989
  error: {
6990
+ code: "E_BAD_REQUEST",
6386
6991
  name: "InputError",
6387
6992
  message: `${abs} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
6388
6993
  }
6389
6994
  });
6390
6995
  }
6391
6996
  } else {
6392
- if (!args.manifest) return JSON.stringify({
6997
+ if (!args.manifest) return envelope({
6393
6998
  ok: false,
6999
+ command: theme_verify_COMMAND,
7000
+ status: "bad-request",
6394
7001
  error: {
7002
+ code: "E_BAD_REQUEST",
6395
7003
  name: "InputError",
6396
7004
  message: "Pass `manifest` (an object) or `manifestPath` (a file path)."
6397
7005
  }
@@ -6403,9 +7011,12 @@ async function theme_verify_handler(args) {
6403
7011
  try {
6404
7012
  ({ manifest, theme } = coerceInput(raw));
6405
7013
  } catch (err) {
6406
- return JSON.stringify({
7014
+ return envelope({
6407
7015
  ok: false,
7016
+ command: theme_verify_COMMAND,
7017
+ status: "bad-input",
6408
7018
  error: {
7019
+ code: "E_BAD_REQUEST",
6409
7020
  name: "InputError",
6410
7021
  message: err instanceof Error ? err.message : String(err)
6411
7022
  }
@@ -6504,74 +7115,77 @@ async function theme_verify_handler(args) {
6504
7115
  how: "a Chrome theme is an unpacked extension: extension_dev then extension_logs on the exported theme dir (headless, focus-safe)"
6505
7116
  }
6506
7117
  ];
6507
- return JSON.stringify({
7118
+ return envelope({
6508
7119
  ok: true,
6509
- tool: "extension_theme_verify",
6510
- verdict,
6511
- needsAttended: true,
6512
- summary: {
6513
- errors: grammarErrors.length,
6514
- warnings: findings.filter((f)=>"warn" === f.severity).length,
6515
- advisories: findings.filter((f)=>"info" === f.severity).length,
6516
- byClass: {
6517
- D1: findings.filter((f)=>"D1" === f.class).length,
6518
- D3: findings.filter((f)=>"D3" === f.class).length,
6519
- D4: findings.filter((f)=>"D4" === f.class).length
6520
- }
6521
- },
6522
- legs: {
6523
- appShows: {
6524
- status: "needs-attended",
6525
- detail: "Not run here (needs a browser). The app self-verifies app == resolve(manifest) via the seed door.",
6526
- how: attended[0].how
6527
- },
6528
- manifestSays: {
6529
- status: hasError ? "invalid" : "verified",
6530
- name,
6531
- version,
6532
- grammar: {
6533
- nameValid,
6534
- versionValid,
6535
- errors: grammarErrors
6536
- },
6537
- declared: {
6538
- colors: Object.keys(declaredColors),
6539
- tints: Object.keys(declaredTints),
6540
- images: Object.keys(declaredImages),
6541
- properties: Object.keys(declaredProps)
7120
+ command: theme_verify_COMMAND,
7121
+ status: verdict,
7122
+ value: {
7123
+ needsAttended: true,
7124
+ summary: {
7125
+ errors: grammarErrors.length,
7126
+ warnings: findings.filter((f)=>"warn" === f.severity).length,
7127
+ advisories: findings.filter((f)=>"info" === f.severity).length,
7128
+ byClass: {
7129
+ D1: findings.filter((f)=>"D1" === f.class).length,
7130
+ D3: findings.filter((f)=>"D3" === f.class).length,
7131
+ D4: findings.filter((f)=>"D4" === f.class).length
6542
7132
  }
6543
7133
  },
6544
- chromePaints: {
6545
- resolver: {
6546
- status: "reported",
6547
- detail: "Headless proxy: every color current stable Chrome derives from this manifest.",
6548
- resolved
6549
- },
6550
- realPaint: {
7134
+ legs: {
7135
+ appShows: {
6551
7136
  status: "needs-attended",
6552
- how: attended[1].how
7137
+ detail: "Not run here (needs a browser). The app self-verifies app == resolve(manifest) via the seed door.",
7138
+ how: attended[0].how
7139
+ },
7140
+ manifestSays: {
7141
+ status: hasError ? "invalid" : "verified",
7142
+ name,
7143
+ version,
7144
+ grammar: {
7145
+ nameValid,
7146
+ versionValid,
7147
+ errors: grammarErrors
7148
+ },
7149
+ declared: {
7150
+ colors: Object.keys(declaredColors),
7151
+ tints: Object.keys(declaredTints),
7152
+ images: Object.keys(declaredImages),
7153
+ properties: Object.keys(declaredProps)
7154
+ }
7155
+ },
7156
+ chromePaints: {
7157
+ resolver: {
7158
+ status: "reported",
7159
+ detail: "Headless proxy: every color current stable Chrome derives from this manifest.",
7160
+ resolved
7161
+ },
7162
+ realPaint: {
7163
+ status: "needs-attended",
7164
+ how: attended[1].how
7165
+ }
7166
+ },
7167
+ chromeAccepts: {
7168
+ status: "reported",
7169
+ detail: "Static analysis of what Chrome parses but discards (the D4 acceptance gap).",
7170
+ discarded: findings.filter((f)=>"chrome-accepts" === f.leg).map((f)=>({
7171
+ key: f.key,
7172
+ detail: f.detail
7173
+ })),
7174
+ live: {
7175
+ status: "needs-attended",
7176
+ how: attended[2].how
7177
+ }
6553
7178
  }
6554
7179
  },
6555
- chromeAccepts: {
6556
- status: "reported",
6557
- detail: "Static analysis of what Chrome parses but discards (the D4 acceptance gap).",
6558
- discarded: findings.filter((f)=>"chrome-accepts" === f.leg).map((f)=>({
6559
- key: f.key,
6560
- detail: f.detail
6561
- })),
6562
- live: {
6563
- status: "needs-attended",
6564
- how: attended[2].how
6565
- }
6566
- }
6567
- },
6568
- findings,
6569
- attended
7180
+ findings,
7181
+ attended
7182
+ }
6570
7183
  });
6571
7184
  }
7185
+ const analyze_COMMAND = "extension_analyze";
6572
7186
  const analyze_schema = {
6573
7187
  name: "extension_analyze",
6574
- description: "Analyze a BUILT extension on disk: file sizes, declared entry points, permissions, bundle composition, and store-readiness checks. Static only, reads dist/<browser> from the filesystem and never touches a browser. The extension must be built first (extension_build). For a RUNNING extension's live DOM and console use extension_inspect.",
7188
+ description: "Analyze a BUILT extension on disk: file sizes, declared entry points, permissions, bundle composition, and store-readiness checks. This is static only: it reads dist/<browser> from the filesystem and never touches a browser, so build first with extension_build. Use extension_inspect for a running extension's live DOM and console.",
6575
7189
  inputSchema: {
6576
7190
  type: "object",
6577
7191
  properties: {
@@ -6663,8 +7277,14 @@ function formatBytes(bytes) {
6663
7277
  async function analyze_handler(args) {
6664
7278
  const browser = args.browser ?? "chrome";
6665
7279
  const distPath = node_path.resolve(args.projectPath, "dist", browser);
6666
- if (!node_fs.existsSync(distPath)) return JSON.stringify({
6667
- error: `Build output not found at ${distPath}. Run extension_build first.`,
7280
+ if (!node_fs.existsSync(distPath)) return envelope({
7281
+ ok: false,
7282
+ command: analyze_COMMAND,
7283
+ status: "no-dist",
7284
+ error: {
7285
+ code: "E_NO_DIST",
7286
+ message: `Build output not found at ${distPath}. Run extension_build first.`
7287
+ },
6668
7288
  hint: `Use extension_build with browser: "${browser}" to build the extension.`
6669
7289
  });
6670
7290
  const files = walkDir(distPath);
@@ -6720,25 +7340,18 @@ async function analyze_handler(args) {
6720
7340
  if (PROMO_RE.test(f.path)) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) looks like a store-listing promo image shipped inside the extension package, move it out of the bundled sources so it does not inflate the store zip.`);
6721
7341
  else if ("image" === f.type && !f.path.includes("icon") && f.size > 51200 && shippableSize > 0 && f.size / shippableSize > 0.25) sizeWarnings.push(`${f.path} (${formatBytes(f.size)}) is ${Math.round(f.size / shippableSize * 100)}% of the shipped bundle, unusually large for a shipped asset.`);
6722
7342
  }
7343
+ const note = "development" === buildType ? `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.` : void 0;
7344
+ const archiveNote = archiveSize > 0 ? `This dist contains ${formatBytes(archiveSize)} of .zip archive(s) (store packaging output, written into dist by zip builds). shippableSize excludes them so the packaged copy does not double-count the files it contains.` : void 0;
6723
7345
  const result = {
6724
7346
  browser,
6725
7347
  distPath,
6726
7348
  entrypoints,
6727
- ...sizeWarnings.length ? {
6728
- sizeWarnings
6729
- } : {},
6730
7349
  buildType,
6731
7350
  totalSize,
6732
7351
  totalSizeFormatted: formatBytes(totalSize),
6733
7352
  shippableSize,
6734
7353
  shippableSizeFormatted: formatBytes(shippableSize),
6735
7354
  fileCount: files.length,
6736
- ..."development" === buildType ? {
6737
- note: `This dist contains ${formatBytes(sourcemapSize)} of sourcemaps and looks like a dev build; run extension_build for production sizes. shippableSize excludes sourcemaps.`
6738
- } : {},
6739
- ...archiveSize > 0 ? {
6740
- archiveNote: `This dist contains ${formatBytes(archiveSize)} of .zip archive(s) (store packaging output, written into dist by zip builds). shippableSize excludes them so the packaged copy does not double-count the files it contains.`
6741
- } : {},
6742
7355
  manifest: {
6743
7356
  name: manifest.name,
6744
7357
  version: manifest.version,
@@ -6774,7 +7387,17 @@ async function analyze_handler(args) {
6774
7387
  under10MB: totalSize - archiveSize < 10485760
6775
7388
  }
6776
7389
  };
6777
- return JSON.stringify(result);
7390
+ return envelope({
7391
+ ok: true,
7392
+ command: analyze_COMMAND,
7393
+ status: "analyzed",
7394
+ value: result,
7395
+ warnings: [
7396
+ ...sizeWarnings,
7397
+ note,
7398
+ archiveNote
7399
+ ]
7400
+ });
6778
7401
  }
6779
7402
  function rdp_define_property(obj, key, value) {
6780
7403
  if (key in obj) Object.defineProperty(obj, key, {
@@ -7013,6 +7636,7 @@ async function rdpCollectConsoleMessages(port, options) {
7013
7636
  return messages;
7014
7637
  });
7015
7638
  }
7639
+ const TOOL = "extension_inspect";
7016
7640
  function buildBridgeInspectExpression(opts) {
7017
7641
  const parts = [
7018
7642
  "const out = {};"
@@ -7090,7 +7714,7 @@ async function collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes)
7090
7714
  "--timeout",
7091
7715
  String(args.timeout)
7092
7716
  ] : []
7093
- ], args.projectPath, args.timeout);
7717
+ ], args.projectPath, args.timeout, TOOL);
7094
7718
  let parsed;
7095
7719
  try {
7096
7720
  parsed = JSON.parse(raw);
@@ -7130,11 +7754,11 @@ async function collectGeckoConsole(args, browser, urlFilter, result, notes) {
7130
7754
  async function inspectViaBridge(args, browser, include, maxBytes) {
7131
7755
  const notes = [];
7132
7756
  if (args.url) {
7133
- const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
7757
+ const listed = await listBridgeTabs(args.projectPath, browser, args.timeout, "extension_inspect");
7134
7758
  if ("error" in listed) return listed.error;
7135
7759
  const already = listed.tabs.some((t)=>t.url.includes(args.url));
7136
7760
  if (!already) {
7137
- const nav = await navigateToUrlViaBridge(args.projectPath, browser, args.url, args.timeout);
7761
+ const nav = await navigateToUrlViaBridge(args.projectPath, browser, args.url, args.timeout, "extension_inspect");
7138
7762
  try {
7139
7763
  if (JSON.parse(nav)?.ok !== true) return nav;
7140
7764
  } catch {
@@ -7167,7 +7791,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
7167
7791
  "--timeout",
7168
7792
  String(args.timeout)
7169
7793
  ] : []
7170
- ], args.projectPath, args.timeout);
7794
+ ], args.projectPath, args.timeout, TOOL);
7171
7795
  let parsed;
7172
7796
  try {
7173
7797
  parsed = JSON.parse(raw);
@@ -7188,7 +7812,7 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
7188
7812
  "--timeout",
7189
7813
  String(args.timeout)
7190
7814
  ] : []
7191
- ], args.projectPath, args.timeout);
7815
+ ], args.projectPath, args.timeout, TOOL);
7192
7816
  try {
7193
7817
  parsed = JSON.parse(raw);
7194
7818
  } catch {
@@ -7196,9 +7820,12 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
7196
7820
  }
7197
7821
  const frame = Array.isArray(parsed?.value?.frames) ? parsed.value.frames[0] : null;
7198
7822
  if (parsed?.ok === true && frame && "object" == typeof frame) value = frame;
7199
- else if (parsed?.ok === true) return JSON.stringify({
7823
+ else if (parsed?.ok === true) return envelope({
7200
7824
  ok: false,
7825
+ command: TOOL,
7826
+ status: "inspect-failed",
7201
7827
  error: {
7828
+ code: "E_BRIDGE",
7202
7829
  name: "InspectFailed",
7203
7830
  message: String(parsed?.value?.error ?? "the content-script inspect returned nothing")
7204
7831
  },
@@ -7224,10 +7851,11 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
7224
7851
  }
7225
7852
  if (include.has("dom_snapshot") && value.domSnapshot) result.domSnapshot = value.domSnapshot;
7226
7853
  if (include.has("extension_roots") && void 0 !== value.extensionRoots) result.extensionRoots = value.extensionRoots;
7854
+ let probeWarning = null;
7227
7855
  if (value.probes) {
7228
7856
  result.probes = value.probes;
7229
7857
  const jsLooking = (args.probe ?? []).filter((p)=>/^typeof\s|^(chrome|browser|window|document)\.|\(\)|=>|===/.test(p));
7230
- if (jsLooking.length) result.probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
7858
+ if (jsLooking.length) probeWarning = `Probes are CSS selectors run through querySelectorAll against the live page, NOT JavaScript expressions. ${jsLooking.map((s)=>`"${s}"`).join(", ")} parsed as selectors and will match nothing. To evaluate JS, use extension_eval.`;
7231
7859
  }
7232
7860
  const urlFilter = args.url ?? ("string" == typeof value.meta?.url ? value.meta.url : void 0);
7233
7861
  if (include.has("console")) await collectGeckoConsole(args, browser, urlFilter, result, notes);
@@ -7235,12 +7863,20 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
7235
7863
  const cap = maxBytes > 0 ? maxBytes : 65536;
7236
7864
  await collectGeckoDeepDom(args, browser, urlFilter, cap, result, notes);
7237
7865
  }
7238
- if (notes.length) result.notes = notes;
7239
- return JSON.stringify(result);
7866
+ return envelope({
7867
+ ok: true,
7868
+ command: TOOL,
7869
+ status: "inspected",
7870
+ value: result,
7871
+ warnings: [
7872
+ ...notes,
7873
+ probeWarning
7874
+ ]
7875
+ });
7240
7876
  }
7241
7877
  const inspect_schema = {
7242
7878
  name: "extension_inspect",
7243
- description: "DEEP inspection of a RUNNING extension over the browser's debugger protocol: full HTML including shadow DOM, DOM structure, content-script injection, console messages, and CSS selector queries (`probe`). The ONLY tool that pierces CLOSED shadow roots (`deepDom`), runs selector probes, and NAVIGATES a tab to `url` before reading it. It reads a web or override page and picks the first inspectable target (or the first whose url contains `url`); it cannot address an extension surface by name and takes no chrome.tabs id. To choose WHICH tab or which OPEN surface (popup/options/sidebar/devtools) to read, or to enumerate what is open, use extension_dom_snapshot. For a built extension's files and sizes on disk use extension_analyze. Chromium rides Chrome DevTools Protocol (needs the session's debug port, not allowControl). Firefox is fully paired: summary/meta/html/dom_snapshot/extension_roots/probes ride the agent bridge (needs allowEval: true), console rides the RDP watcher replay (engine 4.0.15+), and deepDom needs an MV2 session with host permissions for the target url (Firefox MV3 background CSP blocks bridge evals). Requires an active dev or start session.",
7879
+ description: "Inspect a running extension deeply over the browser's debugger protocol: full HTML including shadow DOM, DOM structure, content-script injection, console messages, and CSS selector queries through `probe`. This is the ONLY tool that pierces closed shadow roots (deepDom), runs selector probes, and navigates a tab to `url` before reading it. It reads a web or override page and picks the first inspectable target, or the first whose url contains `url`; it cannot address an extension surface by name and takes no chrome.tabs id. Use extension_dom_snapshot to choose which tab or which open surface (popup, options, sidebar, devtools) to read, or to enumerate what is open. Use extension_analyze for a built extension's files and sizes on disk. Chromium rides the Chrome DevTools Protocol and needs the session's debug port, not allowControl. Firefox is fully paired: summary, meta, html, dom_snapshot, extension_roots and probes ride the agent bridge and need allowEval:true, console rides the RDP watcher replay on engine 4.0.15 and later, and deepDom needs an MV2 session with host permissions for the target url, because the Firefox MV3 background CSP blocks bridge evals. This requires an active dev or start session.",
7244
7880
  inputSchema: {
7245
7881
  type: "object",
7246
7882
  properties: {
@@ -7303,8 +7939,14 @@ async function inspect_handler(args) {
7303
7939
  const maxBytes = args.maxBytes ?? 262144;
7304
7940
  if (!isChromiumFamily(browser)) return inspectViaBridge(args, browser, include, maxBytes);
7305
7941
  const resolved = await resolveCdpPort(args.projectPath, browser);
7306
- if (!resolved) return JSON.stringify({
7307
- error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
7942
+ if (!resolved) return envelope({
7943
+ ok: false,
7944
+ command: inspect_schema.name,
7945
+ status: "no-session",
7946
+ error: {
7947
+ code: "E_NO_SESSION",
7948
+ message: "No active dev session found. Cannot connect to Chrome DevTools Protocol."
7949
+ },
7308
7950
  hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${CDP_PORT_MISSING_HINT}`
7309
7951
  });
7310
7952
  const cdpPort = resolved.port;
@@ -7321,14 +7963,22 @@ async function inspect_handler(args) {
7321
7963
  const pageTargets = allTargets.filter((t)=>"page" === t.type && !t.url.startsWith("devtools://") && (!t.url.startsWith("chrome://") || isOverridePage(t.url)));
7322
7964
  if (0 === pageTargets.length) {
7323
7965
  const chromeOnly = allTargets.some((t)=>"page" === t.type && t.url.startsWith("chrome://"));
7324
- return JSON.stringify({
7325
- cdpPort,
7326
- browser,
7327
- warning: chromeOnly ? "No inspectable page targets found. Only internal chrome:// pages are open; open the extension's surface (or pass a url to navigate a tab) first." : "No inspectable page targets found. The extension may not have opened a page yet.",
7328
- allTargets: allTargets.map((t)=>({
7329
- type: t.type,
7330
- url: t.url?.slice(0, 100)
7331
- }))
7966
+ return envelope({
7967
+ ok: false,
7968
+ command: inspect_schema.name,
7969
+ status: "no-inspectable-target",
7970
+ error: {
7971
+ code: "E_NO_TARGET",
7972
+ message: chromeOnly ? "No inspectable page targets found. Only internal chrome:// pages are open; open the extension's surface (or pass a url to navigate a tab) first." : "No inspectable page targets found. The extension may not have opened a page yet."
7973
+ },
7974
+ value: {
7975
+ cdpPort,
7976
+ browser,
7977
+ allTargets: allTargets.map((t)=>({
7978
+ type: t.type,
7979
+ url: t.url?.slice(0, 100)
7980
+ }))
7981
+ }
7332
7982
  });
7333
7983
  }
7334
7984
  const target = args.url ? pageTargets.find((t)=>t.url.includes(args.url)) ?? pageTargets[0] : pageTargets[0];
@@ -7392,11 +8042,29 @@ async function inspect_handler(args) {
7392
8042
  result.closedShadowRoots = closed;
7393
8043
  result.deepDom = true;
7394
8044
  }
7395
- return JSON.stringify(result);
8045
+ const probeWarning = result.probeWarning;
8046
+ delete result.probeWarning;
8047
+ return envelope({
8048
+ ok: true,
8049
+ command: inspect_schema.name,
8050
+ status: "inspected",
8051
+ value: result,
8052
+ warnings: [
8053
+ "string" == typeof probeWarning ? probeWarning : null
8054
+ ]
8055
+ });
7396
8056
  } catch (err) {
7397
- return JSON.stringify({
7398
- error: `CDP inspection failed: ${err instanceof Error ? err.message : err}`,
7399
- cdpPort,
8057
+ return envelope({
8058
+ ok: false,
8059
+ command: inspect_schema.name,
8060
+ status: "cdp-failed",
8061
+ error: {
8062
+ code: "E_CDP",
8063
+ message: `CDP inspection failed: ${err instanceof Error ? err.message : err}`
8064
+ },
8065
+ value: {
8066
+ cdpPort
8067
+ },
7400
8068
  hint: "Ensure a dev session is running. The browser may have closed or the CDP port may have changed."
7401
8069
  });
7402
8070
  } finally{
@@ -7405,7 +8073,7 @@ async function inspect_handler(args) {
7405
8073
  }
7406
8074
  const list_extensions_schema = {
7407
8075
  name: "extension_list_extensions",
7408
- description: "List the extensions in the running dev browser: id, name, version, and (on Chromium) live contexts. THIS session's own extension is flagged ownExtension: true, with name and version from the ready contract even when the browser exposes no identity. Chromium rides the Chrome DevTools Protocol, so an entry needs at least one live context (a dormant MV3 service worker may be absent until it wakes). Firefox rides the RDP root actor (listAddons, engine 4.0.15+), so entries are INSTALLED add-ons regardless of contexts, marked temporarilyInstalled where relevant, and carry none. Either way other extensions' contexts are never attached to or evaluated in. Requires an active dev or start session.",
8076
+ description: "List the extensions in the running dev browser: id, name, version, and, on Chromium, live contexts. This session's own extension carries ownExtension:true, with name and version from the ready contract even when the browser exposes no identity. Chromium rides the Chrome DevTools Protocol, so an entry needs at least one live context, and a dormant MV3 service worker may be absent until it wakes. Firefox rides the RDP root actor (listAddons, engine 4.0.15 and later), so entries are installed add-ons regardless of contexts, are marked temporarilyInstalled where relevant, and carry no contexts. Other extensions' contexts are never attached to or evaluated in. This requires an active dev or start session.",
7409
8077
  inputSchema: {
7410
8078
  type: "object",
7411
8079
  properties: {
@@ -7461,13 +8129,25 @@ const UNRESOLVED_NOTE = "Identity unresolved: the browser's Extensions CDP domai
7461
8129
  async function list_extensions_handler(args) {
7462
8130
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser, "chrome");
7463
8131
  if (isGeckoFamily(browser)) return listGeckoExtensions(args.projectPath, browser);
7464
- if (!isChromiumFamily(browser)) return JSON.stringify({
7465
- error: `Listing extensions for ${browser} is not supported: no debugging-protocol pairing exists for this browser family.`,
8132
+ if (!isChromiumFamily(browser)) return envelope({
8133
+ ok: false,
8134
+ command: list_extensions_schema.name,
8135
+ status: "unsupported-browser",
8136
+ error: {
8137
+ code: "E_UNSUPPORTED_BROWSER",
8138
+ message: `Listing extensions for ${browser} is not supported: no debugging-protocol pairing exists for this browser family.`
8139
+ },
7466
8140
  hint: "Target a Chromium-family (CDP) or Firefox-family (RDP) dev session."
7467
8141
  });
7468
8142
  const resolved = await resolveCdpPort(args.projectPath, browser);
7469
- if (!resolved) return JSON.stringify({
7470
- error: "No active dev session found. Cannot connect to Chrome DevTools Protocol.",
8143
+ if (!resolved) return envelope({
8144
+ ok: false,
8145
+ command: list_extensions_schema.name,
8146
+ status: "no-session",
8147
+ error: {
8148
+ code: "E_NO_SESSION",
8149
+ message: "No active dev session found. Cannot connect to Chrome DevTools Protocol."
8150
+ },
7471
8151
  hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${CDP_PORT_MISSING_HINT}`
7472
8152
  });
7473
8153
  const cdpPort = resolved.port;
@@ -7540,17 +8220,30 @@ async function list_extensions_handler(args) {
7540
8220
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
7541
8221
  });
7542
8222
  const ownEntry = extensions.find((e)=>e.ownExtension);
7543
- return JSON.stringify({
7544
- cdpPort,
7545
- browser,
7546
- count: extensions.length,
7547
- ownExtensionId: ownEntry?.id ?? null,
7548
- extensions,
7549
- note: "Lists extensions that currently have at least one live context (service worker or open page). An MV3 service worker that has gone dormant with no open page may be absent until it wakes. ownExtension marks the extension this dev session serves, identified from the session's ready contract. Other identity is read read-only via the Extensions domain; other extensions' contexts are never attached to or evaluated in."
8223
+ return envelope({
8224
+ ok: true,
8225
+ command: list_extensions_schema.name,
8226
+ status: "listed",
8227
+ value: {
8228
+ cdpPort,
8229
+ browser,
8230
+ count: extensions.length,
8231
+ ownExtensionId: ownEntry?.id ?? null,
8232
+ extensions
8233
+ },
8234
+ warnings: [
8235
+ "Lists extensions that currently have at least one live context (service worker or open page). An MV3 service worker that has gone dormant with no open page may be absent until it wakes. ownExtension marks the extension this dev session serves, identified from the session's ready contract. Other identity is read read-only via the Extensions domain; other extensions' contexts are never attached to or evaluated in."
8236
+ ]
7550
8237
  });
7551
8238
  } catch (error) {
7552
- return JSON.stringify({
7553
- error: `Failed to list extensions: ${error.message}`
8239
+ return envelope({
8240
+ ok: false,
8241
+ command: list_extensions_schema.name,
8242
+ status: "cdp-failed",
8243
+ error: {
8244
+ code: "E_CDP",
8245
+ message: `Failed to list extensions: ${error.message}`
8246
+ }
7554
8247
  });
7555
8248
  } finally{
7556
8249
  cdp.disconnect();
@@ -7558,8 +8251,14 @@ async function list_extensions_handler(args) {
7558
8251
  }
7559
8252
  async function listGeckoExtensions(projectPath, browser) {
7560
8253
  const resolved = await resolveRdpPort(projectPath, browser);
7561
- if (!resolved) return JSON.stringify({
7562
- error: "No active dev session with a Firefox debugger server (RDP) found.",
8254
+ if (!resolved) return envelope({
8255
+ ok: false,
8256
+ command: list_extensions_schema.name,
8257
+ status: "no-session",
8258
+ error: {
8259
+ code: "E_NO_SESSION",
8260
+ message: "No active dev session with a Firefox debugger server (RDP) found."
8261
+ },
7563
8262
  hint: `Start a dev session first with extension_dev, then use extension_wait to confirm it is ready. ${RDP_PORT_MISSING_HINT}`
7564
8263
  });
7565
8264
  const rdpPort = resolved.port;
@@ -7606,17 +8305,30 @@ async function listGeckoExtensions(projectPath, browser) {
7606
8305
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
7607
8306
  });
7608
8307
  const ownEntry = extensions.find((e)=>e.ownExtension);
7609
- return JSON.stringify({
7610
- rdpPort,
7611
- browser,
7612
- count: extensions.length,
7613
- ownExtensionId: ownEntry?.id ?? null,
7614
- extensions,
7615
- note: "Lists INSTALLED add-ons via the RDP root actor (listAddons), regardless of whether a context is currently live, so entries carry no contexts. temporarilyInstalled marks temporary loads; ownExtension marks the extension this dev session serves, matched from the session's ready contract. Add-ons are never attached to or evaluated in."
8308
+ return envelope({
8309
+ ok: true,
8310
+ command: list_extensions_schema.name,
8311
+ status: "listed",
8312
+ value: {
8313
+ rdpPort,
8314
+ browser,
8315
+ count: extensions.length,
8316
+ ownExtensionId: ownEntry?.id ?? null,
8317
+ extensions
8318
+ },
8319
+ warnings: [
8320
+ "Lists INSTALLED add-ons via the RDP root actor (listAddons), regardless of whether a context is currently live, so entries carry no contexts. temporarilyInstalled marks temporary loads; ownExtension marks the extension this dev session serves, matched from the session's ready contract. Add-ons are never attached to or evaluated in."
8321
+ ]
7616
8322
  });
7617
8323
  } catch (error) {
7618
- return JSON.stringify({
7619
- error: `Failed to list extensions over RDP: ${error.message}`
8324
+ return envelope({
8325
+ ok: false,
8326
+ command: list_extensions_schema.name,
8327
+ status: "rdp-failed",
8328
+ error: {
8329
+ code: "E_RDP",
8330
+ message: `Failed to list extensions over RDP: ${error.message}`
8331
+ }
7620
8332
  });
7621
8333
  }
7622
8334
  }
@@ -7672,7 +8384,7 @@ function makeFilter(args) {
7672
8384
  }
7673
8385
  const logs_schema_schema = {
7674
8386
  name: "extension_logs",
7675
- description: "Read or stream logs from every context of a running dev session (service worker, content scripts, popup, options, sidebar, devtools, pages) in one ordered timeline. Reads the same agent-bridge plane as the `extension logs` CLI: a one-shot returns the most recent matching lines from logs.ndjson; `follow:true` collects from the live control channel for a bounded window. Requires an active `extension dev` session.",
8387
+ description: "Read or stream logs from every context of a running dev session (service worker, content scripts, popup, options, sidebar, devtools, pages) in one ordered timeline. This reads the same agent-bridge plane as the `extension logs` CLI: a one-shot returns the most recent matching lines from logs.ndjson, and follow:true collects from the live control channel for a bounded window. This requires an active extension_dev session.",
7676
8388
  inputSchema: {
7677
8389
  type: "object",
7678
8390
  properties: {
@@ -7749,6 +8461,7 @@ const logs_schema_schema = {
7749
8461
  ]
7750
8462
  }
7751
8463
  };
8464
+ const logs_TOOL = "extension_logs";
7752
8465
  function logsFilePath(projectPath, browser) {
7753
8466
  return node_path.resolve(projectPath, "dist", "extension-js", browser, "logs.ndjson");
7754
8467
  }
@@ -7798,24 +8511,26 @@ function summarize(events, source, browser, runId, limit, dropped, projectPath,
7798
8511
  const { events: out, truncated } = capRecent(events, limit);
7799
8512
  const lastSeq = out.length ? out.reduce((m, e)=>"number" == typeof e.seq && e.seq > m ? e.seq : m, -1) : -1;
7800
8513
  const reason = 0 === matched && projectPath ? emptyReason(projectPath, browser) : void 0;
7801
- return JSON.stringify({
8514
+ const stale = Boolean(staleNote) && matched > 0;
8515
+ return envelope({
7802
8516
  ok: true,
7803
- source,
7804
- browser,
7805
- runId: runId || void 0,
7806
- matched,
7807
- count: out.length,
7808
- truncated,
7809
- dropped: dropped || void 0,
7810
- nextSince: lastSeq >= 0 ? lastSeq : void 0,
7811
- ...reason ? {
7812
- emptyReason: reason
7813
- } : {},
7814
- ...staleNote && matched > 0 ? {
7815
- stale: true,
7816
- warning: staleNote
7817
- } : {},
7818
- events: out
8517
+ command: logs_TOOL,
8518
+ status: 0 === matched ? "empty" : stale ? "stale" : "read",
8519
+ value: {
8520
+ source,
8521
+ browser,
8522
+ runId: runId || void 0,
8523
+ matched,
8524
+ count: out.length,
8525
+ truncated,
8526
+ dropped: dropped || void 0,
8527
+ nextSince: lastSeq >= 0 ? lastSeq : void 0,
8528
+ events: out
8529
+ },
8530
+ warnings: [
8531
+ reason ?? null,
8532
+ stale ? staleNote ?? null : null
8533
+ ]
7819
8534
  });
7820
8535
  }
7821
8536
  function staleFileNote(projectPath, browser, eventsRunId) {
@@ -7838,8 +8553,14 @@ function staleFileNote(projectPath, browser, eventsRunId) {
7838
8553
  }
7839
8554
  async function readFromFile(args, browser, limit) {
7840
8555
  const file = logsFilePath(args.projectPath, browser);
7841
- if (!node_fs.existsSync(file)) return JSON.stringify({
7842
- error: `No logs found at ${file}.`,
8556
+ if (!node_fs.existsSync(file)) return envelope({
8557
+ ok: false,
8558
+ command: logs_TOOL,
8559
+ status: "no-log-file",
8560
+ error: {
8561
+ code: "E_LOGS_MISSING",
8562
+ message: `No logs found at ${file}.`
8563
+ },
7843
8564
  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."
7844
8565
  });
7845
8566
  const matches = makeFilter(args);
@@ -7866,8 +8587,14 @@ async function readFromStream(args, browser, limit) {
7866
8587
  if (!ready) {
7867
8588
  const running = knownSessionBrowsers(args.projectPath).filter((b)=>b !== browser);
7868
8589
  const retarget = running.length ? `An active session exists for browser(s): ${running.join(", ")}, pass that as \`browser\`. Otherwise run` : "Run";
7869
- return JSON.stringify({
7870
- error: `No active control channel found for ${browser}.`,
8590
+ return envelope({
8591
+ ok: false,
8592
+ command: logs_TOOL,
8593
+ status: "no-control-channel",
8594
+ error: {
8595
+ code: "E_NO_CONTROL_CHANNEL",
8596
+ message: `No active control channel found for ${browser}.`
8597
+ },
7871
8598
  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.`
7872
8599
  });
7873
8600
  }
@@ -7883,8 +8610,14 @@ async function readFromStream(args, browser, limit) {
7883
8610
  try {
7884
8611
  socket = new ws_0(url);
7885
8612
  } catch (err) {
7886
- resolve(JSON.stringify({
7887
- error: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
8613
+ resolve(envelope({
8614
+ ok: false,
8615
+ command: logs_TOOL,
8616
+ status: "control-channel-failed",
8617
+ error: {
8618
+ code: "E_CONTROL_CHANNEL",
8619
+ message: `Could not open control channel at ${url}: ${err instanceof Error ? err.message : String(err)}`
8620
+ }
7888
8621
  }));
7889
8622
  return;
7890
8623
  }
@@ -7924,8 +8657,14 @@ async function readFromStream(args, browser, limit) {
7924
8657
  if (settled) return;
7925
8658
  settled = true;
7926
8659
  clearTimeout(timer);
7927
- resolve(JSON.stringify({
7928
- error: `Control channel error at ${url}.`,
8660
+ resolve(envelope({
8661
+ ok: false,
8662
+ command: logs_TOOL,
8663
+ status: "control-channel-failed",
8664
+ error: {
8665
+ code: "E_CONTROL_CHANNEL",
8666
+ message: `Control channel error at ${url}.`
8667
+ },
7929
8668
  hint: "The dev session may have stopped or the control port changed. Re-check with extension_wait."
7930
8669
  }));
7931
8670
  });
@@ -7940,7 +8679,7 @@ async function logs_handler(args) {
7940
8679
  }
7941
8680
  const eval_schema = {
7942
8681
  name: "extension_eval",
7943
- description: "Evaluate an expression in a running extension context. Requires the session to have been started with allowEval: true (extension_dev; writes a 0600 session token). Context defaults to `background`, EXCEPT on a Chromium MV3 session (the default template) where it is `page`, the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content/page, pass `url` to pick the tab or omit both `url` and `tab` for the ACTIVE tab; a numeric `tab` only disambiguates. Extension surfaces (popup/options/sidebar/devtools) and override pages evaluate over the in-bundle relay and need NO tab id, but must be OPEN (extension_open first; a closed one returns an explicit error). extension_dom_snapshot with listTabs: true enumerates {tabId,url,title}.",
8682
+ description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages evaluate over the in-bundle relay and need no tab id, but must already be open: open one with extension_open first, because a closed one returns an explicit error. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
7944
8683
  inputSchema: {
7945
8684
  type: "object",
7946
8685
  properties: {
@@ -8016,28 +8755,32 @@ async function eval_handler(args) {
8016
8755
  context,
8017
8756
  browser
8018
8757
  })
8019
- ], args.projectPath, args.timeout);
8758
+ ], args.projectPath, args.timeout, eval_schema.name);
8020
8759
  if ("content" === args.context) try {
8021
8760
  const parsed = JSON.parse(raw);
8022
8761
  if (parsed?.ok === true && (null === parsed.value || void 0 === parsed.value)) {
8023
- parsed.note = "On Extension.js >= 4.0.14 a failed injection errors explicitly, so this null is the expression's real result. On OLDER engines (bug 61) it could mean the injection never ran; if this result looks wrong, check the engine version with extension_doctor, or verify with extension_logs or context:'page'.";
8024
- return JSON.stringify(parsed);
8762
+ addWarning(parsed, "On Extension.js >= 4.0.14 a failed injection errors explicitly, so this null is the expression's real result. On OLDER engines (bug 61) it could mean the injection never ran; if this result looks wrong, check the engine version with extension_doctor, or verify with extension_logs or context:'page'.");
8763
+ return actFrameJson(parsed);
8025
8764
  }
8026
8765
  } catch {}
8027
8766
  if (defaulted) try {
8028
8767
  const parsed = JSON.parse(raw);
8029
8768
  if (parsed && "object" == typeof parsed) {
8030
- parsed.defaultedContext = "page";
8031
- parsed.contextNote = 'No context given: defaulted to "page" (the active tab) because this Chromium session\'s MV3 background is a service worker whose CSP blocks eval. Pass context: "background" explicitly to target the worker (works on Firefox/MV2 builds).';
8032
- if (false === parsed.ok && /cannot access|chrome-extension:\/\/|chrome:\/\//i.test(JSON.stringify(parsed.error ?? ""))) parsed.hint = "The active tab is a browser or extension page that eval cannot reach. Navigate the dev browser to a regular web page, or pass url (match pattern) or tab to pick one; extension_dom_snapshot with listTabs: true lists open tabs.";
8033
- return JSON.stringify(parsed);
8769
+ patchValue(parsed, {
8770
+ defaultedContext: "page"
8771
+ });
8772
+ addWarning(parsed, 'No context given: defaulted to "page" (the active tab) because this Chromium session\'s MV3 background is a service worker whose CSP blocks eval. Pass context: "background" explicitly to target the worker (works on Firefox/MV2 builds).');
8773
+ const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
8774
+ const unreachable = "E_TARGET_NOT_FOUND" === code || /cannot access|chrome-extension:\/\/|chrome:\/\//i.test(JSON.stringify(parsed.error ?? ""));
8775
+ if (false === parsed.ok && unreachable) parsed.hint = "The active tab is a browser or extension page that eval cannot reach. Navigate the dev browser to a regular web page, or pass url (match pattern) or tab to pick one; extension_dom_snapshot with listTabs: true lists open tabs.";
8776
+ return actFrameJson(parsed);
8034
8777
  }
8035
8778
  } catch {}
8036
8779
  return raw;
8037
8780
  }
8038
8781
  const storage_schema = {
8039
8782
  name: "extension_storage",
8040
- description: "Read or write chrome.storage in a running extension. Requires the session to have been started with allowControl: true (extension_dev).",
8783
+ description: "Read or write chrome.storage in a running extension. Start the session with allowControl:true (extension_dev). Set one key per call: there is no bulk-object set.",
8041
8784
  inputSchema: {
8042
8785
  type: "object",
8043
8786
  properties: {
@@ -8097,16 +8840,22 @@ async function storage_handler(args) {
8097
8840
  if (args.area) cli.push("--area", args.area);
8098
8841
  if (args.key) cli.push("--key", args.key);
8099
8842
  if ("set" === args.action) {
8100
- if (void 0 === args.value) return JSON.stringify({
8843
+ if (void 0 === args.value) return envelope({
8101
8844
  ok: false,
8845
+ command: storage_schema.name,
8846
+ status: "bad-request",
8102
8847
  error: {
8848
+ code: "E_BAD_REQUEST",
8103
8849
  name: "BadRequest",
8104
8850
  message: "storage set requires a value"
8105
8851
  }
8106
8852
  });
8107
- if (void 0 === args.key) return JSON.stringify({
8853
+ if (void 0 === args.key) return envelope({
8108
8854
  ok: false,
8855
+ command: storage_schema.name,
8856
+ status: "bad-request",
8109
8857
  error: {
8858
+ code: "E_BAD_REQUEST",
8110
8859
  name: "BadRequest",
8111
8860
  message: 'storage set requires `key` (string) and `value` args, one key per call. There is no bulk-object set: to seed {a: 1, b: 2}, call once with key: "a" and once with key: "b".'
8112
8861
  }
@@ -8116,11 +8865,11 @@ async function storage_handler(args) {
8116
8865
  if (args.context) cli.push("--context", args.context);
8117
8866
  cli.push("--browser", browser);
8118
8867
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
8119
- return runActVerb(cli, args.projectPath, args.timeout);
8868
+ return runActVerb(cli, args.projectPath, args.timeout, storage_schema.name);
8120
8869
  }
8121
8870
  const reload_schema = {
8122
8871
  name: "extension_reload",
8123
- description: "Reload a running extension (background) or a tab. Requires the session to have been started with allowControl: true (extension_dev).",
8872
+ description: "Reload a running extension's background context, or a tab. Start the session with allowControl:true (extension_dev).",
8124
8873
  inputSchema: {
8125
8874
  type: "object",
8126
8875
  properties: {
@@ -8155,7 +8904,7 @@ async function reload_handler(args) {
8155
8904
  ...args,
8156
8905
  browser
8157
8906
  })
8158
- ], args.projectPath, args.timeout);
8907
+ ], args.projectPath, args.timeout, reload_schema.name);
8159
8908
  }
8160
8909
  const TARGET_ID_NOTE = "targetId is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_snapshot with listTabs: true.";
8161
8910
  function filterPageTargets(raw) {
@@ -8178,7 +8927,7 @@ function matchTargetsByUrl(targets, needle) {
8178
8927
  const RDP_ACTOR_NOTE = "actor is an RDP tab descriptor actor id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_snapshot with listTabs: true.";
8179
8928
  const dom_snapshot_schema = {
8180
8929
  name: "extension_dom_snapshot",
8181
- description: "Take a SHALLOW structured DOM snapshot of ONE CHOSEN surface through the agent bridge (CDP-free, localhost): element counts, extension roots, OPEN shadow roots, optional byte-capped HTML, optional recent console lines. This is the SURFACE PICKER: the only tool that reads an OPEN extension surface by name (`context`: popup/options/sidebar/devtools) or an override page, the only one that takes a numeric chrome.tabs id, and the only one that enumerates what is open (listTargets for CDP targetIds and RDP tab actors, listTabs for numeric tab ids). An ambiguous `tabUrl` returns the candidates instead of guessing. It does NOT pierce closed shadow roots, run selector probes, or navigate: for those, and for a deep read of an already-open web page, use extension_inspect. Requires the session to have been started with allowControl: true (extension_dev).",
8930
+ description: "Take a shallow structured DOM snapshot of one chosen surface through the agent bridge (no CDP, localhost only): element counts, extension roots, open shadow roots, optional byte-capped HTML, and optional recent console lines. This is the SURFACE PICKER: the only tool that reads an open extension surface by name (`context`: popup, options, sidebar, devtools) or an override page, the only one that takes a numeric chrome.tabs id, and the only one that enumerates what is open (listTargets for CDP targetIds and RDP tab actors, listTabs for numeric tab ids). An ambiguous `tabUrl` returns the candidates instead of guessing. It does not pierce closed shadow roots, run selector probes, or navigate: use extension_inspect for those, and for a deep read of an already-open web page. Start the session with allowControl:true (extension_dev).",
8182
8931
  inputSchema: {
8183
8932
  type: "object",
8184
8933
  properties: {
@@ -8256,9 +9005,12 @@ const dom_snapshot_schema = {
8256
9005
  };
8257
9006
  async function cdpPortOrError(projectPath, browser, feature) {
8258
9007
  if (!isChromiumFamily(browser)) return {
8259
- error: JSON.stringify({
9008
+ error: envelope({
8260
9009
  ok: false,
9010
+ command: dom_snapshot_schema.name,
9011
+ status: "unsupported-browser",
8261
9012
  error: {
9013
+ code: "E_UNSUPPORTED_BROWSER",
8262
9014
  name: "Unsupported",
8263
9015
  message: `${feature} reads the browser's CDP page targets, which ${browser} (Gecko) does not expose. Target the tab with \`url\` or \`tab\` instead, and discover tabs with listTabs: true (agent bridge, works on every browser).`
8264
9016
  }
@@ -8266,9 +9018,12 @@ async function cdpPortOrError(projectPath, browser, feature) {
8266
9018
  };
8267
9019
  const resolved = await resolveCdpPort(projectPath, browser);
8268
9020
  if (!resolved) return {
8269
- error: JSON.stringify({
9021
+ error: envelope({
8270
9022
  ok: false,
9023
+ command: dom_snapshot_schema.name,
9024
+ status: "no-session",
8271
9025
  error: {
9026
+ code: "E_NO_SESSION",
8272
9027
  name: "NoSession",
8273
9028
  message: `No active dev session / CDP port for ${browser}, so ${feature} has no browser to ask. Start extension_dev and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
8274
9029
  }
@@ -8284,34 +9039,46 @@ async function dom_snapshot_handler(args) {
8284
9039
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
8285
9040
  if (isGeckoFamily(browser)) {
8286
9041
  const resolved = await resolveRdpPort(args.projectPath, browser);
8287
- if (!resolved) return JSON.stringify({
9042
+ if (!resolved) return envelope({
8288
9043
  ok: false,
9044
+ command: dom_snapshot_schema.name,
9045
+ status: "no-session",
8289
9046
  error: {
9047
+ code: "E_NO_SESSION",
8290
9048
  name: "NoSession",
8291
9049
  message: `No active dev session with a Firefox debugger server (RDP) for ${browser}, so listTargets has no browser to ask. Start extension_dev and extension_wait for ready. ${RDP_PORT_MISSING_HINT}`
8292
9050
  }
8293
9051
  });
8294
9052
  try {
8295
9053
  const tabs = await rdpListTabs(resolved.port);
8296
- return JSON.stringify({
9054
+ return envelope({
8297
9055
  ok: true,
8298
- browser,
8299
- transport: "rdp",
8300
- targets: tabs.map((t)=>({
8301
- actor: String(t.actor ?? ""),
8302
- type: "tab",
8303
- url: String(t.url ?? ""),
8304
- title: String(t.title ?? ""),
8305
- ...true === t.selected ? {
8306
- selected: true
8307
- } : {}
8308
- })),
8309
- note: RDP_ACTOR_NOTE
9056
+ command: dom_snapshot_schema.name,
9057
+ status: "listed-targets",
9058
+ value: {
9059
+ browser,
9060
+ transport: "rdp",
9061
+ targets: tabs.map((t)=>({
9062
+ actor: String(t.actor ?? ""),
9063
+ type: "tab",
9064
+ url: String(t.url ?? ""),
9065
+ title: String(t.title ?? ""),
9066
+ ...true === t.selected ? {
9067
+ selected: true
9068
+ } : {}
9069
+ }))
9070
+ },
9071
+ warnings: [
9072
+ RDP_ACTOR_NOTE
9073
+ ]
8310
9074
  });
8311
9075
  } catch (e) {
8312
- return JSON.stringify({
9076
+ return envelope({
8313
9077
  ok: false,
9078
+ command: dom_snapshot_schema.name,
9079
+ status: "rdp-failed",
8314
9080
  error: {
9081
+ code: "E_RDP",
8315
9082
  name: "RdpError",
8316
9083
  message: `Could not list tab targets over RDP: ${e instanceof Error ? e.message : String(e)}`
8317
9084
  },
@@ -8323,16 +9090,25 @@ async function dom_snapshot_handler(args) {
8323
9090
  if ("error" in cdp) return cdp.error;
8324
9091
  try {
8325
9092
  const targets = await listPageTargets(cdp.port);
8326
- return JSON.stringify({
9093
+ return envelope({
8327
9094
  ok: true,
8328
- browser,
8329
- targets,
8330
- note: TARGET_ID_NOTE
9095
+ command: dom_snapshot_schema.name,
9096
+ status: "listed-targets",
9097
+ value: {
9098
+ browser,
9099
+ targets
9100
+ },
9101
+ warnings: [
9102
+ TARGET_ID_NOTE
9103
+ ]
8331
9104
  });
8332
9105
  } catch (e) {
8333
- return JSON.stringify({
9106
+ return envelope({
8334
9107
  ok: false,
9108
+ command: dom_snapshot_schema.name,
9109
+ status: "cdp-failed",
8335
9110
  error: {
9111
+ code: "E_CDP",
8336
9112
  name: "CdpError",
8337
9113
  message: `Could not list page targets: ${e instanceof Error ? e.message : String(e)}`
8338
9114
  },
@@ -8350,14 +9126,17 @@ async function dom_snapshot_handler(args) {
8350
9126
  "--timeout",
8351
9127
  String(args.timeout)
8352
9128
  ] : []
8353
- ], args.projectPath, args.timeout);
9129
+ ], args.projectPath, args.timeout, dom_snapshot_schema.name);
8354
9130
  let targetUrl = args.url;
8355
9131
  let targetTab = args.tab;
8356
9132
  let resolvedTarget = null;
8357
9133
  if (args.tabUrl) {
8358
- if (null != args.tab || args.url) return JSON.stringify({
9134
+ if (null != args.tab || args.url) return envelope({
8359
9135
  ok: false,
9136
+ command: dom_snapshot_schema.name,
9137
+ status: "bad-request",
8360
9138
  error: {
9139
+ code: "E_BAD_REQUEST",
8361
9140
  name: "BadRequest",
8362
9141
  message: "Pass ONE tab selector: `tabUrl` (URL substring, resolved against live targets), `url` (engine-side match), or `tab` (numeric chrome.tabs id), not several."
8363
9142
  }
@@ -8370,9 +9149,12 @@ async function dom_snapshot_handler(args) {
8370
9149
  try {
8371
9150
  targets = await listPageTargets(cdp.port);
8372
9151
  } catch (e) {
8373
- return JSON.stringify({
9152
+ return envelope({
8374
9153
  ok: false,
9154
+ command: dom_snapshot_schema.name,
9155
+ status: "cdp-failed",
8375
9156
  error: {
9157
+ code: "E_CDP",
8376
9158
  name: "CdpError",
8377
9159
  message: `Could not list page targets to resolve tabUrl: ${e instanceof Error ? e.message : String(e)}`
8378
9160
  },
@@ -8380,22 +9162,32 @@ async function dom_snapshot_handler(args) {
8380
9162
  });
8381
9163
  }
8382
9164
  const matches = matchTargetsByUrl(targets, args.tabUrl);
8383
- if (0 === matches.length) return JSON.stringify({
9165
+ if (0 === matches.length) return envelope({
8384
9166
  ok: false,
9167
+ command: dom_snapshot_schema.name,
9168
+ status: "no-matching-target",
8385
9169
  error: {
9170
+ code: "E_NO_MATCHING_TARGET",
8386
9171
  name: "NoMatchingTarget",
8387
9172
  message: `No open page target's url (or title) contains "${args.tabUrl}" (case-insensitive).`
8388
9173
  },
8389
- availableTargets: targets,
9174
+ value: {
9175
+ availableTargets: targets
9176
+ },
8390
9177
  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}`
8391
9178
  });
8392
- if (matches.length > 1) return JSON.stringify({
9179
+ if (matches.length > 1) return envelope({
8393
9180
  ok: false,
9181
+ command: dom_snapshot_schema.name,
9182
+ status: "ambiguous-target",
8394
9183
  error: {
9184
+ code: "E_AMBIGUOUS_TARGET",
8395
9185
  name: "AmbiguousTabUrl",
8396
9186
  message: `${matches.length} page targets match "${args.tabUrl}"; refusing to guess which tab you mean.`
8397
9187
  },
8398
- matchingTargets: matches,
9188
+ value: {
9189
+ matchingTargets: matches
9190
+ },
8399
9191
  hint: `Narrow \`tabUrl\` to a longer substring that matches exactly one url in matchingTargets. ${TARGET_ID_NOTE}`
8400
9192
  });
8401
9193
  resolvedTarget = {
@@ -8406,22 +9198,32 @@ async function dom_snapshot_handler(args) {
8406
9198
  const listed = await listBridgeTabs(args.projectPath, browser, args.timeout);
8407
9199
  if ("error" in listed) return listed.error;
8408
9200
  const matches = matchTabsByUrl(listed.tabs, args.tabUrl);
8409
- if (0 === matches.length) return JSON.stringify({
9201
+ if (0 === matches.length) return envelope({
8410
9202
  ok: false,
9203
+ command: dom_snapshot_schema.name,
9204
+ status: "no-matching-target",
8411
9205
  error: {
9206
+ code: "E_NO_MATCHING_TARGET",
8412
9207
  name: "NoMatchingTarget",
8413
9208
  message: `No open tab's url (or title) contains "${args.tabUrl}" (case-insensitive).`
8414
9209
  },
8415
- availableTabs: listed.tabs,
9210
+ value: {
9211
+ availableTabs: listed.tabs
9212
+ },
8416
9213
  hint: "Pick one from availableTabs and retry with a `tabUrl` substring of its url, or open the page first (extension_open with `url`)."
8417
9214
  });
8418
- if (matches.length > 1) return JSON.stringify({
9215
+ if (matches.length > 1) return envelope({
8419
9216
  ok: false,
9217
+ command: dom_snapshot_schema.name,
9218
+ status: "ambiguous-target",
8420
9219
  error: {
9220
+ code: "E_AMBIGUOUS_TARGET",
8421
9221
  name: "AmbiguousTabUrl",
8422
9222
  message: `${matches.length} tabs match "${args.tabUrl}"; refusing to guess which tab you mean.`
8423
9223
  },
8424
- matchingTabs: matches,
9224
+ value: {
9225
+ matchingTabs: matches
9226
+ },
8425
9227
  hint: "Narrow `tabUrl` to a longer substring that matches exactly one url in matchingTabs, or pass its numeric tabId as `tab`."
8426
9228
  });
8427
9229
  resolvedTarget = {
@@ -8443,22 +9245,24 @@ async function dom_snapshot_handler(args) {
8443
9245
  if (null != withConsole) cli.push("--with-console", String(withConsole));
8444
9246
  cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
8445
9247
  if (null != args.timeout) cli.push("--timeout", String(args.timeout));
8446
- const raw = await runActVerb(cli, args.projectPath, args.timeout);
9248
+ const raw = await runActVerb(cli, args.projectPath, args.timeout, dom_snapshot_schema.name);
8447
9249
  if (!resolvedTarget) return raw;
8448
9250
  try {
8449
9251
  const parsed = JSON.parse(raw);
8450
- parsed.resolvedTarget = {
8451
- ...resolvedTarget,
8452
- matchedBy: "tabUrl"
8453
- };
8454
- return JSON.stringify(parsed);
9252
+ patchValue(parsed, {
9253
+ resolvedTarget: {
9254
+ ...resolvedTarget,
9255
+ matchedBy: "tabUrl"
9256
+ }
9257
+ });
9258
+ return actFrameJson(parsed);
8455
9259
  } catch {
8456
9260
  return raw;
8457
9261
  }
8458
9262
  }
8459
9263
  const publish_schema = {
8460
9264
  name: "extension_publish",
8461
- description: "Publish the project your stored token is scoped to (extension_auth, or EXTENSION_DEV_TOKEN) to extension.dev and return its shareable URL. This is what \"deploy\" or \"ship\" an extension usually means; extension_submit is the separate store-review path. The target is the token's project: there is no projectPath and no local file is uploaded. For a PUBLIC project the URL is the canonical public page and ttlHours does not apply; for a PRIVATE one it is a fresh time-limited share link (?share=) whose lifetime is ttlHours.",
9265
+ description: "Publish the project your stored token is scoped to (extension_auth, or EXTENSION_DEV_TOKEN) to extension.dev, and return its shareable URL. This is what \"deploy\" or \"ship\" an extension usually means; extension_submit is the separate store-review path. The target is the token's project: there is no projectPath, and no local file is uploaded. For a public project the URL is the canonical public page and ttlHours does not apply. For a private one it is a fresh time-limited share link (?share=) whose lifetime is ttlHours.",
8462
9266
  inputSchema: {
8463
9267
  type: "object",
8464
9268
  properties: {
@@ -8475,10 +9279,13 @@ const publish_schema = {
8475
9279
  required: []
8476
9280
  }
8477
9281
  };
8478
- function fail(name, message) {
8479
- return JSON.stringify({
9282
+ function fail(name, message, status, code) {
9283
+ return envelope({
8480
9284
  ok: false,
9285
+ command: "extension_publish",
9286
+ status,
8481
9287
  error: {
9288
+ code,
8482
9289
  name,
8483
9290
  message
8484
9291
  }
@@ -8486,13 +9293,13 @@ function fail(name, message) {
8486
9293
  }
8487
9294
  async function publish_handler(args) {
8488
9295
  const token = resolveToken();
8489
- if (!token) return fail("PublishAuthError", "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).");
9296
+ if (!token) return fail("PublishAuthError", "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard).", "auth-required", "E_AUTH_REQUIRED");
8490
9297
  if (null != args.ttlHours) {
8491
9298
  const t = Number(args.ttlHours);
8492
- if (!Number.isInteger(t) || t < 1 || t > 168) return fail("PublishBadRequest", "ttlHours must be an integer between 1 and 168.");
9299
+ if (!Number.isInteger(t) || t < 1 || t > 168) return fail("PublishBadRequest", "ttlHours must be an integer between 1 and 168.", "bad-request", "E_BAD_REQUEST");
8493
9300
  }
8494
9301
  if (null != args.buildSha && "" !== args.buildSha) {
8495
- if (!/^[0-9a-f]{7,40}$/i.test(args.buildSha)) return fail("PublishBadRequest", "buildSha must be a 7-40 character hex git sha.");
9302
+ if (!/^[0-9a-f]{7,40}$/i.test(args.buildSha)) return fail("PublishBadRequest", "buildSha must be a 7-40 character hex git sha.", "bad-request", "E_BAD_REQUEST");
8496
9303
  }
8497
9304
  const result = await publish({
8498
9305
  ttlHours: args.ttlHours,
@@ -8500,9 +9307,20 @@ async function publish_handler(args) {
8500
9307
  api: args.api,
8501
9308
  token
8502
9309
  });
8503
- if (!result.ok) return JSON.stringify(result);
9310
+ if (!result.ok) return envelope({
9311
+ ok: false,
9312
+ command: "extension_publish",
9313
+ status: "publish-failed",
9314
+ error: {
9315
+ code: "E_PLATFORM",
9316
+ name: result.error.name,
9317
+ message: result.error.message
9318
+ }
9319
+ });
8504
9320
  const data = result.data;
8505
- if (null != args.ttlHours && "public" === data.visibility) data.note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
9321
+ let note = null;
9322
+ if (null != args.ttlHours && "public" === data.visibility) note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
9323
+ let buildNote = null;
8506
9324
  const ref = resolveProjectRef();
8507
9325
  if (ref) {
8508
9326
  const buildsUrl = registryFileUrl(ref, "builds/index.json");
@@ -8524,15 +9342,24 @@ async function publish_handler(args) {
8524
9342
  if (null == data.version && served.version) data.version = served.version;
8525
9343
  if (null == data.channel && served.channel) data.channel = served.channel;
8526
9344
  data.registryUrl = buildsUrl;
8527
- if (!pinned && null == args.buildSha) data.buildNote = "buildSha/builtAt/version describe the newest successful build in the project's registry index, which is what the share link serves. Pin buildSha to serve a specific build.";
9345
+ if (!pinned && null == args.buildSha) buildNote = "buildSha/builtAt/version describe the newest successful build in the project's registry index, which is what the share link serves. Pin buildSha to serve a specific build.";
8528
9346
  }
8529
9347
  }
8530
9348
  }
8531
- return JSON.stringify(data);
9349
+ return envelope({
9350
+ ok: true,
9351
+ command: "extension_publish",
9352
+ status: "published",
9353
+ value: data,
9354
+ warnings: [
9355
+ note,
9356
+ buildNote
9357
+ ]
9358
+ });
8532
9359
  }
8533
9360
  const release_promote_schema = {
8534
9361
  name: "extension_release_promote",
8535
- description: "Promote a built extension to a release channel (stable, preview, beta, ...) on extension.dev, headless. This WRITES: it is the only verb that changes what a channel points at. Auth-gated by your stored login (extension_auth) or a release token in EXTENSION_DEV_TOKEN, minted and revoked under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry. The project comes from the token. Use extension_release_status to find a valid buildId. Cutting a version-bump PR is not available headlessly (it writes to your source repo and needs an interactive login).",
9362
+ description: "Promote a built extension to a release channel (stable, preview, beta, …) on extension.dev, headless. This WRITES: it is the only verb that changes what a channel points at. It is auth-gated by your stored login (extension_auth) or a release token in EXTENSION_DEV_TOKEN, minted and revoked under project settings, Access tokens. Tokens live at most 7 days, so CI must re-mint before expiry. The project comes from the token. Call extension_release_status to find a valid buildId. Cutting a version-bump PR is not available headlessly, because it writes to your source repo and needs an interactive login.",
8536
9363
  inputSchema: {
8537
9364
  type: "object",
8538
9365
  properties: {
@@ -8571,10 +9398,13 @@ const release_promote_schema = {
8571
9398
  ]
8572
9399
  }
8573
9400
  };
8574
- function release_promote_fail(name, message) {
8575
- return JSON.stringify({
9401
+ function release_promote_fail(name, message, status, code) {
9402
+ return envelope({
8576
9403
  ok: false,
9404
+ command: "extension_release_promote",
9405
+ status,
8577
9406
  error: {
9407
+ code,
8578
9408
  name,
8579
9409
  message
8580
9410
  }
@@ -8582,12 +9412,12 @@ function release_promote_fail(name, message) {
8582
9412
  }
8583
9413
  async function release_promote_handler(args) {
8584
9414
  const token = resolveToken();
8585
- if (!token) return release_promote_fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry), or run extension_auth (action: login).");
9415
+ if (!token) return release_promote_fail("ReleaseAuthError", "No token. Set EXTENSION_DEV_TOKEN to a release token (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry), or run extension_auth (action: login).", "auth-required", "E_AUTH_REQUIRED");
8586
9416
  const buildId = String(args.buildId || "").trim();
8587
9417
  const channel = String(args.channel || "").trim();
8588
- if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.");
9418
+ if (!buildId || !channel) return release_promote_fail("ReleaseInputError", "buildId and channel are required.", "bad-request", "E_BAD_REQUEST");
8589
9419
  const apiCheck = safeApiBase(resolveApiBase(args.api));
8590
- if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message);
9420
+ if (!apiCheck.ok) return release_promote_fail("ReleaseConfigError", apiCheck.message, "bad-config", "E_CONFIG");
8591
9421
  const url = `${apiCheck.base}/api/cli/release/promote`;
8592
9422
  const body = {
8593
9423
  buildId,
@@ -8608,7 +9438,7 @@ async function release_promote_handler(args) {
8608
9438
  body: JSON.stringify(body)
8609
9439
  });
8610
9440
  } catch (err) {
8611
- return release_promote_fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`);
9441
+ return release_promote_fail("ReleaseNetworkError", `Could not reach ${url}: ${err?.message || err}`, "network-failed", "E_NETWORK");
8612
9442
  }
8613
9443
  const text = await res.text();
8614
9444
  let data;
@@ -8622,10 +9452,11 @@ async function release_promote_handler(args) {
8622
9452
  if (!res.ok) {
8623
9453
  const code = "string" == typeof data?.code ? data.code : void 0;
8624
9454
  const enrich = {};
9455
+ let hint = "";
8625
9456
  const ref = resolveProjectRef();
8626
9457
  if (404 === res.status || "UNKNOWN_BUILD" === code) {
8627
9458
  enrich.buildsPageUrl = consoleProjectUrl(ref, "builds", args.api);
8628
- enrich.hint = "Run extension_release_status to see this project's channels, their promoted shas, and recent builds.";
9459
+ hint = "Run extension_release_status to see this project's channels, their promoted shas, and recent builds.";
8629
9460
  if (ref) {
8630
9461
  const channelsUrl = registryFileUrl(ref, "channels.json");
8631
9462
  const channelsRes = await fetchRegistryJson(channelsUrl, fetch, {
@@ -8642,16 +9473,20 @@ async function release_promote_handler(args) {
8642
9473
  }
8643
9474
  }
8644
9475
  }
8645
- return JSON.stringify({
9476
+ return envelope({
8646
9477
  ok: false,
9478
+ command: "extension_release_promote",
9479
+ status: "promote-failed",
8647
9480
  error: {
9481
+ code: "E_PLATFORM",
8648
9482
  name: "ReleaseError",
8649
9483
  message: `promote failed (${res.status}): ${data?.message || text || "unknown error"}`,
8650
9484
  ...code ? {
8651
- code
9485
+ platformCode: code
8652
9486
  } : {}
8653
9487
  },
8654
- ...enrich
9488
+ value: enrich,
9489
+ hint
8655
9490
  });
8656
9491
  }
8657
9492
  const promotedRef = resolveProjectRef();
@@ -8666,21 +9501,29 @@ async function release_promote_handler(args) {
8666
9501
  publicBuildUrl
8667
9502
  } : {}
8668
9503
  } : data;
8669
- return JSON.stringify(enriched);
9504
+ return envelope({
9505
+ ok: true,
9506
+ command: "extension_release_promote",
9507
+ status: "promoted",
9508
+ value: enriched
9509
+ });
8670
9510
  }
8671
- function release_list_fail(name, message, extra) {
8672
- return JSON.stringify({
9511
+ function release_list_fail(name, message, status, code, extra) {
9512
+ return envelope({
8673
9513
  ok: false,
9514
+ command: "extension_release_status",
9515
+ status,
9516
+ value: extra ?? {},
8674
9517
  error: {
9518
+ code,
8675
9519
  name,
8676
9520
  message
8677
- },
8678
- ...extra ?? {}
9521
+ }
8679
9522
  });
8680
9523
  }
8681
9524
  async function readReleases(args) {
8682
9525
  const ref = resolveProjectRef(args);
8683
- if (!ref) return release_list_fail("ReleaseListInputError", "No project to list. Run extension_auth (action: login), which names the project, or pass workspace + project explicitly.");
9526
+ if (!ref) return release_list_fail("ReleaseListInputError", "No project to list. Run extension_auth (action: login), which names the project, or pass workspace + project explicitly.", "auth-required", "E_AUTH_REQUIRED");
8684
9527
  const channelsUrl = registryFileUrl(ref, "channels.json");
8685
9528
  const metaUrl = registryFileUrl(ref, "meta.json");
8686
9529
  const buildsUrl = registryFileUrl(ref, "builds/index.json");
@@ -8699,7 +9542,7 @@ async function readReleases(args) {
8699
9542
  })
8700
9543
  ]);
8701
9544
  const buildsPageUrl = consoleProjectUrl(ref, "builds", args.api);
8702
- if (!channelsRes.ok && !metaRes.ok && !buildsRes.ok) return release_list_fail("ReleaseListNotFound", `No registry data for ${ref.workspace}/${ref.project} (${channelsUrl} returned ${channelsRes.status ?? "no response"}). The project may have no builds yet, or the workspace/project slugs may be wrong. If it is private, make sure extension_auth covers this exact project (a token scoped elsewhere cannot read it). The console Builds page is the authoritative view: ${buildsPageUrl}`, {
9545
+ if (!channelsRes.ok && !metaRes.ok && !buildsRes.ok) return release_list_fail("ReleaseListNotFound", `No registry data for ${ref.workspace}/${ref.project} (${channelsUrl} returned ${channelsRes.status ?? "no response"}). The project may have no builds yet, or the workspace/project slugs may be wrong. If it is private, make sure extension_auth covers this exact project (a token scoped elsewhere cannot read it). The console Builds page is the authoritative view: ${buildsPageUrl}`, "unavailable", "E_PLATFORM", {
8703
9546
  workspace: ref.workspace,
8704
9547
  project: ref.project,
8705
9548
  registryUrl: channelsUrl,
@@ -8720,31 +9563,37 @@ async function readReleases(args) {
8720
9563
  ...b,
8721
9564
  publicUrl: userlandProjectUrl(ref, userland.UserlandProjectPage.build(b.sha), args.api)
8722
9565
  }));
8723
- const result = {
9566
+ 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;
9567
+ const channelsUnavailable = channelsRes.ok ? null : `channels.json unreadable: ${channelsRes.message}`;
9568
+ const buildsUnavailable = buildsRes.ok ? null : `builds/index.json unreadable: ${buildsRes.message}`;
9569
+ return envelope({
8724
9570
  ok: true,
8725
- workspace: ref.workspace,
8726
- project: ref.project,
8727
- ...meta?.name ? {
8728
- name: meta.name
8729
- } : {},
8730
- ...meta?.visibility ? {
8731
- visibility: meta.visibility
8732
- } : {},
8733
- channels: channelsWithUrls,
8734
- recentBuilds: buildsWithUrls,
8735
- registryUrl: channelsUrl,
8736
- buildsPageUrl,
8737
- ...publicProjectUrl ? {
8738
- publicProjectUrl
8739
- } : {},
8740
- ...publicProjectUrl ? {
8741
- publicUrlNote: 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."
8742
- } : {},
8743
- message: promotable.length > 0 || recentBuilds.length > 0 ? `Promotable shas: channels currently pin ${promotable.length > 0 ? promotable.join(", ") : "none"}; recent builds add ${recentBuilds.filter((b)=>"success" === b.status).map((b)=>b.sha).join(", ") || "none"}. Use one of these as buildId/buildSha for promote/deploy/publish.` : `No channels or builds are recorded on the registry yet for ${ref.workspace}/${ref.project}. Push a commit to produce a build, then check ${buildsPageUrl}.`
8744
- };
8745
- if (!channelsRes.ok) result.channelsUnavailable = `channels.json unreadable: ${channelsRes.message}`;
8746
- if (!buildsRes.ok) result.buildsUnavailable = `builds/index.json unreadable: ${buildsRes.message}`;
8747
- return JSON.stringify(result);
9571
+ command: "extension_release_status",
9572
+ status: "read",
9573
+ value: {
9574
+ workspace: ref.workspace,
9575
+ project: ref.project,
9576
+ ...meta?.name ? {
9577
+ name: meta.name
9578
+ } : {},
9579
+ ...meta?.visibility ? {
9580
+ visibility: meta.visibility
9581
+ } : {},
9582
+ channels: channelsWithUrls,
9583
+ recentBuilds: buildsWithUrls,
9584
+ registryUrl: channelsUrl,
9585
+ buildsPageUrl,
9586
+ ...publicProjectUrl ? {
9587
+ publicProjectUrl
9588
+ } : {}
9589
+ },
9590
+ hint: promotable.length > 0 || recentBuilds.length > 0 ? `Promotable shas: channels currently pin ${promotable.length > 0 ? promotable.join(", ") : "none"}; recent builds add ${recentBuilds.filter((b)=>"success" === b.status).map((b)=>b.sha).join(", ") || "none"}. Use one of these as buildId/buildSha for promote/deploy/publish.` : `No channels or builds are recorded on the registry yet for ${ref.workspace}/${ref.project}. Push a commit to produce a build, then check ${buildsPageUrl}.`,
9591
+ warnings: [
9592
+ publicUrlNote,
9593
+ channelsUnavailable,
9594
+ buildsUnavailable
9595
+ ]
9596
+ });
8748
9597
  }
8749
9598
  const KNOWN_STORES = [
8750
9599
  "chrome",
@@ -8830,19 +9679,22 @@ function normalizeStoresStatus(json) {
8830
9679
  }
8831
9680
  return out;
8832
9681
  }
8833
- function store_status_fail(name, message, extra) {
8834
- return JSON.stringify({
9682
+ function store_status_fail(name, message, status, code, extra) {
9683
+ return envelope({
8835
9684
  ok: false,
9685
+ command: "extension_release_status",
9686
+ status,
9687
+ value: extra ?? {},
8836
9688
  error: {
9689
+ code,
8837
9690
  name,
8838
9691
  message
8839
- },
8840
- ...extra ?? {}
9692
+ }
8841
9693
  });
8842
9694
  }
8843
9695
  async function readStores(args) {
8844
9696
  const ref = resolveProjectRef(args);
8845
- if (!ref) return store_status_fail("StoreStatusInputError", "No project to inspect. Run extension_auth (action: login), which names the project, or pass workspace + project explicitly.");
9697
+ if (!ref) return store_status_fail("StoreStatusInputError", "No project to inspect. Run extension_auth (action: login), which names the project, or pass workspace + project explicitly.", "auth-required", "E_AUTH_REQUIRED");
8846
9698
  const healthUrl = registryFileUrl(ref, "stores/health.json");
8847
9699
  const statusUrl = registryFileUrl(ref, "stores/status.json");
8848
9700
  const submissionsUrl = registryFileUrl(ref, "stores/submissions.json");
@@ -8861,7 +9713,7 @@ async function readStores(args) {
8861
9713
  api: args.api
8862
9714
  })
8863
9715
  ]);
8864
- if (!healthRes.ok && !statusRes.ok && !submissionsRes.ok) return store_status_fail("StoreStatusNotFound", `No store data on the registry for ${ref.workspace}/${ref.project} (${healthUrl} returned ${healthRes.status ?? "no response"}). The project may have no stores configured yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. Configure stores at ${consoleStoresUrl}/new; the console Stores page is the authoritative view: ${consoleStoresUrl}`, {
9716
+ if (!healthRes.ok && !statusRes.ok && !submissionsRes.ok) return store_status_fail("StoreStatusNotFound", `No store data on the registry for ${ref.workspace}/${ref.project} (${healthUrl} returned ${healthRes.status ?? "no response"}). The project may have no stores configured yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. Configure stores at ${consoleStoresUrl}/new; the console Stores page is the authoritative view: ${consoleStoresUrl}`, "unavailable", "E_PLATFORM", {
8865
9717
  workspace: ref.workspace,
8866
9718
  project: ref.project,
8867
9719
  registryUrls: {
@@ -8917,33 +9769,41 @@ async function readStores(args) {
8917
9769
  if (review?.status) tail.push(`review ${review.status}${review.checkedAt ? ` (checked ${review.checkedAt})` : ""}`);
8918
9770
  return tail.length > 0 ? `${head}; ${tail.join("; ")}` : head;
8919
9771
  });
8920
- const result = {
9772
+ const healthUnavailable = healthRes.ok ? null : `stores/health.json unreadable: ${healthRes.message}`;
9773
+ const statusUnavailable = statusRes.ok ? null : `stores/status.json unreadable: ${statusRes.message}`;
9774
+ const submissionsUnavailable = submissionsRes.ok || 404 === submissionsRes.status ? null : `stores/submissions.json unreadable: ${submissionsRes.message}`;
9775
+ return envelope({
8921
9776
  ok: true,
8922
- workspace: ref.workspace,
8923
- project: ref.project,
8924
- stores: rows,
8925
- ...status.lastSubmission ? {
8926
- lastSubmission: status.lastSubmission
8927
- } : {},
8928
- ...status.lastPollAt ? {
8929
- lastPollAt: status.lastPollAt
8930
- } : {},
8931
- registryUrls: {
8932
- health: healthUrl,
8933
- status: statusUrl,
8934
- submissions: submissionsUrl
9777
+ command: "extension_release_status",
9778
+ status: "read",
9779
+ value: {
9780
+ workspace: ref.workspace,
9781
+ project: ref.project,
9782
+ stores: rows,
9783
+ ...status.lastSubmission ? {
9784
+ lastSubmission: status.lastSubmission
9785
+ } : {},
9786
+ ...status.lastPollAt ? {
9787
+ lastPollAt: status.lastPollAt
9788
+ } : {},
9789
+ registryUrls: {
9790
+ health: healthUrl,
9791
+ status: statusUrl,
9792
+ submissions: submissionsUrl
9793
+ },
9794
+ consoleStoresUrl
8935
9795
  },
8936
- consoleStoresUrl,
8937
- message: `${summaryParts.join(". ")}. This is the registry's recorded state (submissions and the review poller write it); the store dashboards are authoritative and may be ahead of it.`
8938
- };
8939
- if (!healthRes.ok) result.healthUnavailable = `stores/health.json unreadable: ${healthRes.message}`;
8940
- if (!statusRes.ok) result.statusUnavailable = `stores/status.json unreadable: ${statusRes.message}`;
8941
- if (!submissionsRes.ok && 404 !== submissionsRes.status) result.submissionsUnavailable = `stores/submissions.json unreadable: ${submissionsRes.message}`;
8942
- return JSON.stringify(result);
9796
+ hint: `${summaryParts.join(". ")}. This is the registry's recorded state (submissions and the review poller write it); the store dashboards are authoritative and may be ahead of it.`,
9797
+ warnings: [
9798
+ healthUnavailable,
9799
+ statusUnavailable,
9800
+ submissionsUnavailable
9801
+ ]
9802
+ });
8943
9803
  }
8944
9804
  const release_status_schema = {
8945
9805
  name: "extension_release_status",
8946
- description: "Read where a project stands on extension.dev, from the public registry (registry.extension.land). Read-only: it dispatches nothing and promotes nothing. include:'releases' returns the release channels (channel -> promoted build sha), recent builds, and a public build-page URL for each, which is how you find a valid sha for extension_release_promote, extension_submit, or extension_publish. include:'stores' returns the per-store picture after an extension_submit (chrome, firefox, edge, safari): configured or not, the last credential health check, the last recorded submission, and the latest review status, read from stores/health.json, stores/status.json and stores/submissions.json. Both are included by default. Defaults to the logged-in project (extension_auth); pass workspace + project to read another. Private projects work when your stored login covers them. Registry state can lag the store dashboards by up to a polling interval.",
9806
+ description: "Read where a project stands on extension.dev, from the public registry (registry.extension.land). This is read-only: it dispatches nothing and promotes nothing. Pass include:'releases' for the release channels (channel to promoted build sha), recent builds, and a public build-page URL for each, which is how you find a valid sha for extension_release_promote, extension_submit or extension_publish. Pass include:'stores' for the per-store picture after an extension_submit (chrome, firefox, edge, safari): configured or not, the last credential health check, the last recorded submission, and the latest review status, read from stores/health.json, stores/status.json and stores/submissions.json. Both are included by default. This defaults to the logged-in project (extension_auth); pass workspace and project to read another. Private projects work when your stored login covers them. Registry state can lag the store dashboards by up to a polling interval.",
8947
9807
  inputSchema: {
8948
9808
  type: "object",
8949
9809
  properties: {
@@ -8979,13 +9839,16 @@ function parse(raw) {
8979
9839
  try {
8980
9840
  return JSON.parse(raw);
8981
9841
  } catch {
8982
- return {
9842
+ return envelopeObject({
8983
9843
  ok: false,
9844
+ command: "extension_release_status",
9845
+ status: "unavailable",
8984
9846
  error: {
9847
+ code: "E_PARSE",
8985
9848
  name: "ParseError",
8986
9849
  message: raw
8987
9850
  }
8988
- };
9851
+ });
8989
9852
  }
8990
9853
  }
8991
9854
  async function release_status_handler(args) {
@@ -9006,14 +9869,19 @@ async function release_status_handler(args) {
9006
9869
  releases,
9007
9870
  stores
9008
9871
  ].filter(Boolean);
9009
- return JSON.stringify({
9010
- ok: sections.some((section)=>true === section.ok),
9011
- ...releases ? {
9012
- releases
9013
- } : {},
9014
- ...stores ? {
9015
- stores
9016
- } : {}
9872
+ const ok = sections.some((section)=>true === section.ok);
9873
+ return envelope({
9874
+ ok,
9875
+ command: "extension_release_status",
9876
+ status: ok ? "read" : "unavailable",
9877
+ value: {
9878
+ ...releases ? {
9879
+ releases
9880
+ } : {},
9881
+ ...stores ? {
9882
+ stores
9883
+ } : {}
9884
+ }
9017
9885
  });
9018
9886
  }
9019
9887
  function storeMdWarnings(browsers, cwd) {
@@ -9044,7 +9912,7 @@ function storeMdWarnings(browsers, cwd) {
9044
9912
  }
9045
9913
  const submit_schema = {
9046
9914
  name: "extension_submit",
9047
- description: "SUBMIT FOR REVIEW: send a built extension to the Chrome Web Store, Firefox AMO, Edge Add-ons and/or the App Store (Safari) THROUGH extension.dev, which holds your store credentials and dispatches from your project's mirror CI. Store review only. It does NOT push a build to the extension.dev platform and does NOT make a shareable link: that is extension_publish, which is what \"deploy\" or \"ship\" an extension almost always means. Reach for this only when the ask is explicitly a store submission. DEFAULTS TO A DRY RUN that dispatches nothing: the platform verifies auth, project, build and store workflow, and this tool adds each store's credential-health verdict; trust those per-store rows over the platform's bare preflight line, which does not check store health. dryRun:false actually submits, which is irreversible and enters store review. The project comes from your token (extension_auth or EXTENSION_DEV_TOKEN; tokens live at most 7 days, so CI must re-mint from the console's Access tokens page). Store credentials are never arguments and no local file is uploaded. extension_release_status lists valid shas and, after a real submission, reads the recorded outcome and review state.",
9915
+ description: "Submit a built extension for store REVIEW through extension.dev, which holds your store credentials and dispatches from your project's mirror CI: the Chrome Web Store, Firefox AMO, Edge Add-ons and the App Store (Safari). This is store review only. It does not push a build to the extension.dev platform, and it does not make a shareable link: that is extension_publish, which is what \"deploy\" or \"ship\" an extension almost always means. Reach for this only when the ask is explicitly a store submission. It defaults to a dry run that dispatches nothing: the platform verifies auth, project, build and store workflow, and this tool adds each store's credential-health verdict. Trust those per-store rows over the platform's bare preflight line, which does not check store health. Pass dryRun:false to actually submit, which is irreversible and enters store review. The project comes from your token (extension_auth or EXTENSION_DEV_TOKEN; tokens live at most 7 days, so CI must re-mint from the console's Access tokens page). Store credentials are never arguments, and no local file is uploaded. Call extension_release_status for valid shas, and, after a real submission, for the recorded outcome and review state.",
9048
9916
  inputSchema: {
9049
9917
  type: "object",
9050
9918
  properties: {
@@ -9086,10 +9954,13 @@ const submit_schema = {
9086
9954
  ]
9087
9955
  }
9088
9956
  };
9089
- function submit_fail(name, message) {
9090
- return JSON.stringify({
9957
+ function submit_fail(name, message, status, code) {
9958
+ return envelope({
9091
9959
  ok: false,
9960
+ command: "extension_submit",
9961
+ status,
9092
9962
  error: {
9963
+ code,
9093
9964
  name,
9094
9965
  message
9095
9966
  }
@@ -9097,13 +9968,13 @@ function submit_fail(name, message) {
9097
9968
  }
9098
9969
  async function submit_handler(args) {
9099
9970
  const token = resolveToken();
9100
- if (!token) return submit_fail("SubmitAuthError", "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry).");
9971
+ if (!token) return submit_fail("SubmitAuthError", "No token. Run extension_auth (action: login), or set EXTENSION_DEV_TOKEN (create one in the extension.dev dashboard under project settings -> Access tokens; tokens live at most 7 days, so CI must re-mint before expiry).", "auth-required", "E_AUTH_REQUIRED");
9101
9972
  const browsers = (Array.isArray(args.browsers) ? args.browsers : []).map((b)=>String(b).trim().toLowerCase()).filter(Boolean);
9102
- if (0 === browsers.length) return submit_fail("SubmitInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).');
9973
+ if (0 === browsers.length) return submit_fail("SubmitInputError", 'browsers is required (e.g. ["chrome","firefox","edge","safari"]).', "bad-request", "E_BAD_REQUEST");
9103
9974
  const buildSha = String(args.buildSha || "").trim();
9104
- if (!buildSha) return submit_fail("SubmitInputError", "buildSha is required (the built commit to submit).");
9975
+ if (!buildSha) return submit_fail("SubmitInputError", "buildSha is required (the built commit to submit).", "bad-request", "E_BAD_REQUEST");
9105
9976
  const apiCheck = safeApiBase(resolveApiBase(args.api));
9106
- if (!apiCheck.ok) return submit_fail("SubmitConfigError", apiCheck.message);
9977
+ if (!apiCheck.ok) return submit_fail("SubmitConfigError", apiCheck.message, "bad-config", "E_CONFIG");
9107
9978
  const url = `${apiCheck.base}/api/cli/stores/submit`;
9108
9979
  const dryRun = false !== args.dryRun;
9109
9980
  const body = {
@@ -9125,7 +9996,7 @@ async function submit_handler(args) {
9125
9996
  body: JSON.stringify(body)
9126
9997
  });
9127
9998
  } catch (err) {
9128
- return submit_fail("SubmitNetworkError", `Could not reach ${url}: ${err?.message || err}`);
9999
+ return submit_fail("SubmitNetworkError", `Could not reach ${url}: ${err?.message || err}`, "network-failed", "E_NETWORK");
9129
10000
  }
9130
10001
  const text = await res.text();
9131
10002
  let data;
@@ -9136,7 +10007,7 @@ async function submit_handler(args) {
9136
10007
  message: text
9137
10008
  };
9138
10009
  }
9139
- if (!res.ok) return submit_fail("SubmitError", `${dryRun ? "preflight" : "submit"} failed (${res.status}): ${data?.message || text || "unknown error"}`);
10010
+ if (!res.ok) return submit_fail("SubmitError", `${dryRun ? "preflight" : "submit"} failed (${res.status}): ${data?.message || text || "unknown error"}`, "submit-failed", "E_PLATFORM");
9140
10011
  const warnings = Array.isArray(data?.warnings) ? [
9141
10012
  ...data.warnings
9142
10013
  ] : [];
@@ -9146,6 +10017,13 @@ async function submit_handler(args) {
9146
10017
  dryRun,
9147
10018
  ...data
9148
10019
  };
10020
+ delete result.ok;
10021
+ delete result.warnings;
10022
+ delete result.message;
10023
+ let ok = data?.ok !== false;
10024
+ let message = "string" == typeof data?.message ? data.message : "";
10025
+ let channelNote = null;
10026
+ let statusNote = null;
9149
10027
  if (dryRun) {
9150
10028
  const ref = resolveProjectRef();
9151
10029
  const consoleStoresUrl = consoleProjectUrl(ref, "stores", args.api);
@@ -9213,18 +10091,28 @@ async function submit_handler(args) {
9213
10091
  if (actionable.length > 0) summaryParts.push(`Preflight passed for ${actionable.join(", ")}: the platform verified auth, the project, build ${data?.buildId ?? buildSha}, and the store workflow, and the store credentials passed their last health check.`);
9214
10092
  for (const p of blocked)summaryParts.push(`${p.browser}: ${"unknown" === p.configured ? "cannot be verified" : "NOT actionable"} - ${p.reason}`);
9215
10093
  summaryParts.push(storeModeNote);
9216
- result.ok = actionable.length > 0;
10094
+ ok = actionable.length > 0;
9217
10095
  result.preflight = preflight;
9218
10096
  result.channel = resolvedChannel;
9219
10097
  result.channelDefaulted = channelDefaulted;
9220
- if (channelDefaulted) result.channelNote = `channel: ${resolvedChannel} (default)`;
10098
+ if (channelDefaulted) channelNote = `channel: ${resolvedChannel} (default)`;
9221
10099
  result.consoleStoresUrl = consoleStoresUrl;
9222
10100
  if ("string" == typeof data?.message) result.platformMessage = data.message;
9223
- result.message = summaryParts.join(" ");
9224
- }
9225
- if (!dryRun) result.statusNote = "Track this submission with extension_release_status: it reads the recorded outcome, per-store credential health, and review state from the public registry.";
9226
- if (warnings.length > 0) result.warnings = warnings;
9227
- return JSON.stringify(result);
10101
+ message = summaryParts.join(" ");
10102
+ }
10103
+ 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.";
10104
+ return envelope({
10105
+ ok,
10106
+ command: "extension_submit",
10107
+ status: dryRun ? "preflight" : "submitted",
10108
+ value: result,
10109
+ hint: message,
10110
+ warnings: [
10111
+ ...warnings,
10112
+ channelNote,
10113
+ statusNote
10114
+ ]
10115
+ });
9228
10116
  }
9229
10117
  const ENGINE_COMPANION_IDS = new Set([
9230
10118
  "kgdaecdpfkikjncaalnmmnjjfpofkcbl",
@@ -9293,7 +10181,7 @@ function doctor_readReadyContract(projectPath, browser) {
9293
10181
  }
9294
10182
  const doctor_schema = {
9295
10183
  name: "extension_doctor",
9296
- description: "Diagnose a dev session end-to-end: ready contract, dev-server process, control-port agreement, control channel, eval token, executor, browser liveness. Returns one {check, status, detail, remediation?} per leg in dependency order; a 'skip' names the check that blocked it and is NOT a pass. Run this first when any act tool (storage/reload/eval/open) errors unexpectedly. Call with no projectPath for a pre-flight environment check (node, extension CLI, template cache) before any project exists.",
10184
+ 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.",
9297
10185
  inputSchema: {
9298
10186
  type: "object",
9299
10187
  properties: {
@@ -9337,16 +10225,21 @@ async function environmentPreflight() {
9337
10225
  detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (extension_templates will fetch it)"
9338
10226
  });
9339
10227
  const healthy = checks.every((c)=>"fail" !== c.status);
9340
- return JSON.stringify({
9341
- mode: "environment",
9342
- healthy,
9343
- checks,
10228
+ return envelope({
10229
+ ok: healthy,
10230
+ command: doctor_schema.name,
10231
+ status: healthy ? "healthy" : "unhealthy",
10232
+ value: {
10233
+ mode: "environment",
10234
+ checks
10235
+ },
9344
10236
  hint: "Pass projectPath to diagnose a live dev session end-to-end."
9345
10237
  });
9346
10238
  }
9347
10239
  function safeStringify(value) {
9348
10240
  try {
9349
- return JSON.stringify(value) ?? String(value);
10241
+ const text = JSON.stringify(value);
10242
+ return text ?? String(value);
9350
10243
  } catch {
9351
10244
  return String(value);
9352
10245
  }
@@ -9402,7 +10295,8 @@ async function doctor_handler(args) {
9402
10295
  });
9403
10296
  const out = stdout.trim();
9404
10297
  try {
9405
- const checks = JSON.parse(out);
10298
+ const parsed = JSON.parse(out);
10299
+ const checks = isEnvelope(parsed) ? parsed.value?.checks : parsed;
9406
10300
  if (!Array.isArray(checks)) throw new Error("not a check array");
9407
10301
  for (const check of checks){
9408
10302
  if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
@@ -9445,23 +10339,30 @@ async function doctor_handler(args) {
9445
10339
  } : {}
9446
10340
  });
9447
10341
  }
9448
- return JSON.stringify({
9449
- browser,
9450
- ...engineVersion ? {
9451
- engineVersion
9452
- } : {},
9453
- healthy,
9454
- checks
10342
+ return envelope({
10343
+ ok: healthy,
10344
+ command: doctor_schema.name,
10345
+ status: healthy ? "healthy" : "unhealthy",
10346
+ value: {
10347
+ browser,
10348
+ ...engineVersion ? {
10349
+ engineVersion
10350
+ } : {},
10351
+ checks
10352
+ }
9455
10353
  });
9456
10354
  } catch {
9457
10355
  const message = stderr.trim() || `extension exited with code ${code}`;
9458
- return JSON.stringify({
10356
+ return envelope({
9459
10357
  ok: false,
10358
+ command: doctor_schema.name,
10359
+ status: "cli-failed",
9460
10360
  error: {
10361
+ code: "E_CLI",
9461
10362
  name: "CliError",
9462
- message: toMcpSpeak(message),
9463
- hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
9464
- }
10363
+ message: toMcpSpeak(message)
10364
+ },
10365
+ hint: "extension doctor requires a recent extension CLI, the project's local install may predate it."
9465
10366
  });
9466
10367
  }
9467
10368
  }
@@ -9478,7 +10379,7 @@ function wait_isAlive(pid) {
9478
10379
  }
9479
10380
  const wait_schema = {
9480
10381
  name: "extension_wait",
9481
- description: "Wait for a running dev or start session to be ready. Polls the ready.json contract and reports compiled (the compiler finished), browserAttached (the runtime executor connected), and guestLoaded (the browser's OWN target list shows your extension). guestLoaded is the trustworthy load signal: it catches a silently rejected --load-extension that leaves ready.json stamped attached with empty logs; it is null when it could not be checked (no CDP port, e.g. a gecko session). Every result reports budgetMs and elapsedMs; on status:'timeout' call again to keep waiting on the same contract. In a noBrowser session it returns as soon as the compile lands instead of waiting for a browser that will never attach. Ports come from the contract, so they match what the server actually bound.",
10382
+ description: "Wait for a running dev or start session to be ready. This polls the ready.json contract and reports compiled (the compiler finished), browserAttached (the runtime executor connected), and guestLoaded (the browser's own target list shows your extension). Read guestLoaded as the trustworthy load signal: it catches a silently rejected --load-extension that leaves ready.json stamped attached with empty logs. It is null when it could not be checked, for example a gecko session with no CDP port. Every result reports budgetMs and elapsedMs; on status 'timeout', call again to keep waiting on the same contract. In a noBrowser session this returns as soon as the compile lands, instead of waiting for a browser that will never attach. Ports come from the contract, so they match what the server actually bound.",
9482
10383
  inputSchema: {
9483
10384
  type: "object",
9484
10385
  properties: {
@@ -9516,31 +10417,42 @@ async function wait_handler(args) {
9516
10417
  const contract = JSON.parse(raw);
9517
10418
  lastContractStatus = contract.status;
9518
10419
  if ("ready" === contract.status) {
9519
- if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return JSON.stringify({
10420
+ if ("number" == typeof contract.pid && !wait_isAlive(contract.pid)) return envelope({
10421
+ ok: false,
10422
+ command: wait_schema.name,
9520
10423
  status: "stale",
9521
- message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead, the session exited. Restart with extension_dev; extension_doctor will confirm.`,
9522
- browser: contract.browser,
9523
- pid: contract.pid,
9524
- budgetMs,
9525
- elapsedMs: Date.now() - start
10424
+ error: {
10425
+ code: "E_STALE_CONTRACT",
10426
+ message: `ready.json reports ready but its dev-server pid ${contract.pid} is dead, the session exited. Restart with extension_dev; extension_doctor will confirm.`
10427
+ },
10428
+ value: {
10429
+ browser: contract.browser,
10430
+ pid: contract.pid,
10431
+ budgetMs,
10432
+ elapsedMs: Date.now() - start
10433
+ }
9526
10434
  });
9527
10435
  const attached = "attached" === contract.runtime || "string" == typeof contract.executorAttachedAt;
9528
- if (!attached && buildOnly) return JSON.stringify({
10436
+ if (!attached && buildOnly) return envelope({
10437
+ ok: true,
10438
+ command: wait_schema.name,
9529
10439
  status: "ready",
9530
- buildOnly: true,
9531
- compiled: true,
9532
- browserAttached: false,
9533
- message: "Build-only session (noBrowser): the extension compiled and the dev server is live, but no browser was launched, so browserAttached will never become true. Do not call extension_wait again to wait for a browser. The control verbs (storage/reload/open/dom_snapshot/eval) need a live browser and will not work against this session.",
9534
- command: contract.command,
9535
- browser: contract.browser,
9536
- port: contract.port,
9537
- pid: contract.pid,
9538
- distPath: contract.distPath,
9539
- manifestPath: contract.manifestPath,
9540
- compiledAt: contract.compiledAt,
9541
- startedAt: contract.startedAt,
9542
- budgetMs,
9543
- elapsedMs: Date.now() - start
10440
+ value: {
10441
+ buildOnly: true,
10442
+ compiled: true,
10443
+ browserAttached: false,
10444
+ sessionCommand: contract.command,
10445
+ browser: contract.browser,
10446
+ port: contract.port,
10447
+ pid: contract.pid,
10448
+ distPath: contract.distPath,
10449
+ manifestPath: contract.manifestPath,
10450
+ compiledAt: contract.compiledAt,
10451
+ startedAt: contract.startedAt,
10452
+ budgetMs,
10453
+ elapsedMs: Date.now() - start
10454
+ },
10455
+ hint: "Build-only session (noBrowser): the extension compiled and the dev server is live, but no browser was launched, so browserAttached will never become true. Do not call extension_wait again to wait for a browser. The control verbs (storage/reload/open/dom_snapshot/eval) need a live browser and will not work against this session."
9544
10456
  });
9545
10457
  if (!attached) {
9546
10458
  await new Promise((r)=>setTimeout(r, pollInterval));
@@ -9552,72 +10464,98 @@ async function wait_handler(args) {
9552
10464
  const warnings = [];
9553
10465
  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.`);
9554
10466
  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.");
9555
- return JSON.stringify({
10467
+ return envelope({
10468
+ ok: true,
10469
+ command: wait_schema.name,
9556
10470
  status: "ready",
9557
- compiled: true,
9558
- browserAttached: true,
9559
- guestLoaded: guestCheck.checked ? guestCheck.loaded : null,
9560
- ...guestCheck.checked ? {
9561
- guestIds: guestCheck.guestIds
9562
- } : {
9563
- guestLoadNote: guestCheck.reason
10471
+ value: {
10472
+ compiled: true,
10473
+ browserAttached: true,
10474
+ guestLoaded: guestCheck.checked ? guestCheck.loaded : null,
10475
+ ...guestCheck.checked ? {
10476
+ guestIds: guestCheck.guestIds
10477
+ } : {
10478
+ guestLoadNote: guestCheck.reason
10479
+ },
10480
+ sessionCommand: contract.command,
10481
+ browser: contract.browser,
10482
+ port: contract.port,
10483
+ pid: contract.pid,
10484
+ distPath: contract.distPath,
10485
+ manifestPath: contract.manifestPath,
10486
+ compiledAt: contract.compiledAt,
10487
+ startedAt: contract.startedAt,
10488
+ budgetMs,
10489
+ elapsedMs: Date.now() - start,
10490
+ ...runtimeErrors.length ? {
10491
+ runtimeErrors
10492
+ } : {}
9564
10493
  },
9565
- command: contract.command,
9566
- browser: contract.browser,
9567
- port: contract.port,
9568
- pid: contract.pid,
9569
- distPath: contract.distPath,
9570
- manifestPath: contract.manifestPath,
9571
- compiledAt: contract.compiledAt,
9572
- startedAt: contract.startedAt,
9573
- budgetMs,
9574
- elapsedMs: Date.now() - start,
9575
- ...runtimeErrors.length ? {
9576
- runtimeErrors
9577
- } : {},
9578
- ...warnings.length ? {
9579
- warning: warnings.join(" ")
9580
- } : {}
10494
+ warnings
9581
10495
  });
9582
10496
  }
9583
- if ("error" === contract.status) return JSON.stringify({
9584
- status: "error",
9585
- message: contract.message,
9586
- errors: contract.errors,
9587
- code: contract.code,
9588
- browser: contract.browser,
9589
- budgetMs,
9590
- elapsedMs: Date.now() - start
10497
+ if ("error" === contract.status) return envelope({
10498
+ ok: false,
10499
+ command: wait_schema.name,
10500
+ status: "contract-error",
10501
+ error: {
10502
+ code: contract.code ? `E_${String(contract.code).toUpperCase()}` : "E_CONTRACT_ERROR",
10503
+ message: contract.message ?? "The dev server stamped its ready contract with an error.",
10504
+ contractCode: contract.code
10505
+ },
10506
+ value: {
10507
+ errors: contract.errors,
10508
+ browser: contract.browser,
10509
+ budgetMs,
10510
+ elapsedMs: Date.now() - start
10511
+ }
9591
10512
  });
9592
10513
  } catch {}
9593
10514
  await new Promise((resolve)=>setTimeout(resolve, pollInterval));
9594
10515
  }
9595
- if (sawCompiledButUnattached) return JSON.stringify({
10516
+ if (sawCompiledButUnattached) return envelope({
10517
+ ok: false,
10518
+ command: wait_schema.name,
9596
10519
  status: "compiled-not-attached",
9597
- compiled: true,
9598
- browserAttached: false,
9599
- message: `The extension compiled, but the runtime executor never attached within this call's ${budgetMs}ms budget. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`,
9600
- readyPath,
9601
- budgetMs,
9602
- elapsedMs: Date.now() - start,
10520
+ error: {
10521
+ code: "E_NOT_ATTACHED",
10522
+ message: `The extension compiled, but the runtime executor never attached within this call's ${budgetMs}ms budget. The build is fine; the browser side is not connected, so extension_eval/storage/reload/open will fail with "no executor connected".`
10523
+ },
10524
+ value: {
10525
+ compiled: true,
10526
+ browserAttached: false,
10527
+ readyPath,
10528
+ budgetMs,
10529
+ elapsedMs: Date.now() - start
10530
+ },
9603
10531
  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."
9604
10532
  });
9605
- return JSON.stringify({
10533
+ return envelope({
10534
+ ok: false,
10535
+ command: wait_schema.name,
9606
10536
  status: "timeout",
9607
- compiled: false,
9608
- browserAttached: false,
9609
- message: "starting" === lastContractStatus ? `Not ready after ${budgetMs}ms this call: the dev server stamped its contract (status: starting) but the first compile has not landed yet.` : `Not ready after ${budgetMs}ms this call: no ready contract was observed at ${readyPath}, so neither the compile nor a browser attach has been seen.`,
9610
- readyPath,
9611
- budgetMs,
9612
- elapsedMs: Date.now() - start,
9613
- clamped: clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : void 0,
10537
+ error: {
10538
+ code: "E_WAIT_TIMEOUT",
10539
+ message: "starting" === lastContractStatus ? `Not ready after ${budgetMs}ms this call: the dev server stamped its contract (status: starting) but the first compile has not landed yet.` : `Not ready after ${budgetMs}ms this call: no ready contract was observed at ${readyPath}, so neither the compile nor a browser attach has been seen.`
10540
+ },
10541
+ value: {
10542
+ compiled: false,
10543
+ browserAttached: false,
10544
+ readyPath,
10545
+ budgetMs,
10546
+ elapsedMs: Date.now() - start
10547
+ },
10548
+ warnings: [
10549
+ clamped ? `requested ${requested}ms was clamped to ${SAFE_CEILING_MS}ms to stay under the MCP client request timeout` : null
10550
+ ],
9614
10551
  hint: "Still building, call extension_wait again to keep waiting (it resumes polling the same contract). If it never readies, check the dev process with extension_doctor."
9615
10552
  });
9616
10553
  }
10554
+ const add_feature_COMMAND = "extension_add_feature";
9617
10555
  const EXAMPLES_TREE_BASE = `https://github.com/extension-js/examples/tree/${PINNED_COMMIT}/examples`;
9618
10556
  const add_feature_schema = {
9619
10557
  name: "extension_add_feature",
9620
- description: "Plan a new feature surface for an existing extension. Returns step-by-step instructions, the manifest additions to make, and reference templates from the extension.dev catalog. Does not modify files; apply the returned plan yourself.",
10558
+ description: "Plan a new feature surface for an existing extension. This returns step-by-step instructions, the manifest additions to make, and reference templates from the extension.dev catalog. It modifies no files: apply the returned plan yourself.",
9621
10559
  inputSchema: {
9622
10560
  type: "object",
9623
10561
  properties: {
@@ -9769,13 +10707,25 @@ async function add_feature_handler(args) {
9769
10707
  const projectPath = node_path.resolve(args.projectPath);
9770
10708
  const srcDir = node_path.join(projectPath, "src");
9771
10709
  const manifestPath = node_path.join(srcDir, "manifest.json");
9772
- if (!node_fs.existsSync(manifestPath)) return JSON.stringify({
9773
- error: `No manifest.json found at ${manifestPath}`,
10710
+ if (!node_fs.existsSync(manifestPath)) return envelope({
10711
+ ok: false,
10712
+ command: add_feature_COMMAND,
10713
+ status: "manifest-not-found",
10714
+ error: {
10715
+ code: "E_MANIFEST_NOT_FOUND",
10716
+ message: `No manifest.json found at ${manifestPath}`
10717
+ },
9774
10718
  hint: "Ensure projectPath points to an extension project root with src/manifest.json"
9775
10719
  });
9776
10720
  const templateSlug = FEATURE_TEMPLATE_MAP[args.feature]?.[framework];
9777
- if (!templateSlug) return JSON.stringify({
9778
- error: `No reference template for feature "${args.feature}" with framework "${framework}"`
10721
+ if (!templateSlug) return envelope({
10722
+ ok: false,
10723
+ command: add_feature_COMMAND,
10724
+ status: "no-reference-template",
10725
+ error: {
10726
+ code: "E_NO_REFERENCE_TEMPLATE",
10727
+ message: `No reference template for feature "${args.feature}" with framework "${framework}"`
10728
+ }
9779
10729
  });
9780
10730
  const template = await getTemplateBySlug(templateSlug);
9781
10731
  const referenceFiles = template?.keyFiles ?? template?.files ?? [];
@@ -9850,29 +10800,40 @@ async function add_feature_handler(args) {
9850
10800
  hint: "Background service worker / script"
9851
10801
  });
9852
10802
  const conflicts = filesToCreate.filter((f)=>node_fs.existsSync(node_path.join(projectPath, f.path)));
9853
- return JSON.stringify({
9854
- feature: args.feature,
9855
- framework,
9856
- referenceTemplate: {
9857
- slug: templateSlug,
9858
- repositoryUrl: `${EXAMPLES_TREE_BASE}/${templateSlug}`,
9859
- referenceFiles: referenceFiles.filter((f)=>f.includes(featureDir) || f.includes("manifest"))
10803
+ const conflictHint = `Warning: ${conflicts.length} file(s) already exist and would be overwritten.`;
10804
+ return envelope({
10805
+ ok: true,
10806
+ command: add_feature_COMMAND,
10807
+ status: conflicts.length ? "planned-with-conflicts" : "planned",
10808
+ value: {
10809
+ feature: args.feature,
10810
+ framework,
10811
+ referenceTemplate: {
10812
+ slug: templateSlug,
10813
+ repositoryUrl: `${EXAMPLES_TREE_BASE}/${templateSlug}`,
10814
+ referenceFiles: referenceFiles.filter((f)=>f.includes(featureDir) || f.includes("manifest"))
10815
+ },
10816
+ manifestUpdates,
10817
+ filesToCreate: filesToCreate.map((f)=>({
10818
+ ...f,
10819
+ exists: node_fs.existsSync(node_path.join(projectPath, f.path))
10820
+ })),
10821
+ conflicts: conflicts.map((c)=>c.path),
10822
+ instructions: [
10823
+ `1. Add these fields to your src/manifest.json:\n${JSON.stringify(manifestUpdates, null, 2)}`,
10824
+ "2. Create the following files in your project:",
10825
+ ...filesToCreate.map((f)=>` - ${f.path} (${f.hint})`),
10826
+ "sidebar" === args.feature ? "3. Add background.ts to handle sidebar open: chromium uses chrome.sidePanel.setPanelBehavior, firefox uses browser.sidebarAction.open()" : "",
10827
+ `4. Reference template source: ${EXAMPLES_TREE_BASE}/${templateSlug}/src`,
10828
+ "5. Run npm run dev to test"
10829
+ ].filter(Boolean)
9860
10830
  },
9861
- manifestUpdates,
9862
- filesToCreate: filesToCreate.map((f)=>({
9863
- ...f,
9864
- exists: node_fs.existsSync(node_path.join(projectPath, f.path))
9865
- })),
9866
- conflicts: conflicts.map((c)=>c.path),
9867
- instructions: [
9868
- `1. Add these fields to your src/manifest.json:\n${JSON.stringify(manifestUpdates, null, 2)}`,
9869
- "2. Create the following files in your project:",
9870
- ...filesToCreate.map((f)=>` - ${f.path} (${f.hint})`),
9871
- "sidebar" === args.feature ? "3. Add background.ts to handle sidebar open: chromium uses chrome.sidePanel.setPanelBehavior, firefox uses browser.sidebarAction.open()" : "",
9872
- `4. Reference template source: ${EXAMPLES_TREE_BASE}/${templateSlug}/src`,
9873
- "5. Run npm run dev to test"
9874
- ].filter(Boolean),
9875
- hint: conflicts.length ? `Warning: ${conflicts.length} file(s) already exist and would be overwritten.` : "No conflicts detected. Safe to create all files."
10831
+ warnings: conflicts.length ? [
10832
+ conflictHint
10833
+ ] : [],
10834
+ ...conflicts.length ? {} : {
10835
+ hint: "No conflicts detected. Safe to create all files."
10836
+ }
9876
10837
  });
9877
10838
  }
9878
10839
  async function requestDeviceCode(args) {
@@ -9969,10 +10930,14 @@ async function pollDeviceToken(args) {
9969
10930
  }
9970
10931
  const FIRST_CALL_BUDGET_MS = 8000;
9971
10932
  const RESUME_BUDGET_MS = 22000;
9972
- function login_fail(name, message) {
9973
- return JSON.stringify({
10933
+ 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).";
10934
+ function login_fail(name, message, status, code) {
10935
+ return envelope({
9974
10936
  ok: false,
10937
+ command: "extension_auth",
10938
+ status,
9975
10939
  error: {
10940
+ code,
9976
10941
  name,
9977
10942
  message
9978
10943
  }
@@ -9980,52 +10945,78 @@ function login_fail(name, message) {
9980
10945
  }
9981
10946
  function success(creds) {
9982
10947
  const expiresAt = creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null;
9983
- return JSON.stringify({
10948
+ return envelope({
9984
10949
  ok: true,
10950
+ command: "extension_auth",
9985
10951
  status: "logged-in",
9986
- workspaceSlug: creds.workspaceSlug,
9987
- projectSlug: creds.projectSlug,
9988
- expiresAt,
9989
- tokenTtlNote: tokenTtlNote(creds.workspaceSlug, creds.projectSlug),
9990
- message: `Logged in to ${creds.workspaceSlug}/${creds.projectSlug}. extension_publish can now use the stored token. The token expires ${expiresAt ?? "within 7 days"}: extension.dev CLI tokens live at most 7 days, so CI must re-mint before then (console: project settings -> Access tokens).`
10952
+ value: {
10953
+ workspaceSlug: creds.workspaceSlug,
10954
+ projectSlug: creds.projectSlug,
10955
+ expiresAt
10956
+ },
10957
+ hint: `Logged in to ${creds.workspaceSlug}/${creds.projectSlug}. extension_publish can now use the stored token. The token expires ${expiresAt ?? "within 7 days"}: extension.dev CLI tokens live at most 7 days, so CI must re-mint before then (console: project settings -> Access tokens).`,
10958
+ warnings: [
10959
+ tokenTtlNote(creds.workspaceSlug, creds.projectSlug)
10960
+ ]
9991
10961
  });
9992
10962
  }
9993
10963
  function login_pending(start) {
9994
10964
  const complete = String(start.verificationUriComplete || "").trim();
9995
10965
  const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
9996
10966
  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.`;
9997
- return JSON.stringify({
10967
+ return envelope({
9998
10968
  ok: false,
9999
- status: "authorization_pending",
10000
- userCode: start.userCode,
10001
- verificationUri: start.verificationUri,
10002
- ...hasCompleteLink ? {
10003
- verificationUriComplete: complete
10004
- } : {},
10005
- deviceCode: start.deviceCode,
10006
- tokenTtlNote: "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).",
10007
- message
10969
+ command: "extension_auth",
10970
+ status: "authorization-pending",
10971
+ error: {
10972
+ code: "E_AUTH_PENDING",
10973
+ message
10974
+ },
10975
+ value: {
10976
+ userCode: start.userCode,
10977
+ verificationUri: start.verificationUri,
10978
+ ...hasCompleteLink ? {
10979
+ verificationUriComplete: complete
10980
+ } : {},
10981
+ deviceCode: start.deviceCode,
10982
+ legacyStatus: "authorization_pending"
10983
+ },
10984
+ hint: message,
10985
+ warnings: [
10986
+ PENDING_TTL_NOTE
10987
+ ]
10008
10988
  });
10009
10989
  }
10010
10990
  function resumePending(deviceCode, verificationUri) {
10011
- return JSON.stringify({
10991
+ 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.`;
10992
+ return envelope({
10012
10993
  ok: false,
10013
- status: "authorization_pending",
10014
- verificationUri,
10015
- deviceCode,
10016
- tokenTtlNote: "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).",
10017
- 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.`
10994
+ command: "extension_auth",
10995
+ status: "authorization-pending",
10996
+ error: {
10997
+ code: "E_AUTH_PENDING",
10998
+ message
10999
+ },
11000
+ value: {
11001
+ verificationUri,
11002
+ deviceCode,
11003
+ legacyStatus: "authorization_pending"
11004
+ },
11005
+ hint: message,
11006
+ warnings: [
11007
+ PENDING_TTL_NOTE
11008
+ ]
10018
11009
  });
10019
11010
  }
10020
11011
  async function loginToProject(args) {
10021
11012
  const project = String(args.project || "").trim();
10022
- if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'.");
11013
+ if (!/^[^/]+\/[^/]+$/.test(project)) return login_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
10023
11014
  const apiBase = resolveApiBase(args.api);
10024
11015
  let config;
10025
11016
  try {
10026
11017
  config = await fetchLoginConfig(apiBase);
10027
11018
  } catch (err) {
10028
- return login_fail("LoginConfigError", err?.message || "Could not load login config.");
11019
+ return login_fail("LoginConfigError", err?.message || "Could not load login config.", "login-failed", "E_AUTH_FAILED");
10029
11020
  }
10030
11021
  if (args.deviceCode) {
10031
11022
  const poll = await pollDeviceToken({
@@ -10037,9 +11028,9 @@ async function loginToProject(args) {
10037
11028
  budgetMs: RESUME_BUDGET_MS
10038
11029
  });
10039
11030
  if (poll.ok) return success(poll.creds);
10040
- if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_auth (action: login) again to restart.");
10041
- if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
10042
- if ("error" === poll.reason) return login_fail("LoginError", poll.message || "Device login failed.");
11031
+ if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired. Run extension_auth (action: login) again to restart.", "login-expired", "E_AUTH_EXPIRED");
11032
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.", "login-denied", "E_AUTH_DENIED");
11033
+ if ("error" === poll.reason) return login_fail("LoginError", poll.message || "Device login failed.", "login-failed", "E_AUTH_FAILED");
10043
11034
  return resumePending(String(args.deviceCode), config.verificationUri);
10044
11035
  }
10045
11036
  let start;
@@ -10050,7 +11041,7 @@ async function loginToProject(args) {
10050
11041
  project
10051
11042
  });
10052
11043
  } catch (err) {
10053
- return login_fail("LoginStartError", err?.message || "Could not start the device flow.");
11044
+ return login_fail("LoginStartError", err?.message || "Could not start the device flow.", "login-failed", "E_AUTH_FAILED");
10054
11045
  }
10055
11046
  const poll = await pollDeviceToken({
10056
11047
  apiBase,
@@ -10061,7 +11052,7 @@ async function loginToProject(args) {
10061
11052
  budgetMs: FIRST_CALL_BUDGET_MS
10062
11053
  });
10063
11054
  if (poll.ok) return success(poll.creds);
10064
- if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.");
11055
+ if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.", "login-denied", "E_AUTH_DENIED");
10065
11056
  return login_pending({
10066
11057
  deviceCode: start.deviceCode,
10067
11058
  userCode: start.userCode,
@@ -10071,10 +11062,12 @@ async function loginToProject(args) {
10071
11062
  }
10072
11063
  async function readIdentity() {
10073
11064
  const creds = readCredentials();
10074
- if (!creds) return JSON.stringify({
11065
+ if (!creds) return envelope({
10075
11066
  ok: true,
11067
+ command: "extension_auth",
10076
11068
  status: "logged-out",
10077
- message: "No stored credentials. Run extension_auth (action: login) to authenticate."
11069
+ value: {},
11070
+ hint: "No stored credentials. Run extension_auth (action: login) to authenticate."
10078
11071
  });
10079
11072
  const now = Math.floor(Date.now() / 1000);
10080
11073
  const expired = Boolean(creds.expiresAt && creds.expiresAt <= now);
@@ -10082,28 +11075,36 @@ async function readIdentity() {
10082
11075
  const effectiveDefaultApi = resolveApiBase();
10083
11076
  const apiDiverges = Boolean(recordedApi) && recordedApi !== effectiveDefaultApi;
10084
11077
  const envTokenSet = Boolean(String(process.env.EXTENSION_DEV_TOKEN || "").trim());
10085
- const messageParts = [];
10086
- messageParts.push(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.`);
10087
- if (apiDiverges) messageParts.push(`This login was minted via ${recordedApi}, but authenticated tools do not read that recorded value: they target ${effectiveDefaultApi} unless given an api argument.`);
10088
- if (envTokenSet) messageParts.push("EXTENSION_DEV_TOKEN is set and takes precedence over this stored login for authenticated tools; this report describes only the stored login.");
10089
- return JSON.stringify({
11078
+ 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.`;
11079
+ const apiDivergesNote = apiDiverges ? `This login was minted via ${recordedApi}, but authenticated tools do not read that recorded value: they target ${effectiveDefaultApi} unless given an api argument.` : null;
11080
+ 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;
11081
+ const message = [
11082
+ identityNote,
11083
+ apiDivergesNote,
11084
+ envTokenNote
11085
+ ].filter(Boolean).join(" ");
11086
+ return envelope({
10090
11087
  ok: true,
11088
+ command: "extension_auth",
10091
11089
  status: expired ? "expired" : "logged-in",
10092
- workspaceSlug: creds.workspaceSlug,
10093
- projectSlug: creds.projectSlug,
10094
- ...recordedApi ? {
10095
- apiRecordedAtLogin: recordedApi
10096
- } : {},
10097
- apiDefault: effectiveDefaultApi,
10098
- provider: creds.provider ?? "extensiondev",
10099
- expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
10100
- expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
10101
- expired,
10102
- ...envTokenSet ? {
10103
- envTokenOverride: true
10104
- } : {},
10105
- tokenTtlNote: tokenTtlNote(creds.workspaceSlug, creds.projectSlug),
10106
- message: messageParts.join(" ")
11090
+ value: {
11091
+ workspaceSlug: creds.workspaceSlug,
11092
+ projectSlug: creds.projectSlug,
11093
+ ...recordedApi ? {
11094
+ apiRecordedAtLogin: recordedApi
11095
+ } : {},
11096
+ apiDefault: effectiveDefaultApi,
11097
+ provider: creds.provider ?? "extensiondev",
11098
+ expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null,
11099
+ expiresInSeconds: creds.expiresAt ? creds.expiresAt - now : null,
11100
+ expired
11101
+ },
11102
+ hint: message,
11103
+ warnings: [
11104
+ tokenTtlNote(creds.workspaceSlug, creds.projectSlug),
11105
+ apiDivergesNote,
11106
+ envTokenNote
11107
+ ]
10107
11108
  });
10108
11109
  }
10109
11110
  async function clearLocalCredentials() {
@@ -10113,18 +11114,20 @@ async function clearLocalCredentials() {
10113
11114
  project: creds.projectSlug
10114
11115
  }, "settings/access-tokens") : null;
10115
11116
  const result = clearCredentials();
10116
- return JSON.stringify({
11117
+ return envelope({
10117
11118
  ok: true,
10118
- cleared: result.cleared,
10119
- ...result.cleared && revokeUrl ? {
10120
- revokeUrl
10121
- } : {},
10122
- message: result.cleared ? revokeUrl ? `Local credentials removed. The token stays valid server-side until it expires; revoke it now at ${revokeUrl} (takes about a minute to propagate).` : "Local credentials removed. The token stays valid server-side until it expires; revoke it from the project's access-tokens page if needed." : "No stored credentials to remove."
11119
+ command: "extension_auth",
11120
+ status: result.cleared ? "logged-out" : "nothing-to-clear",
11121
+ value: {
11122
+ cleared: result.cleared,
11123
+ revokeUrl: result.cleared && revokeUrl ? revokeUrl : null
11124
+ },
11125
+ hint: result.cleared ? revokeUrl ? `Local credentials removed. The token stays valid server-side until it expires; revoke it now at ${revokeUrl} (takes about a minute to propagate).` : "Local credentials removed. The token stays valid server-side until it expires; revoke it from the project's access-tokens page if needed." : "No stored credentials to remove."
10123
11126
  });
10124
11127
  }
10125
11128
  const auth_schema = {
10126
11129
  name: "extension_auth",
10127
- description: "Sign this machine in to extension.dev, report that login, or clear it. action:'status' (default) names the workspace/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. action:'login' is two-phase: 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. action:'logout' deletes the local credentials only; the token stays valid server-side until revoked at the URL the response returns.",
11130
+ 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.",
10128
11131
  inputSchema: {
10129
11132
  type: "object",
10130
11133
  properties: {
@@ -10444,12 +11447,17 @@ async function detectBrowsers(browsers) {
10444
11447
  }
10445
11448
  const available = detected.filter((d)=>"not_found" !== d.source);
10446
11449
  const missing = detected.filter((d)=>"not_found" === d.source);
10447
- return JSON.stringify({
10448
- detected,
10449
- managed,
10450
- summary: {
10451
- available: available.map((d)=>d.browser),
10452
- missing: missing.map((d)=>d.browser)
11450
+ return envelope({
11451
+ ok: true,
11452
+ command: "extension_browsers",
11453
+ status: "detected",
11454
+ value: {
11455
+ detected,
11456
+ managed,
11457
+ summary: {
11458
+ available: available.map((d)=>d.browser),
11459
+ missing: missing.map((d)=>d.browser)
11460
+ }
10453
11461
  },
10454
11462
  hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_browsers with action: "install" to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
10455
11463
  });
@@ -10496,11 +11504,16 @@ async function listManagedBrowsers() {
10496
11504
  });
10497
11505
  }
10498
11506
  }
10499
- return JSON.stringify({
10500
- cacheRoot,
10501
- cacheExists: existsSync(cacheRoot),
10502
- installed,
10503
- availableToInstall: BROWSER_NAMES.filter((b)=>!installed.some((i)=>i.browser === b)),
11507
+ return envelope({
11508
+ ok: true,
11509
+ command: "extension_browsers",
11510
+ status: "listed",
11511
+ value: {
11512
+ cacheRoot,
11513
+ cacheExists: existsSync(cacheRoot),
11514
+ installed,
11515
+ availableToInstall: BROWSER_NAMES.filter((b)=>!installed.some((i)=>i.browser === b))
11516
+ },
10504
11517
  hint: 0 === installed.length ? "No managed browsers found. Use extension_browsers with action: \"install\" to install one, or use a system-installed browser." : `${installed.length} managed browser(s) found. Use extension_browsers with action: "detect" for a full system scan.`
10505
11518
  });
10506
11519
  }
@@ -10510,51 +11523,78 @@ async function installManagedBrowser(browser) {
10510
11523
  await extensionInstall({
10511
11524
  browser
10512
11525
  });
10513
- return JSON.stringify({
11526
+ return envelope({
11527
+ ok: true,
11528
+ command: "extension_browsers",
10514
11529
  status: "installed",
10515
- browser,
10516
- duration: Date.now() - start,
11530
+ value: {
11531
+ browser,
11532
+ duration: Date.now() - start
11533
+ },
10517
11534
  hint: `Browser "${browser}" is now available. Use extension_dev or extension_start with browser: "${browser}".`
10518
11535
  });
10519
11536
  } catch (err) {
10520
- return JSON.stringify({
10521
- status: "error",
10522
- browser,
10523
- message: err instanceof Error ? err.message : String(err),
10524
- duration: Date.now() - start,
11537
+ return envelope({
11538
+ ok: false,
11539
+ command: "extension_browsers",
11540
+ status: "install-failed",
11541
+ value: {
11542
+ browser,
11543
+ duration: Date.now() - start
11544
+ },
11545
+ error: {
11546
+ code: "E_BROWSER_INSTALL",
11547
+ message: err instanceof Error ? err.message : String(err)
11548
+ },
10525
11549
  hint: "edge" === browser ? "Edge installation on Linux may require elevated privileges. Try using Chrome or Chromium instead." : "Check network connectivity and disk space. You can also install browsers manually."
10526
11550
  });
10527
11551
  }
10528
11552
  }
10529
11553
  async function uninstallManagedBrowser(args) {
10530
11554
  const start = Date.now();
10531
- if (!args.browser && !args.all) return JSON.stringify({
10532
- status: "error",
10533
- message: "Provide a browser to remove, or set all: true."
11555
+ if (!args.browser && !args.all) return envelope({
11556
+ ok: false,
11557
+ command: "extension_browsers",
11558
+ status: "bad-request",
11559
+ error: {
11560
+ code: "E_BAD_REQUEST",
11561
+ message: "Provide a browser to remove, or set all: true."
11562
+ }
10534
11563
  });
10535
11564
  try {
10536
11565
  await extensionUninstall({
10537
11566
  browser: args.browser,
10538
11567
  all: args.all
10539
11568
  });
10540
- return JSON.stringify({
11569
+ return envelope({
11570
+ ok: true,
11571
+ command: "extension_browsers",
10541
11572
  status: "uninstalled",
10542
- target: args.all ? "all" : args.browser,
10543
- duration: Date.now() - start,
11573
+ value: {
11574
+ target: args.all ? "all" : args.browser,
11575
+ duration: Date.now() - start
11576
+ },
10544
11577
  hint: 'Use extension_browsers with action: "list" to confirm what remains in the managed cache.'
10545
11578
  });
10546
11579
  } catch (err) {
10547
- return JSON.stringify({
10548
- status: "error",
10549
- target: args.all ? "all" : args.browser,
10550
- message: err instanceof Error ? err.message : String(err),
10551
- duration: Date.now() - start
11580
+ return envelope({
11581
+ ok: false,
11582
+ command: "extension_browsers",
11583
+ status: "uninstall-failed",
11584
+ value: {
11585
+ target: args.all ? "all" : args.browser,
11586
+ duration: Date.now() - start
11587
+ },
11588
+ error: {
11589
+ code: "E_BROWSER_UNINSTALL",
11590
+ message: err instanceof Error ? err.message : String(err)
11591
+ }
10552
11592
  });
10553
11593
  }
10554
11594
  }
10555
11595
  const browsers_schema = {
10556
11596
  name: "extension_browsers",
10557
- description: "Find, install, and remove the browsers extension tooling can launch. action:'detect' (default) scans BOTH system-installed and managed browsers and reports each one's binary path, version, engine, and debugger support. action:'list' reports only the managed cache this tool downloads into, with sizes on disk. action:'install' downloads a managed binary: ~580-625 MB in one blocking call, so allow a generous client timeout. action:'uninstall' removes managed binaries and never touches a system install.",
11597
+ description: "Find, install and remove the browsers Extension.js tooling can launch. Pass action:'detect' (the default) to scan both system-installed and managed browsers, and report each one's binary path, version, engine and debugger support. Pass action:'list' for the managed cache this tool downloads into, with sizes on disk. Pass action:'install' to download a managed binary: 580 to 625 MB in one blocking call, so allow a generous client timeout. Pass action:'uninstall' to remove managed binaries; it never touches a system install.",
10558
11598
  inputSchema: {
10559
11599
  type: "object",
10560
11600
  properties: {
@@ -10594,10 +11634,14 @@ async function browsers_handler(args) {
10594
11634
  const action = args.action ?? "detect";
10595
11635
  if ("list" === action) return listManagedBrowsers();
10596
11636
  if ("install" === action) {
10597
- if (!args.browser) return JSON.stringify({
11637
+ if (!args.browser) return envelope({
10598
11638
  ok: false,
10599
- status: "error",
10600
- message: "action 'install' needs a browser: one of chrome, chromium, edge, firefox."
11639
+ command: "extension_browsers",
11640
+ status: "bad-request",
11641
+ error: {
11642
+ code: "E_BAD_REQUEST",
11643
+ message: "action 'install' needs a browser: one of chrome, chromium, edge, firefox."
11644
+ }
10601
11645
  });
10602
11646
  return installManagedBrowser(args.browser);
10603
11647
  }
@@ -10759,11 +11803,16 @@ function describeToolArgs(inputSchema) {
10759
11803
  };
10760
11804
  }
10761
11805
  function inputValidationError(toolName, issues, inputSchema) {
10762
- return JSON.stringify({
11806
+ return envelope({
10763
11807
  ok: false,
11808
+ command: toolName,
11809
+ status: "invalid-arguments",
10764
11810
  error: {
11811
+ code: "E_INPUT_VALIDATION",
10765
11812
  name: "InputValidationError",
10766
- message: `Invalid arguments for ${toolName}: ${issues.map((i)=>`${i.path}: ${i.message}`).join("; ")}`,
11813
+ message: `Invalid arguments for ${toolName}: ${issues.map((i)=>`${i.path}: ${i.message}`).join("; ")}`
11814
+ },
11815
+ value: {
10767
11816
  issues,
10768
11817
  ...inputSchema ? {
10769
11818
  args: describeToolArgs(inputSchema)
@@ -10826,9 +11875,17 @@ async function startServer() {
10826
11875
  content: [
10827
11876
  {
10828
11877
  type: "text",
10829
- text: JSON.stringify({
10830
- error: `Unknown tool: ${name}`,
10831
- availableTools: tools.map((t)=>t.schema.name)
11878
+ text: envelope({
11879
+ ok: false,
11880
+ command: name,
11881
+ status: "unknown-tool",
11882
+ error: {
11883
+ code: "E_UNKNOWN_TOOL",
11884
+ message: `Unknown tool: ${name}`
11885
+ },
11886
+ value: {
11887
+ availableTools: tools.map((t)=>t.schema.name)
11888
+ }
10832
11889
  })
10833
11890
  }
10834
11891
  ],
@@ -10860,9 +11917,15 @@ async function startServer() {
10860
11917
  content: [
10861
11918
  {
10862
11919
  type: "text",
10863
- text: JSON.stringify({
10864
- error: err instanceof Error ? err.message : String(err),
10865
- tool: name
11920
+ text: envelope({
11921
+ ok: false,
11922
+ command: name,
11923
+ status: "internal-error",
11924
+ error: {
11925
+ code: "E_INTERNAL",
11926
+ name: err instanceof Error ? err.name : "Error",
11927
+ message: err instanceof Error ? err.message : String(err)
11928
+ }
10866
11929
  })
10867
11930
  }
10868
11931
  ],