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,85 @@
1
+ #!/usr/bin/env bash
2
+ # Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
3
+ # Edit the generator, never this file: every wrapper shares these bytes and
4
+ # differs only in the hook name and in how it finds the Mirror entry
5
+ # (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
6
+ set -uo pipefail
7
+ HOOK="claude:stop"
8
+
9
+ # Resolve the tree from THIS FILE, never from the cwd: a runtime spawns hooks
10
+ # from whatever directory the session is in. The tree is a checkout or the
11
+ # installed package; the wrapper's depth to its root is the same in both.
12
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
13
+ REPO_ROOT="$(cd "$HERE/../.." && pwd -P)"
14
+
15
+ # A hook never fails the user's turn, and never fails in silence: when it
16
+ # cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
17
+ # The home is found the way the core finds it -- MIRROR_HOME, else
18
+ # MIRROR_USER -- from the environment when set there, else from the tree's
19
+ # .env when the wrapper is in a tree, else from the user's config file
20
+ # (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
21
+ # neither variable, and the first wrappers then wrote to a home nobody reads.
22
+ env_file_value() {
23
+ [ -f "$2" ] || return 0
24
+ sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
25
+ | tail -n 1 | tr -d "\"'\r"
26
+ }
27
+ env_value() {
28
+ local value=""
29
+ if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
30
+ [ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
31
+ [ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
32
+ printf '%s' "$value"
33
+ }
34
+ note() {
35
+ local home user
36
+ home="$(env_value MIRROR_HOME)"
37
+ user="$(env_value MIRROR_USER)"
38
+ if [ -n "$home" ]; then
39
+ home="${home/#\~/$HOME}"
40
+ elif [ -n "$user" ]; then
41
+ home="$HOME/.mirror-minds/$user"
42
+ [ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
43
+ else
44
+ return 0
45
+ fi
46
+ mkdir -p "$home" 2>/dev/null \
47
+ && printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
48
+ }
49
+
50
+ ENTRY="$REPO_ROOT/bin/mirror-hook.js"
51
+
52
+ # Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
53
+ # nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
54
+ # hooks could end in `|| true` because they always found their interpreter;
55
+ # the same silence here would hide a new failure — no logging, no inject, no
56
+ # close tail — that a user would meet weeks later as "Mirror stopped
57
+ # remembering". So an unresolvable Node is RECORDED, never swallowed. The
58
+ # directory the entry was found in is searched first: an npm global bin holds
59
+ # the `node` that installed the package beside `mirror-hook`, while a system
60
+ # `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
61
+ NODE="${MIRROR_NODE:-}"
62
+ if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
63
+ NODE="$(command -v node 2>/dev/null || true)"
64
+ fi
65
+ for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
66
+ [ -n "$NODE" ] && break
67
+ [ -x "$candidate" ] && NODE="$candidate"
68
+ done
69
+
70
+ if [ -z "$NODE" ]; then
71
+ note "node not found on PATH; hook skipped. Set MIRROR_NODE."
72
+ exit 0
73
+ fi
74
+
75
+ # Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
76
+ # means Node itself could not run the hook -- too old for the loader shim, or
77
+ # it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
78
+ # review, finding N1). No flags: the entry reads configuration and silences
79
+ # its own warnings (US3, D3).
80
+ "$NODE" "$ENTRY" "$HOOK" "$@"
81
+ status=$?
82
+ if [ "$status" -ne 0 ]; then
83
+ note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
84
+ fi
85
+ exit 0
@@ -4,10 +4,12 @@
4
4
  * Integrates Pi with the Mirror Mind memory system.
5
5
  *
6
6
  * Events handled:
7
- * - session_start → unmute + close stale orphans + extract pending memories
8
- * - before_agent_start → log user prompt with explicit session id
9
- * - agent_end → log assistant response (all messages in the turn)
10
- * - session_shutdown → close conversation + backup database
7
+ * - session_start → unmute + welcome card + status line, in ONE front-door
8
+ * process (CV9.E2.S32); maintenance detached
9
+ * - before_agent_start → log user prompt, NOT awaited: queued on a per-session
10
+ * chain so the turn starts at once (CV9.E2.S32)
11
+ * - agent_end → log assistant response, after the chain settles
12
+ * - session_shutdown → close conversation (after the chain settles) + backup
11
13
  *
12
14
  * All heavy logic lives in the TypeScript core, reached through the `mirror`
13
15
  * bin that ships beside this file (`bin/mirror.js`). This extension is a thin
@@ -274,8 +276,21 @@ export default function (pi: ExtensionAPI) {
274
276
  }
275
277
  }
276
278
 
279
+ /**
280
+ * CV9.E2.S32 test knob: a delay before each awaited front-door exec, so the
281
+ * quit-mid-write and ordering cases can be driven deterministically.
282
+ * Honored ONLY under `MEMORY_ENV=test`; a production session never sleeps.
283
+ */
284
+ function testFrontDoorDelayMs(): number {
285
+ if (process.env.MEMORY_ENV !== "test") return 0;
286
+ const raw = Number.parseInt(process.env.MIRROR_TEST_FRONT_DOOR_DELAY_MS ?? "", 10);
287
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
288
+ }
289
+
277
290
  async function runMirror(args: string[]): Promise<string> {
278
291
  try {
292
+ const delay = testFrontDoorDelayMs();
293
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
279
294
  const result = await pi.exec("node", [...FRONT_DOOR_ARGV, ...args], {
280
295
  timeout: 30_000,
281
296
  });
@@ -291,6 +306,60 @@ export default function (pi: ExtensionAPI) {
291
306
  }
292
307
  }
293
308
 
309
+ /**
310
+ * The `session-start --fast --welcome --format json` envelope (CV9.E2.S32).
311
+ * One process answers what used to take three: the unmute, the welcome
312
+ * card, and the status line. A parse failure is a WARN and the same empty
313
+ * result the three calls used to yield when one of them failed.
314
+ */
315
+ interface SessionOpenEnvelope {
316
+ sessionStart: string;
317
+ welcome: string;
318
+ statusLine: string;
319
+ }
320
+
321
+ function parseSessionOpenEnvelope(stdout: string): SessionOpenEnvelope | null {
322
+ if (!stdout.trim()) return null;
323
+ try {
324
+ const parsed: unknown = JSON.parse(stdout);
325
+ if (!parsed || typeof parsed !== "object") throw new Error("not an object");
326
+ const record = parsed as Record<string, unknown>;
327
+ const field = (name: string): string => (typeof record[name] === "string" ? (record[name] as string) : "");
328
+ return { sessionStart: field("sessionStart"), welcome: field("welcome"), statusLine: field("statusLine") };
329
+ } catch (err: unknown) {
330
+ log("WARN", `session-open envelope unreadable: ${err instanceof Error ? err.message : String(err)}`);
331
+ return null;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * CV9.E2.S32: the per-session chain of pending `log-user` writes.
337
+ *
338
+ * The invariant protected: the `log-user` process has EXITED before the
339
+ * `log-assistant` process for the same turn is SPAWNED, and before
340
+ * `session-end-pi` runs. Message reads are `ORDER BY created_at, id` and
341
+ * `created_at` is stamped by the writing process at insert time, so order
342
+ * in the database is wall-clock order of the inserts; the chain is what
343
+ * makes that order the prompt order. Consecutive prompts serialize on the
344
+ * same chain. A link that fails (or times out at `pi.exec`'s 30 s) settles
345
+ * the chain all the same: `runMirror` swallows, and the `.then(\u2026, \u2026)` is
346
+ * belt and braces so a rejection can never wedge it.
347
+ */
348
+ const pendingUserLog = new Map<string, Promise<void>>();
349
+
350
+ function enqueueUserLog(sessionId: string, args: string[]): void {
351
+ const prior = pendingUserLog.get(sessionId) ?? Promise.resolve();
352
+ const next = prior.then(() => runMirror(args)).then(
353
+ () => undefined,
354
+ () => undefined,
355
+ );
356
+ pendingUserLog.set(sessionId, next);
357
+ }
358
+
359
+ async function settleUserLog(sessionId: string): Promise<void> {
360
+ await (pendingUserLog.get(sessionId) ?? Promise.resolve());
361
+ }
362
+
294
363
  /** Extract readable text from a content blocks array or plain string. */
295
364
  function extractText(content: unknown): string {
296
365
  if (typeof content === "string") return content;
@@ -307,14 +376,9 @@ export default function (pi: ExtensionAPI) {
307
376
  return text.slice(0, MAX_CONTENT_SIZE) + "\n[… truncated]";
308
377
  }
309
378
 
310
- async function refreshMirrorStatus(ctx: MirrorStatusContext): Promise<void> {
379
+ /** The status bar line: the front door's compact status, or `?`, plus the external-skill count. */
380
+ function setMirrorStatus(ctx: MirrorStatusContext, compactStatus: string): void {
311
381
  if (!ctx.hasUI) return;
312
- const sessionId = ctx.sessionManager.getSessionFile() ?? null;
313
- const statusArgs = ["welcome", "--status-line"];
314
- if (sessionId) {
315
- statusArgs.push("--session-id", sessionId);
316
- }
317
- const compactStatus = (await runMirror(statusArgs)).trim();
318
382
  const externalCatalog = loadInstalledPiExternalSkills();
319
383
  const externalSkills = externalCatalog?.extensions ?? [];
320
384
  const status = compactStatus || "◇ Mirror · ?";
@@ -324,6 +388,16 @@ export default function (pi: ExtensionAPI) {
324
388
  );
325
389
  }
326
390
 
391
+ async function refreshMirrorStatus(ctx: MirrorStatusContext): Promise<void> {
392
+ if (!ctx.hasUI) return;
393
+ const sessionId = ctx.sessionManager.getSessionFile() ?? null;
394
+ const statusArgs = ["welcome", "--status-line"];
395
+ if (sessionId) {
396
+ statusArgs.push("--session-id", sessionId);
397
+ }
398
+ setMirrorStatus(ctx, (await runMirror(statusArgs)).trim());
399
+ }
400
+
327
401
  // Resolve the active Mirror home the same way the Python core does
328
402
  // (src/memory/config.py resolve_mirror_home), including the per-workspace
329
403
  // .env that Node would otherwise never see.
@@ -436,7 +510,12 @@ export default function (pi: ExtensionAPI) {
436
510
  log("INFO", `operating instructions appended from ${AGENTS_FILE}`);
437
511
  });
438
512
 
439
- // --- 1. session_start → unmute + close stale orphans + extract pending ---
513
+ // --- 1. session_start → unmute + welcome + status line in one process ---
514
+ //
515
+ // CV9.E2.S32. `session-start --fast` WRITES the orientation that `welcome`
516
+ // and `welcome --status-line` READ, so the three cannot run in parallel;
517
+ // the composite runs them in one process, write first. Maintenance stays
518
+ // detached. Fires on startup, reload, new, resume, and fork alike.
440
519
 
441
520
  pi.on("session_start", async (_event, ctx) => {
442
521
  log("INFO", "session_start fired");
@@ -452,10 +531,11 @@ export default function (pi: ExtensionAPI) {
452
531
  log("WARN", line);
453
532
  if (ctx.hasUI) ctx.ui.notify(line, "warning");
454
533
  }
455
- const summary = await runMirror(["conversation-logger", "session-start", "--fast"]);
456
- if (ctx.hasUI) {
457
- ctx.ui.setStatus("mirror", "◇ Mirror · checking release status…");
458
- }
534
+ const sessionId = ctx.sessionManager.getSessionFile() ?? null;
535
+ const openArgs = ["conversation-logger", "session-start", "--fast", "--welcome", "--format", "json"];
536
+ if (sessionId) openArgs.push("--session-id", sessionId);
537
+ const envelope = parseSessionOpenEnvelope(await runMirror(openArgs));
538
+ const summary = envelope?.sessionStart ?? "";
459
539
  const externalCatalog = loadInstalledPiExternalSkills();
460
540
  const externalSkills = externalCatalog?.extensions ?? [];
461
541
  const externalSkillSummary = externalSkills.length
@@ -464,7 +544,7 @@ export default function (pi: ExtensionAPI) {
464
544
  log("INFO", `session-start result: ${summary || "(empty)"}`);
465
545
  log("INFO", externalSkillSummary);
466
546
 
467
- const welcome = (await runMirror(["welcome"])).trim();
547
+ const welcome = (envelope?.welcome ?? "").trim();
468
548
  if (welcome) {
469
549
  log("INFO", `welcome: ${welcome.split("\n")[0]}`);
470
550
  }
@@ -473,11 +553,9 @@ export default function (pi: ExtensionAPI) {
473
553
  if (welcome) {
474
554
  ctx.ui.notify(welcome, "info");
475
555
  }
476
- await refreshMirrorStatus(ctx);
477
- runMirrorBackground(["conversation-logger", "session-maintenance"], "session-maintenance");
478
- } else {
479
- runMirrorBackground(["conversation-logger", "session-maintenance"], "session-maintenance");
556
+ setMirrorStatus(ctx, (envelope?.statusLine ?? "").trim());
480
557
  }
558
+ runMirrorBackground(["conversation-logger", "session-maintenance"], "session-maintenance");
481
559
  });
482
560
 
483
561
  // --- 2. before_agent_start → log user prompt with explicit session id ---
@@ -490,7 +568,9 @@ export default function (pi: ExtensionAPI) {
490
568
  if (!prompt || prompt.startsWith("/")) return;
491
569
 
492
570
  log("INFO", `log-user: ${prompt.slice(0, 80)}...`);
493
- await runMirror([
571
+ // Not awaited (CV9.E2.S32): the turn starts now; `agent_end` and
572
+ // `session_shutdown` settle the chain before anything that must follow.
573
+ enqueueUserLog(sessionId, [
494
574
  "conversation-logger",
495
575
  "log-user",
496
576
  sessionId,
@@ -535,6 +615,8 @@ export default function (pi: ExtensionAPI) {
535
615
  const combined = assistantTexts.join("\n\n---\n\n");
536
616
  const content = truncate(combined);
537
617
 
618
+ // The user message's process has exited before this one is spawned.
619
+ await settleUserLog(sessionId);
538
620
  runMirrorBackground(
539
621
  ["conversation-logger", "log-assistant", sessionId, content, "--interface", "pi"],
540
622
  "log-assistant",
@@ -551,6 +633,9 @@ export default function (pi: ExtensionAPI) {
551
633
  const sessionId = ctx.sessionManager.getSessionFile() ?? null;
552
634
 
553
635
  if (sessionId) {
636
+ // A prompt still in flight must land before the conversation closes.
637
+ await settleUserLog(sessionId);
638
+ pendingUserLog.delete(sessionId);
554
639
  await runMirror(["conversation-logger", "session-end-pi", sessionId]);
555
640
  log("INFO", `session closed: ${sessionId}`);
556
641
  }
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.33.0 — One Plugin, Three Runtimes](v0.33.0.md) — minor release closing CV21.DS2: the one plugin works as it is on Claude Code, Codex, and Grok. Its MCP server starts on all three, the explicit Mirror form is recognized in every runtime's spelling, and each session is logged under its runtime with every reply beside its prompt. Also: a faster Pi session start, and notice that Gemini CLI is retired in favor of Antigravity.
15
16
  - [v0.32.1 — The Release That Shipped](v0.32.1.md) — patch release that is the published CV22 release: `v0.32.0` was tagged and its publication refused by the retired-surface tripwire over one sentence in Getting Started, now rephrased. No code, behavior, or schema differs from `v0.32.0`; read that note for the release itself.
16
17
  - [v0.32.0 — One Language, One Install](v0.32.0.md) — minor release that closes CV22, the TypeScript Core Port. Mirror Mind is one TypeScript package run by Node.js 24, installed with `npm install -g mirror-mind` and wired to Pi, Claude Code, Gemini CLI, or Codex by one documented step each; no Python remains in the repository or the shipped artifact; every command answers as it did. Eleven surfaces are retired with cutoffs, recorded in [Pending Cutoffs](pending-cutoffs.md), which this note carries. A new home imports only the Pi sessions newer than itself.
17
18
  - [v0.31.14 — Change Request Resume](v0.31.14.md) — patch release that adds the public Ariad Workbench `change-request resume` verb so a non-terminal advanced Change Request that lost the active pointer can continue without direct SQLite edits or artificial status regression. Resume preserves the CR record, restores only the Refinement cursor, allows `implemented` to proceed to `validate` and `validated` to proceed to `done`, and keeps terminal CRs closed.
@@ -0,0 +1,164 @@
1
+ ---
2
+ digest: >
3
+ v0.33.0 closes CV21.DS2, the Mirror Plugin & MCP Foundation: the one plugin
4
+ Mirror ships now works as it is on Claude Code, Codex, and Grok. Its MCP
5
+ server starts on all three, from a GUI launch too, because its manifest no
6
+ longer names a path a runtime must expand; the explicit Mirror form is
7
+ recognized in every runtime's spelling; and each session is logged under
8
+ its own runtime, with the Mirror question and every reply logged beside
9
+ its prompt. It was validated in isolated live sessions on Claude Code
10
+ 2.1.296, Codex 0.162.1, and Grok 1.0.52. The release also carries a
11
+ faster Pi session start (CV9.E2.S32). Package and Claude Code users update
12
+ as usual; a plugin installed into Codex or Grok by copy is re-installed to
13
+ receive the new entry.
14
+ ---
15
+
16
+ # v0.33.0 — One Plugin, Three Runtimes
17
+
18
+ **Date:** 2026-10-13
19
+
20
+ ## Highlights
21
+
22
+ - **The MCP server starts on every runtime that takes the plugin.** The manifest's entry
23
+ used to name `${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh`, which Codex never expanded, so the
24
+ server never started there. It is now a small locator run by `/bin/sh` that finds the
25
+ installed `mirror` and `node` itself (`MIRROR_BIN`, the `PATH`, the global bin
26
+ directories), so it also starts when a runtime is launched from the GUI without your
27
+ shell's `PATH`.
28
+ - **Every reply is logged, beside its prompt, under the runtime it came from.** The shared
29
+ hooks detect Claude Code, Codex, or Grok, label the session `claude_code`, `codex`, or
30
+ `grok_build`, and log the turn's final answer from the runtime's Stop event, paired with
31
+ its prompt by the runtime's own turn id.
32
+ - **A Mirror turn is a conversation turn.** `/mirror-mind:mm-mirror …`, `$mirror-mind:mm:mirror …`,
33
+ `/mm-mirror …`, and the other spellings are recognized by the inject hook, and the
34
+ question is logged without the form, so memory extraction sees the question with its
35
+ answer.
36
+ - **The plugin validates cleanly and its hooks cannot hang a turn.** Hook commands quote
37
+ the plugin root, so `claude plugin validate` passes with no warnings, and hook timeouts
38
+ are the seconds the runtimes read them as. The values shipped before meant hours.
39
+ - **Codex's plugin tools, and Grok's, are not yet in the model's hands.** Codex starts the
40
+ server and keeps a plugin's MCP tools behind its own `tool_search`; Grok starts it and
41
+ lists its seven tools, but does not attach it to a session. The second is the next Grok
42
+ story's to investigate.
43
+
44
+ ## Where We Started
45
+
46
+ CV21 converges Mirror's runtimes on one artifact: a Claude-format plugin carrying the
47
+ skills, the lifecycle hooks, and the Mirror MCP server, taken by each runtime by install
48
+ rather than wired by hand. Its re-baseline, earlier the same day, measured what the
49
+ installed runtimes do with the plugin `v0.32.1` ships. Three of four take it as it is,
50
+ and four things in it were wrong:
51
+ - Claude Code's validator warned on every hook command;
52
+ - the MCP entry did not start on Codex;
53
+ - the inject hook missed the Mirror skill on every plugin install, because each runtime
54
+ spells it differently;
55
+ - the shared hooks logged Codex and Grok sessions as Claude Code's, without their replies.
56
+
57
+ ## What Changed
58
+
59
+ The four fixes were planned as one Delivery Story, after a review through six lenses
60
+ (engineering, operations, quality, security, data, and model behavior).
61
+
62
+ **The MCP entry was chosen by measurement.** Two placeholder-free entries were run on all
63
+ three runtimes before either was written: the inline locator, and the bare `mirror mcp`.
64
+ Both start from a terminal. Only the locator starts from a GUI-launch `PATH`, as written
65
+ and inside Claude Code, where `mirror mcp` failed with "executable not found". The
66
+ measurement also showed Grok substituting set environment variables inside MCP arguments,
67
+ so the locator's own variables carry names nobody exports. `mcp/launch.sh` is retired.
68
+
69
+ **The reply rule changed before a line was written.** The plan first paired replies with
70
+ logged prompts and kept skipping slash commands. The review showed that would drop the
71
+ Mirror answers on Claude Code, which until now reached the database only through the
72
+ transcript import at session end. So the Mirror question is logged too, and the reply
73
+ beside it. Replies to other slash commands, such as `/mm-journeys`, are not recorded:
74
+ they are commands, not conversation.
75
+
76
+ **Sessions know their runtime.** Grok's runner sets its own variables on every hook and
77
+ names events in camelCase, and Codex's payload carries a `turn_id`; Claude Code is what
78
+ remains. The label is stamped on every prompt, so a conversation the Mirror skill opens
79
+ inherits it.
80
+
81
+ **A defect of the release's own was paid before it shipped.** A skipped reply was written
82
+ to `hooks.log`, which `runtime diagnose` reads as failures, so every slash command would
83
+ have raised a warning. Only a reply genuinely lost, its prompt never having reached
84
+ Mirror, is written there now.
85
+
86
+ ### What you will notice
87
+
88
+ - **On Claude Code**, replies are logged as each turn ends rather than imported when the
89
+ session closes, and an explicit Mirror turn's question is logged with its answer. The
90
+ `mm-mirror` skill no longer records a summary by hand where the hooks run.
91
+ - **In `runtime diagnose`**, a hook warning now means a real failure.
92
+ - **For a plugin installed by copy** (Codex from a marketplace, Grok with `grok plugin
93
+ install`), nothing changes until you re-install: the copy keeps its own manifest and
94
+ launcher, and keeps working as before.
95
+
96
+ ## Upgrading
97
+
98
+ ```bash
99
+ mirror runtime update # or: npm install -g mirror-mind
100
+ ```
101
+
102
+ There is no database migration in this release.
103
+
104
+ - **Claude Code**, wired the documented way (`~/.claude/skills/mirror-mind` linked to the
105
+ package's plugin), reads the plugin in place: new sessions get the new hooks and MCP
106
+ entry with nothing to do.
107
+ - **Codex and Grok, if you installed the plugin into them**, re-install it to receive
108
+ the new MCP entry and the Stop hook. Codex copies a plugin into its cache at a version;
109
+ Grok copies it on install. Codex's documented wiring is still `v0.32.1`'s two steps and
110
+ wrapper, which this release does not change.
111
+ - **Pi** gets the faster session start described below, with nothing to do.
112
+ - **Gemini CLI** is unaffected by this release; see the notice below.
113
+
114
+ ## Also In This Release
115
+
116
+ - **Pi opens a session in one front-door call** (CV9.E2.S32). The orientation write and
117
+ the two renders that read it, the welcome card and the status line, run in one process
118
+ instead of three. Startup to the status line went from 846 ms to 470 ms, the median of
119
+ five starts in the development home. The prompt log no longer holds the turn: it is
120
+ queued, and settled before the reply is logged.
121
+ - **Notice: Gemini CLI is retired as a Mirror runtime, and Antigravity is its
122
+ successor** ([decision](../project/decisions.md#gemini-cli-is-retired-and-antigravity-is-its-successor)).
123
+ Google deprecated the Gemini Code Assist backend for individuals. The integration still
124
+ ships in this release and still works. CV21.DS8 removes it behind a documented cutoff,
125
+ only once Antigravity gives a Gemini CLI user what they have now.
126
+
127
+ ## Conscious Exclusions
128
+
129
+ - **Codex through the plugin, as the documented route**, and its marketplace: CV21.DS4.
130
+ - **Grok's attachment of the MCP server to a session**, and Grok's honest L3 (it offers no
131
+ per-turn injection): CV21.DS10.
132
+ - **The SessionEnd time bound.** Codex gives a SessionEnd hook with no timeout 1 second,
133
+ Grok 1.5 seconds. With a provider key the close tail's extraction may not finish in
134
+ that time; the conversation is closed first and extracted at the next session start, so
135
+ nothing is lost. Left to CV21.DS4 and DS10.
136
+ - **A status line in the plugin.** A plugin cannot carry one (CV21.DS12); CV21.DS11 will
137
+ document it as a user step.
138
+ - **Three small findings, captured** as CR129 (`mirror mcp` with no home prints a stack
139
+ trace), CR130 (double-encoded dashes in the configuration reference), and CR131
140
+ (`runtime diagnose` does not run the MCP locator).
141
+
142
+ ## What We Learned
143
+
144
+ The instrument needs proving before the thing it measures. The live validation found four
145
+ defects in its own route before it found anything about the plugin:
146
+ - the real config file reaching a scratch session;
147
+ - Claude Code remembering an earlier MCP failure for fifteen minutes;
148
+ - macOS's bash 3.2 refusing a construct;
149
+ - a static step that could not run twice.
150
+
151
+ Writing the proof of the last fix found a fifth: Codex leaves an app-server daemon running
152
+ from its home after a session, and it had outlived the previous story's cleanup too.
153
+
154
+ A runtime's own view is often the better witness. Codex's log showed the server's
155
+ handshake, and its `/mcp` view showed the seven tools, where the model could not name
156
+ them. Grok's MCP view showed the server ready while its session log said none was
157
+ attached. That contradiction is now the next Grok story's starting point.
158
+
159
+ ## Next Horizon
160
+
161
+ CV21 continues with Codex through the canonical plugin (DS4), then Antigravity through
162
+ its skills and a native layer (DS7) and the Gemini CLI sunset behind it (DS8), Grok
163
+ through the plugin (DS10), user extensions on every runtime (DS5), and the contract and
164
+ docs refresh (DS11).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Gemini CLI, Codex)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
7
7
  },
8
8
  "mcpServers": {
9
9
  "mirror-mind": {
10
- "command": "${CLAUDE_PLUGIN_ROOT}/mcp/launch.sh"
10
+ "command": "/bin/sh",
11
+ "args": [
12
+ "-c",
13
+ "mmbin=\"\"; [ -n \"$MIRROR_BIN\" ] && [ -x \"$MIRROR_BIN/mirror\" ] && mmbin=\"$MIRROR_BIN/mirror\"; [ -n \"$mmbin\" ] || mmbin=\"$(command -v mirror 2>/dev/null)\"; for mmdir in \"$HOME/.nvm/current/bin\" /opt/homebrew/bin /usr/local/bin; do [ -n \"$mmbin\" ] && break; [ -x \"$mmdir/mirror\" ] && mmbin=\"$mmdir/mirror\"; done; [ -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; }; mmnode=\"$MIRROR_NODE\"; [ -n \"$mmnode\" ] && [ -x \"$mmnode\" ] || mmnode=\"$(command -v node 2>/dev/null)\"; for mmdir in \"$(dirname \"$mmbin\")\" \"$HOME/.nvm/current/bin\" /opt/homebrew/bin /usr/local/bin; do [ -n \"$mmnode\" ] && break; [ -x \"$mmdir/node\" ] && mmnode=\"$mmdir/node\"; done; [ -n \"$mmnode\" ] || { echo \"mirror-mind: node not found on PATH; the MCP server cannot start. Set MIRROR_NODE.\" >&2; exit 1; }; exec \"$mmnode\" \"$mmbin\" mcp"
14
+ ]
11
15
  }
12
16
  }
13
17
  }
@@ -6,8 +6,8 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh",
10
- "timeout": 15000
9
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
10
+ "timeout": 300
11
11
  }
12
12
  ]
13
13
  }
@@ -18,8 +18,8 @@
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mirror-inject.sh",
22
- "timeout": 30000
21
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/mirror-inject.sh\"",
22
+ "timeout": 30
23
23
  }
24
24
  ]
25
25
  },
@@ -28,7 +28,19 @@
28
28
  "hooks": [
29
29
  {
30
30
  "type": "command",
31
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/log-user-prompt.sh",
31
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-user-prompt.sh\"",
32
+ "async": true
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "Stop": [
38
+ {
39
+ "matcher": "",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-assistant-reply.sh\"",
32
44
  "async": true
33
45
  }
34
46
  ]
@@ -40,7 +52,7 @@
40
52
  "hooks": [
41
53
  {
42
54
  "type": "command",
43
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/log-session-end.sh",
55
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/log-session-end.sh\"",
44
56
  "async": true
45
57
  }
46
58
  ]
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Mirror Mind hook wrapper — GENERATED by scripts/generate_hook_wrappers.sh.
3
+ # Edit the generator, never this file: every wrapper shares these bytes and
4
+ # differs only in the hook name and in how it finds the Mirror entry
5
+ # (CV22.DS10.TS5 decision D4; CV22.DS10.US3 decision D5).
6
+ set -uo pipefail
7
+ HOOK="claude:stop"
8
+
9
+ # This wrapper is the Claude plugin's, and a plugin is copied into Claude
10
+ # Code's own cache: no relative path from here reaches the Mirror tree. It
11
+ # runs the installed `mirror-hook` bin instead, and reads no tree .env.
12
+ REPO_ROOT=""
13
+
14
+ # A hook never fails the user's turn, and never fails in silence: when it
15
+ # cannot do its job it appends ONE line to <mirror home>/hooks.log and exits 0.
16
+ # The home is found the way the core finds it -- MIRROR_HOME, else
17
+ # MIRROR_USER -- from the environment when set there, else from the tree's
18
+ # .env when the wrapper is in a tree, else from the user's config file
19
+ # (~/.config/mirror/env), in the core's order (US3, D3). A GUI launch carries
20
+ # neither variable, and the first wrappers then wrote to a home nobody reads.
21
+ env_file_value() {
22
+ [ -f "$2" ] || return 0
23
+ sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?$1[[:space:]]*=[[:space:]]*//p" "$2" \
24
+ | tail -n 1 | tr -d "\"'\r"
25
+ }
26
+ env_value() {
27
+ local value=""
28
+ if [ -n "${!1+set}" ]; then printf '%s' "${!1}"; return; fi
29
+ [ -z "$REPO_ROOT" ] || value="$(env_file_value "$1" "$REPO_ROOT/.env")"
30
+ [ -n "$value" ] || value="$(env_file_value "$1" "${XDG_CONFIG_HOME:-$HOME/.config}/mirror/env")"
31
+ printf '%s' "$value"
32
+ }
33
+ note() {
34
+ local home user
35
+ home="$(env_value MIRROR_HOME)"
36
+ user="$(env_value MIRROR_USER)"
37
+ if [ -n "$home" ]; then
38
+ home="${home/#\~/$HOME}"
39
+ elif [ -n "$user" ]; then
40
+ home="$HOME/.mirror-minds/$user"
41
+ [ -d "$home" ] || [ ! -d "$HOME/.mirror/$user" ] || home="$HOME/.mirror/$user"
42
+ else
43
+ return 0
44
+ fi
45
+ mkdir -p "$home" 2>/dev/null \
46
+ && printf '%s %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$HOOK" "$1" >> "$home/hooks.log" 2>/dev/null
47
+ }
48
+
49
+ # Find the bin. MIRROR_BIN names the directory holding `mirror` and
50
+ # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
51
+ # then the PATH; then the global bin directories a GUI launch leaves off it.
52
+ ENTRY=""
53
+ [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
54
+ [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
55
+ for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
56
+ [ -n "$ENTRY" ] && break
57
+ [ -x "$candidate/mirror-hook" ] && ENTRY="$candidate/mirror-hook"
58
+ done
59
+
60
+ if [ -z "$ENTRY" ]; then
61
+ note "mirror-hook not found on PATH; hook skipped. Install mirror-mind (npm install -g mirror-mind) or set MIRROR_BIN to its bin directory."
62
+ exit 0
63
+ fi
64
+
65
+ # Find Node. /usr/bin/python3 exists on every macOS; node usually lives under
66
+ # nvm or Homebrew and is NOT on the PATH of a GUI-launched runtime. The Python
67
+ # hooks could end in `|| true` because they always found their interpreter;
68
+ # the same silence here would hide a new failure — no logging, no inject, no
69
+ # close tail — that a user would meet weeks later as "Mirror stopped
70
+ # remembering". So an unresolvable Node is RECORDED, never swallowed. The
71
+ # directory the entry was found in is searched first: an npm global bin holds
72
+ # the `node` that installed the package beside `mirror-hook`, while a system
73
+ # `/usr/local/bin/node` can be years older (CI's Ubuntu runner has one).
74
+ NODE="${MIRROR_NODE:-}"
75
+ if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then
76
+ NODE="$(command -v node 2>/dev/null || true)"
77
+ fi
78
+ for candidate in "$(dirname "$ENTRY")/node" "$HOME/.nvm/current/bin/node" /opt/homebrew/bin/node /usr/local/bin/node; do
79
+ [ -n "$NODE" ] && break
80
+ [ -x "$candidate" ] && NODE="$candidate"
81
+ done
82
+
83
+ if [ -z "$NODE" ]; then
84
+ note "node not found on PATH; hook skipped. Set MIRROR_NODE."
85
+ exit 0
86
+ fi
87
+
88
+ # Not `exec`: the entry exits 0 whatever happens inside it, so a non-zero exit
89
+ # means Node itself could not run the hook -- too old for the loader shim, or
90
+ # it crashed. That used to vanish into a stderr no runtime shows (TS5 handoff
91
+ # review, finding N1). No flags: the entry reads configuration and silences
92
+ # its own warnings (US3, D3).
93
+ "$NODE" "$ENTRY" "$HOOK" "$@"
94
+ status=$?
95
+ if [ "$status" -ne 0 ]; then
96
+ note "node exited $status before the hook finished ($NODE, $("$NODE" --version 2>/dev/null || echo 'version unknown')); Mirror needs Node 24 or later. Set MIRROR_NODE."
97
+ fi
98
+ exit 0