mirror-mind 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +3 -3
  3. package/docs/releases/index.md +2 -0
  4. package/docs/releases/v0.36.0.md +222 -0
  5. package/docs/releases/v0.36.1.md +137 -0
  6. package/package.json +1 -1
  7. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  8. package/plugins/mirror-mind/plugin.json +11 -1
  9. package/plugins/mirror-mind/rules/AGENTS.md +2 -2
  10. package/ts/src/conversation/loggerRuntime.ts +5 -18
  11. package/ts/src/conversation/runtimePolicy.ts +26 -3
  12. package/ts/src/conversation/sessionComposites.ts +5 -7
  13. package/ts/src/conversation/transcriptBackfill.ts +5 -1
  14. package/ts/src/db/migrations.ts +4 -7
  15. package/ts/src/extensions/bindings.ts +53 -2
  16. package/ts/src/extensions/catalogCommands.ts +82 -23
  17. package/ts/src/extensions/catalogWrites.ts +12 -0
  18. package/ts/src/extensions/contextRuntime.ts +36 -1
  19. package/ts/src/extensions/dispatch.ts +43 -10
  20. package/ts/src/extensions/extensionPlugin.ts +325 -0
  21. package/ts/src/extensions/manifest.ts +7 -1
  22. package/ts/src/extensions/settings.ts +147 -0
  23. package/ts/src/frontDoor/cli.ts +16 -1
  24. package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
  25. package/ts/src/frontDoor/runtimeRoute.ts +11 -0
  26. package/ts/src/guards/claudePlugin.ts +24 -7
  27. package/ts/src/guards/retiredSurfaces.ts +42 -5
  28. package/ts/src/hooks/claude.ts +6 -0
  29. package/ts/src/hooks/main.ts +12 -1
  30. package/ts/src/runtime/brokenPipe.ts +52 -0
  31. package/ts/src/runtime/diagnose.ts +122 -13
  32. package/ts/src/runtime/extensionPluginWiring.ts +222 -0
  33. package/ts/src/runtime/pluginWiring.ts +168 -29
  34. package/ts/src/runtime/status.ts +28 -1
  35. package/ts/src/runtime/update.ts +25 -11
  36. package/ts/src/runtime/wiring.ts +135 -3
  37. package/ts/src/conversation/backfill.ts +0 -271
  38. package/ts/src/conversation/sessionImport.ts +0 -90
@@ -22,8 +22,10 @@ import { join } from "node:path";
22
22
  import type { Database, WritableDatabase } from "#db/database.ts";
23
23
  import { PROGRAM } from "#util/program.ts";
24
24
  import type { RenderedCommand } from "./catalog.ts";
25
+ import { readDeclaredCapabilityIds } from "./contextRuntime.ts";
25
26
  import { extensionNotInstalled, installedExtensionDir } from "./dispatch.ts";
26
- import { ExtensionError } from "./errors.ts";
27
+ import { ExtensionError, ExtensionValidationError } from "./errors.ts";
28
+ import { loadExtensionManifest, SKILL_ID_RE } from "./manifest.ts";
27
29
  import { runMigrations } from "./migrations.ts";
28
30
 
29
31
  /** Python `datetime.now(timezone.utc).isoformat()`: `+00:00`, microseconds. */
@@ -49,14 +51,50 @@ function label(target: BindingTarget): string {
49
51
  return target.id ? `${target.kind}/${target.id}` : target.kind;
50
52
  }
51
53
 
52
- /** Port of `_cmd_bind`. */
54
+ /**
55
+ * `_cmd_bind` checked nothing it bound: a missing extension, an undeclared
56
+ * capability, and a persona that does not exist all answered `bound`, and the
57
+ * person learned of the typo when the context they expected never appeared.
58
+ * CR149 diverges on purpose: four checks, outside in, before the one insert,
59
+ * each refusing with one line on stdout at exit 1 -- the shape of the
60
+ * family's other refusals -- and writing no row.
61
+ *
62
+ * The id check comes first and is not cosmetic. `installedExtensionDir` does
63
+ * not normalize (Python's bytes), so `../x` would pass the existence check
64
+ * when `<home>/x/skill.yaml` exists, and the context runtime later builds a
65
+ * path from whatever this row holds.
66
+ */
53
67
  export function runBind(
54
68
  db: WritableDatabase,
69
+ mirrorHome: string,
55
70
  extensionId: string,
56
71
  capabilityId: string,
57
72
  target: BindingTarget,
58
73
  deps: BindingDeps,
59
74
  ): RenderedCommand {
75
+ if (!SKILL_ID_RE.test(extensionId)) {
76
+ return out(`'${extensionId}' is not a valid extension id\n`, 1);
77
+ }
78
+ const extensionDir = installedExtensionDir(mirrorHome, extensionId);
79
+ if (!existsSync(extensionDir)) return extensionNotInstalled(mirrorHome, extensionId);
80
+ // The validator first, as `install` runs it: its message is already the
81
+ // user-facing one. Then the context runtime's reader for the ids.
82
+ try {
83
+ loadExtensionManifest(extensionDir);
84
+ } catch (error) {
85
+ if (!(error instanceof ExtensionValidationError)) throw error;
86
+ return out(`${error.message}\n`, 1);
87
+ }
88
+ const declared = readDeclaredCapabilityIds(extensionDir);
89
+ if (!declared.includes(capabilityId)) {
90
+ const list = declared.length === 0 ? "declares none" : `declared: ${declared.join(", ")}`;
91
+ return out(`extension/${extensionId} declares no capability '${capabilityId}' (${list})\n`, 1);
92
+ }
93
+ if (target.kind !== "global" && !targetExists(db, target.kind, target.id ?? "")) {
94
+ // The fix in the line, as `missing target: pass --persona <id>, ...` does.
95
+ const shows = target.kind === "persona" ? `${PROGRAM} list personas` : `${PROGRAM} journeys`;
96
+ return out(`no ${target.kind} '${target.id ?? ""}' (${shows} shows this home's)\n`, 1);
97
+ }
60
98
  db.prepare(
61
99
  "INSERT OR IGNORE INTO _ext_bindings " +
62
100
  "(extension_id, capability_id, target_kind, target_id, created_at) " +
@@ -65,6 +103,19 @@ export function runBind(
65
103
  return out(`bound ${extensionId}/${capabilityId} -> ${label(target)}\n`);
66
104
  }
67
105
 
106
+ /**
107
+ * The rows a load is later matched against (`selectExtensionBindings`), and
108
+ * nothing else: `layer = 'journey'`, not `journey_path`; exact key, so
109
+ * `Engineer` is not `engineer`. What `bind` accepts is what a load can find.
110
+ */
111
+ function targetExists(db: Database, layer: "persona" | "journey", key: string): boolean {
112
+ return (
113
+ db
114
+ .prepare("SELECT 1 AS found FROM identity WHERE layer = ? AND key = ? LIMIT 1")
115
+ .get(layer, key) !== undefined
116
+ );
117
+ }
118
+
68
119
  /** Port of `_cmd_unbind`. `target_id IS ?` is what makes a global unbind work. */
69
120
  export function runUnbind(
70
121
  db: WritableDatabase,
@@ -17,8 +17,12 @@
17
17
 
18
18
  import { existsSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { join } from "node:path";
20
+ import { basename, join } from "node:path";
21
21
  import type { WritableDatabase } from "#db/database.ts";
22
+ import {
23
+ extensionPluginStates,
24
+ renderExtensionPluginBlock,
25
+ } from "#runtime/extensionPluginWiring.ts";
22
26
  import { PROGRAM } from "#util/program.ts";
23
27
  import {
24
28
  type BindingDeps,
@@ -59,6 +63,8 @@ import {
59
63
  renderSubcommandListing,
60
64
  } from "./dispatch.ts";
61
65
  import { ExtensionValidationError } from "./errors.ts";
66
+ import { regenerateExtensionPlugin } from "./extensionPlugin.ts";
67
+ import { readExtensionSettings } from "./settings.ts";
62
68
 
63
69
  export class UnsupportedCatalogCommandError extends Error {}
64
70
 
@@ -98,6 +104,15 @@ const EXTENSIONS_VERBS = new Set([
98
104
  export interface CatalogContext {
99
105
  /** The resolved Mirror home; `--mirror-home` has already been applied. */
100
106
  readonly mirrorHome: string;
107
+ /**
108
+ * The environment the extensions-plugin readers take their homes and PATH
109
+ * from (CV21.DS5.US2): HOME, CODEX_HOME, GROK_HOME, PATH. A test passes a
110
+ * stand-in; the route passes the process's. Absent, the readers see no
111
+ * runtime and the plugin block is its first line alone.
112
+ */
113
+ readonly env?: NodeJS.ProcessEnv;
114
+ /** Whether a PATH candidate is executable; absent, nothing is. */
115
+ readonly isExecutable?: (path: string) => boolean;
101
116
  }
102
117
 
103
118
  /** The writable half, for the four `ext` verbs that touch the database. */
@@ -201,6 +216,30 @@ function writeContext(context: CatalogContext, command: string): ExtWriteContext
201
216
  * the recorded divergence shape -- the same exit code, one line on stderr --
202
217
  * and, like Python, writes nothing to stdout.
203
218
  */
219
+ /**
220
+ * CV21.DS5.US2: after a write that changed the home's `claude` catalog, the
221
+ * home's extensions plugin is regenerated and the lines a person needs are
222
+ * appended to the report — the runtimes on the PATH that still need a step, or
223
+ * one line saying the plugin is wired everywhere. New lines at the end, never
224
+ * changed ones, so a caller parsing the report sees what it saw before.
225
+ */
226
+ function withExtensionPluginBlock(
227
+ context: CatalogContext,
228
+ rendered: RenderedCommand,
229
+ removed: readonly string[] = [],
230
+ ): RenderedCommand {
231
+ if (rendered.exitCode !== 0) return rendered;
232
+ const plugin = regenerateExtensionPlugin(context.mirrorHome);
233
+ if (plugin === null) return rendered;
234
+ const states = extensionPluginStates(
235
+ plugin,
236
+ context.env ?? {},
237
+ context.isExecutable ?? (() => false),
238
+ );
239
+ const block = renderExtensionPluginBlock(plugin, states, removed);
240
+ return { ...rendered, stdout: `${rendered.stdout}${block.join("\n")}\n` };
241
+ }
242
+
204
243
  function failLoudly(run: () => RenderedCommand): RenderedCommand {
205
244
  try {
206
245
  return run();
@@ -229,15 +268,18 @@ export function runExtensionsCommand(
229
268
  if (options.extensionsRoot === null) return usage("install requires --extensions-root PATH");
230
269
  const write = writeContext(context, "install");
231
270
  return failLoudly(() =>
232
- renderInstallReport(
233
- installExtension({
234
- extensionId: options.positional[1] as string,
235
- sourceRoot: options.extensionsRoot as string,
236
- mirrorHome: write.mirrorHome,
237
- runtime: options.runtime,
238
- db: write.db,
239
- deps: write.deps,
240
- }),
271
+ withExtensionPluginBlock(
272
+ context,
273
+ renderInstallReport(
274
+ installExtension({
275
+ extensionId: options.positional[1] as string,
276
+ sourceRoot: options.extensionsRoot as string,
277
+ mirrorHome: write.mirrorHome,
278
+ runtime: options.runtime,
279
+ db: write.db,
280
+ deps: write.deps,
281
+ }),
282
+ ),
241
283
  ),
242
284
  );
243
285
  }
@@ -252,15 +294,19 @@ export function runExtensionsCommand(
252
294
  // `install` lets the same class escape as a traceback. Same command, two
253
295
  // refusal shapes, measured rather than unified.
254
296
  try {
255
- return renderUninstallReport(
256
- uninstallExtension({
257
- extensionId: options.positional[1] as string,
258
- mirrorHome: write.mirrorHome,
259
- runtime: options.runtime,
260
- db: write.db,
261
- deps: write.deps,
262
- }),
263
- );
297
+ const report = uninstallExtension({
298
+ extensionId: options.positional[1] as string,
299
+ mirrorHome: write.mirrorHome,
300
+ runtime: options.runtime,
301
+ db: write.db,
302
+ deps: write.deps,
303
+ });
304
+ // What the copying runtimes "still have" after the removal: the claude
305
+ // command names this uninstall took out of the catalog.
306
+ const removed = report.removed
307
+ .filter(([runtimeName]) => runtimeName === "claude")
308
+ .flatMap(([, paths]) => paths.map((path) => basename(path)));
309
+ return withExtensionPluginBlock(context, renderUninstallReport(report), removed);
264
310
  } catch (error) {
265
311
  if (!(error instanceof ExtensionValidationError)) throw error;
266
312
  return out(`${error.message}\n`, 1);
@@ -337,7 +383,9 @@ export function runExtensionsCommand(
337
383
  `${stdout}Synced ${synced.length} extension(s) to ${options.targetRoot}`,
338
384
  ...synced.map((item) => ` ${item.command_name} -> ${item.installed_skill_path}`),
339
385
  ];
340
- return out(`${lines.join("\n")}\n`);
386
+ // A verb that regenerates the plugin without installing (plan amendment 6):
387
+ // from the home's own claude catalog, when the home has one.
388
+ return withExtensionPluginBlock(context, out(`${lines.join("\n")}\n`));
341
389
  }
342
390
 
343
391
  /**
@@ -404,7 +452,7 @@ export function runExtCommand(
404
452
  if ("stdout" in parsed) return parsed;
405
453
  const target = { kind: parsed.kind, id: parsed.id };
406
454
  return verb === "bind"
407
- ? runBind(context.db, extensionId, parsed.capabilityId, target, context.deps)
455
+ ? runBind(context.db, home, extensionId, parsed.capabilityId, target, context.deps)
408
456
  : runUnbind(context.db, extensionId, parsed.capabilityId, target);
409
457
  }
410
458
 
@@ -444,13 +492,24 @@ function dispatchOrNotInstalled(
444
492
  if (!existsSync(extensionRoot)) return extensionNotInstalled(mirrorHome, extensionId);
445
493
 
446
494
  const entries = readSubcommandListing(extensionRoot);
447
- if (HELP_FLAGS.has(subcommand)) return out(renderSubcommandListing(extensionId, entries));
495
+ // The listing says what the settings file holds (CR145) on both paths that
496
+ // render it -- a help spelling and an unknown subcommand -- by decision, not
497
+ // by accident: it is the same listing wherever it appears.
498
+ if (HELP_FLAGS.has(subcommand)) {
499
+ return out(
500
+ renderSubcommandListing(extensionId, entries, readExtensionSettings(mirrorHome, extensionId)),
501
+ );
502
+ }
448
503
 
449
504
  const documented = entries.find((entry) => entry.name === subcommand);
450
505
  if (documented === undefined) {
451
506
  return out(
452
507
  `unknown subcommand '${subcommand}' for extension/${extensionId}\n` +
453
- renderSubcommandListing(extensionId, entries),
508
+ renderSubcommandListing(
509
+ extensionId,
510
+ entries,
511
+ readExtensionSettings(mirrorHome, extensionId),
512
+ ),
454
513
  1,
455
514
  );
456
515
  }
@@ -56,6 +56,7 @@ import { readSubcommandListing } from "./dispatch.ts";
56
56
  import { ExtensionError, ExtensionValidationError } from "./errors.ts";
57
57
  import { type ExtensionManifest, loadExtensionManifest } from "./manifest.ts";
58
58
  import { runMigrations } from "./migrations.ts";
59
+ import { extensionSettingsPath } from "./settings.ts";
59
60
 
60
61
  /** Port of `_COPY_IGNORE_PATTERNS`: never shipped inside an installed tree. */
61
62
  const COPY_IGNORE_EXACT = new Set([
@@ -400,6 +401,13 @@ export interface UninstallReport {
400
401
  bindingsRemoved: number;
401
402
  removed: Array<readonly [string, string[]]>;
402
403
  sourceRemoved: boolean;
404
+ /**
405
+ * The settings file (CR145) a full uninstall found and left in place, or
406
+ * null. Kept on purpose -- deleting configuration a person wrote is the
407
+ * worse surprise -- and named on purpose: a credential that outlives the
408
+ * extension it served must not be silent.
409
+ */
410
+ settingsKept: string | null;
403
411
  }
404
412
 
405
413
  /** Port of `uninstall_extension`. */
@@ -461,6 +469,8 @@ export function uninstallExtension(options: {
461
469
  }
462
470
  rmSync(installedDir, { recursive: true, force: true });
463
471
  }
472
+ const settingsPath = extensionSettingsPath(mirrorHome, extensionId);
473
+ const settingsKept = options.runtime === null && existsSync(settingsPath) ? settingsPath : null;
464
474
 
465
475
  return {
466
476
  extensionId,
@@ -468,6 +478,7 @@ export function uninstallExtension(options: {
468
478
  bindingsRemoved,
469
479
  removed,
470
480
  sourceRemoved: options.runtime === null,
481
+ settingsKept,
471
482
  };
472
483
  }
473
484
 
@@ -641,6 +652,7 @@ export function renderUninstallReport(report: UninstallReport): RenderedCommand
641
652
  ` installed: ${report.installedDir}`,
642
653
  ];
643
654
  if (report.sourceRemoved) lines.push(" source tree: removed");
655
+ if (report.settingsKept !== null) lines.push(` settings left in place: ${report.settingsKept}`);
644
656
  if (report.bindingsRemoved) {
645
657
  lines.push(` bindings: ${report.bindingsRemoved} row(s) removed`);
646
658
  }
@@ -3,6 +3,11 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { isAbsolute, relative, resolve } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import type { Database } from "#db/database.ts";
6
+ import {
7
+ type ExtensionSettings,
8
+ readExtensionSettings,
9
+ settingsUnderEnvironment,
10
+ } from "./settings.ts";
6
11
 
7
12
  export const MIRROR_CONTEXT_PROTOCOL = "mirror-context-v1";
8
13
  const DEFAULT_TIMEOUT_MS = 60_000;
@@ -47,6 +52,13 @@ export type ExtensionContextDiagnosticKind =
47
52
  * going dark is visible as a migration state, not as an empty context.
48
53
  */
49
54
  | "no_provider_runtime"
55
+ /**
56
+ * The extension's settings file (CR145) exists and could not be read. Its
57
+ * own kind, not `provider_failed`: months later the Mirror Mode warning
58
+ * must name the cause, and "the provider crashed" and "a file you chmod'ed
59
+ * cannot be read" are different repairs.
60
+ */
61
+ | "settings_unreadable"
50
62
  | "provider_failed"
51
63
  | "provider_timeout"
52
64
  | "invalid_output";
@@ -181,11 +193,17 @@ export function collectExtensionContext(
181
193
  diagnostics.push({ kind: "no_provider_runtime" });
182
194
  continue;
183
195
  }
196
+ const settings = readExtensionSettings(options.mirrorHome, binding.extensionId);
197
+ if (settings.kind === "unreadable") {
198
+ diagnostics.push({ kind: "settings_unreadable" });
199
+ continue;
200
+ }
184
201
  const outcome = invokeProvider(
185
202
  capability.providerRuntime.command,
186
203
  extensionRoot,
187
204
  request,
188
205
  options,
206
+ settings,
189
207
  );
190
208
  if (outcome.diagnostic) {
191
209
  diagnostics.push({ kind: outcome.diagnostic });
@@ -221,6 +239,20 @@ export function readProvidersWithoutRuntime(extensionRoot: string): string[] {
221
239
  }
222
240
  }
223
241
 
242
+ /**
243
+ * The capability ids the manifest declares, for `bind` to check against
244
+ * (CR149). Tolerant like its siblings: an unreadable manifest declares
245
+ * nothing, and `bind` has already run the validator, whose message is the
246
+ * one a person should read for that case.
247
+ */
248
+ export function readDeclaredCapabilityIds(extensionRoot: string): string[] {
249
+ try {
250
+ return readContextManifest(extensionRoot).capabilities.map((capability) => capability.id);
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
224
256
  function readContextManifest(extensionRoot: string): ExtensionManifest {
225
257
  const manifestPath = resolve(extensionRoot, "skill.yaml");
226
258
  const raw = parse(readFileSync(manifestPath, "utf8")) as unknown;
@@ -286,12 +318,15 @@ function invokeProvider(
286
318
  cwd: string,
287
319
  request: ExtensionContextRequest,
288
320
  options: CollectExtensionContextOptions,
321
+ settings: ExtensionSettings,
289
322
  ): { text?: string | null; diagnostic?: ExtensionContextDiagnosticKind } {
290
323
  const result = spawnSync(command[0] as string, command.slice(1), {
291
324
  cwd,
292
325
  encoding: "utf8",
293
326
  input: `${JSON.stringify(request)}\n`,
294
- env: options.environment ?? process.env,
327
+ // The settings file under the environment (CR145): the same rule the
328
+ // dispatcher applies, minus the `MIRROR_*` layer a provider gets on stdin.
329
+ env: settingsUnderEnvironment(options.environment ?? process.env, settings),
295
330
  timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
296
331
  maxBuffer: options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
297
332
  windowsHide: true,
@@ -44,6 +44,12 @@ import { isAbsolute, join, relative, resolve } from "node:path";
44
44
  import { parse } from "yaml";
45
45
  import type { RenderedCommand } from "./catalog.ts";
46
46
  import { tablePrefixFor } from "./migrations.ts";
47
+ import {
48
+ describeExtensionSettings,
49
+ type ExtensionSettings,
50
+ readExtensionSettings,
51
+ settingsUnderEnvironment,
52
+ } from "./settings.ts";
47
53
 
48
54
  export const MIRROR_CLI_PROTOCOL = "mirror-cli-v1";
49
55
 
@@ -189,13 +195,18 @@ function commandStaysInside(extensionRoot: string, command: readonly string[]):
189
195
  * one thing the legacy bridge cannot offer. Argv is appended to the declared
190
196
  * command verbatim, so a declared command sees exactly what a legacy handler
191
197
  * sees.
198
+ *
199
+ * Three layers, lowest first: the extension's settings file (CR145), the
200
+ * environment the `mirror` process had, and the five names Mirror owns. The
201
+ * spread order IS the precedence rule.
192
202
  */
193
203
  function declaredEnvironment(
194
204
  dispatch: ExtensionDispatch,
195
205
  options: DispatchOptions,
206
+ settings: ExtensionSettings,
196
207
  ): NodeJS.ProcessEnv {
197
208
  return {
198
- ...(options.environment ?? process.env),
209
+ ...settingsUnderEnvironment(options.environment ?? process.env, settings),
199
210
  MIRROR_HOME: dispatch.mirrorHome,
200
211
  MIRROR_DATABASE_PATH: options.databasePath,
201
212
  MIRROR_EXTENSION_ID: dispatch.extensionId,
@@ -292,19 +303,29 @@ export function readSubcommandListing(extensionRoot: string): SubcommandEntry[]
292
303
  * this TypeScript's surface from here on -- a recorded divergence, not a
293
304
  * parity failure. Sorted by name, as Python sorted its registry, so the bytes
294
305
  * stay stable across manifest reordering.
306
+ *
307
+ * The last line is the extension's settings file (CR145): its absolute path,
308
+ * in the spelling `extension not installed:` uses, and its state. It is
309
+ * unindented so it reads as a footer, not as a subcommand named `settings:`.
310
+ * Pure: the caller read the file; this function only says what it found.
295
311
  */
296
- export function renderSubcommandListing(extensionId: string, entries: SubcommandEntry[]): string {
312
+ export function renderSubcommandListing(
313
+ extensionId: string,
314
+ entries: SubcommandEntry[],
315
+ settings: ExtensionSettings,
316
+ ): string {
297
317
  const lines = [`=== subcommands of extension/${extensionId} ===`];
298
318
  if (entries.length === 0) {
299
319
  lines.push(" (none declared)");
300
- return `${lines.join("\n")}\n`;
301
- }
302
- const sorted = [...entries].sort((left, right) => (left.name < right.name ? -1 : 1));
303
- for (const entry of sorted) {
304
- const summary = entry.summary ? ` — ${entry.summary}` : "";
305
- const flag = entry.hasRuntime ? "" : " (no runtime declared)";
306
- lines.push(` ${entry.name}${summary}${flag}`);
320
+ } else {
321
+ const sorted = [...entries].sort((left, right) => (left.name < right.name ? -1 : 1));
322
+ for (const entry of sorted) {
323
+ const summary = entry.summary ? ` — ${entry.summary}` : "";
324
+ const flag = entry.hasRuntime ? "" : " (no runtime declared)";
325
+ lines.push(` ${entry.name}${summary}${flag}`);
326
+ }
307
327
  }
328
+ lines.push(describeExtensionSettings(settings));
308
329
  return `${lines.join("\n")}\n`;
309
330
  }
310
331
 
@@ -343,11 +364,23 @@ export function runExtensionSubcommand(
343
364
  }
344
365
  if (!existsSync(dispatch.extensionRoot)) return 1;
345
366
 
367
+ // Read after the id and installed checks, before the spawn: running without
368
+ // the settings the user wrote is the failure the file exists to prevent, so
369
+ // an unreadable file is a refusal, and a refusal starts nothing.
370
+ const settings = readExtensionSettings(dispatch.mirrorHome, dispatch.extensionId);
371
+ if (settings.kind === "unreadable") {
372
+ report(
373
+ `Mirror: extension/${dispatch.extensionId} has a settings file that cannot be read: ` +
374
+ `${settings.path} (${settings.reason}).`,
375
+ );
376
+ return 1;
377
+ }
378
+
346
379
  const [executable, ...head] = dispatch.command;
347
380
  const spawn = options.spawn ?? spawnSync;
348
381
  const result = spawn(executable as string, [...head, ...dispatch.argv], {
349
382
  cwd: dispatch.extensionRoot,
350
- env: declaredEnvironment(dispatch, options),
383
+ env: declaredEnvironment(dispatch, options, settings),
351
384
  // All three streams are the user's: a declared command may read stdin,
352
385
  // which is precisely what the retired bridge could never allow.
353
386
  stdio: "inherit",