mirror-mind 0.39.3 → 0.39.4

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,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
12
12
 
13
13
  ## Releases
14
14
 
15
+ - [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.
15
16
  - [v0.39.3 — Windows Gets Its Hooks](v0.39.3.md) — patch release of PR #53, PR #50, and PR #54 (@rodrigoimmaginario): the plugin's hook wrappers and MCP locator run the package's own bin when npm's global entry is a shell shim, so Claude Code hooks and the MCP server work on a Windows package install; `runtime diagnose` no longer counts its own verdict as a front-door error (CR139); the docs point a Windows reader to the installer in its own repository. No migration.
16
17
  - [v0.39.2 — Input, Read the Platform's Way](v0.39.2.md) — patch release of PR #49 and PR #48 (@rodrigoimmaginario), CR194, and CR193: `runtime diagnose` stops warning every Windows install about Node; a PowerShell byte-order mark is not stored; stdin is decoded by its mark, UTF-8 or UTF-16, and `identity set` and `journey update` gain `--file`, the route around PowerShell 5.1's ASCII pipe; `package-lock.json` names the package at its version, and the release doctor checks it. No migration.
17
18
  - [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.
@@ -0,0 +1,81 @@
1
+ ---
2
+ digest: >
3
+ v0.39.4 is a patch release of one pull request by Alan Nascimento, his first here. On
4
+ Claude Code, two ordinary situations made the Stop hook drop a real reply: a prompt typed
5
+ while a turn was still running, whose prompt hook fires at queue time under the running
6
+ turn's id, and a journey entered in plain words, whose Builder load switches the
7
+ conversation mid-turn. Both replies are now logged beside their prompts, and
8
+ `nothing_waiting` in `hooks.log` means what it says again. No schema change, no
9
+ migration, and no model or prompt change.
10
+ ---
11
+
12
+ # v0.39.4 — The Reply Finds Its Prompt
13
+
14
+ **Date:** 2026-11-03
15
+
16
+ ## Highlights
17
+
18
+ - **A prompt typed while a turn runs keeps its reply**
19
+ ([PR #51](https://github.com/mirror-mind-ai/mirror/pull/51), by @alannascimento1;
20
+ [CR196](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr196-a-prompt-typed-while-a-turn-runs-loses-its-reply-on-claude-code.md)).
21
+ Claude Code fires the prompt hook when such a prompt enters its queue, with the running
22
+ turn's id; the prompt later runs under an id no hook sent, and its Stop found nothing
23
+ waiting. The prompt is now recorded as queued during the running turn, the running turn's
24
+ reply stays with its own question, and the Stop of the turn the queued prompt ran as
25
+ claims it. Never for a command turn, and never a prompt an ordinary one has since
26
+ followed.
27
+ - **Entering a journey in plain words keeps the turn together**
28
+ ([CR166](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr166-a-plain-language-journey-switch-strands-its-turn.md)).
29
+ "I want to enter journey X" logs the prompt in one conversation; the model's `build load`
30
+ closes it and binds the journey's; the Stop paired in the new, empty one. A hook prompt
31
+ still owed its reply now moves with the switch. A prompt logged by hand stays where it
32
+ was, so `mm-new` and the logger CLI behave as before. Pi gets this half too: its prompts
33
+ carry turn ids.
34
+ - **`nothing_waiting` means a lost reply again.** On one host, 20 of 22 such lines in a
35
+ week were these two cases; `runtime diagnose`'s `hook_failures_recorded` was mostly
36
+ counting them.
37
+ - No schema change (`queued_in_turn` is one more optional key in the `metadata` JSON
38
+ column `turn_id` already lives in), no migration, and no model or prompt change.
39
+
40
+ ## Where We Started
41
+
42
+ Alan Nascimento opened PR #51 on 2026-10-30 with the session transcript that shows the
43
+ mechanism, Claude Code's own `queue-operation` record between the prompt hook and the
44
+ Stop, and a week of `hooks.log` counted by cause. The journey-switch half had been in the
45
+ backlog as CR166 since 2026-10-24, unassigned; the queued-prompt half no record described.
46
+ The review, through four lenses, had nothing to amend: the reply rule gains one branch
47
+ between "by turn id" and "by position", each new path bounded by an explicit guard, and
48
+ ten tests of which five fail without the change.
49
+
50
+ ## What Changed
51
+
52
+ All in `ts/src/conversation/logger.ts`. `promptMetadata` records a second prompt under a
53
+ turn still owed its reply as `queued_in_turn`. `handleStopHook`, for a turn id with no
54
+ prompt of its own, claims the oldest unclaimed queued prompt that no ordinary prompt has
55
+ followed, and logs the reply as paired by `queued_prompt`. `switchConversation` moves the
56
+ old conversation's last row to the new one when it is a hook prompt with no reply after
57
+ it. The reply rule's docblock names the new branch.
58
+
59
+ ## Conscious Exclusions
60
+
61
+ Both named by the author and recorded in CR166 and CR196:
62
+
63
+ - In the queued case the running turn's reply is still written after the queued prompt,
64
+ because the prompt hook fires at queue time: the rows pair by `turn_id`, but a reader
65
+ ordering by `created_at` sees `Q1, Q2, A1, A2`. Rewriting `created_at` would make it lie
66
+ about when the prompt was typed.
67
+ - The waiting prompt moves after the old conversation's close-time extraction, which
68
+ therefore still saw it. Moving it earlier would mean starting the new conversation
69
+ before closing the old.
70
+ - The two `nothing_waiting` lines in the week that match neither pattern.
71
+
72
+ ## Upgrading
73
+
74
+ ```bash
75
+ mirror runtime update
76
+ ```
77
+
78
+ ## Next Horizon
79
+
80
+ Moacir Moda's PR #27; CR190, CR192, and CR195 in RS010's backlog. CV24 continues on its
81
+ branch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
- "version": "0.39.3",
3
+ "version": "0.39.4",
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.39.3",
3
+ "version": "0.39.4",
4
4
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
5
5
  "author": {
6
6
  "name": "Mirror Mind"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mirror-mind",
3
3
  "description": "Mirror Mind — local-first memory and identity for agentic runtimes.",
4
- "version": "0.39.3",
4
+ "version": "0.39.4",
5
5
  "mcpServers": {
6
6
  "mirror-mind": {
7
7
  "command": "/bin/sh",
@@ -212,11 +212,13 @@ function discardMarkerActive(session: RuntimeSessionRow | null): boolean {
212
212
  }
213
213
 
214
214
  /**
215
- * What a message row may carry beside its text. The runtime's turn id (US8)
216
- * is the one field today: written by the prompt and Stop hooks, read by the
217
- * reply rule, and NULL for every message logged another way, as before.
215
+ * What a message row may carry beside its text: the runtime's turn id (US8),
216
+ * written by the prompt and Stop hooks and read by the reply rule, and the
217
+ * turn a prompt was queued during, when it arrived while that turn still owed
218
+ * its reply. NULL for every message logged another way, as before.
218
219
  */
219
220
  export interface MessageMetadata {
221
+ readonly queued_in_turn?: string;
220
222
  readonly turn_id?: string;
221
223
  }
222
224
 
@@ -375,7 +377,15 @@ export async function endConversation(
375
377
  }
376
378
  }
377
379
 
378
- /** Python `switch_conversation`: close the old conversation, bind a new one. */
380
+ /**
381
+ * Python `switch_conversation`: close the old conversation, bind a new one.
382
+ *
383
+ * A prompt still owed its reply moves to the new conversation: when the
384
+ * model switches during a turn (a Builder load asked for in plain words), the
385
+ * turn's Stop pairs its reply in the conversation the session is bound to by
386
+ * then, and the prompt it answers has to be there. Only a hook prompt counts,
387
+ * one that carries a turn id; a prompt logged by hand stays where it was.
388
+ */
379
389
  export async function switchConversation(
380
390
  db: WritableDatabase,
381
391
  sessionId: string | null,
@@ -388,6 +398,8 @@ export async function switchConversation(
388
398
 
389
399
  const runtimeSession = getRuntimeSession(db, resolved);
390
400
  const oldConversationId = runtimeSession?.conversationId ?? null;
401
+ const last = oldConversationId ? lastMessage(db, oldConversationId) : null;
402
+ const waitingPrompt = last && last.role === "user" && turnIdOfRow(last) ? last : null;
391
403
  if (oldConversationId) {
392
404
  await endConversation(db, oldConversationId, { extract: true }, deps, hooks);
393
405
  }
@@ -404,6 +416,12 @@ export async function switchConversation(
404
416
  },
405
417
  deps,
406
418
  );
419
+ if (waitingPrompt) {
420
+ db.prepare("UPDATE messages SET conversation_id = ? WHERE rowid = ?").run(
421
+ newConversationId,
422
+ waitingPrompt.rowid,
423
+ );
424
+ }
407
425
  upsertRuntimeSession(
408
426
  db,
409
427
  resolved,
@@ -550,7 +568,11 @@ export type StopHookOutcome =
550
568
  // command -- the reply is lost. The only skip the caller reports.
551
569
  | "nothing_waiting";
552
570
  }
553
- | { action: "logged"; sessionId: string; pairedBy: "turn_id" | "last_message" }
571
+ | {
572
+ action: "logged";
573
+ sessionId: string;
574
+ pairedBy: "turn_id" | "queued_prompt" | "last_message";
575
+ }
554
576
  | { action: "failed"; error: string };
555
577
 
556
578
  export interface HookLoggingOptions {
@@ -653,7 +675,7 @@ export function handleUserPromptHook(
653
675
  db,
654
676
  sessionId,
655
677
  content,
656
- { interface: interfaceName, metadata: turnMetadata(turnIdOf(payload)) },
678
+ { interface: interfaceName, metadata: promptMetadata(db, sessionId, turnIdOf(payload)) },
657
679
  deps,
658
680
  );
659
681
  return { action: "logged", sessionId };
@@ -725,6 +747,64 @@ function turnIdOfRow(row: MessageRow): string {
725
747
  return typeof value === "string" ? value : "";
726
748
  }
727
749
 
750
+ /**
751
+ * A prompt's metadata. Claude Code fires the prompt hook when a prompt typed
752
+ * during a running turn enters its queue, with the running turn's id; the
753
+ * prompt later runs as a turn of its own, under an id no prompt hook sends. A
754
+ * second prompt under a turn whose reply is still owed is therefore recorded
755
+ * as queued during it, not as its prompt, so the running turn's reply stays
756
+ * with its own question and the queued turn's Stop can claim this one.
757
+ */
758
+ function promptMetadata(
759
+ db: WritableDatabase,
760
+ sessionId: string,
761
+ turnId: string,
762
+ ): MessageMetadata | null {
763
+ const conversationId = getRuntimeSession(db, sessionId)?.conversationId ?? null;
764
+ if (turnId && conversationId) {
765
+ const running = promptOfTurn(db, conversationId, turnId);
766
+ if (running && !answeredAfter(db, conversationId, running, turnId)) {
767
+ return { queued_in_turn: turnId };
768
+ }
769
+ }
770
+ return turnMetadata(turnId);
771
+ }
772
+
773
+ /**
774
+ * The oldest queued prompt no turn has claimed, among those typed after the
775
+ * conversation's last ordinary prompt. A queued prompt Claude Code folded into
776
+ * the running turn instead of running it as its own is never claimed, and a
777
+ * later ordinary prompt retires it, so a Stop long after cannot take it.
778
+ */
779
+ function unclaimedQueuedPrompt(db: WritableDatabase, conversationId: string): MessageRow | null {
780
+ const row = db
781
+ .prepare(
782
+ `SELECT rowid, role, created_at, metadata FROM messages AS queued
783
+ WHERE conversation_id = ? AND role = 'user'
784
+ AND json_extract(metadata, '$.queued_in_turn') IS NOT NULL
785
+ AND json_extract(metadata, '$.turn_id') IS NULL
786
+ AND NOT EXISTS (
787
+ SELECT 1 FROM messages AS later
788
+ WHERE later.conversation_id = queued.conversation_id AND later.role = 'user'
789
+ AND json_extract(later.metadata, '$.queued_in_turn') IS NULL
790
+ AND (later.created_at > queued.created_at
791
+ OR (later.created_at = queued.created_at AND later.rowid > queued.rowid)))
792
+ ORDER BY created_at, rowid LIMIT 1`,
793
+ )
794
+ .get(conversationId);
795
+ return (row as MessageRow | undefined) ?? null;
796
+ }
797
+
798
+ /** Give a queued prompt the id of the turn it ran as. */
799
+ function claimQueuedPrompt(db: WritableDatabase, row: MessageRow, turnId: string): void {
800
+ const metadata = decodeMetadata(row.metadata);
801
+ metadata.turn_id = turnId;
802
+ db.prepare("UPDATE messages SET metadata = ? WHERE rowid = ?").run(
803
+ pythonJsonDumps(metadata),
804
+ row.rowid,
805
+ );
806
+ }
807
+
728
808
  /**
729
809
  * The Stop hook (CV21.DS2.US8): log the turn's final answer beside its prompt.
730
810
  *
@@ -734,6 +814,9 @@ function turnIdOfRow(row: MessageRow): string {
734
814
  * same -- unless a reply with that turn id already follows it, which is a
735
815
  * repeat Stop (a Stop re-fired after another hook blocked). An assistant
736
816
  * row without the turn id (a hand-logged summary) does not count;
817
+ * * else, when the turn id is new, by the oldest queued prompt no turn has
818
+ * claimed (`promptMetadata`): the turn a prompt typed mid-turn ran as.
819
+ * Never for a turn the prompt hook skipped as a command;
737
820
  * * else by position, when the conversation's last message is a logged
738
821
  * prompt -- unless both sides carry a turn id and they differ, which means
739
822
  * this turn's prompt was a command and an older prompt is waiting because
@@ -770,11 +853,11 @@ export function handleStopHook(
770
853
  const session = getRuntimeSession(db, sessionId);
771
854
  if (discardMarkerActive(session)) return { action: "skipped", reason: "discarded" };
772
855
  const turnId = turnIdOf(payload);
856
+ const commandTurn = (): boolean =>
857
+ Boolean(turnId) && decodeMetadata(session?.metadata ?? null)[SKIPPED_TURN_KEY] === turnId;
773
858
  const nothingWaiting = (): StopHookOutcome => {
774
859
  if (!turnId) return { action: "skipped", reason: "session_event" };
775
- if (decodeMetadata(session?.metadata ?? null)[SKIPPED_TURN_KEY] === turnId) {
776
- return { action: "skipped", reason: "command_turn" };
777
- }
860
+ if (commandTurn()) return { action: "skipped", reason: "command_turn" };
778
861
  return { action: "skipped", reason: "nothing_waiting" };
779
862
  };
780
863
  const conversationId = session?.conversationId ?? null;
@@ -798,6 +881,18 @@ export function handleStopHook(
798
881
  );
799
882
  return { action: "logged", sessionId, pairedBy: "turn_id" };
800
883
  }
884
+ const queued = commandTurn() ? null : unclaimedQueuedPrompt(db, conversationId);
885
+ if (queued) {
886
+ claimQueuedPrompt(db, queued, turnId);
887
+ logAssistantMessage(
888
+ db,
889
+ sessionId,
890
+ message,
891
+ { interface: interfaceName, metadata: turnMetadata(turnId) },
892
+ deps,
893
+ );
894
+ return { action: "logged", sessionId, pairedBy: "queued_prompt" };
895
+ }
801
896
  if (last && last.role === "user" && !turnIdOfRow(last)) {
802
897
  logAssistantMessage(
803
898
  db,