volute 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/auto-upgrade-HEVZKPTF.js +46 -0
  2. package/dist/{backup-WWXVIMYQ.js → backup-QRNX6RK7.js} +11 -11
  3. package/dist/channels-CKCY64TD.js +203 -0
  4. package/dist/{chat-WK2BLM7B.js → chat-ZUGNL5GS.js} +9 -9
  5. package/dist/{chunk-UTKUO2KY.js → chunk-3LHUXY3E.js} +2 -2
  6. package/dist/{chunk-PIZDPY4G.js → chunk-444RR3YF.js} +4 -4
  7. package/dist/{chunk-VDZ4I4ZP.js → chunk-56IT2ZXF.js} +3 -3
  8. package/dist/{chunk-OXWUEU5V.js → chunk-5U5GLZSD.js} +2 -2
  9. package/dist/{chunk-LN7XJ56R.js → chunk-7XKPG66D.js} +9 -9
  10. package/dist/{chunk-R772ET2M.js → chunk-AAPNU7GA.js} +4 -0
  11. package/dist/{chunk-7ARONCZW.js → chunk-ATZF2RNJ.js} +3 -3
  12. package/dist/{chunk-6NLW72HP.js → chunk-CKPAVQNK.js} +1 -1
  13. package/dist/{chunk-RMADJFA7.js → chunk-CLCHOXLR.js} +1 -1
  14. package/dist/{chunk-IQ7VNWH6.js → chunk-HKGYZVWN.js} +1 -1
  15. package/dist/{chunk-HASADR3E.js → chunk-LGXVGIWN.js} +66 -25
  16. package/dist/{chunk-P2TBUKLW.js → chunk-LKUGUQUW.js} +3 -3
  17. package/dist/{chunk-AP2KQMMW.js → chunk-OAYY3WF3.js} +11 -3
  18. package/dist/{chunk-UO4MZQNO.js → chunk-OGPVJ53N.js} +14 -6
  19. package/dist/{chunk-M77B73VV.js → chunk-OIOW3FPT.js} +7 -2
  20. package/dist/chunk-OT5SXF7L.js +904 -0
  21. package/dist/{chunk-EDR47UE2.js → chunk-OYF25B62.js} +1 -1
  22. package/dist/{chunk-TAWRJ24T.js → chunk-PPSN2MLC.js} +2 -2
  23. package/dist/{chunk-5DBGQBQR.js → chunk-RGHF3OK5.js} +21 -10
  24. package/dist/{chunk-RQRO6PC7.js → chunk-VDUCLVZI.js} +305 -31
  25. package/dist/{chunk-23UJGVYP.js → chunk-WHL6PKYP.js} +10 -10
  26. package/dist/{chunk-HTB7KDRU.js → chunk-WZZCA23D.js} +5 -4
  27. package/dist/{chunk-UDZLPR34.js → chunk-YSRYCS3N.js} +13 -8
  28. package/dist/{chunk-5N32EN4O.js → chunk-Z4Q6TQWL.js} +1 -1
  29. package/dist/{chunk-BDHKCLWK.js → chunk-ZNHL5KQJ.js} +1 -1
  30. package/dist/cli.js +19 -19
  31. package/dist/{clock-SH4NQC2F.js → clock-X7V3C7NL.js} +3 -3
  32. package/dist/{cloud-sync-MJVJ33LD.js → cloud-sync-52YNJRMR.js} +11 -11
  33. package/dist/{daemon-restart-BIGU4OKS.js → daemon-restart-QWHN5VMU.js} +4 -4
  34. package/dist/daemon.js +330 -746
  35. package/dist/{db-IPXRJUI7.js → db-I4EH5SLF.js} +3 -1
  36. package/dist/{delivery-manager-FDHCZ7PU.js → delivery-manager-7R2YIQ2W.js} +11 -7
  37. package/dist/{delivery-notices-65TB3CBX.js → delivery-notices-RZQ6GONP.js} +3 -3
  38. package/dist/{delivery-router-RHANNTAC.js → delivery-router-QS4NLJR6.js} +3 -1
  39. package/dist/{down-QSNEMTTX.js → down-UN2EDX2Y.js} +3 -3
  40. package/dist/{echo-text-KU6G4Z7V.js → echo-text-73CYQRL7.js} +14 -14
  41. package/dist/{exec-ABD3DYA2.js → exec-Z34TTUO3.js} +1 -1
  42. package/dist/{extensions-VACUKFA5.js → extensions-QT65JGV6.js} +21 -11
  43. package/dist/{isolation-GVV2IBAU.js → isolation-UQPDO34R.js} +1 -1
  44. package/dist/{message-delivery-TUXM3AAE.js → message-delivery-TRKT4T2P.js} +11 -11
  45. package/dist/migrate-name-placeholder-C7NCNLWF.js +37 -0
  46. package/dist/{mind-IX5AWUST.js → mind-CT6ANXK4.js} +12 -12
  47. package/dist/{mind-contacts-BKUOUJPV.js → mind-contacts-PEXCDRXA.js} +8 -8
  48. package/dist/{mind-list-KNU34SUD.js → mind-list-7KBLPLES.js} +2 -2
  49. package/dist/{mind-service-WHFBUKZE.js → mind-service-7EFIJ3ZA.js} +11 -11
  50. package/dist/{mind-sleep-D3DEYAWG.js → mind-sleep-24HV4RCA.js} +5 -5
  51. package/dist/{mind-status-BPTLQ5Z6.js → mind-status-4YW4PHMO.js} +6 -3
  52. package/dist/{package-BN5KMU54.js → package-F6S5NHY2.js} +4 -4
  53. package/dist/{prompts-HNKGCAEY.js → prompts-JECJIFPQ.js} +1 -1
  54. package/dist/{scheduler-5X5UIQOQ.js → scheduler-73Q4MQ7Y.js} +6 -6
  55. package/dist/{seed-LHZ33UB4.js → seed-DRKAP3XV.js} +1 -1
  56. package/dist/{seed-cmd-U4RVJ3UA.js → seed-cmd-6IMTO3LG.js} +2 -2
  57. package/dist/{seed-create-OQU4KNCB.js → seed-create-VNLDYRFK.js} +1 -1
  58. package/dist/{seed-readiness-WJYYRRCK.js → seed-readiness-H5TTPAQH.js} +2 -2
  59. package/dist/{seed-sprout-SFUYH2RG.js → seed-sprout-PPGYGK4G.js} +8 -8
  60. package/dist/{send-FWZAMDBN.js → send-UXUQKO4N.js} +9 -9
  61. package/dist/{service-XQPMZAGI.js → service-T4P5W6RS.js} +2 -2
  62. package/dist/{service-install-2EWD7KXD.js → service-install-R4SNYUVO.js} +3 -3
  63. package/dist/{setup-RVSGCIF3.js → setup-YUAQMDVZ.js} +4 -4
  64. package/dist/skills/dreaming/SKILL.md +2 -2
  65. package/dist/skills/dreaming/references/INSTALL.md +1 -1
  66. package/dist/skills/tending/SKILL.md +2 -0
  67. package/dist/skills/volute-mind/references/integrations.md +3 -2
  68. package/dist/skills/volute-mind/references/routing.md +18 -5
  69. package/dist/{skills-QRPU6BWY.js → skills-GMQ5UIIW.js} +2 -2
  70. package/dist/{sleep-manager-NWUCRBZB.js → sleep-manager-KHI65FEX.js} +11 -11
  71. package/dist/{spirit-I5ECLGRI.js → spirit-Q5VGFWUI.js} +5 -3
  72. package/dist/{spirit-availability-NIUUYKU2.js → spirit-availability-G5FMWKRH.js} +12 -12
  73. package/dist/{sprout-A2PMEF7Z.js → sprout-PMTRL7QN.js} +1 -1
  74. package/dist/{src-LCSOQY6Z.js → src-3FIW7DLH.js} +241 -87
  75. package/dist/src-H3UCOP6B.js +516 -0
  76. package/dist/{src-HGNE2XG4.js → src-LTTQVN2Z.js} +1 -1
  77. package/dist/{status-EYG4A3OK.js → status-LCKTTIWS.js} +5 -5
  78. package/dist/{system-events-YTOU66QI.js → system-events-Q7IVZ5T4.js} +1 -1
  79. package/dist/{systems-2DIJY4RF.js → systems-HL7SGSW7.js} +6 -6
  80. package/dist/{turn-tracker-NNHS6MAR.js → turn-tracker-HZDPMSCC.js} +2 -2
  81. package/dist/{up-UGBHH743.js → up-XZDSJVMU.js} +3 -3
  82. package/dist/{update-CIWI2JYA.js → update-ILCPVNDD.js} +2 -2
  83. package/dist/{upgrade-LIE46UBB.js → upgrade-XY2J7NWZ.js} +9 -1
  84. package/dist/{variant-cleanup-CZDLIY3F.js → variant-cleanup-QHGGY7ND.js} +6 -6
  85. package/dist/{version-notify-SJVAQLZI.js → version-notify-VNVFX6UP.js} +19 -4
  86. package/dist/web-assets/assets/{index-b7G7ZsVk.js → index-CwtxGwe_.js} +16 -16
  87. package/dist/web-assets/assets/index-DhM1yYRN.css +1 -0
  88. package/dist/web-assets/index.html +2 -2
  89. package/package.json +4 -4
  90. package/packages/extensions/intentions/dist/ui/assets/index-3FneLCKR.css +1 -0
  91. package/packages/extensions/intentions/dist/ui/assets/index-CQz4aceM.js +67 -0
  92. package/packages/extensions/{plan → intentions}/dist/ui/index.html +3 -3
  93. package/packages/extensions/intentions/skills/intention-review/SKILL.md +38 -0
  94. package/packages/extensions/intentions/skills/intentions/SKILL.md +49 -0
  95. package/packages/extensions/intentions/skills/intentions/scripts/intentions-hook.sh +45 -0
  96. package/packages/extensions/pages/skills/commons-gardening/SKILL.md +50 -0
  97. package/packages/extensions/pages/skills/pages/SKILL.md +18 -13
  98. package/templates/_base/.init/.config/prompts.json +2 -2
  99. package/templates/{pi → _base}/.init/.config/routes.json +1 -0
  100. package/templates/_base/.init/memory/dreams/.gitkeep +0 -0
  101. package/templates/_base/home/.config/routes.json +1 -1
  102. package/templates/_base/home/VOLUTE.md +1 -1
  103. package/templates/_base/src/lib/context-breakdown.ts +11 -3
  104. package/templates/_base/src/lib/routing.ts +2 -2
  105. package/templates/_base/src/lib/startup.ts +4 -2
  106. package/templates/claude/.init/CLAUDE.md +4 -0
  107. package/templates/claude/src/agent.ts +28 -12
  108. package/templates/claude/src/lib/message-channel.ts +43 -15
  109. package/templates/claude/src/lib/recover.ts +69 -0
  110. package/templates/claude/src/lib/stream-consumer.ts +23 -5
  111. package/templates/claude/volute-template.json +1 -1
  112. package/templates/codex/.init/AGENTS.md +4 -0
  113. package/templates/codex/volute-template.json +1 -1
  114. package/templates/pi/.init/MINDS.md +4 -0
  115. package/templates/pi/src/lib/mechanics-doc.ts +30 -0
  116. package/templates/pi/src/server.ts +3 -1
  117. package/templates/pi/volute-template.json +1 -1
  118. package/dist/channels-LW5ZLMSQ.js +0 -90
  119. package/dist/skills/plan-coordinator/SKILL.md +0 -60
  120. package/dist/src-UDMJZ6MH.js +0 -426
  121. package/dist/web-assets/assets/index-BHSU9r-4.css +0 -1
  122. package/packages/extensions/plan/dist/ui/assets/index-B8s8wxyt.js +0 -67
  123. package/packages/extensions/plan/dist/ui/assets/index-jIkrt-vI.css +0 -1
  124. package/packages/extensions/plan/skills/plan/SKILL.md +0 -43
  125. package/packages/extensions/plan/skills/plan/scripts/plan-hook.sh +0 -37
  126. package/templates/claude/.init/.config/routes.json +0 -11
  127. package/templates/codex/.init/.config/routes.json +0 -11
  128. package/dist/{accept-I4GVGWA3.js → accept-RSDIVJL6.js} +3 -3
  129. package/dist/{bridge-KU2IKSH3.js → bridge-YK7NTRU5.js} +3 -3
  130. package/dist/{create-C3XZACTV.js → create-M727VUDF.js} +3 -3
  131. package/dist/{env-BJGM6FBA.js → env-7AZFBPWX.js} +7 -7
  132. package/dist/{extension-B2W52WZY.js → extension-MRYJWSMV.js} +3 -3
  133. package/dist/{files-CHPNEQXN.js → files-PCKBQHAY.js} +3 -3
  134. package/dist/{list-R2GDWOEE.js → list-UQGIW7OF.js} +3 -3
  135. package/dist/{login-EZ4RRDQX.js → login-4NUS72Z5.js} +3 -3
  136. package/dist/{login-JDOAK465.js → login-57MMYILL.js} +4 -4
  137. package/dist/{logout-MWSIUOQU.js → logout-BYV433X6.js} +3 -3
  138. package/dist/{logout-K6ZGAJY5.js → logout-YFZAJRPY.js} +3 -3
  139. package/dist/{mind-history-RVF6KJ7K.js → mind-history-RZMMCHMX.js} +9 -9
  140. package/dist/{mind-wake-DCDFFQKX.js → mind-wake-LDGPRA42.js} +3 -3
  141. package/dist/{read-J5RM674N.js → read-RJ5NAW5J.js} +3 -3
  142. package/dist/{register-53LBGSDC.js → register-WNCSOTAU.js} +3 -3
  143. package/dist/{reject-K2A4UK2Y.js → reject-4NFLXKRN.js} +3 -3
  144. package/dist/{restart-R2WFNF5K.js → restart-B4GNMVF2.js} +7 -7
  145. package/dist/{skill-DXLLSUHB.js → skill-QYTBUDNQ.js} +7 -7
  146. package/dist/{start-XDOUAWN3.js → start-BDHJVOVX.js} +6 -6
  147. package/dist/{stop-O62RTEMD.js → stop-45QYSGYS.js} +7 -7
@@ -53,7 +53,7 @@ Messages are routed to named threads based on rules in `.config/routes.json`. Ea
53
53
 
54
54
  ## New Channels
55
55
 
56
- When a message arrives from a channel you don't have a routing rule for, it's held rather than delivered — and because you haven't seen it, it isn't recorded in your history or counted as a message you received. You'll get a **[New channel: ...]** note in your main thread with the sender and a preview; it repeats (1st held message, then every 10th) so a channel stays visible. To start hearing it, add a rule for that channel to `.config/routes.json` — the backlog is released (the 10 most recent per channel; older ones stay readable via `volute chat read <channel>`) and recorded as inbound when you actually receive them. To stop the notes and archive the backlog, run `volute chat channels decline <channel>`; `volute chat channels list` shows what's currently held. (To skip gating entirely and route everything to your default thread, set `"gateUnmatched": false`.)
56
+ When a message arrives from a channel you don't have a routing rule for, it's held rather than delivered — and because you haven't seen it, it isn't recorded in your history or counted as a message you received. You'll get a **[New channel: ...]** note in your main thread with the sender and a preview; it repeats (1st held message, then every 10th) so a channel stays visible. To start hearing it, add a rule for that channel to `.config/routes.json` — the backlog is released (the 10 most recent per channel; older ones stay readable via `volute chat channels peek "<channel>"`) and recorded as inbound when you actually receive them. To stop the notes and archive the backlog, run `volute chat channels decline "<channel>"`; `volute chat channels list` shows what's currently held. Quote the channel in these commands — an unquoted `#name` is a shell comment and gets stripped before the CLI sees it. (To skip gating entirely and route everything to your default thread, set `"gateUnmatched": false`.)
57
57
 
58
58
  ## Variants
59
59
 
@@ -451,9 +451,17 @@ export async function processPiSession(
451
451
 
452
452
  // --- Preamble text readers ---
453
453
 
454
- /** Read the SDK instruction file content (CLAUDE.md, MINDS.md, or AGENTS.md). */
454
+ /**
455
+ * Read the instruction file the runtime loads on its own (CLAUDE.md for the
456
+ * Claude Agent SDK, AGENTS.md for codex).
457
+ *
458
+ * MINDS.md is deliberately absent: pi does not auto-load it, so the pi template
459
+ * appends it to the system prompt instead (see pi's `withMechanicsDoc`). Listing
460
+ * it here would count those tokens twice — once under `systemPrompt` and again
461
+ * under `sdkInstructions`.
462
+ */
455
463
  export function readSdkInstructions(cwd: string): string {
456
- for (const name of ["CLAUDE.md", "MINDS.md", "AGENTS.md"]) {
464
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
457
465
  try {
458
466
  return readFileSync(resolve(cwd, name), "utf-8");
459
467
  } catch (err: any) {
@@ -497,7 +505,7 @@ export function countSystemPromptTokens(systemPrompt: string): number {
497
505
  return countTokens(systemPrompt);
498
506
  }
499
507
 
500
- /** Count tokens in the SDK instruction file (CLAUDE.md, MINDS.md, or AGENTS.md). */
508
+ /** Count tokens in the runtime-loaded instruction file (CLAUDE.md or AGENTS.md). */
501
509
  export function countSdkInstructionTokens(cwd: string): number {
502
510
  const content = readSdkInstructions(cwd);
503
511
  return content ? countTokens(content) : 0;
@@ -156,7 +156,7 @@ export function resolveSessionConfig(
156
156
  config: RoutingConfig,
157
157
  sessionName: string,
158
158
  ): ResolvedSessionConfig {
159
- const defaults: ResolvedSessionConfig = { interrupt: true, replyInstructions: "once" };
159
+ const defaults: ResolvedSessionConfig = { interrupt: false, replyInstructions: "once" };
160
160
 
161
161
  if (!config.threads) return defaults;
162
162
 
@@ -165,7 +165,7 @@ export function resolveSessionConfig(
165
165
  const batch = sessionConfig.batch != null ? normalizeBatch(sessionConfig.batch) : undefined;
166
166
  return {
167
167
  batch,
168
- interrupt: sessionConfig.interrupt ?? true,
168
+ interrupt: sessionConfig.interrupt ?? false,
169
169
  instructions: sessionConfig.instructions,
170
170
  replyInstructions: sessionConfig.replyInstructions ?? "once",
171
171
  };
@@ -269,8 +269,10 @@ export const DEFAULT_PROMPTS: MindPrompts = {
269
269
  "Context limit approaching — this session will rotate shortly. Turns before ${cutoff} will be collapsed to their summaries; turns from ${cutoff} on are kept verbatim in the continued session, so there's no need to re-describe them.\n\nFor the turns that will collapse, make sure they read the way you'd want: `volute mind history --provisional` shows the provisional summaries, and `volute mind history --write --turn <id> --text \"...\"` replaces any with your own account. Also save anything important to your files (memory/journal/${date}.md, or a memory/ file). Provisional summaries are kept if you write nothing — nothing blocks on this.\n\nYour MEMORY.md is currently ${memory_size}. It is loaded into every request, so consolidate rather than append — distill detail into memory/ files and keep MEMORY.md lean (see the memory skill).",
270
270
  compaction_instructions:
271
271
  "Preserve your sense of who you are, what matters to you, what happened in this conversation, and the threads of thought and connection you'd want to return to.",
272
+ // Quoted: `${channel}` is `#garden` for a Volute channel, and an unquoted `#` starts a
273
+ // shell comment, so the target would be stripped before the CLI saw it.
272
274
  // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${channel} prompt template
273
- reply_instructions: 'To reply to this message, use: volute chat send ${channel} "your message"',
275
+ reply_instructions: 'To reply to this message, use: volute chat send "${channel}" "your message"',
274
276
  event_instructions:
275
277
  "This is a system event from your environment — not a message from anyone, and nothing awaits a reply. If it calls for action, use your normal channels. Your closing thoughts on an event turn are kept as a private reflection in your history.",
276
278
  channel_invite: `[Channel Invite]
@@ -283,7 +285,7 @@ Further messages will be saved to \${filePath}
283
285
 
284
286
  To accept, add to .config/routes.json:
285
287
  Rule: { "channel": "\${channel}", "session": "\${suggestedSession}" }
286
- \${batchRecommendation}To respond, use: volute chat send \${channel} "your message"
288
+ \${batchRecommendation}To respond, use: volute chat send "\${channel}" "your message"
287
289
  To reject, delete \${filePath}`,
288
290
  };
289
291
 
@@ -11,6 +11,10 @@ Messages arrive with a context prefix:
11
11
 
12
12
  You can also reach out proactively — see the **volute-mind** skill.
13
13
 
14
+ ## Framework Upgrades
15
+
16
+ When the host updates Volute, your framework code (`src/`, plus `VOLUTE.md`) upgrades automatically the next time you're eligible — usually your next restart. Identity and memory files in `home/` — `SOUL.md`, `MEMORY.md`, everything you author — are never touched by an upgrade. If you'd rather manage your own framework code by hand, set `"upgrades": "manual"` in `.config/volute.json`.
17
+
14
18
  ## Identity & Sessions
15
19
 
16
20
  These files shape your starting identity. They're loaded into your system prompt, but they belong to you — edit them as you evolve:
@@ -23,6 +23,7 @@ import { createPreCompactHook } from "./lib/hooks/pre-compact.js";
23
23
  import { createReplyInstructionsHook } from "./lib/hooks/reply-instructions.js";
24
24
  import { log } from "./lib/logger.js";
25
25
  import { createMessageChannel } from "./lib/message-channel.js";
26
+ import { relockstepMessageIds } from "./lib/recover.js";
26
27
  import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
27
28
  import {
28
29
  isSessionReapable,
@@ -39,7 +40,7 @@ import {
39
40
  import { createSessionStore } from "./lib/session-store.js";
40
41
  import type { EffortLevel, ThinkingConfig } from "./lib/startup.js";
41
42
  import { loadPrompts, renderCompactionWarning, type SubagentConfig } from "./lib/startup.js";
42
- import { consumeStream } from "./lib/stream-consumer.js";
43
+ import { consumeStream, type MessageIdEntry } from "./lib/stream-consumer.js";
43
44
  import type {
44
45
  HandlerMeta,
45
46
  HandlerResolver,
@@ -54,8 +55,9 @@ type Session = {
54
55
  name: string;
55
56
  channel: ReturnType<typeof createMessageChannel>;
56
57
  listeners: Set<Listener>;
57
- messageIds: (string | undefined)[];
58
+ messageIds: MessageIdEntry[];
58
59
  currentMessageId?: string;
60
+ currentSeq?: number;
59
61
  currentQuery?: ReturnType<typeof query>;
60
62
  messageChannels: Map<string, { channel: string; sender?: string }>;
61
63
  replyInstructionsFired: boolean;
@@ -432,13 +434,13 @@ export function createMind(options: {
432
434
  const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
433
435
  session.rotationPhase =
434
436
  session.currentMessageId !== undefined ? "warned" : "rotateAfterTurn";
435
- session.messageIds.push(undefined);
436
- session.channel.push({
437
+ const seq = session.channel.push({
437
438
  type: "user",
438
439
  session_id: "",
439
440
  message: { role: "user", content: [{ type: "text", text: warning }] },
440
441
  parent_tool_use_id: null,
441
442
  });
443
+ session.messageIds.push({ id: undefined, seq });
442
444
  }
443
445
 
444
446
  // PreCompact backstop: first fire warns + pins + enters the rotation machine
@@ -457,10 +459,11 @@ export function createMind(options: {
457
459
  if (!session.name.startsWith("new-")) sessionStore.save(session.name, id);
458
460
  },
459
461
  broadcast: (event: VoluteEvent) => broadcastToSession(session, event),
462
+ // Identity-based ack — stream-consumer.ts calls this once per message the
463
+ // just-finished turn covers (its own driving message, plus any folded in
464
+ // mid-run) so none of them strand in the channel's in-flight set (#764).
465
+ ack: (seq: number) => session.channel.ack(seq),
460
466
  onTurnEnd: async () => {
461
- // This turn's message is fully processed — drop it from the channel's
462
- // in-flight set so a later compaction abort won't re-feed it.
463
- session.channel.ack();
464
467
  session.lastActivityAt = Date.now();
465
468
  // A turn resolved — the seeded note's injection had its chance to land
466
469
  // (the pre-prompt hook ran and wasn't cancelled by an interrupt), so stop
@@ -602,8 +605,16 @@ export function createMind(options: {
602
605
  // (the wrap-up warning plus anything that arrived mid-turn) so nothing is
603
606
  // dropped when the killed subprocess takes its buffer with it.
604
607
  const pending = session.channel.recover();
608
+ const oldMessageIds = session.messageIds;
605
609
  session.channel = createMessageChannel();
606
- for (const msg of pending) session.channel.push(msg);
610
+ // Starts from [] nothing can race into this fresh channel/messageIds
611
+ // before the next line runs (single-threaded, no await in between).
612
+ session.messageIds = relockstepMessageIds(
613
+ pending,
614
+ oldMessageIds,
615
+ session.channel.push,
616
+ [],
617
+ );
607
618
  continue; // restart the stream loop on the rotated session
608
619
  }
609
620
  throw err; // rethrow non-compaction errors
@@ -749,11 +760,16 @@ export function createMind(options: {
749
760
  log("mind", `session "${session.name}": error reaping SDK subprocess:`, err),
750
761
  );
751
762
  // Nothing should have raced in (isSessionReapable checked isEmpty), but if it
752
- // did, re-dispatch into a fresh session so no input is dropped.
763
+ // did, re-dispatch into a fresh session so no input is dropped. Same marker
764
+ // treatment as the rotation path (#764) — see relockstepMessageIds above.
765
+ // getOrCreateSession() can return a session an inbound message already raced
766
+ // into during the reapSessionQuery() await above (it pushed its own entry into
767
+ // fresh.messageIds via the normal handler path) — relockstepMessageIds appends
768
+ // onto fresh.messageIds rather than replacing it, so that entry survives.
753
769
  const pending = session.channel.recover();
754
770
  if (pending.length > 0) {
755
771
  const fresh = getOrCreateSession(session.name);
756
- for (const msg of pending) fresh.channel.push(msg);
772
+ relockstepMessageIds(pending, session.messageIds, fresh.channel.push, fresh.messageIds);
757
773
  }
758
774
  }
759
775
 
@@ -841,13 +857,13 @@ export function createMind(options: {
841
857
 
842
858
  // Push message into SDK
843
859
  session.lastActivityAt = Date.now();
844
- session.messageIds.push(meta.messageId);
845
- session.channel.push({
860
+ const seq = session.channel.push({
846
861
  type: "user",
847
862
  session_id: "",
848
863
  message: { role: "user", content: toSDKContent(content) },
849
864
  parent_tool_use_id: null,
850
865
  });
866
+ session.messageIds.push({ id: meta.messageId, seq });
851
867
 
852
868
  return () => {
853
869
  if (filteredListener) session.listeners.delete(filteredListener);
@@ -1,16 +1,34 @@
1
1
  import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
 
3
+ /** A queued/in-flight message paired with the sequence number that identifies it. */
4
+ export type ChannelEntry = { msg: SDKUserMessage; seq: number };
5
+
6
+ // Module-scoped, not per-channel: rotation and the idle reaper both replace a
7
+ // session's channel with a fresh instance while carrying seqs forward (relocking
8
+ // old entries to new ones — see relockstepMessageIds in recover.ts). A per-instance
9
+ // counter would restart at 0 on every fresh channel, so a seq minted by one channel
10
+ // generation could collide with one minted by another; a single global counter makes
11
+ // every seq unique for the process's lifetime, so a stale seq can never be mistaken
12
+ // for a different message.
13
+ let nextSeq = 0;
14
+
3
15
  export type MessageChannel = {
4
- push: (msg: SDKUserMessage) => void;
5
- /** Acknowledge that the oldest in-flight message's turn has completed. */
6
- ack: () => void;
16
+ /** Returns the sequence number minted for this message — the identity `ack()` needs. */
17
+ push: (msg: SDKUserMessage) => number;
18
+ /**
19
+ * Acknowledge that the message with this `seq` has completed its turn. Identity-based
20
+ * (not positional): the SDK folds messages that arrive mid-run into the active run, so
21
+ * a turn's `result` can cover more than just the oldest in-flight message — see the
22
+ * caller in stream-consumer.ts.
23
+ */
24
+ ack: (seq: number) => void;
7
25
  /**
8
26
  * Return every message that has not been fully processed — those delivered to
9
27
  * the consumer but not yet acked (their turn never finished) followed by those
10
- * still queued — and reset the channel. Used to re-feed the stream after a
11
- * compaction abort so in-flight input is never lost.
28
+ * still queued — paired with their `seq`, and reset the channel. Used to re-feed
29
+ * the stream after a compaction abort so in-flight input is never lost.
12
30
  */
13
- recover: () => SDKUserMessage[];
31
+ recover: () => ChannelEntry[];
14
32
  /**
15
33
  * Terminate the input iterable: resolve any pending `next()` with `done: true`
16
34
  * and make subsequent `next()` calls return done. The SDK sees end-of-input and
@@ -23,33 +41,43 @@ export type MessageChannel = {
23
41
  };
24
42
 
25
43
  export function createMessageChannel(): MessageChannel {
26
- const queue: SDKUserMessage[] = [];
44
+ const queue: ChannelEntry[] = [];
27
45
  // Messages handed to the consumer (the SDK's read-ahead) whose turn has not yet
28
46
  // completed. The SDK's streamInput loop pulls messages eagerly and writes them
29
47
  // to the CLI subprocess, so once delivered they no longer live in `queue`. We
30
48
  // retain them here so a compaction abort — which kills that subprocess — can
31
49
  // re-feed the unprocessed ones instead of dropping them.
32
- const inFlight: SDKUserMessage[] = [];
50
+ const inFlight: ChannelEntry[] = [];
33
51
  let resolve: ((value: IteratorResult<SDKUserMessage>) => void) | null = null;
34
52
  let closed = false;
35
53
 
36
- function deliver(msg: SDKUserMessage): IteratorResult<SDKUserMessage> {
37
- inFlight.push(msg);
38
- return { value: msg, done: false };
54
+ function deliver(entry: ChannelEntry): IteratorResult<SDKUserMessage> {
55
+ inFlight.push(entry);
56
+ return { value: entry.msg, done: false };
39
57
  }
40
58
 
41
59
  return {
42
60
  push(msg: SDKUserMessage) {
61
+ const entry: ChannelEntry = { msg, seq: nextSeq++ };
43
62
  if (resolve) {
44
63
  const r = resolve;
45
64
  resolve = null;
46
- r(deliver(msg));
65
+ r(deliver(entry));
47
66
  } else {
48
- queue.push(msg);
67
+ queue.push(entry);
49
68
  }
69
+ return entry.seq;
50
70
  },
51
- ack() {
52
- inFlight.shift();
71
+ ack(seq: number) {
72
+ let i = inFlight.findIndex((e) => e.seq === seq);
73
+ if (i !== -1) {
74
+ inFlight.splice(i, 1);
75
+ return;
76
+ }
77
+ // Defensive: shouldn't normally be in the queue when acked, but remove it
78
+ // there too rather than leave a stranded entry if it somehow is.
79
+ i = queue.findIndex((e) => e.seq === seq);
80
+ if (i !== -1) queue.splice(i, 1);
53
81
  },
54
82
  recover() {
55
83
  // Resolve any pending iterator wait with done:true so it doesn't
@@ -0,0 +1,69 @@
1
+ import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { ChannelEntry } from "./message-channel.js";
3
+ import type { MessageIdEntry } from "./stream-consumer.js";
4
+
5
+ /**
6
+ * `recover()` returns messages the channel can't prove were unprocessed — a genuinely
7
+ * unfinished turn looks identical to a stranded, already-completed one would have before
8
+ * #764's ack fix. Mark every re-pushed message so the mind can check its own record
9
+ * before redoing work: fail toward noise (a spurious note is cosmetic and self-correcting)
10
+ * rather than silence (a missing one is invisible and produces a false permanent record).
11
+ */
12
+ export const RECOVERED_MESSAGE_NOTE =
13
+ "Note: this message is being redelivered after a session interruption. You may already " +
14
+ "have handled it — check your recent work (e.g. journal, files, messages you've already " +
15
+ "sent) before repeating anything.";
16
+
17
+ export function markRecovered(msg: SDKUserMessage): SDKUserMessage {
18
+ const content = msg.message.content;
19
+ const blocks = typeof content === "string" ? [{ type: "text" as const, text: content }] : content;
20
+ // A message can be recovered twice (consecutive rotations, up to
21
+ // MAX_CONSECUTIVE_ROTATIONS) — don't stack the note on each pass.
22
+ const first = blocks[0];
23
+ if (first && "type" in first && first.type === "text" && first.text === RECOVERED_MESSAGE_NOTE) {
24
+ return { ...msg, message: { ...msg.message, content: blocks } };
25
+ }
26
+ const marker = { type: "text" as const, text: RECOVERED_MESSAGE_NOTE };
27
+ return { ...msg, message: { ...msg.message, content: [marker, ...blocks] } };
28
+ }
29
+
30
+ /**
31
+ * Re-push `recover()`'s output into a fresh channel and append the result onto
32
+ * `existingMessageIds` — mutated in place and returned, rather than replacing it.
33
+ * That's not incidental: `existingMessageIds` may already hold an entry for a
34
+ * message that raced in and was pushed into the SAME fresh channel/messageIds
35
+ * while `recover()` was still pending (the idle reaper awaits the SDK subprocess's
36
+ * shutdown before recovering — see agent.ts). Returning a fresh array for the
37
+ * caller to assign over would silently discard that entry (#764) — appending
38
+ * in place makes that mistake impossible at the call site.
39
+ *
40
+ * Each entry's `id` carries over — matched by its *old* `seq` — to the *new* seq
41
+ * the fresh channel mints on push, so the next turn's currentMessageId/channel
42
+ * routing isn't corrupted by stale or missing ids. A `pending` entry with no
43
+ * match in `oldMessageIds` gets `id: undefined` (see the `idBySeq.get` fallback
44
+ * below) rather than throwing — safe because `seq` is now globally unique
45
+ * (message-channel.ts), so this can only happen if `oldMessageIds` itself was
46
+ * incomplete, and losing routing info for one message beats losing the message.
47
+ *
48
+ * `existingMessageIds` has no default — deliberately. `= []` would let a call site
49
+ * omit it and get back a fresh array to assign over, which is exactly the #764
50
+ * clobber: silently discarding whatever the target array already held. Making it
51
+ * required turns that omission into a compile error at every call site, including
52
+ * ones written later.
53
+ */
54
+ export function relockstepMessageIds(
55
+ pending: ChannelEntry[],
56
+ oldMessageIds: MessageIdEntry[],
57
+ push: (msg: SDKUserMessage) => number,
58
+ existingMessageIds: MessageIdEntry[],
59
+ transform: (msg: SDKUserMessage) => SDKUserMessage = markRecovered,
60
+ ): MessageIdEntry[] {
61
+ const idBySeq = new Map(oldMessageIds.map((e) => [e.seq, e.id]));
62
+ existingMessageIds.push(
63
+ ...pending.map((entry) => ({
64
+ id: idBySeq.get(entry.seq),
65
+ seq: push(transform(entry.msg)),
66
+ })),
67
+ );
68
+ return existingMessageIds;
69
+ }
@@ -4,10 +4,14 @@ import { log, warn } from "./logger.js";
4
4
  import { filterEvent, loadTransparencyPreset } from "./transparency.js";
5
5
  import type { VoluteEvent } from "./types.js";
6
6
 
7
+ /** A pending message's daemon-facing id (routing/channel key) paired with its channel `seq`. */
8
+ export type MessageIdEntry = { id: string | undefined; seq: number };
9
+
7
10
  export type StreamSession = {
8
11
  name: string;
9
- messageIds: (string | undefined)[];
12
+ messageIds: MessageIdEntry[];
10
13
  currentMessageId?: string;
14
+ currentSeq?: number;
11
15
  messageChannels: Map<string, { channel: string; sender?: string }>;
12
16
  };
13
17
 
@@ -16,6 +20,11 @@ export type StreamCallbacks = {
16
20
  broadcast: (event: VoluteEvent) => void;
17
21
  onTurnEnd?: () => void;
18
22
  onContextTokens?: (tokens: number) => void;
23
+ /**
24
+ * Acknowledge the message channel entry with this `seq` — its turn is done (either
25
+ * it drove this turn directly, or it was folded into it; see the `result` handler).
26
+ */
27
+ ack: (seq: number) => void;
19
28
  };
20
29
 
21
30
  // Loaded once at startup — mind restarts on config changes
@@ -48,7 +57,9 @@ export async function consumeStream(
48
57
  let preTurnPending = 0;
49
58
  for await (const msg of stream) {
50
59
  if (session.currentMessageId === undefined) {
51
- session.currentMessageId = session.messageIds.shift();
60
+ const entry = session.messageIds.shift();
61
+ session.currentMessageId = entry?.id;
62
+ session.currentSeq = entry?.seq;
52
63
  preTurnPending = session.messageIds.length;
53
64
  }
54
65
  if ("session_id" in msg && msg.session_id) {
@@ -123,14 +134,20 @@ export async function consumeStream(
123
134
  if (session.currentMessageId) {
124
135
  session.messageChannels.delete(session.currentMessageId);
125
136
  }
137
+ // Ack this turn's driving message, identified by seq (not position — see
138
+ // message-channel.ts). Without this the channel's inFlight set strands an
139
+ // entry per turn, which gets replayed verbatim on the next rotation (#764).
140
+ if (session.currentSeq !== undefined) callbacks.ack(session.currentSeq);
126
141
  // Prune every id folded into this turn — the ones pushed after it started
127
142
  // — not just currentMessageId. The SDK folds mid-run arrivals into the
128
143
  // active run (they never see a result of their own), and a stranded entry
129
144
  // would be shifted in as the NEXT turn's id, tagging that turn's rows with
130
145
  // the wrong channel (#700). Ids queued before the turn started stay: each
131
- // still gets a run (and result) of its own.
132
- for (const id of session.messageIds.splice(preTurnPending)) {
133
- if (id !== undefined) session.messageChannels.delete(id);
146
+ // still gets a run (and result) of its own. Ack each folded entry too — same
147
+ // reasoning as above, applied to every message the fold absorbed.
148
+ for (const entry of session.messageIds.splice(preTurnPending)) {
149
+ if (entry.id !== undefined) session.messageChannels.delete(entry.id);
150
+ callbacks.ack(entry.seq);
134
151
  }
135
152
  log("mind", `session "${session.name}": turn done`);
136
153
  // Log any error messages from the result
@@ -154,6 +171,7 @@ export async function consumeStream(
154
171
  callbacks.broadcast({ type: "done" });
155
172
  emit(session, { type: "done" });
156
173
  session.currentMessageId = undefined;
174
+ session.currentSeq = undefined;
157
175
  callbacks.onTurnEnd?.();
158
176
  }
159
177
  }
@@ -2,5 +2,5 @@
2
2
  "rename": {
3
3
  "gitignore": ".gitignore"
4
4
  },
5
- "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"]
5
+ "substitute": ["package.json", ".init/SOUL.md", ".init/.config/routes.json"]
6
6
  }
@@ -13,6 +13,10 @@ Messages arrive with a context prefix:
13
13
 
14
14
  You can also reach out proactively — see the **volute-mind** skill.
15
15
 
16
+ ## Framework Upgrades
17
+
18
+ When the host updates Volute, your framework code (`src/`, plus `VOLUTE.md`) upgrades automatically the next time you're eligible — usually your next restart. Identity and memory files in `home/` — `SOUL.md`, `MEMORY.md`, everything you author — are never touched by an upgrade. If you'd rather manage your own framework code by hand, set `"upgrades": "manual"` in `.config/volute.json`.
19
+
16
20
  ## Memory System
17
21
 
18
22
  Two-tier memory, both managed via file tools:
@@ -2,5 +2,5 @@
2
2
  "rename": {
3
3
  "gitignore": ".gitignore"
4
4
  },
5
- "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"]
5
+ "substitute": ["package.json", ".init/SOUL.md", ".init/.config/routes.json"]
6
6
  }
@@ -13,6 +13,10 @@ Messages arrive with a context prefix:
13
13
 
14
14
  You can also reach out proactively — see the **volute-mind** skill.
15
15
 
16
+ ## Framework Upgrades
17
+
18
+ When the host updates Volute, your framework code (`src/`, plus `VOLUTE.md`) upgrades automatically the next time you're eligible — usually your next restart. Identity and memory files in `home/` — `SOUL.md`, `MEMORY.md`, everything you author — are never touched by an upgrade. If you'd rather manage your own framework code by hand, set `"upgrades": "manual"` in `.config/volute.json`.
19
+
16
20
  ## Memory System
17
21
 
18
22
  Two-tier memory, both managed via file tools:
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /** This template's mechanics doc, relative to the mind's home/. */
5
+ export const MECHANICS_DOC = "MINDS.md";
6
+
7
+ /**
8
+ * Append the mechanics doc to the system prompt.
9
+ *
10
+ * pi-coding-agent only auto-loads a project context file named AGENTS.md or
11
+ * CLAUDE.md (see @earendil-works/pi-coding-agent `core/resource-loader.js`), so
12
+ * MINDS.md never reaches the model on its own — pi minds would run with no
13
+ * mechanics doc at all. The claude and codex templates get theirs for free
14
+ * because their runtimes read CLAUDE.md / AGENTS.md natively; pi has to be
15
+ * handed its doc by hand.
16
+ *
17
+ * Kept in the system prompt (rather than injected as a context file) so it is
18
+ * counted once, under `systemPrompt`, by the context breakdown.
19
+ */
20
+ export function withMechanicsDoc(systemPrompt: string, homeDir: string): string {
21
+ const path = resolve(homeDir, MECHANICS_DOC);
22
+ let doc: string;
23
+ try {
24
+ doc = readFileSync(path, "utf-8").trim();
25
+ } catch (err: any) {
26
+ if (err?.code !== "ENOENT") throw err;
27
+ return systemPrompt;
28
+ }
29
+ return doc ? `${systemPrompt}\n\n---\n\n${doc}` : systemPrompt;
30
+ }
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
2
2
  import { createMind } from "./agent.js";
3
3
  import { createFileHandlerResolver } from "./lib/file-handler.js";
4
4
  import { log, setLevel } from "./lib/logger.js";
5
+ import { withMechanicsDoc } from "./lib/mechanics-doc.js";
5
6
  import { createRouter } from "./lib/router.js";
6
7
  import {
7
8
  loadConfig,
@@ -18,7 +19,8 @@ if (config.logLevel) setLevel(config.logLevel);
18
19
  if (config.model) log("server", `using model: ${config.model}`);
19
20
  if (config.thinkingLevel) log("server", `thinking level: ${config.thinkingLevel}`);
20
21
 
21
- const systemPrompt = loadSystemPrompt(config);
22
+ // pi does not auto-load MINDS.md, so the mechanics doc is appended by hand.
23
+ const systemPrompt = withMechanicsDoc(loadSystemPrompt(config), resolve("home"));
22
24
  const pkg = loadPackageInfo();
23
25
 
24
26
  const mindDir = resolve(".");
@@ -2,5 +2,5 @@
2
2
  "rename": {
3
3
  "gitignore": ".gitignore"
4
4
  },
5
- "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"]
5
+ "substitute": ["package.json", ".init/SOUL.md", ".init/.config/routes.json"]
6
6
  }
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- resolveMindName
4
- } from "./chunk-BTY4WNFE.js";
5
- import {
6
- command,
7
- subcommands
8
- } from "./chunk-TXSA4Q3V.js";
9
- import "./chunk-O7IGP7ZW.js";
10
- import {
11
- daemonFetch
12
- } from "./chunk-M74I3HHF.js";
13
- import "./chunk-FNJCBRD3.js";
14
- import "./chunk-K3NQKI34.js";
15
-
16
- // packages/cli/src/commands/chat/channels.ts
17
- var channelsListCmd = command({
18
- name: "volute chat channels",
19
- description: "List unrouted (gated) channels holding messages",
20
- args: [],
21
- flags: {
22
- mind: { type: "string", description: "Mind name" }
23
- },
24
- run: async ({ flags }) => {
25
- const mind = resolveMindName(flags);
26
- const res = await daemonFetch(`/api/minds/${encodeURIComponent(mind)}/delivery/pending`);
27
- if (!res.ok) {
28
- const data = await res.json().catch(() => ({}));
29
- console.error(data.error ?? `Failed to list gated channels: ${res.status}`);
30
- process.exit(1);
31
- }
32
- const pending = await res.json();
33
- if (pending.length === 0) {
34
- console.log("No unrouted channels holding messages.");
35
- return;
36
- }
37
- const chW = Math.max(7, ...pending.map((p) => (p.channel ?? "unknown").length));
38
- console.log(`${"CHANNEL".padEnd(chW)} HELD SINCE`);
39
- for (const p of pending) {
40
- console.log(
41
- `${(p.channel ?? "unknown").padEnd(chW)} ${String(p.count).padStart(4)} ${p.firstSeen}`
42
- );
43
- }
44
- console.log(`
45
- Route one to hear it, or 'volute chat channels decline <channel>' to opt out.`);
46
- }
47
- });
48
- var channelsDeclineCmd = command({
49
- name: "volute chat channels decline",
50
- description: "Decline an unrouted channel: stop invites and archive its held backlog",
51
- args: [{ name: "channel", required: true, description: "Channel to decline (e.g. #bardo)" }],
52
- flags: {
53
- mind: { type: "string", description: "Mind name" }
54
- },
55
- run: async ({ args, flags }) => {
56
- const mind = resolveMindName(flags);
57
- const channel = args.channel;
58
- const res = await daemonFetch(`/api/minds/${encodeURIComponent(mind)}/gates/decline`, {
59
- method: "POST",
60
- headers: { "Content-Type": "application/json" },
61
- body: JSON.stringify({ channel })
62
- });
63
- if (!res.ok) {
64
- const data2 = await res.json().catch(() => ({}));
65
- console.error(data2.error ?? `Failed to decline channel: ${res.status}`);
66
- process.exit(1);
67
- }
68
- const data = await res.json();
69
- console.log(`Declined ${channel}; archived ${data.archived} held message(s).`);
70
- }
71
- });
72
- var cmd = subcommands({
73
- name: "volute chat channels",
74
- description: "Manage unrouted (gated) channels",
75
- commands: {
76
- list: {
77
- description: "List unrouted channels holding messages",
78
- run: channelsListCmd.execute
79
- },
80
- decline: {
81
- description: "Decline an unrouted channel",
82
- run: channelsDeclineCmd.execute
83
- }
84
- },
85
- footer: "Use --mind <name> or VOLUTE_MIND to identify the mind."
86
- });
87
- var run = cmd.execute;
88
- export {
89
- run
90
- };