mirror-mind 0.40.0-next.1 → 0.40.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.
@@ -12,6 +12,8 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [v0.40.1 — The Bins You Named, or Why Not](v0.40.1.md) — patch release of CR197: the plugin's hook wrappers and MCP entry use `MIRROR_BIN` only as an absolute path to a directory holding the bin they need, the rule `MIRROR_NODE` follows since v0.39.5; a relative value is never used, so a project's own `bin/mirror-hook` cannot run, and a refused value is named with its reason, once a day in `hooks.log` and on the MCP entry's stderr; the forward-slash `C:/…` form Windows machines set counts as absolute, in CI under Git Bash. Also carries CV9's re-baseline (documents only). No migration.
16
+ - [v0.40.0 — A Reply Stays in Its Own Conversation, and a Journey Can Go](v0.40.0.md) — minor release closing the first release train: CR151, a session's transcript fills only the conversation that session is bound to, each reply stamped with its transcript time, where it used to fill every open reply-less conversation in the transcript's window, another session's included; a transcript no session owns backfills nothing; and CR200, previewed as v0.40.0-next.1, `mirror-mind/desktop` deletes a Journey with every record that belongs to it, by digest, behind a verified backup. Update every install that has a home. No migration.
15
17
  - [v0.40.0-next.1 — A Journey Can Go, Showing What Goes With It](v0.40.0-next.1.md) — preview under npm's `next`, read by no updater, for Nautilus for Mirror (the release train): CR200, `mirror-mind/desktop` gains `previewJourneyDeletion` and `deleteJourneyWithRecords`, deleting a Journey with every record that belongs to it after a preview that counts it, by digest, behind a verified backup; child Journeys refuse. Terminal users receive it with v0.40.0. No migration.
16
18
  - [v0.39.5 — The Node You Named, or Why Not](v0.39.5.md) — patch release of CR192, from PR #47's thread (@rodrigoimmaginario): the hook wrappers, the plugin's MCP entry, and `runtime diagnose` use `MIRROR_NODE` only as an absolute path to an executable file, as the Pi extension does; a relative value is never run, and a refused one is named with its reason, once a day in `hooks.log` and on the MCP entry's stderr; the Windows installer's drive-letter path counts as absolute, in CI under Git Bash and on the installer. No migration.
17
19
  - [v0.39.4 — The Reply Finds Its Prompt](v0.39.4.md) — patch release of PR #51 (@alannascimento1), CR166 and CR196: on Claude Code, a prompt typed while a turn runs and a journey entered in plain words no longer lose the reply; `nothing_waiting` in `hooks.log` means a lost reply again. No schema change, no migration.
@@ -0,0 +1,158 @@
1
+ ---
2
+ digest: >
3
+ v0.40.0 closes the first release train. It carries CR151: a session's transcript is
4
+ written only into the conversation that session is bound to, each reply stamped with its
5
+ own transcript time, where before it was copied into every open, reply-less conversation
6
+ in the transcript's window, another session's included. It also carries CR200, previewed
7
+ as v0.40.0-next.1 for Nautilus for Mirror: a host can delete a Journey with every record
8
+ that belongs to it, after a preview that counts what will go, by digest, behind a
9
+ verified backup. A transcript no session owns, or a session bound to no conversation,
10
+ now backfills nothing. No schema change, no migration, and no model or prompt change.
11
+ ---
12
+
13
+ # v0.40.0 — A Reply Stays in Its Own Conversation, and a Journey Can Go
14
+
15
+ **Date:** 2026-11-06
16
+
17
+ The first stable release under [the release train](../process/versioning.md#the-release-train):
18
+ everything merged into `main` since v0.39.5, in one batch. One of its two changes users
19
+ have never had; the other reached embedding hosts as a preview on the same day.
20
+
21
+ ## Highlights
22
+
23
+ - **A session's replies go into its own conversation, and only there**
24
+ ([CR151](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr151-the-transcript-backfill-writes-a-session-s-replies-into-other-sessions-conversations.md)).
25
+ At `SessionEnd`, on Claude Code and the runtimes that share its hook, the transcript
26
+ filled every conversation in its time window that had no assistant message yet. An open
27
+ conversation that started earlier was always in the window: a session killed in its first
28
+ turn, or a concurrent session still in its own. Now the transcript fills the conversation
29
+ `runtime_sessions` binds to the ending session, inside that conversation's own window.
30
+ - **A reply carries its own time.** The copy used to be stamped with the hook's clock, so
31
+ every reply sorted after every prompt of its conversation, and an extraction read all the
32
+ questions and then all the answers. A reply now carries its transcript timestamp,
33
+ normalized to the logger's microsecond form, so recall, extraction, and the MCP read
34
+ model read the turns in order.
35
+ - **A transcript nobody owns writes nothing.** A payload with no `session_id`, and a session
36
+ bound to no conversation, used to backfill by window alone. Both are retired: with no
37
+ key there is no attribution. A "same runtime only" fallback was rejected because it would
38
+ not have fixed the recorded case, in which the orphan and the session that wrote into it
39
+ were both `claude_code`.
40
+ - **A Journey and its records, deleted in one act**
41
+ ([CR200](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr200-delete-a-journey-with-its-records-after-showing-what-will-go.md),
42
+ previewed as [v0.40.0-next.1](v0.40.0-next.1.md)). `mirror-mind/desktop` gains
43
+ `previewJourneyDeletion` and `deleteJourneyWithRecords`: the preview counts the Journey's
44
+ records, and the deletion acts only on the preview the person saw, in one transaction,
45
+ behind a named backup verified before any write. Child Journeys refuse.
46
+ - No schema change, no migration, and no model or prompt change.
47
+
48
+ ## Where We Started
49
+
50
+ CV21.DS5.US3's live route on 2026-10-20 found three product defects and stopped at its own
51
+ rule: capture, don't fix inside the story. One of them was CR151. A hand-run diagnostic had
52
+ left an open `claude_code` conversation with one prompt and no Stop; the route's Claude
53
+ session ended at 20:41, and its reply `ready` landed in its own conversation and in the
54
+ orphan's, at the same instant. The port had inherited the behavior from the Python fallback
55
+ it replaced, written when one session at a time was the only case, and no test pinned the
56
+ scoping.
57
+
58
+ The Navigator accepted the plan after a review through three lenses. The database
59
+ architect's finding set the shape: the key already existed (`runtime_sessions.conversation_id`)
60
+ and the backfill ignored it, so the fix is to use it, and no fallback survives that the
61
+ evidence case would pass through.
62
+
63
+ ## What Changed
64
+
65
+ **CR151.** `backfillAssistantMessages(db, transcriptPath, conversationId, deps)` takes the
66
+ conversation as a required argument: one row, its own `[started_at, ended_at]` window, and
67
+ skipped when it already logged a reply, so a Stop-logged one is not recorded twice.
68
+ `handleSessionEndHook` reads the bound conversation before `endSession` closes the
69
+ session, rather than relying on the close to preserve it. `normalizeTimestamp` pads
70
+ Claude's millisecond `.123Z` to the logger's `.123000Z`, because as strings `.123Z` sorts
71
+ after `.123456Z`. Both headers say the code departs from the Python port on purpose, so
72
+ nobody fixes it back.
73
+
74
+ The seven-conversation fixture that sat on every window boundary is kept, graded now by
75
+ what it was always meant to show: naming one conversation changes it and leaves the other
76
+ six exactly as they were. The golden that recorded Python filling all seven at once stays as
77
+ a frozen record of the defect, since its generator and its Python source are gone.
78
+
79
+ **Validated on a real session.** The Navigator ran the route against Claude Code 2.1.318:
80
+ the orphan kept its prompt alone, and the session's reply carried the transcript's own
81
+ timestamp. Two earlier runs failed, and told us something worth keeping: the Navigator's
82
+ machine has Claude settings that pin `MIRROR_BIN` to the installed package over whatever the shell exports,
83
+ and a second copy of the plugin loads from `~/.claude/skills`, so both runs executed the
84
+ published 0.39.5. They reproduced the defect on a real session (`ready` in the orphan, after
85
+ the conversation's close, with no `turn_id`), which is the "before" the CR now records.
86
+ The route pins the checkout with `--settings`.
87
+
88
+ **CR200** is as [v0.40.0-next.1](v0.40.0-next.1.md) describes it: four rules for what
89
+ belongs to a Journey, a closed inventory a test holds against the schema, and refusals that
90
+ write nothing.
91
+
92
+ **Also in this release.** The Windows installer's real `MIRROR_NODE` path is named where
93
+ docs and comments had named another. The tests and smokes read their paths through
94
+ `fileURLToPath`, so a checkout under `Código` or `My Projects` no longer fails 113 tests
95
+ (issue #44), and the guard that keeps the product from reading a URL's pathname as a path
96
+ now covers them. The lifecycle smoke asserts the new session-less contract. Captured for
97
+ later: CR198 (`runtime diagnose` does not name a refused `MIRROR_NODE`), CR199
98
+ (`deliverMirrorContext` takes three to seven seconds before a Desktop turn), and CR201.
99
+
100
+ ## Conscious Exclusions
101
+
102
+ - **Replies already copied stay where they are.** A home that ran an earlier version may
103
+ hold a reply under the wrong conversation. Nothing in the data tells it from a real one,
104
+ and removing one is a manual edit; the selector under Upgrading finds the candidates.
105
+ - **A late `SessionEnd` still adds replies after the extraction.** When maintenance closed
106
+ and extracted a conversation first, the replies the backfill then adds are read by no
107
+ extraction. CR151 pins that they land in the right conversation, not what then reads them.
108
+ [CR201](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr201-a-reply-backfilled-after-the-close-misses-the-extraction-that-closed-the-conversation.md)
109
+ holds the decision.
110
+ - **The session-less route is gone, not narrowed.** A caller that fed a transcript alone to
111
+ `conversation-logger session-end` now gets a no-op.
112
+ - No CLI command deletes a Journey with its records; a host is the only caller. No cascade
113
+ through child Journeys; files on disk are the host's.
114
+
115
+ ## What We Learned
116
+
117
+ A fallback written for one case does not know about the second. The Python backfill was
118
+ right for a machine with one session at a time, and ordinary use of Mirror, a Ctrl-C in a
119
+ first turn, was enough to make it wrong.
120
+
121
+ The test that proves a fix is the one that runs the code you changed. Two of the Navigator's three live
122
+ runs exercised the previous release, because a setting the validator did not know about decided
123
+ which Mirror the hooks ran. The checkout's own `mirror-hook` was never in the path. Pinning
124
+ the code under test is part of the route now, and the failed runs stay in the record as
125
+ the defect, measured on a real session, in the published release.
126
+
127
+ ## Upgrading
128
+
129
+ ```bash
130
+ mirror runtime update
131
+ ```
132
+
133
+ Update every install that has a home: the fix lives in the core that the hooks run, and a
134
+ machine whose Claude Code runs a global install keeps the old backfill until that install
135
+ is updated. Plugin copies need no change.
136
+
137
+ To find replies an earlier version copied into the wrong conversation, read-only:
138
+
139
+ ```bash
140
+ sqlite3 ~/.mirror-minds/<user>/memory.db \
141
+ "SELECT substr(a.conversation_id,1,8) AS conv, a.created_at, substr(a.content,1,40)
142
+ FROM messages a JOIN messages b
143
+ ON a.content = b.content
144
+ AND substr(a.created_at,1,19) = substr(b.created_at,1,19)
145
+ AND a.conversation_id < b.conversation_id
146
+ WHERE a.role = 'assistant' AND b.role = 'assistant'
147
+ ORDER BY a.created_at"
148
+ ```
149
+
150
+ A match is one reply written into two conversations within the same second, which is the
151
+ trace of one backfill; read each row, because two identical short replies a second apart in
152
+ different conversations are a different thing. The home's dated backups hold the rows as they
153
+ were.
154
+
155
+ ## Next Horizon
156
+
157
+ CR201's decision, how often the late path is taken on a real home; CR198 and CR199; CR195
158
+ in RS010's backlog. The next train leaves when the Navigator calls it.
@@ -0,0 +1,120 @@
1
+ ---
2
+ digest: >
3
+ v0.40.1 is a patch release of one Change Request, CR197. The plugin's hook wrappers and
4
+ MCP entry now use `MIRROR_BIN` only when it is an absolute path to a directory holding the
5
+ bin they need, the rule `MIRROR_NODE` follows since v0.39.5. A relative value could make
6
+ a plugin hook run a JavaScript file from the project the runtime was opened in, and a
7
+ value that no longer held `mirror-hook` was dropped without a word. Now neither is used,
8
+ and the refused value is named with its reason. The forward-slash drive form Windows
9
+ machines set for the Claude Code forwarders counts as absolute, proven in CI under Git
10
+ Bash. No schema change, no migration, and no model or prompt change.
11
+ ---
12
+
13
+ # v0.40.1 — The Bins You Named, or Why Not
14
+
15
+ **Date:** 2026-11-07
16
+
17
+ ## Highlights
18
+
19
+ - **A relative `MIRROR_BIN` is never used**
20
+ ([CR197](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr197-the-hook-wrappers-and-the-mcp-locator-accept-a-relative-mirror-bin.md)).
21
+ `MIRROR_BIN` names the directory holding `mirror` and `mirror-hook`, for the plugin's
22
+ hook wrappers and MCP entry, which a runtime copies into its own cache. A runtime starts
23
+ them from the project it was opened in, and both readers took any value that passed
24
+ `[ -x ]` there: with `MIRROR_BIN=bin`, Node ran the project's `bin/mirror-hook` as
25
+ JavaScript on every plugin hook, and its `bin/mirror` at every MCP server start. Both
26
+ readers now use the value only as an absolute path to a directory holding the bin they
27
+ need as an executable file.
28
+ - **A value that is not used is named, with its reason.** A value that no longer held the
29
+ bin, such as a prefix left behind by a Node switch, used to be dropped without a word.
30
+ Now the hook wrappers write
31
+ `MIRROR_BIN=bin is relative; searching the PATH and the usual places instead` to
32
+ `<mirror home>/hooks.log`, once a UTC day for the same value, and the MCP entry writes
33
+ the same message to its stderr. The reasons are `relative`, `missing`,
34
+ `not a directory`, and `a directory without an executable mirror-hook` (or `mirror`).
35
+ The search then goes on through the PATH and the usual places, as before.
36
+ - **The Windows form counts as absolute.** The rule uses `MIRROR_NODE`'s pattern, so
37
+ `C:/Users/<you>/.mirror/bin`, the forward-slash form Windows machines set for the Claude
38
+ Code forwarders, is absolute, and a drive-relative `C:bin` is refused as `relative`. The
39
+ Windows CI job runs a plugin wrapper with that form under Git Bash.
40
+ - No schema change, no migration, and no model or prompt change.
41
+
42
+ ## Where We Started
43
+
44
+ v0.39.5 gave `MIRROR_NODE` its rule
45
+ ([CR192](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr192-the-hook-wrappers-and-the-mcp-locator-accept-a-relative-mirror-node.md)),
46
+ and its review found the same exposure one variable over: with `MIRROR_BIN=bin`, the
47
+ plugin's prompt hook made Node run the project's `bin/mirror-hook`, probed on 2026-11-04.
48
+ It was captured as CR197 and left out of v0.39.5 on purpose. On 2026-11-07 the CV9
49
+ re-baseline decided to ship it as a patch ahead of the 1.0 gate.
50
+
51
+ ## What Changed
52
+
53
+ **One rule, two variables.** The plugin's bin and agy wrappers, generated by
54
+ `scripts/generate_hook_wrappers.sh`, and the MCP locator in
55
+ `ts/src/guards/claudePlugin.ts` test `MIRROR_BIN` with `ABSOLUTE_PATH_CASE`, the pattern
56
+ `MIRROR_NODE` uses, and a test fails if the copies drift. The bin search now carries the
57
+ pattern's backslashes, so the generator splices it by the text around its placeholder, as
58
+ it does the other shared fragments. The eight plugin wrappers are regenerated; the five
59
+ tree-form wrappers under `.claude/hooks/` read no `MIRROR_BIN` and are unchanged.
60
+
61
+ **A refusal that cannot forge a line.** Line breaks are removed from the value before it is
62
+ logged, as for `MIRROR_NODE`. The locator reads `$MIRROR_BIN` once, where it read it three
63
+ times, because Grok pastes a set variable's value into the MCP arguments it loads.
64
+
65
+ **Tests and CI.** The new tests ran the project's planted `bin/mirror-hook` and
66
+ `bin/mirror` before the fix. They cover each reason, the daily rule, a forged line break,
67
+ both variables refused in one run, the agy form, and a valid value that outranks the PATH.
68
+ The Windows job's Git Bash step runs a plugin wrapper over a prefix shaped like npm's on
69
+ Windows, with `MIRROR_BIN` in the forward-slash drive form.
70
+
71
+ **Docs.** The rule is stated once, in full, in the
72
+ [configuration reference](../reference/configuration.md#hook-bin-resolution), and
73
+ `REFERENCE.md` points there. Troubleshooting shows the refusal line and the fix, and no
74
+ longer says `MIRROR_BIN` is honored as given. Getting Started says the directory is given
75
+ as an absolute path.
76
+
77
+ Validated by the Navigator in a scratch home: two runs of the plugin's prompt hook with
78
+ `MIRROR_BIN=bin` wrote one line,
79
+
80
+ ```text
81
+ 2026-11-07T19:11:56Z claude:user-prompt: MIRROR_BIN=bin is relative; searching the PATH and the usual places instead
82
+ ```
83
+
84
+ the MCP entry printed the same refusal and answered `initialize`, the project's planted
85
+ bins never ran, the prompt was logged, and `/opt/homebrew/bin`, the value his own Claude
86
+ settings set, was used with no refusal.
87
+
88
+ **Also in this release.** CV9's re-baseline
89
+ ([CV9.DS10](../project/roadmap/cv9-mirror-1-0/cv9-ds10-the-1-0-done-condition-describes-the-system-that-exists/index.md)),
90
+ which changes documents only: the 1.0 done condition is now a table of claims, each with
91
+ the story that owns it.
92
+
93
+ ## Conscious Exclusions
94
+
95
+ - `runtime diagnose` does not name a refused `MIRROR_BIN`. It counts the hooks' line as
96
+ `hook_failures_recorded`, and the value and its reason are in `hooks.log`. Deferred to
97
+ [CR198](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr198-runtime-diagnose-does-not-name-a-refused-mirror-node.md),
98
+ which now covers both variables.
99
+ - A relative entry in your own `PATH`, such as `.`: `command -v` finds a project's file
100
+ through it for every command you run, not only Mirror's.
101
+ - An absolute value is trusted wherever it points: whoever sets it chooses what runs.
102
+ - Rodrigo Immaginario's installer run against `main` was asked for and does not gate this
103
+ tag; the Windows CI job is this release's Windows evidence.
104
+
105
+ ## Upgrading
106
+
107
+ ```bash
108
+ mirror runtime update
109
+ ```
110
+
111
+ If you set `MIRROR_BIN` by hand, make it the absolute path of the directory holding
112
+ `mirror-hook`, `$(npm prefix -g)/bin`: a relative one is now refused, with one line a day
113
+ in `hooks.log`. A plugin copied into a runtime's cache keeps the old wrappers until the
114
+ runtime takes the update; `runtime diagnose` reports a copy that is behind.
115
+
116
+ ## Next Horizon
117
+
118
+ CR198, so that `runtime diagnose` names a refused `MIRROR_NODE` or `MIRROR_BIN`; CV9.DS11,
119
+ journeys that can be reshaped without losing a record; and the rest of the way to 1.0 in
120
+ the [CV9 index](../project/roadmap/cv9-mirror-1-0/index.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.40.0-next.1",
3
+ "version": "0.40.1",
4
4
  "description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Codex, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.40.0-next.1",
3
+ "version": "0.40.1",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -10,7 +10,7 @@
10
10
  "command": "/bin/sh",
11
11
  "args": [
12
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; }; mmjs=\"$(dirname \"$mmbin\")/node_modules/mirror-mind/bin/mirror.js\"; [ -f \"$mmjs\" ] && mmbin=\"$mmjs\"; mmnode=\"$MIRROR_NODE\"; mmwhy=\"\"; [ -n \"$mmnode\" ] && case \"$mmnode\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ -d \"$mmnode\" ]; then mmwhy=\"a directory\"; elif [ ! -e \"$mmnode\" ]; then mmwhy=\"missing\"; elif [ ! -f \"$mmnode\" ] || [ ! -x \"$mmnode\" ]; then mmwhy=\"not executable\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && { printf 'mirror-mind: MIRROR_NODE=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmnode\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; mmnode=\"\"; }; [ -n \"$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"
13
+ "mmbin=\"\"; mmbindir=\"$MIRROR_BIN\"; mmwhy=\"\"; [ -n \"$mmbindir\" ] && case \"$mmbindir\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ ! -e \"$mmbindir\" ]; then mmwhy=\"missing\"; elif [ ! -d \"$mmbindir\" ]; then mmwhy=\"not a directory\"; elif [ ! -f \"$mmbindir/mirror\" ] || [ ! -x \"$mmbindir/mirror\" ]; then mmwhy=\"a directory without an executable mirror\"; else mmbin=\"$mmbindir/mirror\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && printf 'mirror-mind: MIRROR_BIN=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmbindir\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; [ -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; }; mmjs=\"$(dirname \"$mmbin\")/node_modules/mirror-mind/bin/mirror.js\"; [ -f \"$mmjs\" ] && mmbin=\"$mmjs\"; mmnode=\"$MIRROR_NODE\"; mmwhy=\"\"; [ -n \"$mmnode\" ] && case \"$mmnode\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ -d \"$mmnode\" ]; then mmwhy=\"a directory\"; elif [ ! -e \"$mmnode\" ]; then mmwhy=\"missing\"; elif [ ! -f \"$mmnode\" ] || [ ! -x \"$mmnode\" ]; then mmwhy=\"not executable\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && { printf 'mirror-mind: MIRROR_NODE=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmnode\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; mmnode=\"\"; }; [ -n \"$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
14
  ]
15
15
  }
16
16
  }
@@ -62,10 +62,31 @@ note() {
62
62
  }
63
63
 
64
64
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
65
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
66
- # then the PATH; then the global bin directories a GUI launch leaves off it.
65
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
66
+ # directories a GUI launch leaves off it.
67
+ #
68
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
69
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
70
+ # relative one would resolve against the project the runtime spawned this hook
71
+ # from, and Node would run that project's file. Anything else is noted with its
72
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
67
73
  ENTRY=""
68
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
74
+ if [ -n "${MIRROR_BIN:-}" ]; then
75
+ refused=""
76
+ case "$MIRROR_BIN" in
77
+ /*|\\*|[A-Za-z]:[\\/]*)
78
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
79
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
80
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
81
+ else ENTRY="$MIRROR_BIN/mirror-hook"
82
+ fi
83
+ ;;
84
+ *) refused="relative" ;;
85
+ esac
86
+ if [ -n "$refused" ]; then
87
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
88
+ fi
89
+ fi
69
90
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
70
91
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
71
92
  [ -n "$ENTRY" ] && break
@@ -62,10 +62,31 @@ note() {
62
62
  }
63
63
 
64
64
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
65
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
66
- # then the PATH; then the global bin directories a GUI launch leaves off it.
65
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
66
+ # directories a GUI launch leaves off it.
67
+ #
68
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
69
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
70
+ # relative one would resolve against the project the runtime spawned this hook
71
+ # from, and Node would run that project's file. Anything else is noted with its
72
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
67
73
  ENTRY=""
68
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
74
+ if [ -n "${MIRROR_BIN:-}" ]; then
75
+ refused=""
76
+ case "$MIRROR_BIN" in
77
+ /*|\\*|[A-Za-z]:[\\/]*)
78
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
79
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
80
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
81
+ else ENTRY="$MIRROR_BIN/mirror-hook"
82
+ fi
83
+ ;;
84
+ *) refused="relative" ;;
85
+ esac
86
+ if [ -n "$refused" ]; then
87
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
88
+ fi
89
+ fi
69
90
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
70
91
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
71
92
  [ -n "$ENTRY" ] && break
@@ -62,10 +62,31 @@ note() {
62
62
  }
63
63
 
64
64
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
65
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
66
- # then the PATH; then the global bin directories a GUI launch leaves off it.
65
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
66
+ # directories a GUI launch leaves off it.
67
+ #
68
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
69
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
70
+ # relative one would resolve against the project the runtime spawned this hook
71
+ # from, and Node would run that project's file. Anything else is noted with its
72
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
67
73
  ENTRY=""
68
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
74
+ if [ -n "${MIRROR_BIN:-}" ]; then
75
+ refused=""
76
+ case "$MIRROR_BIN" in
77
+ /*|\\*|[A-Za-z]:[\\/]*)
78
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
79
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
80
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
81
+ else ENTRY="$MIRROR_BIN/mirror-hook"
82
+ fi
83
+ ;;
84
+ *) refused="relative" ;;
85
+ esac
86
+ if [ -n "$refused" ]; then
87
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
88
+ fi
89
+ fi
69
90
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
70
91
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
71
92
  [ -n "$ENTRY" ] && break
@@ -57,10 +57,31 @@ note() {
57
57
  }
58
58
 
59
59
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
60
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
61
- # then the PATH; then the global bin directories a GUI launch leaves off it.
60
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
61
+ # directories a GUI launch leaves off it.
62
+ #
63
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
64
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
65
+ # relative one would resolve against the project the runtime spawned this hook
66
+ # from, and Node would run that project's file. Anything else is noted with its
67
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
62
68
  ENTRY=""
63
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
69
+ if [ -n "${MIRROR_BIN:-}" ]; then
70
+ refused=""
71
+ case "$MIRROR_BIN" in
72
+ /*|\\*|[A-Za-z]:[\\/]*)
73
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
74
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
75
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
76
+ else ENTRY="$MIRROR_BIN/mirror-hook"
77
+ fi
78
+ ;;
79
+ *) refused="relative" ;;
80
+ esac
81
+ if [ -n "$refused" ]; then
82
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
83
+ fi
84
+ fi
64
85
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
65
86
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
66
87
  [ -n "$ENTRY" ] && break
@@ -57,10 +57,31 @@ note() {
57
57
  }
58
58
 
59
59
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
60
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
61
- # then the PATH; then the global bin directories a GUI launch leaves off it.
60
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
61
+ # directories a GUI launch leaves off it.
62
+ #
63
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
64
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
65
+ # relative one would resolve against the project the runtime spawned this hook
66
+ # from, and Node would run that project's file. Anything else is noted with its
67
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
62
68
  ENTRY=""
63
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
69
+ if [ -n "${MIRROR_BIN:-}" ]; then
70
+ refused=""
71
+ case "$MIRROR_BIN" in
72
+ /*|\\*|[A-Za-z]:[\\/]*)
73
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
74
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
75
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
76
+ else ENTRY="$MIRROR_BIN/mirror-hook"
77
+ fi
78
+ ;;
79
+ *) refused="relative" ;;
80
+ esac
81
+ if [ -n "$refused" ]; then
82
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
83
+ fi
84
+ fi
64
85
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
65
86
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
66
87
  [ -n "$ENTRY" ] && break
@@ -57,10 +57,31 @@ note() {
57
57
  }
58
58
 
59
59
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
60
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
61
- # then the PATH; then the global bin directories a GUI launch leaves off it.
60
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
61
+ # directories a GUI launch leaves off it.
62
+ #
63
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
64
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
65
+ # relative one would resolve against the project the runtime spawned this hook
66
+ # from, and Node would run that project's file. Anything else is noted with its
67
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
62
68
  ENTRY=""
63
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
69
+ if [ -n "${MIRROR_BIN:-}" ]; then
70
+ refused=""
71
+ case "$MIRROR_BIN" in
72
+ /*|\\*|[A-Za-z]:[\\/]*)
73
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
74
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
75
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
76
+ else ENTRY="$MIRROR_BIN/mirror-hook"
77
+ fi
78
+ ;;
79
+ *) refused="relative" ;;
80
+ esac
81
+ if [ -n "$refused" ]; then
82
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
83
+ fi
84
+ fi
64
85
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
65
86
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
66
87
  [ -n "$ENTRY" ] && break
@@ -57,10 +57,31 @@ note() {
57
57
  }
58
58
 
59
59
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
60
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
61
- # then the PATH; then the global bin directories a GUI launch leaves off it.
60
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
61
+ # directories a GUI launch leaves off it.
62
+ #
63
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
64
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
65
+ # relative one would resolve against the project the runtime spawned this hook
66
+ # from, and Node would run that project's file. Anything else is noted with its
67
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
62
68
  ENTRY=""
63
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
69
+ if [ -n "${MIRROR_BIN:-}" ]; then
70
+ refused=""
71
+ case "$MIRROR_BIN" in
72
+ /*|\\*|[A-Za-z]:[\\/]*)
73
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
74
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
75
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
76
+ else ENTRY="$MIRROR_BIN/mirror-hook"
77
+ fi
78
+ ;;
79
+ *) refused="relative" ;;
80
+ esac
81
+ if [ -n "$refused" ]; then
82
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
83
+ fi
84
+ fi
64
85
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
65
86
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
66
87
  [ -n "$ENTRY" ] && break
@@ -57,10 +57,31 @@ note() {
57
57
  }
58
58
 
59
59
  # Find the bin. MIRROR_BIN names the directory holding `mirror` and
60
- # `mirror-hook` (`$(npm prefix -g)/bin`), the same trust class as MIRROR_NODE;
61
- # then the PATH; then the global bin directories a GUI launch leaves off it.
60
+ # `mirror-hook` (`$(npm prefix -g)/bin`); then the PATH; then the global bin
61
+ # directories a GUI launch leaves off it.
62
+ #
63
+ # MIRROR_BIN is used only as an absolute path to a directory holding an
64
+ # executable `mirror-hook`, by MIRROR_NODE's rule and pattern (CR197): a
65
+ # relative one would resolve against the project the runtime spawned this hook
66
+ # from, and Node would run that project's file. Anything else is noted with its
67
+ # reason, in MIRROR_NODE's words, once a day, and the search goes on without it.
62
68
  ENTRY=""
63
- [ -n "${MIRROR_BIN:-}" ] && [ -x "$MIRROR_BIN/mirror-hook" ] && ENTRY="$MIRROR_BIN/mirror-hook"
69
+ if [ -n "${MIRROR_BIN:-}" ]; then
70
+ refused=""
71
+ case "$MIRROR_BIN" in
72
+ /*|\\*|[A-Za-z]:[\\/]*)
73
+ if [ ! -e "$MIRROR_BIN" ]; then refused="missing"
74
+ elif [ ! -d "$MIRROR_BIN" ]; then refused="not a directory"
75
+ elif [ ! -f "$MIRROR_BIN/mirror-hook" ] || [ ! -x "$MIRROR_BIN/mirror-hook" ]; then refused="a directory without an executable mirror-hook"
76
+ else ENTRY="$MIRROR_BIN/mirror-hook"
77
+ fi
78
+ ;;
79
+ *) refused="relative" ;;
80
+ esac
81
+ if [ -n "$refused" ]; then
82
+ note "MIRROR_BIN=${MIRROR_BIN//[$'\r\n']/} is $refused; searching the PATH and the usual places instead" daily
83
+ fi
84
+ fi
64
85
  [ -n "$ENTRY" ] || ENTRY="$(command -v mirror-hook 2>/dev/null || true)"
65
86
  for candidate in "$HOME/.nvm/current/bin" /opt/homebrew/bin /usr/local/bin; do
66
87
  [ -n "$ENTRY" ] && break
@@ -4,7 +4,7 @@
4
4
  "command": "/bin/sh",
5
5
  "args": [
6
6
  "-c",
7
- "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; }; mmjs=\"$(dirname \"$mmbin\")/node_modules/mirror-mind/bin/mirror.js\"; [ -f \"$mmjs\" ] && mmbin=\"$mmjs\"; mmnode=\"$MIRROR_NODE\"; mmwhy=\"\"; [ -n \"$mmnode\" ] && case \"$mmnode\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ -d \"$mmnode\" ]; then mmwhy=\"a directory\"; elif [ ! -e \"$mmnode\" ]; then mmwhy=\"missing\"; elif [ ! -f \"$mmnode\" ] || [ ! -x \"$mmnode\" ]; then mmwhy=\"not executable\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && { printf 'mirror-mind: MIRROR_NODE=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmnode\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; mmnode=\"\"; }; [ -n \"$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"
7
+ "mmbin=\"\"; mmbindir=\"$MIRROR_BIN\"; mmwhy=\"\"; [ -n \"$mmbindir\" ] && case \"$mmbindir\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ ! -e \"$mmbindir\" ]; then mmwhy=\"missing\"; elif [ ! -d \"$mmbindir\" ]; then mmwhy=\"not a directory\"; elif [ ! -f \"$mmbindir/mirror\" ] || [ ! -x \"$mmbindir/mirror\" ]; then mmwhy=\"a directory without an executable mirror\"; else mmbin=\"$mmbindir/mirror\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && printf 'mirror-mind: MIRROR_BIN=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmbindir\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; [ -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; }; mmjs=\"$(dirname \"$mmbin\")/node_modules/mirror-mind/bin/mirror.js\"; [ -f \"$mmjs\" ] && mmbin=\"$mmjs\"; mmnode=\"$MIRROR_NODE\"; mmwhy=\"\"; [ -n \"$mmnode\" ] && case \"$mmnode\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ -d \"$mmnode\" ]; then mmwhy=\"a directory\"; elif [ ! -e \"$mmnode\" ]; then mmwhy=\"missing\"; elif [ ! -f \"$mmnode\" ] || [ ! -x \"$mmnode\" ]; then mmwhy=\"not executable\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && { printf 'mirror-mind: MIRROR_NODE=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmnode\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; mmnode=\"\"; }; [ -n \"$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"
8
8
  ]
9
9
  }
10
10
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.40.0-next.1",
4
+ "version": "0.40.1",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
8
8
  "args": [
9
9
  "-c",
10
- "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; }; mmjs=\"$(dirname \"$mmbin\")/node_modules/mirror-mind/bin/mirror.js\"; [ -f \"$mmjs\" ] && mmbin=\"$mmjs\"; mmnode=\"$MIRROR_NODE\"; mmwhy=\"\"; [ -n \"$mmnode\" ] && case \"$mmnode\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ -d \"$mmnode\" ]; then mmwhy=\"a directory\"; elif [ ! -e \"$mmnode\" ]; then mmwhy=\"missing\"; elif [ ! -f \"$mmnode\" ] || [ ! -x \"$mmnode\" ]; then mmwhy=\"not executable\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && { printf 'mirror-mind: MIRROR_NODE=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmnode\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; mmnode=\"\"; }; [ -n \"$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"
10
+ "mmbin=\"\"; mmbindir=\"$MIRROR_BIN\"; mmwhy=\"\"; [ -n \"$mmbindir\" ] && case \"$mmbindir\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ ! -e \"$mmbindir\" ]; then mmwhy=\"missing\"; elif [ ! -d \"$mmbindir\" ]; then mmwhy=\"not a directory\"; elif [ ! -f \"$mmbindir/mirror\" ] || [ ! -x \"$mmbindir/mirror\" ]; then mmwhy=\"a directory without an executable mirror\"; else mmbin=\"$mmbindir/mirror\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && printf 'mirror-mind: MIRROR_BIN=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmbindir\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; [ -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; }; mmjs=\"$(dirname \"$mmbin\")/node_modules/mirror-mind/bin/mirror.js\"; [ -f \"$mmjs\" ] && mmbin=\"$mmjs\"; mmnode=\"$MIRROR_NODE\"; mmwhy=\"\"; [ -n \"$mmnode\" ] && case \"$mmnode\" in /*|\\\\*|[A-Za-z]:[\\\\/]*) if [ -d \"$mmnode\" ]; then mmwhy=\"a directory\"; elif [ ! -e \"$mmnode\" ]; then mmwhy=\"missing\"; elif [ ! -f \"$mmnode\" ] || [ ! -x \"$mmnode\" ]; then mmwhy=\"not executable\"; fi ;; *) mmwhy=\"relative\" ;; esac; [ -n \"$mmwhy\" ] && { printf 'mirror-mind: MIRROR_NODE=%s is %s; searching the PATH and the usual places instead\\n' \"$(printf '%s' \"$mmnode\" | tr -d '\\r\\n')\" \"$mmwhy\" >&2; mmnode=\"\"; }; [ -n \"$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"
11
11
  ]
12
12
  }
13
13
  }
@@ -584,7 +584,6 @@ export interface HookLoggingOptions {
584
584
  export type SessionEndHookOutcome =
585
585
  | { action: "skipped"; reason: "malformed_payload" | "nothing_to_do" }
586
586
  | { action: "ended"; sessionId: string; transcriptPath: string | null; backfilled: boolean }
587
- | { action: "backfilled"; transcriptPath: string }
588
587
  | { action: "failed"; error: string };
589
588
 
590
589
  const parsePayload = parseHookPayloadObject;
@@ -917,12 +916,15 @@ export function handleStopHook(
917
916
  }
918
917
 
919
918
  /**
920
- * Python `hook_session_end`: end the session when the payload names one, then
921
- * backfill assistant turns from the transcript when it exists -- in that
922
- * order, so the just-ended conversation's `ended_at` bounds the backfill.
919
+ * Python `hook_session_end`: end the session the payload names, then backfill
920
+ * assistant turns from its transcript when it exists -- in that order, so the
921
+ * just-ended conversation's `ended_at` bounds the backfill.
923
922
  *
924
- * The two halves are independent: an empty `session_id` with a transcript
925
- * still backfills (the session-less route, slice E), and a session without a
923
+ * The backfill fills only the conversation the session is bound to (CR151).
924
+ * Python's session-less route -- no `session_id`, a transcript alone, filling
925
+ * every conversation in the transcript's time window -- is gone: a transcript
926
+ * no session owns cannot be attributed, so it writes nothing. A session with no
927
+ * bound conversation likewise backfills nothing; a session without a
926
928
  * transcript still ends. Everything is swallowed; the hook always exits 0.
927
929
  */
928
930
  export async function handleSessionEndHook(
@@ -943,42 +945,40 @@ export async function handleSessionEndHook(
943
945
  if (!payload) return { action: "skipped", reason: "malformed_payload" };
944
946
 
945
947
  const sessionId = payloadString(payload, "session_id");
948
+ if (!sessionId) return { action: "skipped", reason: "nothing_to_do" };
946
949
  const transcriptPath = resolveTranscriptPath(
947
950
  payloadString(payload, "transcript_path"),
948
951
  sessionId,
949
952
  options.claudeProjectDir,
950
953
  options.homeDir,
951
954
  );
955
+ // Read before the close, so the attribution does not lean on the close
956
+ // preserving `conversation_id` when it deactivates the session.
957
+ const runtimeSession = getRuntimeSession(db, sessionId);
958
+ const conversationId = runtimeSession?.conversationId ?? null;
952
959
  let backfilled = false;
953
960
  // After the close, which bounds the backfill window, and before the tail,
954
- // which should see what the backfill adds (CV21.DS4.TS2).
961
+ // which should see what the backfill adds (CV21.DS4.TS2). A session with no
962
+ // bound conversation closes nothing and backfills nothing (CR151).
955
963
  const backfill = () => {
956
- backfilled = transcriptPath !== null && existsSync(transcriptPath);
957
- if (backfilled && transcriptPath !== null)
958
- backfillAssistantMessages(db, transcriptPath, deps);
964
+ if (conversationId === null || transcriptPath === null || !existsSync(transcriptPath)) return;
965
+ backfilled = true;
966
+ backfillAssistantMessages(db, transcriptPath, conversationId, deps);
959
967
  };
960
968
 
961
- if (sessionId) {
962
- // The label US8 stamps on every prompt; the hook's own detection only when
963
- // the row carries none; today's behavior when neither names a runtime.
964
- const label = getRuntimeSession(db, sessionId)?.interface || options.interface || null;
965
- const finalize = sessionEndRunsLlmTail(label);
966
- const closed = await endSession(
967
- db,
968
- sessionId,
969
- { extract: finalize, finalize, beforeTail: backfill },
970
- deps,
971
- hooks,
972
- );
973
- // A session with no bound conversation closes nothing; the backfill still runs.
974
- if (!closed) backfill();
975
- } else {
976
- backfill();
977
- }
969
+ // The label US8 stamps on every prompt; the hook's own detection only when
970
+ // the row carries none; today's behavior when neither names a runtime.
971
+ const label = runtimeSession?.interface || options.interface || null;
972
+ const finalize = sessionEndRunsLlmTail(label);
973
+ await endSession(
974
+ db,
975
+ sessionId,
976
+ { extract: finalize, finalize, beforeTail: backfill },
977
+ deps,
978
+ hooks,
979
+ );
978
980
 
979
- if (sessionId) return { action: "ended", sessionId, transcriptPath, backfilled };
980
- if (backfilled && transcriptPath !== null) return { action: "backfilled", transcriptPath };
981
- return { action: "skipped", reason: "nothing_to_do" };
981
+ return { action: "ended", sessionId, transcriptPath, backfilled };
982
982
  } catch (error) {
983
983
  return { action: "failed", error: error instanceof Error ? error.message : String(error) };
984
984
  }
@@ -1,23 +1,33 @@
1
- // Assistant-turn backfill from a Claude transcript (CV22.DS7.US10 slice E).
1
+ // Assistant-turn backfill from a Claude transcript (CV22.DS7.US10 slice E,
2
+ // rescoped by CR151).
2
3
  //
3
- // Ports `backfill_assistant_messages` from
4
+ // Started as a port of `backfill_assistant_messages` from
4
5
  // `src/memory/cli/conversation_logger.py` together with the two helpers it
5
6
  // borrows from `src/memory/cli/transcript_export.py` (`parse_jsonl`,
6
- // `_assistant_text`). `hook_session_end` calls it after ending the session --
7
- // and, when the payload carries no session id at all, INSTEAD of ending one:
8
- // that session-less route is the path US5's hook port deliberately left out.
7
+ // `_assistant_text`). `hook_session_end` calls it after ending the session.
9
8
  //
10
- // This is not an import: nothing is created or bound. It appends assistant
11
- // turns to conversations that overlap the transcript's time window and logged
12
- // no assistant message of their own, stamping each with `now` (Python's
13
- // `Message` default), not with the transcript timestamp. Python commits every
14
- // message as it goes and swallows nothing here; a malformed entry reached
15
- // mid-walk raises with the earlier appends already committed. The port keeps
16
- // that sequence so partial state matches, and leaves swallowing to the hook.
9
+ // It now DIVERGES from that Python on purpose (CR151). Python chose the
10
+ // conversations to fill by time window alone, so a session's replies were
11
+ // copied into every open, reply-less conversation overlapping its transcript
12
+ // -- a session killed mid-first-turn, or a concurrent one on another runtime --
13
+ // and stamped them with `now`. Do not restore either behavior:
14
+ //
15
+ // * the caller names the ONE conversation the transcript belongs to (the
16
+ // ending session's bound conversation); nothing else is touched, and a
17
+ // transcript no session owns fills nothing;
18
+ // * an inserted reply carries the transcript's own timestamp, normalized to
19
+ // the logger's microsecond ISO form, so the reply sorts between the prompts
20
+ // around it for every reader that orders by `created_at`.
21
+ //
22
+ // This is not an import: nothing is created or bound. A conversation that
23
+ // already logged an assistant turn of its own (the Stop hook's path) is left
24
+ // alone. Appends commit as they go; a malformed entry reached mid-walk raises
25
+ // with the earlier appends already committed, and the hook swallows it.
17
26
 
18
27
  import { readFileSync } from "node:fs";
19
28
  import type { LoggerDeps } from "#conversation/logger.ts";
20
29
  import type { WritableDatabase } from "#db/database.ts";
30
+ import { toMicrosecondIso } from "#util/pyGenerators.ts";
21
31
 
22
32
  function isRecord(value: unknown): value is Record<string, unknown> {
23
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -76,70 +86,67 @@ export function assistantText(contentBlocks: unknown): string {
76
86
  return parts.join("\n\n");
77
87
  }
78
88
 
79
- /** Python `str` truthiness on the timestamp: absent, null, or empty is skipped. */
89
+ const UTC_ISO = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/;
90
+
91
+ /**
92
+ * A timestamp in the logger's microsecond ISO-`Z` form, or null when it cannot
93
+ * be read as an instant. Claude writes milliseconds (`.123Z`), the logger
94
+ * microseconds (`.123000Z`); compared as strings the two disagree (`.123Z`
95
+ * sorts after `.123456Z`), so every bound and every insert goes through here.
96
+ * A UTC string is re-padded rather than parsed, keeping microseconds intact.
97
+ */
98
+ export function normalizeTimestamp(value: string): string | null {
99
+ const utc = UTC_ISO.exec(value);
100
+ if (utc) return `${utc[1]}.${(utc[2] ?? "").padEnd(6, "0").slice(0, 6)}Z`;
101
+ const parsed = new Date(value);
102
+ return Number.isNaN(parsed.getTime()) ? null : toMicrosecondIso(parsed);
103
+ }
104
+
105
+ /** An entry's normalized timestamp; absent, empty, non-string, or unreadable is null. */
80
106
  function timestampOf(entry: unknown): string | null {
81
107
  const value = getOr(entry, "timestamp", "");
82
- return typeof value === "string" && value ? value : null;
108
+ return typeof value === "string" && value ? normalizeTimestamp(value) : null;
83
109
  }
84
110
 
85
111
  /**
86
- * Python `backfill_assistant_messages`. The transcript window is
87
- * `[min(timestamp), max(timestamp)]` over entries that carry one; a
88
- * conversation is in range when `started_at <= window end` and it either
89
- * ended at or after the window start or is still open. Within an in-range
90
- * conversation that has no assistant turn, every `assistant` entry whose
91
- * timestamp falls inside `[started_at, ended_at or window end]` is appended.
112
+ * Append the transcript's assistant turns to `conversationId` -- the one
113
+ * conversation the transcript's session is bound to, and only it (CR151).
114
+ * Skipped when that conversation does not exist or already logged an
115
+ * assistant turn. An `assistant` entry lands when its timestamp falls inside
116
+ * the conversation's own `[started_at, ended_at]` (open-ended while the
117
+ * conversation is open), and it lands stamped with that timestamp.
92
118
  */
93
119
  export function backfillAssistantMessages(
94
120
  db: WritableDatabase,
95
121
  transcriptPath: string,
96
- deps: LoggerDeps,
122
+ conversationId: string,
123
+ deps: Pick<LoggerDeps, "newId">,
97
124
  ): void {
98
- const entries = parseJsonl(transcriptPath);
99
- if (entries.length === 0) return;
125
+ const conversation = db
126
+ .prepare("SELECT started_at, ended_at FROM conversations WHERE id = ?")
127
+ .get(conversationId) as { started_at: string; ended_at: string | null } | undefined;
128
+ if (!conversation) return;
129
+ const hasAssistant = db
130
+ .prepare("SELECT 1 FROM messages WHERE conversation_id = ? AND role = 'assistant' LIMIT 1")
131
+ .get(conversationId);
132
+ if (hasAssistant) return;
100
133
 
101
- const timestamps = entries.map(timestampOf).filter((value): value is string => value !== null);
102
- if (timestamps.length === 0) return;
103
- // Python's min()/max() on str compare by code point; ISO timestamps are
104
- // ASCII, so plain comparison is the same order.
105
- let startTime = timestamps[0] as string;
106
- let endTime = timestamps[0] as string;
107
- for (const value of timestamps) {
108
- if (value < startTime) startTime = value;
109
- if (value > endTime) endTime = value;
110
- }
111
-
112
- const conversations = db
113
- .prepare(
114
- `SELECT id, started_at, ended_at FROM conversations
115
- WHERE started_at <= ? AND (ended_at >= ? OR ended_at IS NULL)`,
116
- )
117
- .all(endTime, startTime) as { id: string; started_at: string; ended_at: string | null }[];
134
+ const start = normalizeTimestamp(conversation.started_at) ?? conversation.started_at;
135
+ const end =
136
+ conversation.ended_at === null
137
+ ? null
138
+ : (normalizeTimestamp(conversation.ended_at) ?? conversation.ended_at);
118
139
 
119
- const hasAssistant = db.prepare(
120
- "SELECT 1 FROM messages WHERE conversation_id = ? AND role = 'assistant' LIMIT 1",
121
- );
122
140
  const insert = db.prepare(
123
141
  `INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
124
142
  VALUES (?, ?, 'assistant', ?, ?, NULL, NULL)`,
125
143
  );
126
-
127
- for (const conversation of conversations) {
128
- if (hasAssistant.get(conversation.id)) continue;
129
- const conversationStart = conversation.started_at;
130
- const conversationEnd = conversation.ended_at ?? endTime;
131
-
132
- for (const entry of entries) {
133
- const timestamp = timestampOf(entry);
134
- if (!timestamp || !(conversationStart <= timestamp && timestamp <= conversationEnd)) continue;
135
- if (getFrom(entry, "type") !== "assistant") continue;
136
- const message = getOr(entry, "message", {});
137
- const text = assistantText(getOr(message, "content", []));
138
- if (text) {
139
- // Python: Message(...) draws its id before its created_at.
140
- const id = deps.newId();
141
- insert.run(id, conversation.id, text, deps.nowIso());
142
- }
143
- }
144
+ for (const entry of parseJsonl(transcriptPath)) {
145
+ const timestamp = timestampOf(entry);
146
+ if (!timestamp || timestamp < start || (end !== null && timestamp > end)) continue;
147
+ if (getFrom(entry, "type") !== "assistant") continue;
148
+ const message = getOr(entry, "message", {});
149
+ const text = assistantText(getOr(message, "content", []));
150
+ if (text) insert.run(deps.newId(), conversationId, text, timestamp);
144
151
  }
145
152
  }
@@ -111,6 +111,10 @@ export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
111
111
  * reason, in the Pi extension's words, and the search goes on without it. The
112
112
  * value is read once, into `mmnode`, and printed with `printf` and its line
113
113
  * breaks removed: dash's `echo` turns the `\n` of `\nodejs` into a newline.
114
+ * `MIRROR_BIN` follows the same rule (CR197): only an absolute path to a
115
+ * directory holding an executable `mirror`, read once, into `mmbindir`, and
116
+ * refused in the same words. A relative one would resolve against the project
117
+ * the client started the server from, and Node would run that project's file.
114
118
  *
115
119
  * Written for what three runtimes do to the string (the US6 spike):
116
120
  *
@@ -119,14 +123,17 @@ export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
119
123
  * runtime that did so with an unset name would blank it;
120
124
  * * no positional parameter (`$1`, `$@`, ...) and no backtick, for the same
121
125
  * reason, and because the text travels through two JSON encoders;
122
- * * variables named `mmbin`, `mmnode`, `mmdir`: Grok substitutes SET
126
+ * * variables named `mmbin`, `mmbindir`, `mmnode`, `mmdir`: Grok substitutes SET
123
127
  * environment variables by name inside MCP args at load time (`$HOME`
124
128
  * became the path), so a script variable with a name a person exports
125
129
  * would be replaced before the shell ever saw it.
126
130
  */
127
131
  export const MCP_LOCATOR = [
128
132
  'mmbin=""',
129
- '[ -n "$MIRROR_BIN" ] && [ -x "$MIRROR_BIN/mirror" ] && mmbin="$MIRROR_BIN/mirror"',
133
+ 'mmbindir="$MIRROR_BIN"',
134
+ 'mmwhy=""',
135
+ `[ -n "$mmbindir" ] && case "$mmbindir" in ${ABSOLUTE_PATH_CASE}) if [ ! -e "$mmbindir" ]; then mmwhy="missing"; elif [ ! -d "$mmbindir" ]; then mmwhy="not a directory"; elif [ ! -f "$mmbindir/mirror" ] || [ ! -x "$mmbindir/mirror" ]; then mmwhy="a directory without an executable mirror"; else mmbin="$mmbindir/mirror"; fi ;; *) mmwhy="relative" ;; esac`,
136
+ `[ -n "$mmwhy" ] && printf 'mirror-mind: MIRROR_BIN=%s is %s; searching the PATH and the usual places instead\\n' "$(printf '%s' "$mmbindir" | tr -d '\\r\\n')" "$mmwhy" >&2`,
130
137
  '[ -n "$mmbin" ] || mmbin="$(command -v mirror 2>/dev/null)"',
131
138
  `for mmdir in ${GLOBAL_BIN_DIRS.join(" ")}; do [ -n "$mmbin" ] && break; [ -x "$mmdir/mirror" ] && mmbin="$mmdir/mirror"; done`,
132
139
  '[ -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; }',