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.
- package/.claude/hooks/log-assistant-reply.sh +85 -0
- package/.pi/extensions/mirror-logger.ts +107 -22
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.33.0.md +164 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +6 -2
- package/plugins/mirror-mind/hooks/hooks.json +18 -6
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +98 -0
- package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +4 -2
- package/ts/src/conversation/hookPayload.ts +51 -0
- package/ts/src/conversation/logger.ts +300 -25
- package/ts/src/conversation/loggerCli.ts +41 -2
- package/ts/src/frontDoor/cli.ts +8 -1
- package/ts/src/frontDoor/conversationLoggerRoute.ts +20 -10
- package/ts/src/frontDoor/routing.ts +2 -0
- package/ts/src/frontDoor/sessionStartWelcomeRoute.ts +166 -0
- package/ts/src/frontDoor/stdinSource.ts +37 -0
- package/ts/src/guards/claudePlugin.ts +56 -10
- package/ts/src/guards/packContents.ts +1 -1
- package/ts/src/hooks/claude.ts +52 -14
- package/ts/src/hooks/payload.ts +18 -13
- package/ts/src/hooks/runtime.ts +24 -4
- package/ts/src/hooks/runtimeDetection.ts +34 -0
- package/ts/src/mirror/mirrorForm.ts +43 -0
- package/plugins/mirror-mind/mcp/launch.sh +0 -52
|
@@ -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.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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/
|
|
59
|
+
"plugins/mirror-mind/hooks/hooks.json",
|
|
60
60
|
"scripts/codex-hooks/session-start.sh",
|
|
61
61
|
"scripts/codex-mirror.sh",
|
|
62
62
|
];
|
package/ts/src/hooks/claude.ts
CHANGED
|
@@ -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()
|
|
45
|
-
* that surface
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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]
|
|
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
|
-
|
|
83
|
-
|
|
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":
|
package/ts/src/hooks/payload.ts
CHANGED
|
@@ -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 ""
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
35
|
-
|
|
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:
|
|
44
|
-
sessionId:
|
|
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
|
package/ts/src/hooks/runtime.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|