mirror-mind 0.35.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.
- package/AGENTS.md +2 -2
- package/README.md +3 -3
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.36.0.md +222 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/plugin.json +11 -1
- package/plugins/mirror-mind/rules/AGENTS.md +2 -2
- package/ts/src/conversation/runtimePolicy.ts +26 -3
- package/ts/src/extensions/bindings.ts +53 -2
- package/ts/src/extensions/catalogCommands.ts +82 -23
- package/ts/src/extensions/catalogWrites.ts +12 -0
- package/ts/src/extensions/contextRuntime.ts +36 -1
- package/ts/src/extensions/dispatch.ts +43 -10
- package/ts/src/extensions/extensionPlugin.ts +325 -0
- package/ts/src/extensions/manifest.ts +7 -1
- package/ts/src/extensions/settings.ts +147 -0
- package/ts/src/frontDoor/cli.ts +16 -1
- package/ts/src/frontDoor/extensionCatalogRoute.ts +8 -2
- package/ts/src/frontDoor/runtimeRoute.ts +11 -0
- package/ts/src/guards/claudePlugin.ts +24 -7
- package/ts/src/hooks/claude.ts +6 -0
- package/ts/src/hooks/main.ts +12 -1
- package/ts/src/runtime/brokenPipe.ts +52 -0
- package/ts/src/runtime/diagnose.ts +122 -13
- package/ts/src/runtime/extensionPluginWiring.ts +222 -0
- package/ts/src/runtime/pluginWiring.ts +168 -29
- package/ts/src/runtime/status.ts +28 -1
- package/ts/src/runtime/update.ts +25 -11
- package/ts/src/runtime/wiring.ts +135 -3
|
@@ -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
|
+
}
|
package/ts/src/frontDoor/cli.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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, "")),
|
|
@@ -193,14 +193,31 @@ export function marketplaceJson(): string {
|
|
|
193
193
|
// (DERIVED from the manifest's entry — one locator, every carrier), and
|
|
194
194
|
// `rules/AGENTS.md` (agy merges `rules/` into the active rule set; the guard
|
|
195
195
|
// comparing committed to generated bytes is the sync with `AGENTS.md`).
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
//
|
|
197
|
+
// The marker is also what GROK reads (CV21.DS10.US1). Grok accepts a root
|
|
198
|
+
// `plugin.json` and the `.claude-plugin/` equivalent, and takes the root one
|
|
199
|
+
// first and alone: with a name-only marker, `grok plugin validate` dropped
|
|
200
|
+
// "MCP servers" from the components it had listed on 1.0.52, `plugin list`
|
|
201
|
+
// reported `version: null`, and no shape of install offered the MCP server
|
|
202
|
+
// (probe on grok 1.0.62, 2026-10-18, story package). So the marker carries
|
|
203
|
+
// the manifest's `version` and `mcpServers`, from the same source; agy 1.4.2
|
|
204
|
+
// warns `unknown field "mcpServers" is ignored` and stages the same tree.
|
|
205
|
+
// Hooks stay out of it: agy reads the root `hooks.json` beside it.
|
|
206
|
+
|
|
207
|
+
/** The native-plugin marker: agy's detection, and the manifest Grok reads. */
|
|
208
|
+
export function buildAgyPluginMarker(version: string): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
name: PLUGIN_NAME,
|
|
211
|
+
description: PLUGIN_DESCRIPTION,
|
|
212
|
+
version,
|
|
213
|
+
mcpServers: {
|
|
214
|
+
[PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
|
|
215
|
+
},
|
|
216
|
+
};
|
|
200
217
|
}
|
|
201
218
|
|
|
202
|
-
export function agyPluginMarkerJson(): string {
|
|
203
|
-
return `${JSON.stringify(buildAgyPluginMarker(), null, 2)}\n`;
|
|
219
|
+
export function agyPluginMarkerJson(version: string): string {
|
|
220
|
+
return `${JSON.stringify(buildAgyPluginMarker(version), null, 2)}\n`;
|
|
204
221
|
}
|
|
205
222
|
|
|
206
223
|
/**
|
|
@@ -335,7 +352,7 @@ export function planGeneratedFiles(repoRoot: string): GeneratedFile[] {
|
|
|
335
352
|
},
|
|
336
353
|
{ relativePath: MARKETPLACE_PATH, content: marketplaceJson() },
|
|
337
354
|
// The agy-native layer (CV21.DS7.US1).
|
|
338
|
-
{ relativePath: join(PLUGIN_DIR, "plugin.json"), content: agyPluginMarkerJson() },
|
|
355
|
+
{ relativePath: join(PLUGIN_DIR, "plugin.json"), content: agyPluginMarkerJson(version) },
|
|
339
356
|
{ relativePath: join(PLUGIN_DIR, "hooks.json"), content: agyHooksJson() },
|
|
340
357
|
{ relativePath: join(PLUGIN_DIR, "mcp_config.json"), content: agyMcpConfigJson() },
|
|
341
358
|
{
|
package/ts/src/hooks/claude.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// for every prompt and the logger decides), a missing session id still fails
|
|
12
12
|
// loud, and every failure is still non-fatal to the user's turn.
|
|
13
13
|
|
|
14
|
+
import { runtimeReadsPromptContext } from "#conversation/runtimePolicy.ts";
|
|
14
15
|
import { resolveDbPath } from "#frontDoor/dbPath.ts";
|
|
15
16
|
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
16
17
|
import { mirrorForm } from "#mirror/mirrorForm.ts";
|
|
@@ -124,6 +125,11 @@ export async function claudeSessionEnd(stdin: string): Promise<number> {
|
|
|
124
125
|
*/
|
|
125
126
|
export async function claudeInject(stdin: string): Promise<number> {
|
|
126
127
|
const payload = parseHookPayload(stdin);
|
|
128
|
+
// CR143: on a runtime that discards what this hook returns (Grok), neither
|
|
129
|
+
// case is worth its load — nor the state read below, nor the "injected"
|
|
130
|
+
// mark, which would say the model received what it did not. Detected the
|
|
131
|
+
// way the sibling hooks detect it, from the payload and the environment.
|
|
132
|
+
if (!runtimeReadsPromptContext(detectHookRuntime(payload.raw))) return 0;
|
|
127
133
|
const sessionId = payload.sessionId;
|
|
128
134
|
|
|
129
135
|
// Case 1: the explicit invocation.
|
package/ts/src/hooks/main.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// why this file must exit 0: the wrappers read any other exit as Node failing.
|
|
36
36
|
|
|
37
37
|
import { pathToFileURL } from "node:url";
|
|
38
|
+
import { exitQuietlyOnBrokenPipe } from "#runtime/brokenPipe.ts";
|
|
38
39
|
import { loadConfiguration, silenceExperimentalWarnings } from "#runtime/config.ts";
|
|
39
40
|
import { agyPreInvocation, agySessionStart, agyStop } from "./agy.ts";
|
|
40
41
|
import { claudeHook } from "./claude.ts";
|
|
@@ -77,8 +78,18 @@ async function dispatch(name: string): Promise<number> {
|
|
|
77
78
|
*/
|
|
78
79
|
export async function runAsEntry(rawArgv = process.argv.slice(2)): Promise<number> {
|
|
79
80
|
silenceExperimentalWarnings();
|
|
80
|
-
loadConfiguration({ entryPath: import.meta.filename });
|
|
81
81
|
const name = rawArgv[0] ?? "";
|
|
82
|
+
// The try/catch below is the last line of defense, and it has one hole: a
|
|
83
|
+
// stream's `error` event is not a throw in that `await` chain. A runtime
|
|
84
|
+
// that gives up on a hook closes its pipe, and until CR148 the hook answered
|
|
85
|
+
// the next write with an unhandled EPIPE, a stack trace into the host's log,
|
|
86
|
+
// and exit 1 -- the one exit code this entry promises never to return. One
|
|
87
|
+
// line in `hooks.log`, then the quiet exit the contract requires.
|
|
88
|
+
exitQuietlyOnBrokenPipe((code) => {
|
|
89
|
+
noteHookFailure(name || "hooks", "stdout closed early");
|
|
90
|
+
process.exit(code);
|
|
91
|
+
});
|
|
92
|
+
loadConfiguration({ entryPath: import.meta.filename });
|
|
82
93
|
try {
|
|
83
94
|
return await dispatch(name);
|
|
84
95
|
} catch (error) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A closed stdout or stderr ends the command quietly (CR148).
|
|
2
|
+
//
|
|
3
|
+
// `mirror ext <id> | head -1` is a shell idiom, not an error: `head`, `tail`,
|
|
4
|
+
// a pager that quit, a `grep -q` that matched -- every one of them closes its
|
|
5
|
+
// read end on purpose. Node ignores SIGPIPE and surfaces the next write as an
|
|
6
|
+
// `error` event on the stream, and with nobody listening that event is an
|
|
7
|
+
// uncaught exception: `Unhandled 'error' event`, `Error: write EPIPE`, a
|
|
8
|
+
// stack, exit 1. Nothing of ours failed; the reader asked us to stop.
|
|
9
|
+
//
|
|
10
|
+
// One listener at each entry (`frontDoor/cli.ts`, `hooks/main.ts`), nothing
|
|
11
|
+
// per route. On EPIPE: exit 0 and write nothing. The exit status follows
|
|
12
|
+
// `npm`, `cargo`, and `kubectl`, not git's 141: under `set -o pipefail`, 141
|
|
13
|
+
// fails a pipeline the person wrote on purpose, and the fix they reach for is
|
|
14
|
+
// `|| true`, which then hides the real failures too.
|
|
15
|
+
//
|
|
16
|
+
// Stop means stop. The exit happens at the next write after the reader
|
|
17
|
+
// closes, and work the command had not done by then -- a commit after a
|
|
18
|
+
// progress line, a log after an `await` -- is abandoned, exactly as SIGPIPE
|
|
19
|
+
// abandons git's. That is the shell's contract, recorded in the CR.
|
|
20
|
+
//
|
|
21
|
+
// Anything that is not EPIPE is rethrown: a real stream failure keeps the
|
|
22
|
+
// trace it had before. On Linux pipes are synchronous and on macOS
|
|
23
|
+
// asynchronous; in both the failure arrives as this event, never as a throw
|
|
24
|
+
// from `write()`. libuv maps ERROR_BROKEN_PIPE to EPIPE on Windows.
|
|
25
|
+
|
|
26
|
+
type Exit = (code: number) => void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install the handler. `exit` is `process.exit` for the CLI; the hook passes
|
|
30
|
+
* one that leaves a line in `hooks.log` first, because the hook's promise
|
|
31
|
+
* that every path returns 0 cannot be kept by its try/catch -- a stream's
|
|
32
|
+
* `error` event is not a throw in that `await` chain.
|
|
33
|
+
*
|
|
34
|
+
* Called once per process by `runAsEntry`; no guard against a second call,
|
|
35
|
+
* because there is no second call, and no test runs an entry in-process.
|
|
36
|
+
*/
|
|
37
|
+
export function exitQuietlyOnBrokenPipe(
|
|
38
|
+
exit: Exit,
|
|
39
|
+
streams: readonly NodeJS.WritableStream[] = [process.stdout, process.stderr],
|
|
40
|
+
): void {
|
|
41
|
+
let exiting = false;
|
|
42
|
+
for (const stream of streams) {
|
|
43
|
+
stream.on("error", (error: NodeJS.ErrnoException) => {
|
|
44
|
+
if (error.code !== "EPIPE") throw error;
|
|
45
|
+
// Both streams on one pipe (`2>&1 | head`) fail together. A real
|
|
46
|
+
// `process.exit` never returns; an injected one must still count once.
|
|
47
|
+
if (exiting) return;
|
|
48
|
+
exiting = true;
|
|
49
|
+
exit(0);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -25,19 +25,28 @@
|
|
|
25
25
|
// inconclusive without a key; the live fetch belongs to DS8.
|
|
26
26
|
|
|
27
27
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { basename, dirname, join } from "node:path";
|
|
29
29
|
import { openDatabaseReadOnly } from "#db/database.ts";
|
|
30
|
+
import {
|
|
31
|
+
type ExtensionPluginReport,
|
|
32
|
+
extensionPluginDirectory,
|
|
33
|
+
extensionPluginRoot,
|
|
34
|
+
readExtensionPlugin,
|
|
35
|
+
} from "#extensions/extensionPlugin.ts";
|
|
30
36
|
import { identityKeyBreaksGrammar, SLUG_KEYED_LAYERS } from "#identity/identityKey.ts";
|
|
31
37
|
import { pyRepr, sortByCodePoint } from "#util/pythonText.ts";
|
|
32
38
|
import { configFilePath } from "./config.ts";
|
|
39
|
+
import { extensionPluginStates } from "./extensionPluginWiring.ts";
|
|
33
40
|
import type { GitWorktreeEntry } from "./git.ts";
|
|
34
41
|
import {
|
|
35
|
-
claudePluginLinkState,
|
|
36
42
|
codexPluginCacheState,
|
|
43
|
+
grokPluginCopyState,
|
|
37
44
|
installedPluginVersion,
|
|
45
|
+
pluginLinkState,
|
|
38
46
|
} from "./pluginWiring.ts";
|
|
47
|
+
|
|
39
48
|
import type { RuntimeStatusReport } from "./status.ts";
|
|
40
|
-
import { CODEX_PLUGIN_ADD_COMMAND } from "./wiring.ts";
|
|
49
|
+
import { CODEX_PLUGIN_ADD_COMMAND, grokPluginReinstallCommand } from "./wiring.ts";
|
|
41
50
|
|
|
42
51
|
export interface DriftFinding {
|
|
43
52
|
code: string;
|
|
@@ -504,16 +513,19 @@ export function diagnoseRuntime(
|
|
|
504
513
|
}
|
|
505
514
|
|
|
506
515
|
/**
|
|
507
|
-
* The plugin wiring findings (CV21.DS4.TS6; the debt US1 carried
|
|
508
|
-
*
|
|
509
|
-
* behind; Claude Code links it in place, so its failure mode is a
|
|
510
|
-
* link
|
|
511
|
-
*
|
|
516
|
+
* The plugin wiring findings (CV21.DS4.TS6; the debt US1 carried; Grok in
|
|
517
|
+
* CV21.DS10.US1). Codex and Grok copy the plugin, so an npm update leaves
|
|
518
|
+
* the copy behind; Claude Code links it in place, so its failure mode is a
|
|
519
|
+
* dangling link, and Grok's rules link fails the same way. Grok adds one
|
|
520
|
+
* failure of its own: a respelt install path keys a SECOND copy, and Grok
|
|
521
|
+
* loads one of the two without saying which. Absence is not drift: no
|
|
522
|
+
* cache, no registry, and no link fire nothing, so an unwired machine stays
|
|
523
|
+
* green.
|
|
512
524
|
*
|
|
513
525
|
* The defaulting happens HERE, from the env the caller hands in — CODEX_HOME
|
|
514
|
-
* else ~/.codex, and ~/.claude — never inside the
|
|
515
|
-
* test is hermetic on a maintainer's machine with
|
|
516
|
-
* (CR123/128/137/142).
|
|
526
|
+
* else ~/.codex, GROK_HOME else ~/.grok, and ~/.claude — never inside the
|
|
527
|
+
* readers, so every reader test is hermetic on a maintainer's machine with
|
|
528
|
+
* real wiring (CR123/128/137/142).
|
|
517
529
|
*/
|
|
518
530
|
export function pluginWiringFindings(
|
|
519
531
|
installRoot: string | null,
|
|
@@ -539,7 +551,7 @@ export function pluginWiringFindings(
|
|
|
539
551
|
}
|
|
540
552
|
|
|
541
553
|
if (home) {
|
|
542
|
-
const link =
|
|
554
|
+
const link = pluginLinkState(join(home, ".claude", "skills", "mirror-mind"));
|
|
543
555
|
if (link.state === "dangling") {
|
|
544
556
|
findings.push({
|
|
545
557
|
code: "claude_plugin_link_dangling",
|
|
@@ -547,7 +559,50 @@ export function pluginWiringFindings(
|
|
|
547
559
|
subject: link.linkPath,
|
|
548
560
|
detail: `the link points at ${link.target ?? "an unreadable target"}, which cannot be read`,
|
|
549
561
|
recommendation:
|
|
550
|
-
'relink it — `ln -s "<pkg>/plugins/mirror-mind" ~/.claude/skills/mirror-mind` — or remove it if Claude Code is no longer wired',
|
|
562
|
+
'relink it — `rm ~/.claude/skills/mirror-mind && ln -s "<pkg>/plugins/mirror-mind" ~/.claude/skills/mirror-mind` — or remove it if Claude Code is no longer wired',
|
|
563
|
+
repair_route: "relink or remove",
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const grokHome = env.GROK_HOME?.trim() || (home ? join(home, ".grok") : null);
|
|
569
|
+
if (grokHome && installedVersion && installRoot) {
|
|
570
|
+
const copy = grokPluginCopyState(grokHome, installedVersion);
|
|
571
|
+
const reinstall = grokPluginReinstallCommand(installRoot);
|
|
572
|
+
if (copy.copies.length > 1) {
|
|
573
|
+
findings.push({
|
|
574
|
+
code: "grok_plugin_copies_duplicated",
|
|
575
|
+
severity: "warning",
|
|
576
|
+
subject: copy.registryPath,
|
|
577
|
+
detail: `${copy.copies.length} copies of the plugin are installed (${copy.copies.map((c) => `${c.key} at ${c.version}`).join(", ")}); Grok loads one of them and does not say which`,
|
|
578
|
+
recommendation: `run \`grok plugin uninstall mirror-mind --confirm\` until \`grok plugin list\` shows none, then \`${reinstall}\` (a plugin installed from a differently spelt path is a second copy, not an update)`,
|
|
579
|
+
repair_route: reinstall,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (copy.state === "behind") {
|
|
583
|
+
findings.push({
|
|
584
|
+
code: "grok_plugin_copy_behind",
|
|
585
|
+
severity: "warning",
|
|
586
|
+
subject:
|
|
587
|
+
copy.copies.length === 1
|
|
588
|
+
? (copy.copies[0]?.path ?? copy.registryPath)
|
|
589
|
+
: copy.registryPath,
|
|
590
|
+
detail: `copied ${copy.newestCopied} < installed ${installedVersion}; Grok runs the copy it made at install`,
|
|
591
|
+
recommendation: `run \`${reinstall}\` to bring the copy level (\`grok plugin update\` leaves a local-path copy as it is, and an install over the same path is refused)`,
|
|
592
|
+
repair_route: reinstall,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (grokHome) {
|
|
597
|
+
const rules = pluginLinkState(join(grokHome, "rules", "mirror-mind.md"));
|
|
598
|
+
if (rules.state === "dangling") {
|
|
599
|
+
findings.push({
|
|
600
|
+
code: "grok_rules_link_dangling",
|
|
601
|
+
severity: "warning",
|
|
602
|
+
subject: rules.linkPath,
|
|
603
|
+
detail: `the link points at ${rules.target ?? "an unreadable target"}, which cannot be read; Grok loads no Operating Instructions and says nothing`,
|
|
604
|
+
recommendation:
|
|
605
|
+
'relink it — `ln -s "<pkg>/AGENTS.md" ~/.grok/rules/mirror-mind.md` — or remove it if Grok Build is no longer wired',
|
|
551
606
|
repair_route: "relink or remove",
|
|
552
607
|
});
|
|
553
608
|
}
|
|
@@ -798,3 +853,57 @@ export function hookNodeFindings(
|
|
|
798
853
|
},
|
|
799
854
|
];
|
|
800
855
|
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The extensions plugin findings (CV21.DS5.US2): the same reading the `extensions`
|
|
859
|
+
* verbs print, as drift. A home with no plugin, a runtime not wired, and a runtime
|
|
860
|
+
* off the PATH are silence; a dangling link, a stale copy (any version other than
|
|
861
|
+
* the home's), and two Grok copies are warnings, each naming its repair.
|
|
862
|
+
*/
|
|
863
|
+
export function extensionPluginFindings(
|
|
864
|
+
mirrorHome: string | null,
|
|
865
|
+
env: NodeJS.ProcessEnv,
|
|
866
|
+
isExecutable: (path: string) => boolean,
|
|
867
|
+
): DriftFinding[] {
|
|
868
|
+
if (mirrorHome === null) return [];
|
|
869
|
+
const previous = readExtensionPlugin(mirrorHome);
|
|
870
|
+
if (previous === null) return [];
|
|
871
|
+
const name = `mirror-ext-${basename(mirrorHome)}`;
|
|
872
|
+
const skills = Object.keys(previous.files).filter((path) => path.endsWith("/SKILL.md")).length;
|
|
873
|
+
const plugin: ExtensionPluginReport = {
|
|
874
|
+
name,
|
|
875
|
+
directory: extensionPluginDirectory(mirrorHome),
|
|
876
|
+
marketplaceRoot: extensionPluginRoot(mirrorHome),
|
|
877
|
+
version: `0.1.${previous.generation}`,
|
|
878
|
+
skills,
|
|
879
|
+
changed: false,
|
|
880
|
+
notCarried: [],
|
|
881
|
+
};
|
|
882
|
+
const findings: DriftFinding[] = [];
|
|
883
|
+
for (const state of extensionPluginStates(plugin, env, isExecutable)) {
|
|
884
|
+
if (state.state === "wired" || state.state === "not_wired") continue;
|
|
885
|
+
const code =
|
|
886
|
+
state.state === "dangling"
|
|
887
|
+
? "ext_plugin_claude_link_dangling"
|
|
888
|
+
: state.state === "duplicated"
|
|
889
|
+
? "ext_plugin_grok_copies_duplicated"
|
|
890
|
+
: state.runtime === "codex"
|
|
891
|
+
? "ext_plugin_codex_cache_stale"
|
|
892
|
+
: state.runtime === "antigravity"
|
|
893
|
+
? "ext_plugin_agy_copy_stale"
|
|
894
|
+
: "ext_plugin_grok_copy_stale";
|
|
895
|
+
const repair = state.steps.join("; then ");
|
|
896
|
+
findings.push({
|
|
897
|
+
code,
|
|
898
|
+
severity: "warning",
|
|
899
|
+
subject: state.subject,
|
|
900
|
+
detail:
|
|
901
|
+
state.state === "stale"
|
|
902
|
+
? `${state.detail}, the home generates ${plugin.version}; ${state.title} runs the copy it made at install`
|
|
903
|
+
: state.detail,
|
|
904
|
+
recommendation: `run \`${repair}\` to bring ${state.title}'s copy of ${plugin.name} level`,
|
|
905
|
+
repair_route: repair,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return findings;
|
|
909
|
+
}
|