mirror-mind 0.38.1 → 0.39.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.
@@ -43,6 +43,14 @@
43
43
  * it supplies the Operating Instructions (`AGENTS.md`) itself (D13), and says
44
44
  * so once when `mirror` is not on the PATH.
45
45
  *
46
+ * The `node` it runs the core on is resolved as the hook wrappers resolve
47
+ * theirs (CR191): `MIRROR_NODE`, the PATH, the Node that installed the
48
+ * package, then nvm, Homebrew, and `/usr/local`. A GUI-launched Pi, or one from
49
+ * a window older than the Node install, has none on its PATH. When nothing
50
+ * resolves the session says so once, names the fix, and writes one line to
51
+ * `<mirror home>/hooks.log`, where `runtime diagnose` reads it; it spawns
52
+ * nothing, and Pi stays up.
53
+ *
46
54
  * External skill prep note:
47
55
  * Pi reads installed external skills from
48
56
  * ~/.mirror-minds/<user>/runtime/skills/pi/extensions.json
@@ -52,7 +60,7 @@
52
60
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
53
61
  import { accessSync, appendFileSync, closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
54
62
  import { homedir } from "node:os";
55
- import { delimiter, dirname, join } from "node:path";
63
+ import { delimiter, dirname, join, posix, win32 } from "node:path";
56
64
  import { fileURLToPath } from "node:url";
57
65
  import { spawn } from "node:child_process";
58
66
  import { randomUUID } from "node:crypto";
@@ -180,6 +188,95 @@ function _mirrorOnPath(): string | null {
180
188
  return null;
181
189
  }
182
190
 
191
+ /**
192
+ * Where a hook wrapper looks for Node after `MIRROR_NODE`, the PATH, and the
193
+ * Node beside the entry it runs (CR191). The same list as the core's
194
+ * `HOOK_NODE_CANDIDATES` (`ts/src/runtime/diagnose.ts`), which this file
195
+ * cannot import: it is a thin dispatcher and duplicates the contracts it
196
+ * needs, as it does the homes-root names above. `mirrorLoggerExtension.test.ts`
197
+ * fails if the two lists differ, as `hooks.test.ts` does for the wrappers.
198
+ */
199
+ export const NODE_CANDIDATES: readonly string[] = [
200
+ "$HOME/.nvm/current/bin/node",
201
+ "/opt/homebrew/bin/node",
202
+ "/usr/local/bin/node",
203
+ ];
204
+
205
+ /** What `resolveNode` found at a path it probed. */
206
+ export type PathKind = "executable" | "directory" | "not executable" | "missing";
207
+
208
+ /** An executable FILE: a directory passes `X_OK` on its own. */
209
+ function pathKind(path: string): PathKind {
210
+ try {
211
+ if (statSync(path).isDirectory()) return "directory";
212
+ } catch {
213
+ return "missing";
214
+ }
215
+ try {
216
+ accessSync(path, constants.X_OK);
217
+ return "executable";
218
+ } catch {
219
+ return "not executable";
220
+ }
221
+ }
222
+
223
+ export interface NodeResolution {
224
+ /** The Node to run the core on, by its path; null when none resolves. */
225
+ node: string | null;
226
+ /** A `MIRROR_NODE` the search would not use, and why: the caller says so once. */
227
+ rejected: { value: string; reason: string } | null;
228
+ }
229
+
230
+ /**
231
+ * The Node the core runs on, in the hook wrappers' order (CR191): `MIRROR_NODE`
232
+ * when it is an absolute path to an executable file; `node` on the PATH; the
233
+ * Node beside the tree this file ships in, which for a global install is the
234
+ * `<prefix>/bin/node` that installed the package (and nothing for a checkout);
235
+ * then `NODE_CANDIDATES`. Not `process.execPath`: Pi may run on a Node older
236
+ * than the core needs. On Windows the PATH is the last place looked.
237
+ *
238
+ * A relative `MIRROR_NODE` is refused here, though the wrappers accept one: it
239
+ * would resolve against whatever project Pi was opened in.
240
+ *
241
+ * The absolute candidates make "nothing anywhere" a fact about the host, so
242
+ * under `MEMORY_ENV=test` only, `MIRROR_TEST_NODE_SEARCH_ROOT` re-roots them
243
+ * and the prefix candidate. A production session never reads it.
244
+ */
245
+ export function resolveNode(
246
+ env: Record<string, string | undefined>,
247
+ treeRoot: string,
248
+ kindOf: (path: string) => PathKind = pathKind,
249
+ platform: NodeJS.Platform = process.platform,
250
+ ): NodeResolution {
251
+ const paths = platform === "win32" ? win32 : posix;
252
+ let rejected: NodeResolution["rejected"] = null;
253
+ const explicit = (env.MIRROR_NODE ?? "").trim();
254
+ if (explicit !== "") {
255
+ if (!paths.isAbsolute(explicit)) rejected = { value: explicit, reason: "relative" };
256
+ else {
257
+ const kind = kindOf(explicit);
258
+ if (kind === "executable") return { node: explicit, rejected: null };
259
+ rejected = { value: explicit, reason: kind === "directory" ? "a directory" : kind };
260
+ }
261
+ }
262
+ const name = platform === "win32" ? "node.exe" : "node";
263
+ for (const dir of (env.PATH ?? "").split(paths.delimiter).filter(Boolean)) {
264
+ const candidate = paths.join(dir, name);
265
+ if (kindOf(candidate) === "executable") return { node: candidate, rejected };
266
+ }
267
+ if (platform === "win32") return { node: null, rejected };
268
+ const searchRoot = env.MEMORY_ENV === "test" ? (env.MIRROR_TEST_NODE_SEARCH_ROOT ?? "") : "";
269
+ const home = env.HOME ?? homedir();
270
+ const candidates = [
271
+ paths.resolve(treeRoot, "..", "..", "..", "bin", "node"),
272
+ ...NODE_CANDIDATES.map((candidate) => candidate.replace("$HOME", home)),
273
+ ].map((candidate) => (searchRoot ? paths.join(searchRoot, candidate) : candidate));
274
+ for (const candidate of candidates) {
275
+ if (kindOf(candidate) === "executable") return { node: candidate, rejected };
276
+ }
277
+ return { node: null, rejected };
278
+ }
279
+
183
280
  /** True when `path` is the same file as this tree's AGENTS.md (symlinks resolved). */
184
281
  function _isOurAgentsFile(path: string): boolean {
185
282
  try {
@@ -277,6 +374,41 @@ export default function (pi: ExtensionAPI) {
277
374
  */
278
375
  const FRONT_DOOR_ARGV = [MIRROR_BIN];
279
376
 
377
+ /**
378
+ * The Node every front-door process runs on (CR191), or null when none
379
+ * resolves: the caller spawns nothing and logs why. A `MIRROR_NODE` the search
380
+ * refused is said once per registration, with the reason, so the log is
381
+ * enough to fix it; the search went on without it, as the wrappers' does.
382
+ */
383
+ let mirrorNodeWarned = false;
384
+ function nodeFor(): string | null {
385
+ const found = resolveNode(process.env, TREE_ROOT);
386
+ if (found.rejected && !mirrorNodeWarned) {
387
+ mirrorNodeWarned = true;
388
+ log("WARN", `MIRROR_NODE=${found.rejected.value} is ${found.rejected.reason}; searching the PATH and the usual places instead`);
389
+ }
390
+ return found.node;
391
+ }
392
+
393
+ /** The line said once at session start when no Node resolves; also the log's and, in short, hooks.log's. */
394
+ const NO_NODE_NOTICE =
395
+ "Mirror: `node` is not on the PATH Pi inherited, nor in the usual places — set MIRROR_NODE=/path/to/node (Node 24 or later) where Pi is launched. Mirror will not log this session.";
396
+
397
+ /**
398
+ * One line in `<mirror home>/hooks.log`, in the format the wrappers write and
399
+ * `hookFailureFindings` parses (`<stamp> <runtime>:<hook>: <reason>`), so
400
+ * `runtime diagnose` reports it for seven days. Same directory as this log,
401
+ * mode 0600 as `noteHookFailure` writes it.
402
+ */
403
+ function noteHookFailure(hook: string, reason: string): void {
404
+ try {
405
+ mkdirSync(MIRROR_DIR, { recursive: true });
406
+ appendFileSync(join(MIRROR_DIR, "hooks.log"), `${new Date().toISOString()} pi:${hook}: ${reason}\n`, { mode: 0o600 });
407
+ } catch {
408
+ // A hook cannot report that it could not report.
409
+ }
410
+ }
411
+
280
412
  function log(level: string, msg: string): void {
281
413
  try {
282
414
  const ts = new Date().toISOString();
@@ -288,11 +420,16 @@ export default function (pi: ExtensionAPI) {
288
420
  }
289
421
 
290
422
  function runMirrorBackground(args: string[], label: string): void {
423
+ const node = nodeFor();
424
+ if (node === null) {
425
+ log("ERROR", `${label} skipped: no node (see the session's notice)`);
426
+ return;
427
+ }
291
428
  let logFd: number | undefined;
292
429
  try {
293
430
  mkdirSync(MIRROR_DIR, { recursive: true });
294
431
  logFd = openSync(LOG_FILE, "a");
295
- const child = spawn("node", [...FRONT_DOOR_ARGV, ...args], {
432
+ const child = spawn(node, [...FRONT_DOOR_ARGV, ...args], {
296
433
  cwd: process.cwd(),
297
434
  stdio: ["ignore", logFd, logFd],
298
435
  // Windows: a detached console child materializes as a visible
@@ -303,8 +440,24 @@ export default function (pi: ExtensionAPI) {
303
440
  detached: process.platform !== "win32",
304
441
  windowsHide: true,
305
442
  });
443
+ // A child that cannot start reports through 'error', not by throwing,
444
+ // so the catch below never sees it; without a listener the event is an
445
+ // uncaught exception and Pi exits ("pi exiting due to uncaughtException:
446
+ // Error: spawn node ENOENT", PR #47). The path was an executable file a
447
+ // moment ago (`resolveNode`), so this is the record of one that vanished
448
+ // in between, or of any other start failure. A logging failure must never
449
+ // break anything: log it and move on.
450
+ child.on("error", (error) => {
451
+ log("ERROR", `${label} failed to start: ${error.message.slice(0, 500)}`);
452
+ });
453
+ // Its stdout and stderr are in this log already; the exit code says
454
+ // whether to read them (a Node too old for the core exits before it runs).
455
+ child.on("exit", (code, signal) => {
456
+ if (code !== 0) log("ERROR", `${label} exited ${code ?? `on ${signal}`}`);
457
+ });
306
458
  child.unref();
307
- log("INFO", `${label} started in detached background process ${child.pid ?? "(unknown pid)"}`);
459
+ // No pid, no process: the 'error' above is on its way.
460
+ if (child.pid !== undefined) log("INFO", `${label} started in detached background process ${child.pid}`);
308
461
  } catch (err: unknown) {
309
462
  const message = err instanceof Error ? err.message : String(err);
310
463
  log("ERROR", `${label} failed: ${message.slice(0, 500)}`);
@@ -331,10 +484,15 @@ export default function (pi: ExtensionAPI) {
331
484
  }
332
485
 
333
486
  async function runMirror(args: string[]): Promise<string> {
487
+ const node = nodeFor();
488
+ if (node === null) {
489
+ log("ERROR", `runMirror skipped [${args.slice(0, 2).join(" ")}]: no node (see the session's notice)`);
490
+ return "";
491
+ }
334
492
  try {
335
493
  const delay = testFrontDoorDelayMs();
336
494
  if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
337
- const result = await pi.exec("node", [...FRONT_DOOR_ARGV, ...args], {
495
+ const result = await pi.exec(node, [...FRONT_DOOR_ARGV, ...args], {
338
496
  timeout: 30_000,
339
497
  });
340
498
  const stderr = (result?.stderr ?? "").trim();
@@ -682,6 +840,14 @@ export default function (pi: ExtensionAPI) {
682
840
  log("WARN", line);
683
841
  if (ctx.hasUI) ctx.ui.notify(line, "warning");
684
842
  }
843
+ // Likewise when no Node resolves (CR191): once, visibly, naming the fix,
844
+ // and one line where `runtime diagnose` reads. The calls below then skip
845
+ // themselves; a later one that finds nothing logs only.
846
+ if (nodeFor() === null) {
847
+ log("WARN", NO_NODE_NOTICE);
848
+ if (ctx.hasUI) ctx.ui.notify(NO_NODE_NOTICE, "warning");
849
+ noteHookFailure("session-start", "node not found on PATH or in the usual places; Mirror is off for this session. Set MIRROR_NODE.");
850
+ }
685
851
  const sessionId = ctx.sessionManager.getSessionFile() ?? null;
686
852
  const openArgs = ["conversation-logger", "session-start", "--fast", "--welcome", "--format", "json"];
687
853
  if (sessionId) openArgs.push("--session-id", sessionId);
package/bin/loader.js CHANGED
@@ -24,34 +24,52 @@
24
24
  // stands on; `scripts/smoke_npm_package.sh` runs both bins from a real global
25
25
  // install on CI's Node 24.
26
26
  //
27
- // The warning filter below is the shim's own copy, because the hook emits its
27
+ // The warning filter is the shim's own copy, because the hook emits its
28
28
  // ExperimentalWarning before the core's filter can be imported through it.
29
+ // `boot` installs it; `registerTypeStripping` alone does not (CV24.DS1).
29
30
 
30
31
  import { readFileSync } from "node:fs";
31
32
  import { registerHooks, stripTypeScriptTypes } from "node:module";
32
33
  import { fileURLToPath } from "node:url";
33
34
 
34
- process.removeAllListeners("warning");
35
- process.on("warning", (warning) => {
36
- if (warning.name === "ExperimentalWarning") return;
37
- process.stderr.write(`${warning.name}: ${warning.message}\n`);
38
- });
39
-
40
35
  const packageRoot = new URL("../", import.meta.url).href;
36
+ const REGISTERED = Symbol.for("mirror-mind.typeStripping");
41
37
 
42
- registerHooks({
43
- load(url, context, nextLoad) {
44
- if (url.startsWith(packageRoot) && url.endsWith(".ts")) {
45
- const source = readFileSync(fileURLToPath(url), "utf8");
46
- return {
47
- format: "module",
48
- source: stripTypeScriptTypes(source, { mode: "strip" }),
49
- shortCircuit: true,
50
- };
51
- }
52
- return nextLoad(url, context);
53
- },
54
- });
38
+ /**
39
+ * Register the load hook that strips types for `.ts` files under this package
40
+ * root. Idempotent per package root, so the bins and the library entry
41
+ * (`lib/desktop.js`, CV24.DS1) can both call it in one process.
42
+ *
43
+ * It does nothing to the process's warnings: a library entry runs inside a
44
+ * host that owns them. Only `boot`, which owns its process, filters them.
45
+ */
46
+ export function registerTypeStripping() {
47
+ if (!globalThis[REGISTERED]) globalThis[REGISTERED] = new Set();
48
+ const roots = globalThis[REGISTERED];
49
+ if (roots.has(packageRoot)) return;
50
+ roots.add(packageRoot);
51
+ registerHooks({
52
+ load(url, context, nextLoad) {
53
+ if (url.startsWith(packageRoot) && url.endsWith(".ts")) {
54
+ const source = readFileSync(fileURLToPath(url), "utf8");
55
+ return {
56
+ format: "module",
57
+ source: stripTypeScriptTypes(source, { mode: "strip" }),
58
+ shortCircuit: true,
59
+ };
60
+ }
61
+ return nextLoad(url, context);
62
+ },
63
+ });
64
+ }
65
+
66
+ function filterExperimentalWarnings() {
67
+ process.removeAllListeners("warning");
68
+ process.on("warning", (warning) => {
69
+ if (warning.name === "ExperimentalWarning") return;
70
+ process.stderr.write(`${warning.name}: ${warning.message}\n`);
71
+ });
72
+ }
55
73
 
56
74
  /**
57
75
  * Import the entry at `entryPath` (relative to the package root) and run its
@@ -62,6 +80,9 @@ registerHooks({
62
80
  * @returns {Promise<number>}
63
81
  */
64
82
  export async function boot(entryPath) {
83
+ // The filter first: registering the hook emits its own ExperimentalWarning.
84
+ filterExperimentalWarnings();
85
+ registerTypeStripping();
65
86
  const { runAsEntry } = await import(new URL(entryPath, packageRoot).href);
66
87
  return runAsEntry();
67
88
  }
@@ -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.39.1 — Pi Finds Its Node, or Says So](v0.39.1.md) — patch release of PR #47 (@rodrigoimmaginario) and CR191: a Pi opened where `node` is not on the PATH no longer exits; the Pi extension resolves Node where the hook wrappers look, so a global install needs nothing configured; when no Node resolves, one warning names the fix and `runtime diagnose` reports the `hooks.log` line. No migration.
16
+ - [v0.39.0 — The Core as a Library](v0.39.0.md) — minor release of CV24's first eight Delivery Stories: `mirror-mind/desktop`, a library entry through which a host such as Mirror Desktop 0.3 embeds this core; calls for a home's migration state, Conversations, sidebar reads, home creation, migration on request, Mirror's context, learning from open Conversations, and Journey administration, each with an explicit path and no environment. The new `exports` field closes every other deep import. No migration.
15
17
  - [v0.38.1 — The Refusal Names the Way Out](v0.38.1.md) — patch release of three Change Requests, CR185, CR186, and CR187: a package with no home configured names the homes it finds and the `mirror init <name>` that selects one, and `init` adopts a home that already exists; a missing or refused OpenRouter key is one line that names the file, before anything is touched; a `stable` clone ahead only by `main`'s commits is told that `stable` is behind it, not to push or reset. No migration.
16
18
  - [v0.38.0 — The Model Behind the Memories, Chosen by Measurement](v0.38.0.md) — minor release closing CV9.DS9: the model behind memories, tasks, titles, summaries, tags, reception, journal, shadow, and consolidation is Claude Haiku 5.5 at reasoning off, chosen on the eval suite with reasoning on and off and on 24 of the owner's own conversations ranked blind, where it came first on 16 of 26 surfaces. Every extraction-pin call can carry a reasoning effort, and a pin carries its own default. With CR180, summaries open with their subject, every lifecycle prompt sees the owner's name, and a framed opening is written once more. Release gate 70 of 70 probes. Rollback is one env line. No migration.
17
19
  - [v0.37.0 — The Mirror Stays in the Conversation](v0.37.0.md) — minor release closing CV9.DS8 and, with CV21.DS11, CV21: on Pi, Claude Code, Codex, and Antigravity one doorman in the core puts Mirror's context before the model on every turn of Mirror Mode, all of it on the turn Mirror Mode starts and then the persona and memories each question calls for; an explicit turn loads once; one signature, `◇ persona-name`; Pi reaches L4, measured live. One parity matrix in the runtime interface contract, sourced and held by a test. Codex, Antigravity, and Grok Build need their plugin copy brought level. Migration 018, applied on its own.
@@ -0,0 +1,138 @@
1
+ ---
2
+ digest: >
3
+ v0.39.0 is a minor release that ships CV24's first eight Delivery Stories: a library
4
+ entry, `mirror-mind/desktop`, through which a host such as Mirror Desktop 0.3 embeds
5
+ this core in its own Node process instead of spawning a CLI. Twenty-five calls and refusal
6
+ classes, beside `DESKTOP_API_VERSION`, cover a
7
+ home's migration state, Conversations, sidebar reads, home creation, migration on
8
+ request, Mirror's context for a turn, learning from open Conversations, and Journey
9
+ administration, each taking an explicit path and reading no environment. The package
10
+ gains an `exports` field, which closes every deep import other than the new entry. The
11
+ terminal, the bins and the hooks behave as before. No migration, and no model or prompt
12
+ change.
13
+ ---
14
+
15
+ # v0.39.0 — The Core as a Library
16
+
17
+ **Date:** 2026-10-31
18
+
19
+ ## Highlights
20
+
21
+ - **`mirror-mind/desktop`, a library entry for a host**
22
+ ([CV24](../project/roadmap/cv24-embeddable-core-for-mirror-desktop/index.md)). Imported by
23
+ package name from a host's `node_modules`, with no flags: the entry registers the bins'
24
+ type-stripping hook itself. Everything a host may call is re-exported there by name, with
25
+ `DESKTOP_API_VERSION = 1`; a name not in that file is not part of the contract.
26
+ - **Eight stories, one rule.** Every call takes an explicit database or home path, never reads
27
+ `MIRROR_HOME`, `MIRROR_USER`, `DB_PATH` or a key from the environment, never migrates as a side
28
+ effect, and refuses a database that is not `current` before opening it for writing.
29
+ - **Custody chosen per write, with a decision for each.** Conversation writes go through a
30
+ narrowed handle instead of a per-write snapshot; a home is created beside its target and renamed
31
+ into place; a home migrates only behind a uniquely named, verified backup the core never deletes.
32
+ - **Breaking for deep imports only.** The package now declares `exports` with `./desktop` and
33
+ `./package.json`. Any other `import "mirror-mind/…"` path that resolved before no longer does.
34
+ The `mirror` and `mirror-hook` bins are unaffected.
35
+ - **A Windows package install can update itself** (PR #46 by @rodrigoimmaginario, CR189;
36
+ *added on 2026-10-31*). An earlier version needs one manual install to get here; see Upgrading.
37
+ - No migration, and no model or prompt change, so the model-behavior release gate does not apply.
38
+
39
+ ## Where We Started
40
+
41
+ Decision CV22.DS10.TS1 left Mirror Desktop outside the TypeScript migration: it kept calling the
42
+ Python core through the retired Journey administration and a catalog script. On 2026-10-28 Mirror
43
+ Desktop decided to follow this core instead. It froze its 0.2 line on Python and opened a 0.3 line
44
+ that hosts the core in its own Node process, on its own home.
45
+
46
+ That host could not use the CLI's way in. It runs from `node_modules`, where Node refuses to strip
47
+ TypeScript types. A shell that starts it may export another home's `MIRROR_HOME`. It writes on every
48
+ turn of a Conversation that never ends. And it has to ask the person before a home is migrated,
49
+ while every open through the front door migrates first.
50
+
51
+ ## What Changed
52
+
53
+ The contract grew one story at a time on `desktop/library-seam-1a`, each with its spec first, and
54
+ merged as PR #52.
55
+
56
+ - **DS1, inspect a home without migrating.** `inspectMigrationState` reports `missing`,
57
+ `not_mirror`, `current`, `pending` or `ahead`, with the pending list. It shares its three
58
+ ledger readers with `ensureMigratedOnOpen`, so what it calls pending is exactly what an open
59
+ would apply. The loader became `boot` plus an idempotent `registerTypeStripping`; only `boot`,
60
+ which owns its process, filters warnings.
61
+ - **DS2, Conversations as calls.** `ensureConversation`, `appendConversationMessages` (the
62
+ `conversations append` contract, byte for byte), `renameConversation` and
63
+ `rebindConversationJourney`, written through `openDatabaseForConversationWrites`. That handle
64
+ prepares only a single SELECT, INSERT … VALUES into three tables, and an UPDATE of four
65
+ `conversations` columns by id. The per-write `VACUUM INTO` measured about 0.9 s on a 108 MB
66
+ home, too much for every turn.
67
+ - **DS3, Journey reads.** `readJourneyTree` (a new contract in the stored sibling order, not a
68
+ revival of the retired registry dump), `listJourneyConversations` and `inspectConversation`,
69
+ read-only.
70
+ - **DS4, create a home.** `createHome` copies the templates with the person's name, bootstraps,
71
+ and seeds in a staging directory, renaming it into place only when the home reads `current`.
72
+ It never writes `~/.config/mirror/env`.
73
+ - **DS5, migrate on request.** `migrateHome` applies exactly the pending list the person was
74
+ shown, under the bootstrap lock, behind a backup that is quick-checked, hashed, and read back as
75
+ the same pre-image.
76
+ - **DS6, Mirror's context as a call.** `deliverMirrorContext` is the doorman for a host's turn,
77
+ with the key as an argument and Mirror Mode from the first turn; each provider call is tried
78
+ once within its own bound. Its write is `mirror doorman`'s, behind the front door's snapshot.
79
+ - **DS7, learning from an open Conversation.** `learnFromConversations` extracts the stretch an
80
+ idle, unended Conversation gained since its `learned_through` watermark, in bounded pieces,
81
+ with the terminal's prompts, and never ends it.
82
+ - **DS8, Journey administration as calls.** `addJourney`, `setJourneyProjectPath`, `moveJourney`
83
+ and `deleteJourney` port Python 0.31.14's administration, so the requests Desktop 0.2 sent keep
84
+ their meaning. `updateJourney` is new: it edits a Journey's name, description and status, and
85
+ refuses `journey_changed` when the row changed since the host read it.
86
+
87
+ Existing code changed only where the entry needed it: the loader, three exported readers, two
88
+ `export` keywords in `extraction.ts`, the tarball guard (`lib/` allowed, `lib/desktop.js`
89
+ required), and `package.json`. No existing test changed. The npm smoke installs the package and
90
+ imports the entry from a host project for DS1 to DS5.
91
+
92
+ *Added on 2026-10-31:* this release also carries one change outside CV24, merged to `main` the
93
+ day before and missing from this note when it was published, so the paragraph above covers CV24
94
+ only. PR #46 (CR189, by @rodrigoimmaginario) runs the package update's `npm install -g` from the
95
+ parent of `node_modules` instead of from inside the package. npm renames the package's directory
96
+ aside before it lays down the new version, and Windows refuses to rename a directory a process is
97
+ running in, so on Windows `runtime update` failed at apply with `EBUSY` every time. Nothing
98
+ changes on macOS or Linux. See Upgrading.
99
+
100
+ ## Upgrading
101
+
102
+ *Added on 2026-10-31.* Everyone updates as usual:
103
+
104
+ ```bash
105
+ mirror runtime update # or: npm install -g mirror-mind
106
+ ```
107
+
108
+ On Windows, a package install of an earlier version cannot install this one itself: its
109
+ `runtime update` fails at apply with `EBUSY`. Once, by hand, from any directory other than the
110
+ package's own:
111
+
112
+ ```bash
113
+ npm install -g mirror-mind@latest
114
+ ```
115
+
116
+ From then on `mirror runtime update` works.
117
+
118
+ ## Conscious Exclusions
119
+
120
+ - **Renaming a Journey's slug.** The slug keys Conversations, memories and tasks; renaming it is a
121
+ migration, not a call.
122
+ - **A Journey's icon and color, and a receipt ledger for Journey calls.**
123
+ - **The front door's own migration custody.** `ensureMigratedOnOpen` still overwrites one fixed
124
+ backup; giving it DS5's custody is a separate decision.
125
+ - **Installed-package smoke for DS6 to DS8.** DS6 and DS7 need a provider key. DS8 is exercised
126
+ end to end by Mirror Desktop's real-host test; a keyless smoke probe is a cheap follow-up.
127
+
128
+ ## What We Learned
129
+
130
+ A library entry is a stricter consumer than a CLI. Every assumption the bins could make about
131
+ their own process — environment, warnings, migrate-on-open, a snapshot per write — had to become
132
+ an explicit argument or a decision. The retired-surfaces sweep also caught the port twice naming a
133
+ retired verb in comments, which says the sweep is doing its job on new files.
134
+
135
+ ## Next Horizon
136
+
137
+ Mirror Desktop moves its pin from a branch tarball to this release, then its first 0.3 alpha. CV24
138
+ stays open for the pieces that alpha's use asks for.
@@ -0,0 +1,109 @@
1
+ ---
2
+ digest: >
3
+ v0.39.1 is a patch release about one failure: a Pi opened where `node` is not on the
4
+ PATH, which a desktop launch or a window older than the Node install gives it. Through
5
+ v0.39.0 that Pi exited at startup with `spawn node ENOENT`. PR #47 (Rodrigo Immaginario)
6
+ keeps it up, and CR191 makes the Pi extension find Node where the hook wrappers already
7
+ look, so a machine with a global install needs nothing configured; when no Node resolves,
8
+ the session says so once, with the fix, and `runtime diagnose` can see it. No migration,
9
+ and no model or prompt change.
10
+ ---
11
+
12
+ # v0.39.1 — Pi Finds Its Node, or Says So
13
+
14
+ **Date:** 2026-11-02
15
+
16
+ ## Highlights
17
+
18
+ - **A Pi opened without `node` on its PATH no longer exits**
19
+ ([PR #47](https://github.com/mirror-mind-ai/mirror/pull/47), by @rodrigoimmaginario). The
20
+ extension's detached maintenance process had no `'error'` listener, so a `node` that
21
+ could not start was an uncaught exception and Pi went down with it. It is now logged.
22
+ - **The Pi extension resolves Node the way the hook wrappers do**
23
+ ([CR191](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr191-the-pi-extension-cannot-find-node-where-the-hook-wrappers-can.md)):
24
+ `MIRROR_NODE`, the PATH, the Node that installed the package (`<prefix>/bin/node`), then
25
+ `~/.nvm/current/bin`, `/opt/homebrew/bin`, `/usr/local/bin`. On a machine with a global
26
+ install this needs no configuration: the session that used to crash opens as usual.
27
+ - **When no Node resolves, the failure reaches someone.** Pi stays up, shows one warning at
28
+ session start that names the fix (`MIRROR_NODE=/path/to/node`, Node 24 or later), and
29
+ writes one line to `<mirror home>/hooks.log`, where `mirror runtime diagnose` reports it
30
+ for seven days as `hook_failures_recorded`, as it does for the other runtimes' hooks.
31
+ - **A `MIRROR_NODE` the extension will not use is warned once, with the reason**: missing, a
32
+ directory, not executable, or relative. The search goes on without it.
33
+ - No migration, and no model or prompt change.
34
+
35
+ ## Where We Started
36
+
37
+ On 2026-10-16 a Pi opened from a terminal window whose PATH predated the Node install
38
+ exited at once:
39
+
40
+ ```text
41
+ pi exiting due to uncaughtException:
42
+ Error: spawn node ENOENT
43
+ spawnargs: [ '…/node_modules/mirror-mind/bin/mirror.js', 'conversation-logger', 'session-maintenance' ]
44
+ ```
45
+
46
+ The same happens wherever `node` is not on the PATH Pi inherited, and `runtime diagnose`
47
+ had long named that the usual case for a runtime launched from the desktop: `node` lives
48
+ under nvm or Homebrew, not where a GUI looks. The hook wrappers the other runtimes use
49
+ were built for exactly this. They search the usual places and record a failure in
50
+ `hooks.log`. The Pi extension, which runs no wrapper, knew only the bare name `node`.
51
+
52
+ Rodrigo Immaginario's PR #47 fixed the crash: the listener, and `MIRROR_NODE` honored by
53
+ the extension as the wrappers honor it. Reviewed through four lenses on 2026-11-01, it was
54
+ merged as it was. The review also showed what it left: without `MIRROR_NODE`, that Pi now
55
+ ran with Mirror silently absent, no welcome card, no logging, no context, and a line in a
56
+ file no one is pointed at. That is the failure the wrappers' silence-is-forbidden rule was
57
+ written against, and CR191 is its answer for Pi.
58
+
59
+ ## What Changed
60
+
61
+ **One resolver, the wrappers' order (CR191).** The extension resolves Node itself, in the
62
+ same order the wrappers use, taking only an executable file: `MIRROR_NODE`, then `node` on
63
+ each PATH entry, then the `bin/` of the prefix the package is installed under, three
64
+ directories up from the tree the extension ships in, which is the Node that installed the
65
+ package, then nvm, Homebrew, `/usr/local`. The candidate list is the core's
66
+ `HOOK_NODE_CANDIDATES`, duplicated in the extension because it imports no core module, and a
67
+ test fails if the two ever differ, as one already did for the wrappers. On Windows the
68
+ search stops after the PATH, as before.
69
+
70
+ **Nothing found: no spawn, one notice, one line.** When no Node resolves, no front-door
71
+ process is attempted: each call logs that it was skipped. At session start, the extension
72
+ says so once, the way it already says `mirror` is not on the PATH:
73
+
74
+ ```text
75
+ Mirror: `node` is not on the PATH Pi inherited, nor in the usual places — set MIRROR_NODE=/path/to/node (Node 24 or later) where Pi is launched. Mirror will not log this session.
76
+ ```
77
+
78
+ and writes one line to `hooks.log` in the wrappers' format, `pi:session-start: node not
79
+ found on PATH or in the usual places; …`, which `runtime diagnose` counts.
80
+
81
+ **The log tells one story.** A child that did not start is not logged as started; one that
82
+ exits non-zero leaves its code beside its own output. PR #47's listener stays as the record
83
+ of a file that vanished between the check and the spawn.
84
+
85
+ Validated against Pi 1.5.0 with `PATH=/usr/bin:/bin`, the incident's configuration: the
86
+ session opened through `/opt/homebrew/bin/node` with nothing configured; a bad
87
+ `MIRROR_NODE` was warned once; with no Node anywhere, Pi stayed up, the warning was on
88
+ screen, and `runtime diagnose` reported the `hooks.log` line.
89
+
90
+ ## Conscious Exclusions
91
+
92
+ - The hook wrappers and the MCP locator still accept a relative `MIRROR_NODE`; the Pi
93
+ extension refuses one, since it would resolve against whatever project Pi was opened in.
94
+ Whether the family follows is a separate decision.
95
+ - A "Node too old" message with the version, as the wrappers print: the exit code and the
96
+ loader's own output are in the log.
97
+ - Windows candidates beyond `MIRROR_NODE` and the PATH.
98
+
99
+ ## Upgrading
100
+
101
+ ```bash
102
+ mirror runtime update
103
+ ```
104
+
105
+ A Pi session running the previous extension keeps it until Pi restarts.
106
+
107
+ ## Next Horizon
108
+
109
+ CR190, captured this week, and the open RS010 backlog. CV24 continues on its branch.
package/lib/desktop.js ADDED
@@ -0,0 +1,72 @@
1
+ // `mirror-mind/desktop` — the core's library entry for Mirror Desktop. CV24.DS1.
2
+ //
3
+ // Mirror Desktop hosts this core in its own Node process and imports it from
4
+ // `node_modules`, where Node refuses to strip TypeScript types. So this entry
5
+ // registers the same type-stripping hook the bins use, and only then imports
6
+ // the TypeScript modules -- dynamically, because a static `export ... from
7
+ // "./x.ts"` would be loaded before the hook exists.
8
+ //
9
+ // Everything Desktop may call is re-exported here BY NAME. A name missing from
10
+ // this file is not part of the contract, whatever the TypeScript tree holds.
11
+ // `DESKTOP_API_VERSION` changes when a name is removed or changes meaning.
12
+
13
+ import { registerTypeStripping } from "../bin/loader.js";
14
+
15
+ registerTypeStripping();
16
+
17
+ const migrationReport = await import("../ts/src/db/migrationReport.ts");
18
+ const conversations = await import("../ts/src/conversation/hostCalls.ts");
19
+ const reads = await import("../ts/src/conversation/hostReads.ts");
20
+ const homes = await import("../ts/src/init/createHome.ts");
21
+ const migration = await import("../ts/src/db/migrateHome.ts");
22
+ const context = await import("../ts/src/mirror/deliverContext.ts");
23
+ const learning = await import("../ts/src/conversation/learnFromConversations.ts");
24
+ const journeys = await import("../ts/src/journey/journeyCalls.ts");
25
+
26
+ export const DESKTOP_API_VERSION = 1;
27
+ export const inspectMigrationState = migrationReport.inspectMigrationState;
28
+
29
+ // CV24.DS2: Conversations as calls. Each opens only a `current` database,
30
+ // never migrates, and writes through the narrowed Conversation handle.
31
+ export const ensureConversation = conversations.ensureConversation;
32
+ export const appendConversationMessages = conversations.appendConversationMessages;
33
+ export const renameConversation = conversations.renameConversation;
34
+ export const rebindConversationJourney = conversations.rebindConversationJourney;
35
+ export const DatabaseNotCurrentError = conversations.DatabaseNotCurrentError;
36
+ export const ConversationCallRejectedError = conversations.ConversationCallRejectedError;
37
+
38
+ // CV24.DS3: the reads a sidebar is built from. Read-only, `current` homes only.
39
+ export const readJourneyTree = reads.readJourneyTree;
40
+ export const listJourneyConversations = reads.listJourneyConversations;
41
+ export const inspectConversation = reads.inspectConversation;
42
+
43
+ // CV24.DS4: create a complete, seeded home at an explicit path, all or nothing.
44
+ // Writes nothing outside it, never the machine-wide ~/.config/mirror/env.
45
+ export const createHome = homes.createHome;
46
+ export const CreateHomeRejectedError = homes.CreateHomeRejectedError;
47
+
48
+ // CV24.DS5: migrate a home on request, applying exactly the pending list the
49
+ // person was shown, behind a uniquely named, verified, retained backup.
50
+ export const migrateHome = migration.migrateHome;
51
+ export const MigrateHomeRejectedError = migration.MigrateHomeRejectedError;
52
+
53
+ // CV24.DS6: Mirror's context for a host's turn -- the doorman as a call. The
54
+ // key is an argument; nothing is read from the environment. A Conversation is
55
+ // in Mirror Mode from its first turn.
56
+ export const deliverMirrorContext = context.deliverMirrorContext;
57
+ export const forgetMirrorContext = context.forgetMirrorContext;
58
+ export const MirrorContextRejectedError = context.MirrorContextRejectedError;
59
+
60
+ // CV24.DS7: learn from a host's idle Conversations without ending them -- the
61
+ // stretch each gained since it was last learned from. The key is an argument.
62
+ export const learnFromConversations = learning.learnFromConversations;
63
+ export const LearningRejectedError = learning.LearningRejectedError;
64
+
65
+ // CV24.DS8: Journeys as calls -- create, edit, move, set a project path,
66
+ // delete -- each answering with the tree `readJourneyTree` reads.
67
+ export const addJourney = journeys.addJourney;
68
+ export const updateJourney = journeys.updateJourney;
69
+ export const setJourneyProjectPath = journeys.setJourneyProjectPath;
70
+ export const moveJourney = journeys.moveJourney;
71
+ export const deleteJourney = journeys.deleteJourney;
72
+ export const JourneyCallRejectedError = journeys.JourneyCallRejectedError;