mirror-mind 0.32.1 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/log-assistant-reply.sh +85 -0
- package/.pi/extensions/mirror-logger.ts +107 -22
- package/docs/releases/index.md +2 -0
- package/docs/releases/v0.33.0.md +164 -0
- package/docs/releases/v0.33.1.md +174 -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/runtimeRoute.ts +142 -24
- 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/ts/src/runtime/git.ts +8 -2
- package/ts/src/runtime/releaseNotes.ts +230 -52
- package/ts/src/runtime/strategies/clone.ts +10 -3
- package/ts/src/runtime/strategies/package.ts +112 -23
- package/ts/src/util/tarEntries.ts +128 -0
- package/ts/src/welcome/card.ts +182 -60
- package/ts/src/welcome/statusLine.ts +12 -2
- package/ts/src/welcome/updateCache.ts +45 -27
- package/plugins/mirror-mind/mcp/launch.sh +0 -52
|
@@ -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
|
+
}
|
package/ts/src/runtime/git.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
// Timeouts follow the oracle's two budgets, and for the reason recorded there:
|
|
14
14
|
// local inspections are near-instant, while a single shared 2 s budget once
|
|
15
15
|
// made a network operation report failure after it had already succeeded
|
|
16
|
-
// (the v0.30.1 release incident). Network reads get 120 s
|
|
16
|
+
// (the v0.30.1 release incident). Network reads get 120 s, except where a
|
|
17
|
+
// caller holds a tighter budget of its own: the welcome card's update check
|
|
18
|
+
// runs inside the session start and passes what is left of its 5 s (CR135).
|
|
17
19
|
|
|
18
20
|
import { spawnSync } from "node:child_process";
|
|
19
21
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
@@ -260,11 +262,15 @@ function availability(
|
|
|
260
262
|
* Is the channel's remote branch ahead of HEAD? The remote query is the only
|
|
261
263
|
* authority: remote-tracking refs are only as fresh as the last fetch, and
|
|
262
264
|
* trusting them is how a clone two releases behind gets told it is ahead.
|
|
265
|
+
*
|
|
266
|
+
* `networkTimeoutMs` bounds that query; `runtime update --check` keeps the
|
|
267
|
+
* network budget, and the welcome card passes what is left of its own.
|
|
263
268
|
*/
|
|
264
269
|
export function checkUpdateAvailability(
|
|
265
270
|
start: string,
|
|
266
271
|
channel: string | null,
|
|
267
272
|
version: string,
|
|
273
|
+
networkTimeoutMs: number = GIT_NETWORK_TIMEOUT_MS,
|
|
268
274
|
): UpdateAvailability {
|
|
269
275
|
const startPath = resolve(start);
|
|
270
276
|
const git = inspectGit(startPath);
|
|
@@ -303,7 +309,7 @@ export function checkUpdateAvailability(
|
|
|
303
309
|
const lsRemote = runGit(
|
|
304
310
|
["ls-remote", remote, `refs/heads/${branch}`],
|
|
305
311
|
git.repository,
|
|
306
|
-
|
|
312
|
+
networkTimeoutMs,
|
|
307
313
|
);
|
|
308
314
|
if (lsRemote.code !== 0 || !lsRemote.stdout) {
|
|
309
315
|
return availability(version, updateChannel, {
|