mirror-mind 0.34.0 → 0.36.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.
Files changed (58) hide show
  1. package/.pi/skills/mm-build/SKILL.md +1 -1
  2. package/.pi/skills/mm-explore/SKILL.md +1 -1
  3. package/.pi/skills/mm-help/SKILL.md +2 -2
  4. package/.pi/skills/mm-soul/SKILL.md +1 -1
  5. package/AGENTS.md +7 -6
  6. package/README.md +5 -6
  7. package/docs/releases/index.md +2 -0
  8. package/docs/releases/v0.35.0.md +199 -0
  9. package/docs/releases/v0.36.0.md +222 -0
  10. package/package.json +2 -4
  11. package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
  12. package/plugins/mirror-mind/agy/pre-invocation.sh +92 -0
  13. package/plugins/mirror-mind/agy/session-start.sh +92 -0
  14. package/plugins/mirror-mind/agy/stop.sh +92 -0
  15. package/plugins/mirror-mind/hooks.json +26 -0
  16. package/plugins/mirror-mind/mcp_config.json +11 -0
  17. package/plugins/mirror-mind/plugin.json +14 -0
  18. package/plugins/mirror-mind/rules/AGENTS.md +189 -0
  19. package/plugins/mirror-mind/skills/mm-build/SKILL.md +1 -1
  20. package/ts/src/conversation/agyTranscript.ts +105 -0
  21. package/ts/src/conversation/commandTurn.ts +1 -1
  22. package/ts/src/conversation/deferredCloseTail.ts +2 -2
  23. package/ts/src/conversation/logger.ts +1 -1
  24. package/ts/src/conversation/runtimePolicy.ts +36 -4
  25. package/ts/src/docs/docsLint.ts +14 -1
  26. package/ts/src/extensions/bindings.ts +53 -2
  27. package/ts/src/extensions/catalogCommands.ts +82 -23
  28. package/ts/src/extensions/catalogWrites.ts +12 -0
  29. package/ts/src/extensions/contextRuntime.ts +36 -1
  30. package/ts/src/extensions/dispatch.ts +43 -10
  31. package/ts/src/extensions/extensionPlugin.ts +325 -0
  32. package/ts/src/extensions/manifest.ts +7 -1
  33. package/ts/src/extensions/settings.ts +147 -0
  34. package/ts/src/frontDoor/cli.ts +16 -1
  35. package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
  36. package/ts/src/frontDoor/runtimeRoute.ts +11 -0
  37. package/ts/src/frontDoor/stdinSource.ts +2 -2
  38. package/ts/src/guards/claudePlugin.ts +91 -0
  39. package/ts/src/guards/packContents.ts +3 -2
  40. package/ts/src/guards/piSourcedSkills.ts +1 -1
  41. package/ts/src/guards/retiredSurfaces.ts +31 -0
  42. package/ts/src/hooks/agy.ts +229 -0
  43. package/ts/src/hooks/claude.ts +6 -0
  44. package/ts/src/hooks/main.ts +23 -16
  45. package/ts/src/hooks/payload.ts +8 -15
  46. package/ts/src/hooks/runtime.ts +11 -10
  47. package/ts/src/runtime/brokenPipe.ts +52 -0
  48. package/ts/src/runtime/diagnose.ts +122 -13
  49. package/ts/src/runtime/extensionPluginWiring.ts +222 -0
  50. package/ts/src/runtime/pluginWiring.ts +168 -29
  51. package/ts/src/runtime/status.ts +28 -1
  52. package/ts/src/runtime/update.ts +25 -11
  53. package/ts/src/runtime/wiring.ts +149 -35
  54. package/.gemini/hooks/log-assistant.sh +0 -85
  55. package/.gemini/hooks/log-user.sh +0 -85
  56. package/.gemini/hooks/session-end.sh +0 -85
  57. package/.gemini/hooks/session-start.sh +0 -85
  58. package/ts/src/hooks/gemini.ts +0 -128
@@ -0,0 +1,325 @@
1
+ // CV21.DS5.US2 — the per-home extensions plugin.
2
+ //
3
+ // A Mirror home's installed extensions reach Claude Code, Codex, Antigravity, and
4
+ // Grok Build as one generated plugin per home, in the canonical plugin's format:
5
+ //
6
+ // <home>/runtime/plugin/
7
+ // .agents/plugins/marketplace.json the Codex marketplace, named for the home
8
+ // mirror-ext-<user>/
9
+ // .claude-plugin/plugin.json name, version, description — no hooks, no server
10
+ // plugin.json the root marker Grok and agy read (DS10.US1)
11
+ // skills/<dir>/SKILL.md each installed extension's `claude` skill
12
+ //
13
+ // DS5.TS1 measured the three shapes DS12.TS5 left open and chose this one: a plugin is
14
+ // listed and found on all four runtimes, needs no manifest rewritten (it carries the
15
+ // `claude` skill the manifests already declare), and installs beside another home's.
16
+ // Its price is that Codex, agy, and Grok copy a plugin, so a changed extension is live
17
+ // only after a reinstall — which is why the version matters: Codex keys its cache by
18
+ // it and `runtime diagnose` compares it.
19
+ //
20
+ // The generator is a pure function from the home's `claude` catalog — the same reader
21
+ // `expose-claude` projects from, so the plugin carries what `expose-claude` exposes —
22
+ // to a sorted file map. The version is `0.1.<generation>`, advanced by one when the
23
+ // tree differs from the one on disk and held when it does not: an identical
24
+ // regeneration must not tell every runtime it is stale. The writer replaces the tree
25
+ // atomically (write `plugin.next`, rename over `plugin`), so Claude Code, which reads
26
+ // the directory in place through a link, never sees a half tree. The tree holds only
27
+ // regular files the generator wrote.
28
+
29
+ import {
30
+ type Dirent,
31
+ existsSync,
32
+ mkdirSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ renameSync,
36
+ rmSync,
37
+ writeFileSync,
38
+ } from "node:fs";
39
+ import { basename, dirname, join } from "node:path";
40
+ import { pythonJsonDumpsIndentedOrdered } from "#util/pyGenerators.ts";
41
+ import { discoverExtensions, extensionsRootForHome, runtimeSkillsRootForHome } from "./catalog.ts";
42
+ import { filesystemSkillDirName } from "./catalogWrites.ts";
43
+
44
+ export const EXTENSION_PLUGIN_SEGMENTS: readonly string[] = ["runtime", "plugin"];
45
+ const MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.json";
46
+ const VERSION_PREFIX = "0.1.";
47
+
48
+ /** The plugin's name, which is also its Codex marketplace's: one per home. */
49
+ export function extensionPluginName(user: string): string {
50
+ return `mirror-ext-${user}`;
51
+ }
52
+
53
+ /** The marketplace root: what `codex plugin marketplace add` takes. */
54
+ export function extensionPluginRoot(mirrorHome: string): string {
55
+ return join(mirrorHome, ...EXTENSION_PLUGIN_SEGMENTS);
56
+ }
57
+
58
+ /** The plugin directory: what Claude Code links, and agy and Grok install. */
59
+ export function extensionPluginDirectory(mirrorHome: string): string {
60
+ return join(extensionPluginRoot(mirrorHome), extensionPluginName(basename(mirrorHome)));
61
+ }
62
+
63
+ /** Relative path → file content, sorted by path. */
64
+ export type PluginFileMap = Readonly<Record<string, string>>;
65
+
66
+ export interface PluginEntry {
67
+ /** The `claude` block's `command_name`, e.g. `ext:review-copy`. */
68
+ readonly commandName: string;
69
+ /** The installed skill file's bytes, copied verbatim: frontmatter is the author's. */
70
+ readonly content: string;
71
+ }
72
+
73
+ export interface PluginPlan {
74
+ readonly name: string;
75
+ readonly version: string;
76
+ readonly generation: number;
77
+ readonly skills: number;
78
+ /** True when the tree differs from `previous` (or there was none). */
79
+ readonly changed: boolean;
80
+ readonly files: PluginFileMap;
81
+ }
82
+
83
+ export interface PreviousPlugin {
84
+ readonly generation: number;
85
+ readonly files: PluginFileMap;
86
+ }
87
+
88
+ function render(user: string, entries: readonly PluginEntry[], generation: number): PluginFileMap {
89
+ const name = extensionPluginName(user);
90
+ const version = `${VERSION_PREFIX}${generation}`;
91
+ const sorted = [...entries].sort((a, b) =>
92
+ a.commandName < b.commandName ? -1 : a.commandName > b.commandName ? 1 : 0,
93
+ );
94
+ const skills = sorted.length;
95
+ const manifest = {
96
+ name,
97
+ version,
98
+ description: `Extensions of the Mirror home ${user}: ${skills} skill${skills === 1 ? "" : "s"}`,
99
+ };
100
+ const marketplace = {
101
+ name,
102
+ owner: { name: "Mirror Mind" },
103
+ plugins: [
104
+ {
105
+ name,
106
+ source: { source: "local", path: `./${name}` },
107
+ description: manifest.description,
108
+ },
109
+ ],
110
+ };
111
+ const files: Record<string, string> = {
112
+ [MARKETPLACE_RELATIVE]: `${pythonJsonDumpsIndentedOrdered(marketplace)}\n`,
113
+ [`${name}/.claude-plugin/plugin.json`]: `${pythonJsonDumpsIndentedOrdered(manifest)}\n`,
114
+ [`${name}/plugin.json`]: `${pythonJsonDumpsIndentedOrdered(manifest)}\n`,
115
+ };
116
+ for (const entry of sorted) {
117
+ files[`${name}/skills/${filesystemSkillDirName(entry.commandName)}/SKILL.md`] = entry.content;
118
+ }
119
+ return Object.fromEntries(Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
120
+ }
121
+
122
+ function sameFiles(a: PluginFileMap, b: PluginFileMap): boolean {
123
+ const keysA = Object.keys(a);
124
+ const keysB = Object.keys(b);
125
+ if (keysA.length !== keysB.length) return false;
126
+ return keysA.every((key) => key in b && a[key] === b[key]);
127
+ }
128
+
129
+ /**
130
+ * The plan: the tree at the previous generation, compared to the previous tree;
131
+ * when it differs, the tree at the next generation. The version appears in both
132
+ * manifests, so a changed tree is re-rendered after the bump.
133
+ */
134
+ export function planExtensionPlugin(input: {
135
+ user: string;
136
+ entries: readonly PluginEntry[];
137
+ previous: PreviousPlugin | null;
138
+ }): PluginPlan {
139
+ const name = extensionPluginName(input.user);
140
+ const skills = input.entries.length;
141
+ if (input.previous === null) {
142
+ const files = render(input.user, input.entries, 1);
143
+ return { name, version: `${VERSION_PREFIX}1`, generation: 1, skills, changed: true, files };
144
+ }
145
+ const held = render(input.user, input.entries, input.previous.generation);
146
+ if (sameFiles(held, input.previous.files)) {
147
+ return {
148
+ name,
149
+ version: `${VERSION_PREFIX}${input.previous.generation}`,
150
+ generation: input.previous.generation,
151
+ skills,
152
+ changed: false,
153
+ files: held,
154
+ };
155
+ }
156
+ const generation = input.previous.generation + 1;
157
+ return {
158
+ name,
159
+ version: `${VERSION_PREFIX}${generation}`,
160
+ generation,
161
+ skills,
162
+ changed: true,
163
+ files: render(input.user, input.entries, generation),
164
+ };
165
+ }
166
+
167
+ /** The tree on disk as a file map with its generation, or null when there is none. */
168
+ export function readExtensionPlugin(mirrorHome: string): PreviousPlugin | null {
169
+ const root = extensionPluginRoot(mirrorHome);
170
+ const manifestPath = join(extensionPluginDirectory(mirrorHome), ".claude-plugin", "plugin.json");
171
+ if (!existsSync(manifestPath)) return null;
172
+ let generation: number;
173
+ try {
174
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
175
+ const version =
176
+ typeof parsed === "object" && parsed !== null
177
+ ? (parsed as Record<string, unknown>).version
178
+ : undefined;
179
+ const match = typeof version === "string" ? /^0\.1\.(\d+)$/.exec(version) : null;
180
+ if (match === null) return null;
181
+ generation = Number(match[1]);
182
+ } catch {
183
+ return null;
184
+ }
185
+ const files: Record<string, string> = {};
186
+ for (const path of walk(root)) {
187
+ files[path.slice(root.length + 1)] = readFileSync(path, "utf8");
188
+ }
189
+ return {
190
+ generation,
191
+ files: Object.fromEntries(
192
+ Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
193
+ ),
194
+ };
195
+ }
196
+
197
+ function walk(root: string): string[] {
198
+ const found: string[] = [];
199
+ let entries: Dirent[];
200
+ try {
201
+ entries = readdirSync(root, { withFileTypes: true });
202
+ } catch {
203
+ return found;
204
+ }
205
+ for (const entry of entries) {
206
+ const path = join(root, entry.name);
207
+ if (entry.isDirectory()) found.push(...walk(path));
208
+ else if (entry.isFile()) found.push(path);
209
+ }
210
+ return found.sort();
211
+ }
212
+
213
+ /**
214
+ * Replace the tree atomically: the map is written to `plugin.next`, then renamed over
215
+ * `plugin`. The path a runtime links or installed from is unchanged; a removed skill's
216
+ * directory is gone because the whole tree is new. Mode 0700, like the rest of the home.
217
+ */
218
+ export function writeExtensionPlugin(mirrorHome: string, files: PluginFileMap): void {
219
+ const root = extensionPluginRoot(mirrorHome);
220
+ const next = `${root}.next`;
221
+ const old = `${root}.old`;
222
+ rmSync(next, { recursive: true, force: true });
223
+ mkdirSync(next, { recursive: true, mode: 0o700 });
224
+ for (const [relative, content] of Object.entries(files)) {
225
+ const path = join(next, relative);
226
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
227
+ writeFileSync(path, content, { encoding: "utf8", mode: 0o600 });
228
+ }
229
+ rmSync(old, { recursive: true, force: true });
230
+ if (existsSync(root)) renameSync(root, old);
231
+ renameSync(next, root);
232
+ rmSync(old, { recursive: true, force: true });
233
+ }
234
+
235
+ export interface ExtensionPluginReport {
236
+ readonly name: string;
237
+ readonly directory: string;
238
+ readonly marketplaceRoot: string;
239
+ readonly version: string;
240
+ readonly skills: number;
241
+ readonly changed: boolean;
242
+ /** Installed extensions that declare no `claude` block, and so are not carried. */
243
+ readonly notCarried: readonly string[];
244
+ }
245
+
246
+ /** The home's `claude` catalog entries that still have a skill file, as plugin entries. */
247
+ export function claudeCatalogEntries(mirrorHome: string): PluginEntry[] | null {
248
+ const catalogPath = join(runtimeSkillsRootForHome(mirrorHome, "claude"), "extensions.json");
249
+ if (!existsSync(catalogPath)) return null;
250
+ let items: unknown[];
251
+ try {
252
+ const parsed: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
253
+ const list =
254
+ typeof parsed === "object" && parsed !== null
255
+ ? (parsed as Record<string, unknown>).extensions
256
+ : undefined;
257
+ items = Array.isArray(list) ? list : [];
258
+ } catch {
259
+ items = [];
260
+ }
261
+ const entries: PluginEntry[] = [];
262
+ for (const item of items) {
263
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
264
+ const record = item as Record<string, unknown>;
265
+ const commandName = record.command_name;
266
+ const skillPath = record.installed_skill_path;
267
+ if (typeof commandName !== "string" || typeof skillPath !== "string") continue;
268
+ if (!existsSync(skillPath)) continue;
269
+ entries.push({ commandName, content: readFileSync(skillPath, "utf8") });
270
+ }
271
+ return entries;
272
+ }
273
+
274
+ /**
275
+ * Regenerate the home's plugin from its `claude` catalog. A home with no catalog has
276
+ * never installed an extension that declares `claude`, and gets no plugin.
277
+ */
278
+ export function regenerateExtensionPlugin(mirrorHome: string): ExtensionPluginReport | null {
279
+ const entries = claudeCatalogEntries(mirrorHome);
280
+ if (entries === null) return null;
281
+ const carried = new Set(readCatalogIds(mirrorHome) ?? []);
282
+ const notCarried = discoverExtensions(extensionsRootForHome(mirrorHome))
283
+ .manifests.map((manifest) => manifest.id)
284
+ .filter((id) => !carried.has(id))
285
+ .sort();
286
+ const plan = planExtensionPlugin({
287
+ user: basename(mirrorHome),
288
+ entries,
289
+ previous: readExtensionPlugin(mirrorHome),
290
+ });
291
+ if (plan.changed || readExtensionPlugin(mirrorHome) === null)
292
+ writeExtensionPlugin(mirrorHome, plan.files);
293
+ return {
294
+ name: plan.name,
295
+ directory: extensionPluginDirectory(mirrorHome),
296
+ marketplaceRoot: extensionPluginRoot(mirrorHome),
297
+ version: plan.version,
298
+ skills: plan.skills,
299
+ changed: plan.changed,
300
+ notCarried,
301
+ };
302
+ }
303
+
304
+ /** The extension ids the home's `claude` catalog names, or null without a catalog. */
305
+ function readCatalogIds(mirrorHome: string): string[] | null {
306
+ const catalogPath = join(runtimeSkillsRootForHome(mirrorHome, "claude"), "extensions.json");
307
+ if (!existsSync(catalogPath)) return null;
308
+ try {
309
+ const parsed: unknown = JSON.parse(readFileSync(catalogPath, "utf8"));
310
+ const list =
311
+ typeof parsed === "object" && parsed !== null
312
+ ? (parsed as Record<string, unknown>).extensions
313
+ : undefined;
314
+ if (!Array.isArray(list)) return [];
315
+ return list
316
+ .map((item) =>
317
+ typeof item === "object" && item !== null && !Array.isArray(item)
318
+ ? (item as Record<string, unknown>).id
319
+ : undefined,
320
+ )
321
+ .filter((id): id is string => typeof id === "string");
322
+ } catch {
323
+ return [];
324
+ }
325
+ }
@@ -31,7 +31,13 @@ import { tablePrefixFor } from "./migrations.ts";
31
31
 
32
32
  const ALLOWED_KINDS = new Set(["prompt-skill", "command-skill"]);
33
33
  const RUNTIME_NAME_RE = /^[a-z][a-z0-9_-]*$/;
34
- const SKILL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
34
+ /**
35
+ * What an extension id may look like. `install` is the authority on ids, so
36
+ * this is THE grammar; the settings reader (CR145) builds a path from an id
37
+ * only after it passes here. Two older copies of the rule still live in
38
+ * `contextRuntime.ts` and `dispatch.ts` -- CR147 reconciles them.
39
+ */
40
+ export const SKILL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
35
41
  const REQUIRED_FIELDS = ["id", "name", "category", "kind", "summary", "runtimes"] as const;
36
42
  const PATH_LIKE_SUFFIXES = new Set([".js", ".mjs", ".cjs", ".py"]);
37
43
 
@@ -0,0 +1,147 @@
1
+ // An extension's installation settings: `<mirror-home>/extensions/<id>.env` (CR145).
2
+ //
3
+ // The `mirror-cli-v1` and `mirror-context-v1` contracts hand a spawned
4
+ // extension the environment the `mirror` process had, and nothing else. That
5
+ // left an extension's own installation settings -- which host, which TLS mode,
6
+ // which certificate on this machine -- with nowhere to live but the login
7
+ // shell, where two of them (`PGSSLMODE`, `NODE_EXTRA_CA_CERTS`) broke every
8
+ // other Postgres client and Node process on the machine. Node reads
9
+ // `NODE_EXTRA_CA_CERTS` before any extension code runs, so only the spawner can
10
+ // set it, and the spawner is Mirror.
11
+ //
12
+ // The file sits BESIDE the installed directory, not inside it, for two reasons
13
+ // measured on the maintainer's machine: `extensions install` rewrites the
14
+ // directory, and in a development home the directory is a symlink into the
15
+ // extension's own git repository, where a `.env` would be one `git add .` away
16
+ // from a push.
17
+ //
18
+ // Precedence is the ecosystem's and Mirror's own (`runtime/config.ts`): a
19
+ // variable already in the environment wins, the file fills the gaps, and the
20
+ // five `MIRROR_*` names the dispatcher owns can never come from the file. The
21
+ // merged environment reaches the one spawned process; `process.env` is read,
22
+ // never written.
23
+
24
+ import { readFileSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { parseEnv } from "node:util";
27
+ import { extensionsRootForHome } from "./catalog.ts";
28
+ import { SKILL_ID_RE } from "./manifest.ts";
29
+
30
+ /**
31
+ * The names the dispatcher sets last for a subcommand, and that a provider
32
+ * receives on stdin rather than in its environment. Dropped from the file at
33
+ * the merge, at both spawn points, so the file can steer neither.
34
+ */
35
+ export const RESERVED_SETTINGS_NAMES: readonly string[] = [
36
+ "MIRROR_HOME",
37
+ "MIRROR_DATABASE_PATH",
38
+ "MIRROR_EXTENSION_ID",
39
+ "MIRROR_EXTENSION_ROOT",
40
+ "MIRROR_TABLE_PREFIX",
41
+ ];
42
+
43
+ export type ExtensionSettings =
44
+ | { kind: "absent"; path: string }
45
+ | {
46
+ kind: "read";
47
+ path: string;
48
+ /** Sorted by name, as `parseEnv` yields them; a duplicate key's last value. */
49
+ variables: Record<string, string>;
50
+ /** The mode, when group or others can read the file; null when only the owner can. */
51
+ exposedMode: string | null;
52
+ }
53
+ | { kind: "unreadable"; path: string; reason: string };
54
+
55
+ export function extensionSettingsPath(mirrorHome: string, extensionId: string): string {
56
+ return join(extensionsRootForHome(mirrorHome), `${extensionId}.env`);
57
+ }
58
+
59
+ /**
60
+ * Read the settings file, as a result and never a throw.
61
+ *
62
+ * An id outside the manifest's grammar is `unreadable` before any path is
63
+ * built: the context runtime takes ids from `_ext_bindings` rows, and a path
64
+ * must not be steerable by database content. "Unreadable" is the fail-closed
65
+ * answer at both call sites -- the dispatcher refuses, the provider reports.
66
+ *
67
+ * A UTF-8 BOM is stripped before parsing: `parseEnv` would otherwise keep it
68
+ * in the first key's name, and the editor that wrote it never shows it.
69
+ */
70
+ export function readExtensionSettings(mirrorHome: string, extensionId: string): ExtensionSettings {
71
+ if (!SKILL_ID_RE.test(extensionId)) {
72
+ return {
73
+ kind: "unreadable",
74
+ path: join(extensionsRootForHome(mirrorHome), "<id>.env"),
75
+ reason: `'${extensionId}' is not a valid extension id`,
76
+ };
77
+ }
78
+ const path = extensionSettingsPath(mirrorHome, extensionId);
79
+ let text: string;
80
+ let mode: number;
81
+ try {
82
+ const stat = statSync(path, { throwIfNoEntry: false });
83
+ if (stat === undefined) return { kind: "absent", path };
84
+ mode = stat.mode;
85
+ text = readFileSync(path, "utf8");
86
+ } catch (error) {
87
+ const code = (error as NodeJS.ErrnoException).code;
88
+ return { kind: "unreadable", path, reason: code ?? (error as Error).message };
89
+ }
90
+ const variables: Record<string, string> = {};
91
+ for (const [key, value] of Object.entries(parseEnv(text.replace(/^\uFEFF/, "")))) {
92
+ if (value !== undefined) variables[key] = value;
93
+ }
94
+ return { kind: "read", path, variables, exposedMode: exposedModeOf(mode) };
95
+ }
96
+
97
+ /**
98
+ * The file's mode as `0644` when group or others can read it, else null.
99
+ * Mode bits mean nothing on Windows, where every file would read as exposed.
100
+ */
101
+ function exposedModeOf(mode: number): string | null {
102
+ if (process.platform === "win32") return null;
103
+ const permissions = mode & 0o777;
104
+ return (permissions & 0o044) === 0 ? null : `0${permissions.toString(8)}`;
105
+ }
106
+
107
+ /**
108
+ * The file UNDER the environment: `{ ...file, ...environment }`, with the
109
+ * reserved names removed from the file first. A key present in the
110
+ * environment -- even as an empty string -- is already set and wins, the rule
111
+ * `loadEnvFileWithoutOverride` applies to the home's own files.
112
+ */
113
+ export function settingsUnderEnvironment(
114
+ environment: NodeJS.ProcessEnv,
115
+ settings: ExtensionSettings,
116
+ ): NodeJS.ProcessEnv {
117
+ if (settings.kind !== "read") return { ...environment };
118
+ const fromFile: Record<string, string> = {};
119
+ for (const [key, value] of Object.entries(settings.variables)) {
120
+ if (RESERVED_SETTINGS_NAMES.includes(key)) continue;
121
+ fromFile[key] = value;
122
+ }
123
+ return { ...fromFile, ...environment };
124
+ }
125
+
126
+ /**
127
+ * The `ext <id>` listing's last line. Keys only, never a value: a key's name
128
+ * says a setting exists, which is what a person checking the file needs; its
129
+ * value is what must not end up in a transcript.
130
+ */
131
+ export function describeExtensionSettings(settings: ExtensionSettings): string {
132
+ switch (settings.kind) {
133
+ case "absent":
134
+ return `settings: ${settings.path} (not present — create it to pass settings to this extension)`;
135
+ case "unreadable":
136
+ return `settings: ${settings.path} (unreadable: ${settings.reason})`;
137
+ case "read": {
138
+ const keys = Object.keys(settings.variables);
139
+ const state = keys.length === 0 ? "no variables" : keys.join(", ");
140
+ const exposure =
141
+ settings.exposedMode === null
142
+ ? ""
143
+ : `; mode ${settings.exposedMode}, readable by other users`;
144
+ return `settings: ${settings.path} (${state}${exposure})`;
145
+ }
146
+ }
147
+ }
@@ -19,7 +19,7 @@
19
19
  // so neither a `bin` shim nor a skill line needs `--env-file` or
20
20
  // `NODE_OPTIONS=--no-warnings`. Importing this module does neither.
21
21
 
22
- import { existsSync, readFileSync } from "node:fs";
22
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
23
23
  import { basename, dirname } from "node:path";
24
24
  import { pathToFileURL } from "node:url";
25
25
  import {
@@ -68,6 +68,7 @@ import {
68
68
  JOURNAL_TRANSPORT,
69
69
  WEEK_PLAN_TRANSPORT,
70
70
  } from "#providers/transport.ts";
71
+ import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
71
72
  import {
72
73
  configFilePath,
73
74
  loadConfiguration,
@@ -769,6 +770,17 @@ async function runExtensionCatalog(argv: readonly string[]): Promise<DispatchOut
769
770
  deps: { nowIso: () => pythonUtcIsoformat() },
770
771
  withReadOnlyDatabase: (run) => withMirrorReadDb(argv, run),
771
772
  withWritableDatabase: (run) => withMirrorWriteDb(argv, run),
773
+ // CV21.DS5.US2: the extensions-plugin readers take their homes and the
774
+ // PATH from the process environment here, and from nowhere else.
775
+ environment: process.env,
776
+ isExecutable: (path) => {
777
+ try {
778
+ accessSync(path, constants.X_OK);
779
+ return true;
780
+ } catch {
781
+ return false;
782
+ }
783
+ },
772
784
  });
773
785
  }
774
786
 
@@ -1871,6 +1883,9 @@ export async function main(rawArgv = process.argv.slice(2)): Promise<number> {
1871
1883
  */
1872
1884
  export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<number> {
1873
1885
  silenceExperimentalWarnings();
1886
+ // Before anything that could write (CR148): `mirror … | head -1` ends
1887
+ // quietly, exit 0, instead of an unhandled EPIPE and a stack trace.
1888
+ exitQuietlyOnBrokenPipe(process.exit);
1874
1889
  loadConfiguration({ entryPath: import.meta.filename });
1875
1890
  return main(rawArgv);
1876
1891
  }
@@ -62,6 +62,8 @@ export interface ExtensionRouteDeps {
62
62
  run: (db: WritableDatabase) => Promise<number> | number,
63
63
  ) => Promise<number>;
64
64
  environment?: NodeJS.ProcessEnv;
65
+ /** Whether a PATH candidate is executable, for the extensions-plugin block (CV21.DS5.US2). */
66
+ isExecutable?: (path: string) => boolean;
65
67
  }
66
68
 
67
69
  /** The `extensions` verbs that need a writable handle; the rest read. */
@@ -140,7 +142,11 @@ function firstPositional(args: readonly string[]): string | null {
140
142
  }
141
143
 
142
144
  function readContextFor(route: ExtensionRouteDeps): CatalogContext {
143
- return { mirrorHome: route.mirrorHome };
145
+ return {
146
+ mirrorHome: route.mirrorHome,
147
+ ...(route.environment ? { env: route.environment } : {}),
148
+ ...(route.isExecutable ? { isExecutable: route.isExecutable } : {}),
149
+ };
144
150
  }
145
151
 
146
152
  function dispatchOptions(route: ExtensionRouteDeps) {
@@ -151,7 +157,7 @@ function dispatchOptions(route: ExtensionRouteDeps) {
151
157
  }
152
158
 
153
159
  function writeContextFor(route: ExtensionRouteDeps, db: WritableDatabase): ExtWriteContext {
154
- return { mirrorHome: route.mirrorHome, db, deps: route.deps };
160
+ return { ...readContextFor(route), db, deps: route.deps };
155
161
  }
156
162
 
157
163
  export async function runExtensionCatalogRoute(
@@ -24,6 +24,7 @@ import {
24
24
  import { channelFor, channelForReport, renderChannel, setChannel } from "#runtime/channel.ts";
25
25
  import {
26
26
  diagnoseRuntime,
27
+ extensionPluginFindings,
27
28
  hookFailureFindings,
28
29
  hookNodeFindings,
29
30
  pluginWiringFindings,
@@ -336,6 +337,16 @@ export async function runRuntimeReadRoute(
336
337
  // after an npm update; Claude Code's link can dangle. Absence is not
337
338
  // drift, so an unwired machine stays green.
338
339
  ...pluginWiringFindings(installRoot, env),
340
+ // CV21.DS5.US2: the home's extensions plugin, as each runtime on the
341
+ // PATH holds it. A stale copy is any version other than the home's.
342
+ ...extensionPluginFindings(report.mirror_home, env, (path) => {
343
+ try {
344
+ accessSync(path, constants.X_OK);
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }),
339
350
  // No catalog provider until DS8: inconclusive, which is also the
340
351
  // oracle's answer when it cannot reach OpenRouter.
341
352
  ...(await probeModelPins(null, "")),
@@ -10,8 +10,8 @@
10
10
  // A one-shot handoff rather than an argument because the hooks run the front
11
11
  // door in-process (`runFrontDoor`), through the same `main(argv)` every other
12
12
  // caller uses, and a payload does not belong in argv: argv is what `ps`
13
- // shows, and what the Gemini hooks already put a prompt into is the exception
14
- // this seam avoids repeating.
13
+ // shows, and what the Gemini CLI hooks (retired by CV21.DS8.US1) put a prompt
14
+ // into is the exception this seam avoids repeating.
15
15
 
16
16
  import { readFileSync } from "node:fs";
17
17