mirror-mind 0.32.1 → 0.33.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.
@@ -0,0 +1,166 @@
1
+ // `conversation-logger session-start --fast --welcome --format json` (CV9.E2.S32).
2
+ //
3
+ // The Pi extension used to open a session with three awaited front-door
4
+ // processes in a row -- `session-start --fast`, `welcome`, and
5
+ // `welcome --status-line` -- paying the loader's fixed cost three times
6
+ // before the user could type. They cannot run in parallel: the first WRITES
7
+ // the orientation the other two READ. They can run in one process, as long as
8
+ // the write commits before the reads. This route is that sequencing and
9
+ // nothing else.
10
+ //
11
+ // What it deliberately does not do:
12
+ //
13
+ // * It does not teach `loggerCli.ts` to render a welcome card. That module
14
+ // is the conversation domain; composition across domains is this layer's
15
+ // job.
16
+ // * It does not touch `runWelcomeRoute`'s read-only contract. Both renders
17
+ // run through that route with captured io, AFTER the write handle has
18
+ // closed, so the card and the status line are byte-identical to the
19
+ // standalone commands and never share the live write lock with the
20
+ // card's bounded update check.
21
+ // * It has no text form. The card is free text; the only safe delimiter is
22
+ // a JSON envelope.
23
+ //
24
+ // The envelope is for the extension, not for a skill. A skill that called this
25
+ // would put card text in front of the model through a channel the Ariad
26
+ // surface rules do not cover; `checkSkillCommandParity.ts` refuses the flag in
27
+ // every SKILL.md.
28
+
29
+ import type { WritableDatabase } from "#db/database.ts";
30
+ import { runConversationLoggerRoute } from "./conversationLoggerRoute.ts";
31
+ import { runWelcomeRoute } from "./runtimeRoute.ts";
32
+
33
+ export const SESSION_START_WELCOME_SCHEMA_VERSION = "1.0.0";
34
+
35
+ export interface SessionStartWelcomeEnvelope {
36
+ schemaVersion: typeof SESSION_START_WELCOME_SCHEMA_VERSION;
37
+ /** The logger's stdout lines, joined with `\n`: what `session-start --fast` prints. */
38
+ sessionStart: string;
39
+ /** `mirror welcome`'s stdout minus the trailing newline; `""` when disabled. */
40
+ welcome: string;
41
+ /** `mirror welcome --status-line`'s stdout minus the trailing newline; `""` when empty. */
42
+ statusLine: string;
43
+ }
44
+
45
+ export interface SessionStartWelcomeOutcome {
46
+ exitCode: number;
47
+ /** Content-free category for the front-door log when a render failed after the write. */
48
+ detail?: string;
49
+ }
50
+
51
+ export interface SessionStartWelcomeDeps {
52
+ /** The real write path: opens the live write database, runs `write`, closes it. */
53
+ withMirrorWriteDb: (
54
+ argv: readonly string[],
55
+ write: (db: WritableDatabase, dbPath: string) => Promise<number> | number,
56
+ ) => Promise<number>;
57
+ /**
58
+ * The welcome renderer. Defaults to the real route; the render-failure test
59
+ * injects one that throws. The write path is never injected: it is the real
60
+ * one in every test.
61
+ */
62
+ renderWelcome?: typeof runWelcomeRoute;
63
+ stdout?: (text: string) => void;
64
+ stderr?: (text: string) => void;
65
+ }
66
+
67
+ /** Only the `--fast --welcome` composite answers here; everything else is the plain logger route. */
68
+ export function isSessionStartWelcome(argv: readonly string[]): boolean {
69
+ return (
70
+ argv[0] === "conversation-logger" &&
71
+ argv.includes("session-start") &&
72
+ argv.includes("--welcome")
73
+ );
74
+ }
75
+
76
+ function optionValue(args: readonly string[], name: string): string | null {
77
+ const index = args.indexOf(name);
78
+ if (index === -1) return null;
79
+ return args[index + 1] ?? null;
80
+ }
81
+
82
+ /** The logger's `--mirror-home` reaches the welcome renders too, so both read the same home. */
83
+ function welcomeArgv(argv: readonly string[], statusLine: boolean): string[] {
84
+ const out = ["welcome"];
85
+ if (statusLine) out.push("--status-line");
86
+ const home = optionValue(argv, "--mirror-home");
87
+ if (home !== null) out.push("--mirror-home", home);
88
+ const sessionId = optionValue(argv, "--session-id");
89
+ if (statusLine && sessionId !== null) out.push("--session-id", sessionId);
90
+ return out;
91
+ }
92
+
93
+ /** The logger's own argv: the composite flags removed, `--format`'s value with it. */
94
+ function withoutCompositeFlags(argv: readonly string[]): string[] {
95
+ const out: string[] = [];
96
+ for (let i = 0; i < argv.length; i += 1) {
97
+ const arg = argv[i] as string;
98
+ if (arg === "--welcome") continue;
99
+ if (arg === "--format") {
100
+ i += 1;
101
+ continue;
102
+ }
103
+ out.push(arg);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ function stripTrailingNewline(text: string): string {
109
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
110
+ }
111
+
112
+ export async function runSessionStartWelcomeRoute(
113
+ argv: readonly string[],
114
+ deps: SessionStartWelcomeDeps,
115
+ ): Promise<SessionStartWelcomeOutcome> {
116
+ const stdout = deps.stdout ?? ((text: string) => process.stdout.write(text));
117
+ const stderr = deps.stderr ?? ((text: string) => process.stderr.write(text));
118
+ const renderWelcome = deps.renderWelcome ?? runWelcomeRoute;
119
+
120
+ if (!argv.includes("--fast")) {
121
+ stderr("session-start: --welcome requires --fast (the full session-start runs maintenance)\n");
122
+ return { exitCode: 2 };
123
+ }
124
+ const format = optionValue(argv, "--format");
125
+ if (format !== "json") {
126
+ stderr(
127
+ "session-start: --welcome requires --format json (the card has no safe text delimiter)\n",
128
+ );
129
+ return { exitCode: 2 };
130
+ }
131
+
132
+ // 1. The write, through the real path. Its handle is closed when this returns.
133
+ const loggerArgv = withoutCompositeFlags(argv);
134
+ const sessionStartLines: string[] = [];
135
+ const exitCode = await deps.withMirrorWriteDb(loggerArgv, (db, dbPath) =>
136
+ runConversationLoggerRoute(db, dbPath, loggerArgv, {
137
+ stdout: (line) => sessionStartLines.push(line),
138
+ // The logger's refusals keep going to stderr, as they do standalone.
139
+ stderr: (line) => stderr(`${line}\n`),
140
+ }),
141
+ );
142
+ if (exitCode !== 0) return { exitCode };
143
+
144
+ // 2. The reads, after the write committed and its handle closed. A render
145
+ // failure must not lose the unmute: the envelope still goes out, with the
146
+ // failed field empty, and the category reaches the front-door log.
147
+ let detail: string | undefined;
148
+ const render = (statusLine: boolean): string => {
149
+ let captured = "";
150
+ try {
151
+ renderWelcome(welcomeArgv(argv, statusLine), { stdout: (text) => (captured += text) });
152
+ } catch (error) {
153
+ detail = `render-failed=${error instanceof Error ? error.name : "unknown"}`;
154
+ return "";
155
+ }
156
+ return stripTrailingNewline(captured);
157
+ };
158
+ const envelope: SessionStartWelcomeEnvelope = {
159
+ schemaVersion: SESSION_START_WELCOME_SCHEMA_VERSION,
160
+ sessionStart: sessionStartLines.join("\n"),
161
+ welcome: render(false),
162
+ statusLine: render(true),
163
+ };
164
+ stdout(`${JSON.stringify(envelope)}\n`);
165
+ return detail ? { exitCode: 0, detail } : { exitCode: 0 };
166
+ }
@@ -0,0 +1,37 @@
1
+ // Where a stdin-reading route gets its payload (CV21.DS2.US8).
2
+ //
3
+ // Two of the conversation logger's subcommands read a hook payload from
4
+ // stdin: `user-prompt` and `stop`. Until US8 the Claude hook entries did not
5
+ // touch fd 0 and let the route read it. Now an entry reads the payload first,
6
+ // to detect the runtime it is in (`runtimeDetection.ts`), and fd 0 cannot be
7
+ // read twice. So the entry hands the payload over here, and the route takes
8
+ // it before falling back to fd 0.
9
+ //
10
+ // A one-shot handoff rather than an argument because the hooks run the front
11
+ // door in-process (`runFrontDoor`), through the same `main(argv)` every other
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.
15
+
16
+ import { readFileSync } from "node:fs";
17
+
18
+ let provided: string | null = null;
19
+
20
+ /** Hand a payload to the next stdin-reading route in this process. */
21
+ export function provideStdin(text: string): void {
22
+ provided = text;
23
+ }
24
+
25
+ /** The provided payload, once; else fd 0 to its end; "" when there is none. */
26
+ export function takeStdin(): string {
27
+ if (provided !== null) {
28
+ const text = provided;
29
+ provided = null;
30
+ return text;
31
+ }
32
+ try {
33
+ return readFileSync(0, "utf-8");
34
+ } catch {
35
+ return "";
36
+ }
37
+ }
@@ -66,14 +66,64 @@ export function readVersion(repoRoot: string): string {
66
66
  return version;
67
67
  }
68
68
 
69
+ /**
70
+ * The directories the MCP entry and the plugin's hook wrappers search for
71
+ * `mirror` and `node` when the PATH does not hold them: a GUI launch leaves
72
+ * the npm global bin off it. One list, so `mcpEntry.test.ts` can hold the
73
+ * entry's search equal to the wrappers' (`generate_hook_wrappers.sh`).
74
+ */
75
+ export const GLOBAL_BIN_DIRS = ['"$HOME/.nvm/current/bin"', "/opt/homebrew/bin", "/usr/local/bin"];
76
+
77
+ /**
78
+ * The MCP entry's locator (CV21.DS2.US6).
79
+ *
80
+ * The manifest used to name `${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh`. Claude Code
81
+ * expands that variable; Codex does not expand it in an MCP command (DS12.TS2
82
+ * L6), so the server never started there. The locator is the launcher's
83
+ * search, inline: `MIRROR_BIN`, the PATH, then the global bin directories for
84
+ * `mirror`; `MIRROR_NODE`, the PATH, the directory `mirror` was found in, then
85
+ * the same directories for `node`. A client whose PATH holds neither (a GUI
86
+ * launch) still starts the server, and one with no `mirror` anywhere gets one
87
+ * stderr line naming the install step, and exit 1.
88
+ *
89
+ * Written for what three runtimes do to the string (the US6 spike):
90
+ *
91
+ * * POSIX `sh`: `/bin/sh` is dash on Linux;
92
+ * * no `${...}`: Claude Code expands that form in MCP configuration, and a
93
+ * runtime that did so with an unset name would blank it;
94
+ * * no positional parameter (`$1`, `$@`, ...) and no backtick, for the same
95
+ * reason, and because the text travels through two JSON encoders;
96
+ * * variables named `mmbin`, `mmnode`, `mmdir`: Grok substitutes SET
97
+ * environment variables by name inside MCP args at load time (`$HOME`
98
+ * became the path), so a script variable with a name a person exports
99
+ * would be replaced before the shell ever saw it.
100
+ */
101
+ export const MCP_LOCATOR = [
102
+ 'mmbin=""',
103
+ '[ -n "$MIRROR_BIN" ] && [ -x "$MIRROR_BIN/mirror" ] && mmbin="$MIRROR_BIN/mirror"',
104
+ '[ -n "$mmbin" ] || mmbin="$(command -v mirror 2>/dev/null)"',
105
+ `for mmdir in ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmbin" ] && break; [ -x "$mmdir/mirror" ] && mmbin="$mmdir/mirror"; done`,
106
+ '[ -n "$mmbin" ] || { echo "mirror-mind: mirror not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory." >&2; exit 1; }',
107
+ 'mmnode="$MIRROR_NODE"',
108
+ '[ -n "$mmnode" ] && [ -x "$mmnode" ] || mmnode="$(command -v node 2>/dev/null)"',
109
+ `for mmdir in "$(dirname "$mmbin")" ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmnode" ] && break; [ -x "$mmdir/node" ] && mmnode="$mmdir/node"; done`,
110
+ '[ -n "$mmnode" ] || { echo "mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE." >&2; exit 1; }',
111
+ 'exec "$mmnode" "$mmbin" mcp',
112
+ ].join("; ");
113
+
114
+ /** The `mcpServers` entry: the locator, run by the system shell. */
115
+ export const MCP_ENTRY: { readonly command: string; readonly args: readonly string[] } = {
116
+ command: "/bin/sh",
117
+ args: ["-c", MCP_LOCATOR],
118
+ };
119
+
69
120
  /**
70
121
  * The Claude plugin manifest (minimal; no unsupported keys).
71
122
  *
72
- * Declares the Mirror MCP server so the canonical package carries it. Since
73
- * CV22.DS9.TS2 the entry is the plugin's own launcher rather than an engine,
74
- * so reverting never requires editing a plugin installed inside someone's
75
- * runtime (DS9 decision D5). TS5 removes that launcher's Python branch; US3
76
- * repoints this at the npm entry point.
123
+ * Declares the Mirror MCP server so the canonical package carries it. The
124
+ * entry names no file of the plugin's and no variable a runtime must expand
125
+ * (`MCP_LOCATOR`): Claude Code, Codex, and Grok render it as written, and
126
+ * each starts the server from it (CV21.DS2.US6).
77
127
  */
78
128
  export function buildManifest(version: string): Record<string, unknown> {
79
129
  return {
@@ -82,11 +132,7 @@ export function buildManifest(version: string): Record<string, unknown> {
82
132
  description: PLUGIN_DESCRIPTION,
83
133
  author: { name: PLUGIN_AUTHOR },
84
134
  mcpServers: {
85
- // The placeholder is Claude Code's, expanded when it loads the plugin. A
86
- // JavaScript template would resolve at BUILD time, against this
87
- // repository, shipping an absolute path from the release machine.
88
- // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude expands this at load time, not JS
89
- [PLUGIN_NAME]: { command: "${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh" },
135
+ [PLUGIN_NAME]: { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] },
90
136
  },
91
137
  };
92
138
  }
@@ -56,7 +56,7 @@ export const REQUIRED_FILES: readonly string[] = [
56
56
  ".pi/skills/mm-mirror/SKILL.md",
57
57
  ".claude/hooks/session-start.sh",
58
58
  ".gemini/hooks/session-start.sh",
59
- "plugins/mirror-mind/mcp/launch.sh",
59
+ "plugins/mirror-mind/hooks/hooks.json",
60
60
  "scripts/codex-hooks/session-start.sh",
61
61
  "scripts/codex-mirror.sh",
62
62
  ];
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { resolveDbPath } from "#frontDoor/dbPath.ts";
15
15
  import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
16
+ import { mirrorForm } from "#mirror/mirrorForm.ts";
16
17
  import { markInjected, needsInject, readMirrorState } from "./mirrorState.ts";
17
18
  import { parseHookPayload, readStdin } from "./payload.ts";
18
19
  import {
@@ -22,6 +23,7 @@ import {
22
23
  runFrontDoorQuietly,
23
24
  sessionStartOutput,
24
25
  } from "./runtime.ts";
26
+ import { detectHookRuntime } from "./runtimeDetection.ts";
25
27
 
26
28
  /**
27
29
  * `session-start.sh`: make sure logging is on for this session, and hand
@@ -39,17 +41,49 @@ export async function claudeSessionStart(env: NodeJS.ProcessEnv = process.env):
39
41
  }
40
42
 
41
43
  /**
42
- * `log-user-prompt.sh`: record the user's turn.
44
+ * `log-user-prompt.sh`: record the user's turn, under the runtime's own label.
43
45
  *
44
- * The shell piped stdin into `hook_user_prompt()`. The front door already owns
45
- * that surface -- `conversation-logger user-prompt` is one of its two
46
- * stdin-reading subcommands -- so the payload is handed straight to it rather
47
- * than re-parsed here.
46
+ * The shell piped stdin into `hook_user_prompt()`, and the front door owns
47
+ * that surface: `conversation-logger user-prompt` reads the payload itself.
48
+ * Since CV21.DS2.US8 the entry reads it first, to tell which runtime it is in
49
+ * (`runtimeDetection.ts`), and hands it to the route through the one-shot
50
+ * stdin seam, so fd 0 is read once.
48
51
  */
49
- export async function claudeUserPrompt(): Promise<number> {
50
- // stdin is deliberately NOT consumed here: the route reads it itself, so
51
- // handing it the payload means not reading it first.
52
- await runFrontDoorQuietly("claude:user-prompt", ["conversation-logger", "user-prompt"]);
52
+ export async function claudeUserPrompt(stdin: string): Promise<number> {
53
+ const runtime = detectHookRuntime(parseHookPayload(stdin).raw);
54
+ await runFrontDoorQuietly(
55
+ "claude:user-prompt",
56
+ ["conversation-logger", "user-prompt", "--interface", runtime],
57
+ { stdin },
58
+ );
59
+ return 0;
60
+ }
61
+
62
+ /**
63
+ * `log-assistant-reply.sh`, on Stop: record the turn's final answer beside
64
+ * its prompt (CV21.DS2.US8). Every Claude-schema runtime hands its Stop hook
65
+ * `last_assistant_message` (DS12.TS2 L11, TS4 L8; Claude Code 2.1.296's
66
+ * binary), so the reply needs no transcript.
67
+ *
68
+ * This hook prints NOTHING and exits 0, whatever happens: a Stop hook that
69
+ * printed JSON or exited 2 would keep the agent working instead of logging.
70
+ * The route's outcome line is captured in-process and never forwarded. Only a
71
+ * reply lost for no known reason -- its turn's prompt neither logged nor
72
+ * skipped as a command, the race the plan names -- is recorded in `hooks.log`,
73
+ * the fact and never the text: `runtime diagnose` reads that file as failures,
74
+ * and a command turn's reply or a session-scoped Stop is not one.
75
+ */
76
+ export async function claudeStop(stdin: string): Promise<number> {
77
+ const runtime = detectHookRuntime(parseHookPayload(stdin).raw);
78
+ const outcome = await runFrontDoorQuietly(
79
+ "claude:stop",
80
+ ["conversation-logger", "stop", "--interface", runtime],
81
+ { stdin },
82
+ );
83
+ const line = outcome.trim();
84
+ if (line.startsWith("reply skipped: nothing_waiting") || line.startsWith("reply failed")) {
85
+ noteHookFailure("claude:stop", line);
86
+ }
53
87
  return 0;
54
88
  }
55
89
 
@@ -65,7 +99,9 @@ export async function claudeSessionEnd(): Promise<number> {
65
99
  *
66
100
  * Two cases, exactly as the shell had them:
67
101
  *
68
- * 1. an explicit `/mm:mirror [query]` -- load context for that query;
102
+ * 1. an explicit Mirror form -- `/mm:mirror [query]`, or the same skill in
103
+ * any runtime's spelling (`#mirror/mirrorForm.ts`; CV21.DS2.US7) -- load context
104
+ * for that query;
69
105
  * 2. Mirror Mode already active for this session with context not yet
70
106
  * injected -- load it with the session's persona and journey, then mark
71
107
  * it injected so it happens once.
@@ -79,9 +115,9 @@ export async function claudeInject(stdin: string): Promise<number> {
79
115
  const sessionId = payload.sessionId;
80
116
 
81
117
  // Case 1: the explicit invocation.
82
- if (/^\/mm:mirror/.test(payload.prompt)) {
83
- const query = payload.prompt.replace(/^\/mm:mirror\s*/, "");
84
- const argv = ["mirror", "load", "--context-only", "--query", query];
118
+ const explicit = mirrorForm(payload.prompt);
119
+ if (explicit) {
120
+ const argv = ["mirror", "load", "--context-only", "--query", explicit.query];
85
121
  if (sessionId) argv.push("--session-id", sessionId);
86
122
  const result = await runFrontDoor(argv);
87
123
  if (result.stdout.trim()) process.stdout.write(result.stdout);
@@ -164,7 +200,9 @@ export async function claudeHook(name: string): Promise<number> {
164
200
  case "session-start":
165
201
  return claudeSessionStart();
166
202
  case "user-prompt":
167
- return claudeUserPrompt();
203
+ return claudeUserPrompt(readStdin());
204
+ case "stop":
205
+ return claudeStop(readStdin());
168
206
  case "session-end":
169
207
  return claudeSessionEnd();
170
208
  case "inject":
@@ -11,16 +11,22 @@
11
11
  // user's turn. That posture is preserved exactly.
12
12
 
13
13
  import { readFileSync } from "node:fs";
14
+ import {
15
+ type HookPayloadObject,
16
+ parseHookPayloadObject,
17
+ promptOf,
18
+ sessionIdOf,
19
+ } from "#conversation/hookPayload.ts";
14
20
 
15
21
  export interface HookPayload {
16
22
  /** The user's prompt text, or "" when the payload has none. */
17
23
  readonly prompt: string;
18
- /** The runtime's session id, or "" when the payload has none. */
24
+ /** The runtime's session id (`session_id`, or Grok's `sessionId`), or "". */
19
25
  readonly sessionId: string;
20
26
  /** The assistant's response text (Gemini's `prompt_response`), or "". */
21
27
  readonly response: string;
22
28
  /** Everything else, for hooks that need a field these three do not cover. */
23
- readonly raw: Record<string, unknown>;
29
+ readonly raw: HookPayloadObject;
24
30
  }
25
31
 
26
32
  const EMPTY: HookPayload = { prompt: "", sessionId: "", response: "", raw: {} };
@@ -29,19 +35,18 @@ function text(value: unknown): string {
29
35
  return typeof value === "string" ? value : "";
30
36
  }
31
37
 
32
- /** Parse a hook payload. Never throws: an unparseable payload is empty. */
38
+ /**
39
+ * Parse a hook payload. Never throws: an unparseable payload is empty.
40
+ *
41
+ * The field spellings live in `#conversation/hookPayload.ts`, one reader for
42
+ * the entries and the logger (CV21.DS2.US8).
43
+ */
33
44
  export function parseHookPayload(input: string): HookPayload {
34
- let parsed: unknown;
35
- try {
36
- parsed = JSON.parse(input);
37
- } catch {
38
- return EMPTY;
39
- }
40
- if (typeof parsed !== "object" || parsed === null) return EMPTY;
41
- const raw = parsed as Record<string, unknown>;
45
+ const raw = parseHookPayloadObject(input);
46
+ if (raw === null) return EMPTY;
42
47
  return {
43
- prompt: text(raw.prompt),
44
- sessionId: text(raw.session_id),
48
+ prompt: promptOf(raw),
49
+ sessionId: sessionIdOf(raw),
45
50
  // Gemini's AfterAgent payload names it `prompt_response`, and that is the
46
51
  // field the Python hook read. The first port read three guessed names
47
52
  // instead and dropped every Gemini assistant turn in silence until
@@ -21,6 +21,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } fro
21
21
  import { dirname, join } from "node:path";
22
22
 
23
23
  import { resolveMirrorHome } from "#frontDoor/dbPath.ts";
24
+ import { provideStdin } from "#frontDoor/stdinSource.ts";
24
25
  import { runningTreeRoot } from "#runtime/treeRoot.ts";
25
26
 
26
27
  /** Where a hook records that it could not do its job. */
@@ -106,6 +107,14 @@ export interface FrontDoorRun {
106
107
  readonly stdout: string;
107
108
  }
108
109
 
110
+ export interface FrontDoorRunOptions {
111
+ /**
112
+ * A payload the entry already read from fd 0, for a route that reads its
113
+ * payload from stdin (CV21.DS2.US8). The route takes it instead of fd 0.
114
+ */
115
+ readonly stdin?: string;
116
+ }
117
+
109
118
  /**
110
119
  * Run a front-door command in this process, capturing what it printed.
111
120
  *
@@ -113,8 +122,12 @@ export interface FrontDoorRun {
113
122
  * produced -- the shell version tested `[ -n "$CONTEXT" ]` on command
114
123
  * substitution, and this is the same test without a second process.
115
124
  */
116
- export async function runFrontDoor(argv: readonly string[]): Promise<FrontDoorRun> {
125
+ export async function runFrontDoor(
126
+ argv: readonly string[],
127
+ options: FrontDoorRunOptions = {},
128
+ ): Promise<FrontDoorRun> {
117
129
  const { main } = await import("#frontDoor/cli.ts");
130
+ if (options.stdin !== undefined) provideStdin(options.stdin);
118
131
  const chunks: string[] = [];
119
132
  const original = process.stdout.write.bind(process.stdout);
120
133
  // biome-ignore lint/suspicious/noExplicitAny: matching Node's overloaded write signature
@@ -145,15 +158,22 @@ function commandName(argv: readonly string[]): string {
145
158
 
146
159
  /**
147
160
  * Run a front-door command for its effect, swallowing failure the way the
148
- * shell hooks did with `|| true` -- but recording it.
161
+ * shell hooks did with `|| true` -- but recording it. Returns what the route
162
+ * printed, for an entry that reads an outcome line from it.
149
163
  */
150
- export async function runFrontDoorQuietly(hook: string, argv: readonly string[]): Promise<void> {
164
+ export async function runFrontDoorQuietly(
165
+ hook: string,
166
+ argv: readonly string[],
167
+ options: FrontDoorRunOptions = {},
168
+ ): Promise<string> {
151
169
  try {
152
- const result = await runFrontDoor(argv);
170
+ const result = await runFrontDoor(argv, options);
153
171
  if (result.code !== 0) {
154
172
  noteHookFailure(hook, `\`${commandName(argv)}\` exited ${result.code}`);
155
173
  }
174
+ return result.stdout;
156
175
  } catch (error) {
157
176
  noteHookFailure(hook, error instanceof Error ? error.message : String(error));
177
+ return "";
158
178
  }
159
179
  }
@@ -0,0 +1,34 @@
1
+ // Which runtime a Claude-schema hook is running in (CV21.DS2.US8).
2
+ //
3
+ // Three runtimes run the plugin's hooks as they are: Claude Code, Codex, and
4
+ // Grok. The hooks are Mirror's `claude:*` family, and until this story every
5
+ // session they logged was labelled `claude_code` (DS12.TS2 L7, TS4 L7). Each
6
+ // runtime leaves a mark the others do not:
7
+ //
8
+ // * Grok's runner sets `GROK_HOOK_EVENT` and `GROK_SESSION_ID` on every hook
9
+ // process (Grok's bundled guide, "Runner-injected variables"), and its
10
+ // payload names the event in camelCase, `hookEventName` (TS4 L8);
11
+ // * Codex's payload carries `turn_id` (TS2 L11), which Claude Code 2.1.296's
12
+ // common hook input does not: it carries `prompt_id`, read from the binary;
13
+ // * Claude Code is what remains.
14
+ //
15
+ // Grok is tested first because it aliases some fields into snake_case and may
16
+ // alias more; its own name for the event is the mark that cannot be a
17
+ // coincidence. A label is attribution, never authority: nothing privileged
18
+ // keys off it, so a payload that claimed another runtime's mark would mislabel
19
+ // its own session and nothing else.
20
+
21
+ import type { HookPayloadObject } from "#conversation/hookPayload.ts";
22
+
23
+ export type HookRuntime = "claude_code" | "codex" | "grok_build";
24
+
25
+ export function detectHookRuntime(
26
+ payload: HookPayloadObject,
27
+ env: NodeJS.ProcessEnv = process.env,
28
+ ): HookRuntime {
29
+ if (env.GROK_HOOK_EVENT || env.GROK_SESSION_ID || typeof payload.hookEventName === "string") {
30
+ return "grok_build";
31
+ }
32
+ if (typeof payload.turn_id === "string" && payload.turn_id !== "") return "codex";
33
+ return "claude_code";
34
+ }
@@ -0,0 +1,43 @@
1
+ // The explicit Mirror Mode form, in every runtime's skill syntax (CV21.DS2.US7).
2
+ //
3
+ // Lives with the mirror core rather than with the hooks because two readers
4
+ // share it: the inject hook (context for the question) and the prompt logger
5
+ // (the question as the conversation turn, US8).
6
+ //
7
+ // A person invokes the Mirror skill by name, and each runtime spells that
8
+ // name its own way before handing the prompt to the hooks (DS12):
9
+ //
10
+ // /mm:mirror … standalone Claude Code
11
+ // /mirror-mind:mm-mirror … the Claude Code plugin (TS1 L4)
12
+ // /mirror-mind:mm:mirror … Claude Code's menu form (TS1 static 6)
13
+ // $mirror-mind:mm:mirror … the Codex plugin (TS2 L10)
14
+ // /mm-mirror … Grok (TS4 L5)
15
+ // $mm-mirror … Codex's own skill form
16
+ //
17
+ // Three choices, each made by the runtime, not the person: the sigil (`/` or
18
+ // `$`), whether the plugin's name prefixes the skill, and whether the skill's
19
+ // `name: "mm:mirror"` or its directory `mm-mirror` is used. The grammar is
20
+ // their product, so a runtime that combines them differently is covered too.
21
+ //
22
+ // The form must open the prompt and end at whitespace or the end of it: a
23
+ // longer skill name (`/mm-mirrors`) is another skill, and a form quoted in the
24
+ // middle of a sentence is a mention. The inject hook loads context for the
25
+ // query that follows the form, and the prompt logger records that query as
26
+ // the conversation turn (US8), so both read the same grammar.
27
+
28
+ export interface MirrorForm {
29
+ /** The form as the runtime sent it, e.g. `/mirror-mind:mm-mirror`. */
30
+ readonly form: string;
31
+ /** What follows the form, with the whitespace between them removed. */
32
+ readonly query: string;
33
+ }
34
+
35
+ const FORM = /^([/$])(mirror-mind:)?mm[:-]mirror(?=\s|$)/;
36
+
37
+ /** The explicit Mirror Mode form at the start of a prompt, or null. */
38
+ export function mirrorForm(prompt: string): MirrorForm | null {
39
+ const match = FORM.exec(prompt);
40
+ if (!match) return null;
41
+ const form = match[0];
42
+ return { form, query: prompt.slice(form.length).replace(/^\s+/, "").trimEnd() };
43
+ }
@@ -1,52 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Mirror Mind plugin — MCP server launcher (CV22.DS9.TS2, DS9 decision D5;
3
- # CV22.DS10.US3 plateau 3, decision D5).
4
- #
5
- # The manifest points here instead of at an engine, so the entry point can
6
- # change without editing a plugin inside someone's runtime.
7
- #
8
- # A plugin is copied into Claude Code's own cache, so no relative path from
9
- # this file reaches the Mirror tree. The launcher finds the installed `mirror`
10
- # bin the way the plugin's hook wrappers find `mirror-hook`: MIRROR_BIN (the
11
- # directory holding both, `$(npm prefix -g)/bin`), then the PATH, then the
12
- # global bin directories a GUI launch leaves off it. Node is found the same
13
- # way the wrappers find it, MIRROR_NODE first.
14
- #
15
- # Unlike a hook, a server that cannot start has no turn to protect: it says
16
- # why on stderr, which is the client's log for this server, and exits 1.
17
- # `runtime diagnose` reports a missing `mirror` from the caller's shell.
18
- #
19
- # No flags: `mirror mcp` reads configuration and silences `node:sqlite`'s
20
- # ExperimentalWarning in-process (US3, D3), precisely because no MCP client
21
- # will pass --no-warnings and stderr must stay the client's log.
22
-
23
- set -u
24
-
25
- ENTRY=""
26
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror" ] && ENTRY="$MIRROR_BIN/mirror"
27
- [ -n "$ENTRY" ] || ENTRY="$(command -v mirror 2>/dev/null || true)"
28
- for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
29
- [ -n "$ENTRY" ] && break
30
- [ -x "$candidate/mirror" ] && ENTRY="$candidate/mirror"
31
- done
32
-
33
- if [ -z "$ENTRY" ]; then
34
- echo "mirror-mind: \`mirror\` not found on PATH; the MCP server cannot start. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory." >&2
35
- exit 1
36
- fi
37
-
38
- NODE="${MIRROR_NODE:-}"
39
- if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
40
- NODE="$(command -v node 2>/dev/null || true)"
41
- fi
42
- for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
43
- [ -n "$NODE" ] && break
44
- [ -x "$candidate" ] && NODE="$candidate"
45
- done
46
-
47
- if [ -z "$NODE" ]; then
48
- echo "mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE." >&2
49
- exit 1
50
- fi
51
-
52
- exec "$NODE" "$ENTRY" mcp